はじめに
どうもこんにちは、Yamu(@yamu_studio )です。
とある案件でNuxt3に触れる機会があり、主にVue2を扱ってきた自分をステップアップさせるために今回は、勉強としてYouTubeのクローンをつくってみました。
この記事では、
- Nuxt3でWebアプリの作り方
- ページ遷移やコンポなどの使い方
- PugとBulmaの使い方
- FastAPIの使い方
- APIでデータのあれこれ
を解説していますのでよかったらみていってください!
NuxtやTypeScript、フロントエンドエンジニアの方はぜひ読んでほしい
- 何かプログラミングで作ってみたいという人
- 勉強中の人
- 暇してるエンジニアさん
できるやつの概要
できたもの
今回作ったページは、1
- Home(上の写真、最初のページ)
- 動画ページ
- チャンネルページ
- アップロードのページ
機能について
- 動画カードが表示される
- APIからデータ取得する
- 登録者や再生回数によって表示が変わる
できるようになったこと
- 基本的なNuxt3
- FastAPIのあれこれ
- DB ↔︎ API ↔︎ フロント の基本的な流れ
詳しいソース・解説はこのブログで!
つくっていく
今回自分は、Mac(Mac Air)で開発しています。
また、自分のNuxt3とPythonの環境は以下になります。
- Nuxt3 : 3.8.2
- Python : 3.11.6
- Docker Compose : v2.23.3-desktop.2
Home を作る
一般的にサイトは、
- ヘッダー
- サイドメニュー
- メイン
に分かれていますがこのページはモロそれで、単純に上のヘッダー部分、左のサイドメニュー部分、そしてメインのジャンルたちと動画カードを並べる、といった感じですね。
ヘッダーはよく見ると、左のロゴ、真ん中に検索関連、右にプロフィールなどのアイコンとなっていますので、各まとまりを「space-between」でやればいけそうですね。2
ボタンの丸まりはBulmaの「is-rounded」を使っています。
※以降、HTML部分はPugを使用しています。
header#mainHeader
nav.navbar.content-row-space-between
.content-row-space-left
.navbar-item.navbar-burger.m-2(:class="{'is-active':bugerActive}" @click="bugerClick")
span
span
span
.navbar-item
NuxtLink(:to="'/'")
img.image(src="/logo_yp.png")
.content-row-space-left
.navbar-item.control.has-icons-right
input.input#inputLeftRounded(placeholder="検索" v-model="searchWord")
button.button#searchLightRounded(@click="doSearch")
span.icon
i.fas.fa-lg.fa-solid.fa-magnifying-glass
.navbar-item
button.button.is-light.is-rounded(@click="visibleModal(0)")
span.icon
i.fas.fa-lg.fa-solid.fa-microphone
.content-row-space-left
.navbar-item
button.button.is-white.is-rounded(@click="visibleModal(1)")
span.icon
i.fas.fa-lg.fa-solid.fa-video
.navbar-item
button.button.is-white.is-rounded(@click="visibleModal(2)")
span.icon
i.fas.fa-lg.fa-bell(v-if="alertModalActive")
i.far.fa-lg.fa-bell(v-else)
.navbar-item
button.button.is-primary.is-inverted.is-rounded(@click="visibleModal(3)")
img(src="/channelImg.png")
ここでちょこっと「Bulma」を解説します!
Bulmaとは、デザインやレイアウトを効率的に、簡単に作成するためのCSSフレームワークです。
もっと簡単にいうと、すでに整えられたcssを"クラス"を指定するだけで使えるようになるやつ、です。
上の例で言うと、ボタンが
button.button.is-primary.is-inverted.is-rounded(~
となっていましたが、buttonにこのクラス「button」をつけることで「いい感じのボタンのデザイン」になり、ほかにも「is-primary」をつけると「黄緑っぽい色がつく」になります。
こんな感じで0から
.button{
padding: 8px;
margin: 4px;
background-color: #3cff80;
~~~
}
と書かずともいい感じのデザインに仕上げることができます。
細かい部分はブログのソースをご覧ください。3
サイドメニューはシンプルに上から並べるだけで良さそうですね。
通常、リンクは<a>
を使いますが、Nuxt3では<NuxtLink>
を使います。
バーガー(三本線のやつ)クリック時には今回モーダルで対応しました。上に画面がもう一個出てくるやつで、「あなたは18歳以上ですか?」で出てくるやつの同じようなものです。
これもBulmaの「.modal~」で対応しています。
また基礎的な部分でいうと、登録チャンネルの部分はv-for
を使って要素を繰り返しています。
ul.menu-list.border-bottom-light.m-3
li.pl-3.pr-3
NuxtLink.p-2
.content-row-space-left
span.icon
i.fas.fa-lg.fa-regular.fa-house
p.subtitle.is-size-7.m-0.pl-5 ホーム
li.pl-3.pr-3
NuxtLink.p-2
.content-row-space-left
span.icon
i.fas.fa-lg.fa-regular.fa-circle-play
p.subtitle.is-size-7.m-0.pl-5 ショート
li.pl-3.pr-3
NuxtLink.p-2
.content-row-space-left
span.icon
i.fas.fa-lg.fa-tv
p.subtitle.is-size-7.m-0.pl-5 登録チャンネル
ul.menu-list.border-bottom-light.m-3
li.pl-3.pr-3
NuxtLink.p-2
.content-row-space-left
p.subtitle.is-size-7.m-0 マイページ
span.icon
i.fas.fa-lg.fa-solid.fa-angle-right
li.pl-3.pr-3
NuxtLink.p-2
.content-row-space-left
span.icon
i.fas.fa-lg.fa-regular.fa-address-card
p.subtitle.is-size-7.m-0.pl-4 登録チャンネル
p.title.is-size-7.pt-3 登録チャンネル
ul.m-3
li.pl-3.pr-3(v-for="ch in subscedChannelList" :key="ch.channelID")
NuxtLink
ChannelCard(:ch="ch")
メインの部分ですがソースはめちゃくちゃスカスカです。
ジャンルのボタンは横並びにして、動画カードはコンポーネント(部品)として作りそれをv-for
しています。
#homeView
.tabs.content-row-space-left.p-3.m-0.janruTab
button.button.is-small.mr-3(v-for="janru in janruList" :key="janru.cd" :class="[janru.cd == nowJanru ? 'is-black':'is-light']") {{ janru.title }}
ul.columns.is-multiline.p-4.pt-6
li.column.is-one-third(v-for="mv in TopMovieList")
MovieCard(:movie="mv")
動画のカードは、上全体にサムネイルを置いて、下は左からチャンネルのプロフ画像、タイトルとかの情報のまとまりで並べて、中身もそんな感じに並べていきます。
僕の基礎的な考え方ですが、「横並びのかたまり」を縦に並べて、、、と要素を並べるようにしています。
動画ページを作る
- ヘッダー(サイドメニューも)
- 動画の場所
- 動画詳細
- コメント欄
- 関連動画と広告
に分かれています。
そして、ページは「動画についての要素」左の大部分で、右側に「関連動画など」になっています。これはBulmaの「tiles」で対応しています。複雑な2次元レイアウトを構築するときによく使います。
今回は左の大部分:関連動画たちを8:4で実装しました。
#channelView
.tile.is-ancestor.p-3(v-if="!isLoading")
.tile.is-parent.is-vertical.is-8
.is-child
.content
video.contentRounded(
id="movieBox"
controls
v-if="movieData.movie != null && movieData.movie != ''"
v-on:loadedmetadata="loadingMovie()"
v-on:play="onPlay()"
v-on:ended="onEnded()"
controlsList="nodownload"
oncontextmenu="return false;"
muted
playsinline)
source(
:src="movieData.movie"
type='video/mp4; codecs="avc1.42E01E, mp4a.40.2"')
.is-child.has-text-left.p-3
〜〜〜〜
.tile.is-parent.is-vertical.p-2.pl-0
また、本家のページはURLの「v=●●●」の部分
https://www.youtube.com/watch?v=●●●
で動画が特定されます。なのでこの●の部分(動画ID)からデータを引き出し、その内容を表示してます。そのため、このページのソースは、pages/watch.vue
に書いていきます。
まず、ヘッダー系はさっき使ったものを再利用できますね。同じように関連動画の部分もさっきの動画カードを再利用できます。このように再利用できそうなもの(部品として何回も使われそうなもの)はコンポーネントとして作ると、色んな場面で簡単に利用できるようになります。
components
フォルダーにその部品のvueファイルを作ればコンポーネントは(ほぼ)完成です。3
動画の部分は、動画の機能(字幕や5秒スキップ等)は一旦飛ばして単純に動画をボンっと配置しました。
下のタイトルはそのまま、チャンネルやいいねのところはよく見ると
- チャンネル情報
- 高評価などのボタン
のまとまりが左右に分かれているのでjustify-content: space-between
でできます。
.content-row-space-between
.content-row-space-left
figure.image.is-32x32.m-1
img.is-rounded(:src="movieData.channel.thumbnail")
.has-text-left
p.title.is-size-7.cutMaxLength#movieChannelName {{ movieData.channel.name }}
p.subtitle.is-size-7 チャンネル登録者数 {{ $common.millBillUnit(movieData.channel.subscribers) }}人
button.button.is-black.is-rounded.p-2 チャンネル登録
.content-row-space-right
button.button.leftRounded
span.icon
i.fa-lg.fa-regular.fa-thumbs-up
span {{ $common.millBillUnit(movieData.goods) }}
button.button.rightRounded
span.icon
i.fa-lg.fa-regular.fa-thumbs-down
button.button.is-rounded.m-1
span.icon
i.fa-lg.fas.fa-share
span 共有
button.button.is-rounded.m-1
span.icon
i.fas.fa-lg.fa-solid.fa-ellipsis
コメント部分も同じようにできますね!3
チャンネルページを作る
実はやっていることほぼ動画詳細と同じです。
まずは構造理解してみると、
- ヘッダーとサイドバー
- でかい画像
- チャンネル情報
- 「ホーム」とかの切り替えタブ
- タブごとの中身
になっています。
新しく出てきた要素だとまず「タブ」部分ですが、こちらもBulmaの「tabs」を使用しています。これを使えば簡単にタブを構築することができます。親要素に「tabs」クラスをつけ、その中にul、liとすればOKです。選択中の要素には「is-active」クラスをつければOKです。
.tabs
ul
li(:class="{'is-active':tabNo == 0}")
NuxtLink(@click="moveTag(0)") ホーム
li(:class="{'is-active':tabNo == 1}")
NuxtLink(@click="moveTag(1)") 動画
~~~
そして、選択されたタブに対応した要素を下でv-if
で出し分ければOKです。
アップロードページを作る
ここはあくまで「FastAPIで動画をアップロード」を実装したくて作ってみました。
流れとしては、input(type="file" ~)
で動画を選択したらAPIにその動画データをPOST(送信)し、そのデータをデータベースに保存しています。
どれもシンプルになっていて、inputで画像が選択されたらそのファイルをFormDataにいれ、API(今回はローカルで立ち上げたエンドポイント「http://127.0.0.1:8000/movie/upload」)にPOST送信しています。
let file = event.target.files[0];
const formdata = new FormData();
formdata.append("file", file);
formdata.append(
"movie",
JSON.stringify({
movie_id: "aawwa",
channel_id: 2,
title: "タイトル",
description: "概要欄",
})
);
const { data, error } = await useFetch(`http://127.0.0.1:8000/movie/upload`, {
method: "POST",
body: formdata,
});
そして受け取ったデータを元にFastAPI側でDBにInsertします。
# エンドポイント
@router.post("/movie/upload", response_model=y_schemas.Movie)
async def upload_file(movie: Json = Form(), file: UploadFile = File(...), db: Session = Depends(get_db)):
# 一旦読み込み必要
movieFile = await file.read()
res_movie = y_crud.create_movie(db, movie, movieFile)
return res_movie
def create_movie(db: Session, movie, file):
new_movie = y_model.Movie()
new_movie.movie_id = movie["movie_id"]
new_movie.title = movie["title"]
new_movie.description = movie["description"]
new_movie.channel_id = movie["channel_id"]
new_movie.created_at = datetime.datetime.now()
new_movie.updated_at = datetime.datetime.now()
new_movie.src = file
db.add(new_movie)
db.commit()
db.refresh(new_movie)
return new_movie
スキーマやモデルは実際のソースはブログから飛べるので気になる方はみてみてください。3
DBは以下のようなデータが追加されます。
id | movie_id | janru_id | title | description | thumbnail | src | created_at | updated_at | channel_id |
---|---|---|---|---|---|---|---|---|---|
1 | aaaaa | 1 | タイトル | 概要欄 | null | (データ) | 日付(タイムスタンプ) | 日付(タイムスタンプ) | 1(チャンネルのkey id) |
※動画のインサイト(再生回数とか)は別テーブルで管理します。
これで動画のアップロード機能が実装できました。
また、動画情報を(動画ページで)取得する際は動画のIDを指定して、
const { data: resMovData, error: resMovError } = await useFetch(
`http://127.0.0.1:8000/movies/${movieID}`,
{
method: "GET",
headers: {
"content-type": "application/json",
},
}
);
とAPIを呼び出します。API側はその動画IDに該当するデータを返せばいいので、
# エンドポイント
@router.get("/movies/{movie_id}", response_model=y_schemas.Movie)
def get_movie(movie_id: str, db: Session = Depends(get_db)):
movie = y_crud.get_movie(db, movie_id=movie_id)
return movie
def get_movie(db: Session, movie_id: str):
return db.query(y_model.Movie).filter(y_model.Movie.movie_id == movie_id).first()
で取得できます。ほかにもAPIは設定しているので気になる方はソースをご覧ください!3
感想・まとめ
どうだったでしょうか?YouTubeのよくみる3ページをクローンしてみましたが、基本的なUI部分はほぼBulmaを使ったので自分でcssを書く機会はめちゃくちゃ少なくなりました。
後半のAPIの作成やAPIのデータの受け取り・送信はちょっと難しいと感じる方も多いかと思います。
今後もこのようなクローンを作って解説したり、初心者に向けたNuxt3,Pythonなどの解説をしますのでよかったらフォローやお気に入りをお願いします!
同じような内容ですがブログもやっているのでほかの解説を見たい方はぜひいらしゃってください。