Nuxt3で料理レシピブックサイトを構築
構築環境
【デプロイ環境】
認証関連ないしDBはSupabaseを活用予定
【FE:Nuxt】
APIの作成はこっちでやる
【BE:Supabase】
認証とDBはこっちで行う予定
Storybookの導入を検討しているがコンポーネントを真剣に開発するなら必要だと思うけど、意識し出すと開発のスピード感は落ちそう
AIにコンポーネントを食わせてstories.tsを生成するのは手かもしれない
ESLint 導入
インストール
@nuxt/eslintモジュールを追加
npx nuxi module add eslint
アプリケーションを実行するとeslint.config.mjs が自動生成される
必要に応じてカスタマイズできるが今回は特にいじらない
npm run dev
ESLint StylisticをNuxtに設定する
@nuxt/eslint はアプリケーション起動時に自動で追加される
export default defineNuxtConfig({
modules: [
'@nuxt/eslint'
],
+ eslint: {
+ config: {
+ stylistic: true
+ }
+ }
})
npm scriptsにlintコマンドを設定
package.jsonにeslintコマンドを設定
{
"scripts": {
...
"lint": "eslint .",
"lint:fix": "eslint . --fix",
...
},
}
app.vue に試しに実行
npm run lint app.vue
VScodeの設定
保存時の自動フォーマットが実行されるように追加する
※他のプロジェクトに影響が出ないように設定はワークスペースを対象に行う
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"recommendations": [
"dbaeumer.vscode-eslint"
]
}
VScodeの設定もGitの変更に含めておくと他の人にも同じ環境で作業してもらえる
Vuetify導入
インストール
この環境ではどのコマンド使ってライブラリ導入しても良い
npm i -D vuetify vite-plugin-vuetify
npm i @mdi/font
yarn add sass
Config導入
NuxtでVuetifyを利用するための設定
- ビルドはトランスパイルにVuetifyを追加
- モジュールには、autoInportを許可できるように設定
- ViteのテンプレートでアセットURLを参照できるよう設定
+ import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
+ build: {
+ transpile: ['vuetify'],
+ },
+ plugins: [
+ '~/plugins/vuetify.ts',
+ ],
routeRules: {
// prerender index route by default
'/': { prerender: true },
},
modules: ['@nuxt/eslint',
+ (_options, nuxt) => {
+ nuxt.hooks.hook('vite:extendConfig', (config) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ config.plugins.push(vuetify({ autoImport: true }))
+ })
+ },
],
eslint: {
config: {
stylistic: true,
},
},
+ vite: {
+ vue: {
+ template: {
+ transformAssetUrls,
+ },
+ },
+ },
+ css: [
+ 'vuetify/lib/styles/main.sass',
+ '@mdi/font/css/materialdesignicons.css',
+ ],
})
Vuetifyを呼び出すPluginsの作成
~/plugins/vuetify.ts を作成する
デフォルト設定だったり全体に関わる設定はここで行う
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
export default defineNuxtPlugin(nuxtApp => {
const vuetify = createVuetify({
components,
directives,
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi
}
}
})
nuxtApp.vueApp.use(vuetify)
})
Vuetifyで上下中央寄せにする方法
毎度探してるから
Storybookを導入
開発の初速を落としたとしても最適なコンポーネント開発を行っていけるなら導入する価値あり
Vuetifyの設定を切り出す
utils/vuetify.ts を作成する
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
const vuetify = createVuetify({
components,
directives,
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi,
},
},
})
export default vuetify
plugins/vuetify.ts を更新する
+import vuetify from '~/utils/vuetify'
-import { createVuetify } from 'vuetify'
-import * as components from 'vuetify/components'
-import * as directives from 'vuetify/directives'
-import { aliases, mdi } from 'vuetify/iconsets/mdi'
-import '@mdi/font/css/materialdesignicons.css'
export default defineNuxtPlugin((nuxtApp) => {
- const vuetify = createVuetify({
- components,
- directives,
- icons: {
- defaultSet: 'mdi',
- aliases,
- sets: {
- mdi,
- },
- },
- })
nuxtApp.vueApp.use(vuetify)
})
インストール
npx storybook@7 init
起動すると シンタックスエラーが発生します。
initで vite.config.jsが存在しないため vueファイルを読み込めないため起きます。(空ディレクトリで initする場合は、発生しません)
viteの設定追加
vite.config.js の追加
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
})
設定ファイルの書き換え
自動生成された**.storybook/**にある設定ファイルの書き換え
.storybook/main.js を .storybook/main.tsに修正し下記の通りに書き換える。
import type { StorybookConfig } from "@storybook/vue3-vite";
const config: StorybookConfig = {
stories: ["../**/*.mdx", "../**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/vue3-vite",
options: {},
},
docs: {
autodocs: "tag",
},
};
export default config;
.storybook/preview.jsを .storybook/preview.tsに修正し下記の通りに書き換える。
import type { Preview } from "@storybook/vue3";
import { setup } from '@storybook/vue3'
// Styles
import vuetify from "../utils/vuetify";
setup((app) => {
if (app) {
app.use(vuetify)
}
})
export const decorators = [
(story: any) => ({
components: { story },
template: '<v-app><story /></v-app>',
}),
]
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
エイリアスを利用
+ import path from 'path'
const config: StorybookConfig = {
...
+ viteFinal(config) {
+ if (config.resolve) {
+ config.resolve.alias = {
+ ...config.resolve.alias,
+ '@': path.resolve(__dirname, '../'),
+ '~': path.resolve(__dirname, '../'),
+ }
+ }
+ return config
+ },
...
}
export default config
Storybookの活用
Vuetifyのコンポーネント(atomic)を全てStorybookで管理する必要はないが、多様するVuetifyのコンポーネントをラップしたコンポーネントを作成しプロパティにデフォルト値を設定、Storybookで管理しておくと便利
参考ソース
components/TextField.vue
<template>
<v-text-field
variant="outlined"
color="primary"
single-line
class="my-3"
/>
</template>
components/TextField.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import TextField from './TextField.vue'
type Story = StoryObj<typeof Object>
const meta: Meta<typeof Object> = {
title: 'Components/Atomic/TextField',
component: TextField,
tags: ['autodocs'],
argTypes: {
'active': { control: { type: 'boolean' }, description: 'アイテムのアクティブ状態を制御、ハイライトするために使用される' },
'append-icon': { control: { type: 'text' }, description: 'アイテムの末尾にアイコンを追加' },
'append-inner-icon': { control: { type: 'text' }, description: 'アイテムの末尾にアイコンを追加' },
'autofocus': { control: { type: 'boolean' }, description: '自動フォーカス' },
'base-color': { control: { type: 'text' }, description: 'ベースカラー' },
'bg-color': { control: { type: 'text' }, description: '背景色' },
'center-affix': { control: { type: 'boolean' }, description: 'アイコンを中央揃え' },
'clearable': { control: { type: 'boolean' }, description: 'クリアボタンを表示' },
'clear-icon': { control: { type: 'text' }, description: 'クリアアイコン' },
'color': { control: { type: 'text' }, description: 'テキストカラー' },
'counter': { control: { type: 'boolean' }, description: 'カウンターを表示' },
'counter-value': { control: { type: 'number' }, description: 'カウンターの値' },
'density': { control: { type: 'select' }, options: ['default', 'comfortable', 'compact'], description: '手動でダーティー状態のスタイリングを適用する' },
'direction': { control: { type: 'select' }, options: ['horizontal', 'vertical'], description: '入力の方向を変更します。' },
'dirty': { control: { type: 'boolean' }, description: 'ダーティ状態' },
'disabled': { control: { type: 'boolean' }, description: '無効状態' },
'error': { control: { type: 'boolean' }, description: 'エラー状態' },
'error-messages': { control: { type: 'text' }, description: 'エラーメッセージ' },
'flat': { control: { type: 'boolean' }, description: 'フラットスタイル' },
'forcused': { control: { type: 'boolean' }, description: 'フォーカス状態' },
'hide-details': { control: { type: 'select' }, options: ['auto', true, false], description: 'ヒントとバリデーションエラーを非表示にします。auto に設定すると、表示すべきメッセージ (ヒント、エラーメッセージ、カウンタ値など) がある場合にのみメッセージが表示されます。' },
'hide-spin-buttons': { control: { type: 'boolean' }, description: 'タイプが数字に設定されている場合、入力上のスピンボタンを非表示にする。' },
'hint': { control: { type: 'text' }, description: 'ヒント' },
'id': { control: { type: 'text' }, description: 'ID' },
'label': { control: { type: 'text' }, description: 'ラベル' },
'loading': { control: { type: 'boolean' }, description: 'ローディング状態' },
'max-errors': { control: { type: 'number' }, description: 'エラーメッセージの最大数' },
'max-width': { control: { type: 'text' }, description: '最大幅' },
'messages': { control: { type: 'text' }, description: 'メッセージ' },
'min-width': { control: { type: 'text' }, description: '最小幅' },
'model-value': { control: { type: 'text' }, description: 'モデル値' },
'name': { control: { type: 'text' }, description: '名前' },
'persistent-clear': { control: { type: 'boolean' }, description: '入力がダーティな場合は常にクリア可能なアイコンを表示します (デフォルトではホバー時にのみ表示されます)。' },
'persistent-counter': { control: { type: 'boolean' }, description: 'カウンターを常に表示' },
'persistent-hint': { control: { type: 'boolean' }, description: 'ヒントを常に表示' },
'persistent-placeholder': { control: { type: 'boolean' }, description: 'プレースホルダーを常に表示' },
'placeholder': { control: { type: 'text' }, description: 'プレースホルダー' },
'prefix': { control: { type: 'text' }, description: 'プレフィックス' },
'prepend-icon': { control: { type: 'text' }, description: 'アイテムの先頭にアイコンを追加(入力の外側)' },
'prepend-inner-icon': { control: { type: 'text' }, description: 'アイテムの先頭にアイコンを追加(入力の内側)' },
'readonly': { control: { type: 'boolean' }, description: '読み取り専用' },
'reverse': { control: { type: 'boolean' }, description: '入力方向を反転します。' },
'role': { control: { type: 'text' }, description: 'ロール' },
'rounded': { control: { type: 'boolean' }, description: '境界の半径を追加します' },
'rules': { control: { type: 'text' }, description: 'ルール' },
'single-line': { control: { type: 'boolean' }, description: 'シングルライン' },
'suffix': { control: { type: 'text' }, description: 'サフィックス' },
'theme': { control: { type: 'text' }, description: 'このコンポーネントとそのすべての子のテーマを指定します。' },
'tile': { control: { type: 'boolean' }, description: 'border-radiusを削除します' },
'type': { control: { type: 'select' }, options: ['text', 'password', 'email', 'number', 'tel', 'url'], description: '入力タイプ' },
'validate-on': { control: { type: 'select' }, options: ['blur', 'input', 'submit', 'blur lazy', 'input lazy', 'submit lazy', 'lazy blur', 'lazy input', 'lazy submit', 'lazy'], description: 'バリデーションをトリガーするイベント' },
'validation-value': { control: { type: 'text' }, description: 'バリデーション値' },
'variant': { control: { type: 'select' }, options: ['outlined', 'filled'] },
'width': { control: { type: 'text' }, description: '幅' },
},
render: args => ({
components: { TextField },
setup() {
return { args }
},
template: '<TextField v-bind="args" />',
}),
}
export const Default: Story = {
args: {
'active': false,
'append-icon': undefined,
'append-inner-icon': undefined,
'autofocus': false,
'base-color': undefined,
'bg-color': undefined,
'center-affix': undefined,
'clearable': false,
'clear-icon': '$clear',
'color': 'primary',
'counter': false,
'counter-value': undefined,
'density': 'default',
'direction': 'horizontal',
'dirty': false,
'disabled': null,
'error': false,
'error-messages': undefined,
'flat': false,
'forcused': false,
'hide-details': false,
'hide-spin-buttons': false,
'hint': undefined,
'id': undefined,
'label': 'text field',
'loading': false,
'max-errors': 1,
'max-width': undefined,
'messages': undefined,
'min-width': undefined,
'model-value': undefined,
'name': undefined,
'persistent-clear': false,
'persistent-counter': false,
'persistent-hint': false,
'persistent-placeholder': false,
'placeholder': undefined,
'prefix': undefined,
'prepend-icon': undefined,
'prepend-inner-icon': undefined,
'readonly': null,
'reverse': false,
'role': undefined,
'rounded': undefined,
'rules': undefined,
'single-line': false,
'suffix': undefined,
'theme': undefined,
'tile': false,
'type': 'text',
'validate-on': undefined,
'validation-value': undefined,
'variant': 'outlined',
'width': undefined,
},
}
export default meta
Nuxt 3 × Storybook 7で自動インポート
Nuxt3の自動インポート機能がサポートされていないのでこちらで修正する必要がある
StorybookをVercelにデプロイ
Supabaseの導入
GitHubでログイン
Google認証を考慮しましたが、開発の都合でEメール認証にします
Nuxt / Supabaseのパッケージをインストール
npm install @supabase/supabase-js
NuxtにSupabaseの設定を追加
nuxt.config.ts に追記
export default defineNuxtConfig({
...
+ runtimeConfig: {
+ public: {
+ SUPABASE_URL: process.env.SUPABASE_URL,
+ SUPABASE_KEY: process.env.SUPABASE_KEY,
+ },
+ },
...
})
Supabase CLIのインストール
npx supabase login
// Enter -> アクセストークンが自動生成される
npx supabase init
カスタムフックの定義
- サインイン
- サインアウト
- 認証状態の監視
- プロフィール情報の取得
composable/useAuth.ts全容
import type { Session, SupabaseClient } from '@supabase/supabase-js'
import { onMounted, ref, computed } from 'vue'
const useAuth = () => {
const nuxtApp = useNuxtApp()
const supabase = nuxtApp.$supabase as SupabaseClient
const session = ref<Session | null>(null)
const error = ref<string>('')
onMounted(() => {
const { data: authData } = supabase.auth.onAuthStateChange(
(_, newSession) => {
session.value = newSession
},
)
return () => {
authData.subscription.unsubscribe()
}
})
// Emailとパスワードでサインアップ
const signUpWithEmail = async (email: string, password: string) => {
try {
const { error: signInError } = await supabase.auth.signUp({
email,
password,
})
if (signInError) {
error.value = signInError.message
}
}
catch (signInException) {
if (signInException instanceof Error) {
error.value = signInException.message
}
else if (typeof signInException === 'string') {
error.value = signInException
}
else {
console.error('サインアップに失敗しました。')
}
}
}
// Emailとパスワードでサインイン
const signInWithEmail = async (email: string, password: string) => {
try {
const { error: signInError } = await supabase.auth.signInWithPassword({
email,
password,
})
if (signInError) {
error.value = signInError.message
}
}
catch (signInException) {
if (signInException instanceof Error) {
error.value = signInException.message
}
else if (typeof signInException === 'string') {
error.value = signInException
}
else {
console.error('サインインに失敗しました。')
}
}
}
// GitHubアカウントでサインイン
const signInWithGithub = async () => {
try {
const { error: signInError } = await supabase.auth.signInWithOAuth({
provider: 'github',
})
if (signInError) {
error.value = signInError.message
}
}
catch (signInException) {
if (signInException instanceof Error) {
error.value = signInException.message
}
else if (typeof signInException === 'string') {
error.value = signInException
}
else {
console.error('GitHubとの連携に失敗しました。')
}
}
}
const profileFromGithub = computed(() => {
return {
nickName: session.value?.user?.user_metadata?.user_name || '',
avatarUrl: session.value?.user?.user_metadata?.avatar_url || '',
}
})
const signOut = async () => {
await supabase.auth.signOut()
}
return {
session,
error,
profileFromGithub,
signInWithGithub,
signOut,
signUpWithEmail,
signInWithEmail,
}
}
export default useAuth
ローカル環境が立ち上がるかを確認する
CLI起動
npx supabase start
環境変数の変更
NUXT_PUBLIC_SUPABASE_URL=http://localhost:54321
NUXT_PUBLIC_SUPABASE_KEY={upabase start 時に表示される anon key を設定}
Prismaを導入するにあたって
上記記事はNextで行なっているがPrismaの設定とマイグレーション部分までは何でやっても同じなので参考にする
エラー解決
上記記事をそのまま実装しても動かない
理由としては下記の通りでPrismaのマイングレーションが実行される際に使用されるポートが違うので認証が通るがDBに辿り着かないらしい
上の記事で修正すると今度は他に影響出る可能性があるので修正する必要がある
DATABASE_URL="postgresql://postgres[YOUR-ReferenceID]:[YOUR-PASSWORD]@aws-0-ap-northeast-1.pooler.supabase.com:6543/postgres"
DIRECT_URL="postgresql://postgres[YOUR-ReferenceID]:[YOUR-PASSWORD]@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL") //この部分を追加した
}
VScode設定
Prisma migration
エイリアスの設定
コンポーネントへのパスを短くするため
+import path from "path";
export default defineNuxtConfig({
vite: {
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./"),
+ },
+ },
},
})
composables
課題
Nuxtはcomposablesで状態管理を行いPiniaの必要がないかを検討
Piniaを使う場合はcomposablesは不要か、検討する必要がある
調査
Nuxtではpiniaはcomposablesディレクトリで管理するらしい
Piniaの実装
yarn add pinia @pinia/nuxt
Nuxt設定ファイルに追加
export default defineNuxtConfig({
...
+ modules: ['@pinia/nuxt'],
...
})
分割代入するとリアクティブが切れる件の解決方法
defineStore はオブジェクトを返すが、分割代入は禁止されているので
storeToRefs() を使い分割代入できるようにする
画面のルーティング変更
Nuxt は内部で Vue Router を使用してウェブアプリケーション内でルートを作成するためのファイルに基づいたルーティングを提供してくれる
ディレクトリ構造
起動時の表示を変更する事なく、ディレクトリ構造を変更する
.
├── app.vue
└── pages
└── index.vue
app.vueの変更
NuxtPageで子ページを読み込むように変更
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
pages/index.vue
修正前に表示させていたコンテンツをここで記述する
<template>
<NuxtWelcome />
</template>
バリデーションの設定
Nuxt3 pagesとlayoutsについて
Nuxtのページ追加のルーティングなど
Supabase 認証
supabaseのメール認証は制限が厳しいため、別プロバイダを活用することを推奨
認証の実装参考サイト
認証アーキテクチャ
Nuxt3 + middleware + supabase + Google認証
AtomicDesign
ドキュメント作成(図の作成)最強システム
AIとのコンビネーションが間違えなく最強
AIが搭載されているが、自分は他の事もAIにさせつつMermaidで図を作るので搭載されているAIは使わない