Open30

Nuxt3で料理レシピブックサイトを構築

彼方からの呼び声彼方からの呼び声

構築環境

【デプロイ環境】

https://vercel.com/docs/frameworks/nuxt
認証関連ないしDBはSupabaseを活用予定

【FE:Nuxt】

https://nuxt.com/
APIの作成はこっちでやる

【BE:Supabase】

https://supabase.com/
認証とDBはこっちで行う予定

彼方からの呼び声彼方からの呼び声

Storybookの導入を検討しているがコンポーネントを真剣に開発するなら必要だと思うけど、意識し出すと開発のスピード感は落ちそう
AIにコンポーネントを食わせてstories.tsを生成するのは手かもしれない

彼方からの呼び声彼方からの呼び声

ESLint 導入

https://qiita.com/pistachiyoda/items/2d27d394d31e994d3878

インストール

@nuxt/eslintモジュールを追加

npx nuxi module add eslint

アプリケーションを実行するとeslint.config.mjs が自動生成される
必要に応じてカスタマイズできるが今回は特にいじらない

npm run dev

ESLint StylisticをNuxtに設定する

@nuxt/eslint はアプリケーション起動時に自動で追加される

nuxt.config.ts
  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の変更に含めておくと他の人にも同じ環境で作業してもらえる

彼方からの呼び声彼方からの呼び声

VScodeでESLintが動かなくなる不具合があるが、PCの再起動では治らず、プロジェクトを閉じて新しいウィンドウからプロジェクトを開き直すと治る

彼方からの呼び声彼方からの呼び声

Vuetify導入

https://zenn.dev/hamworks/articles/article7-nuxt-vuetify-storybook

インストール

この環境ではどのコマンド使ってライブラリ導入しても良い

npm i -D vuetify vite-plugin-vuetify
npm i @mdi/font
yarn add sass

Config導入

NuxtでVuetifyを利用するための設定

  • ビルドはトランスパイルにVuetifyを追加
  • モジュールには、autoInportを許可できるように設定
  • ViteのテンプレートでアセットURLを参照できるよう設定
nuxt.config.ts
+ 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 を作成する
デフォルト設定だったり全体に関わる設定はここで行う

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)
})

彼方からの呼び声彼方からの呼び声

Storybookを導入

https://storybook.js.org/

https://zenn.dev/hamworks/articles/article7-nuxt-vuetify-storybook

https://zenn.dev/sa2knight/books/storybook-7-with-vue-3

開発の初速を落としたとしても最適なコンポーネント開発を行っていけるなら導入する価値あり

Vuetifyの設定を切り出す

utils/vuetify.ts を作成する

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 を更新する

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 の追加

vite.config.ts
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に修正し下記の通りに書き換える。

.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に修正し下記の通りに書き換える。

.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;
彼方からの呼び声彼方からの呼び声

エイリアスを利用

.storybook/main.ts
+ 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

彼方からの呼び声彼方からの呼び声

Supabaseの導入

GitHubでログイン
https://supabase.com/

https://zenn.dev/chot/articles/ddd2844ad3ae61#githubのプロバイダ認証

Google認証を考慮しましたが、開発の都合でEメール認証にします

Nuxt / Supabaseのパッケージをインストール

npm install @supabase/supabase-js

NuxtにSupabaseの設定を追加

nuxt.config.ts に追記

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 -> アクセストークンが自動生成される

AccessToken

npx supabase init

カスタムフックの定義

  • サインイン
  • サインアウト
  • 認証状態の監視
  • プロフィール情報の取得
composable/useAuth.ts全容
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

彼方からの呼び声彼方からの呼び声

Prismaを導入するにあたって

https://qiita.com/Naoya_pro/items/a2eb6bd5b076a5ae06df
上記記事はNextで行なっているがPrismaの設定とマイグレーション部分までは何でやっても同じなので参考にする

エラー解決

上記記事をそのまま実装しても動かない
理由としては下記の通りでPrismaのマイングレーションが実行される際に使用されるポートが違うので認証が通るがDBに辿り着かないらしい
https://nanoha-code.com/supabase-prisma-migrate-error-handling/

上の記事で修正すると今度は他に影響出る可能性があるので修正する必要がある
https://arc.net/l/quote/voztzxmw

env
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"
schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")  //この部分を追加した
}
彼方からの呼び声彼方からの呼び声

エイリアスの設定

コンポーネントへのパスを短くするため

+import path from "path";
  export default defineNuxtConfig({
    vite: {
+     resolve: {
+       alias: {
+         "@": path.resolve(__dirname, "./"),
+       },
+     },
    },
  })
彼方からの呼び声彼方からの呼び声

composables

https://zenn.dev/peraichi_blog/articles/1504fb916d9d88

https://www.memory-lovers.blog/entry/2022/06/04/180000

https://pinia.vuejs.org/

課題

Nuxtはcomposablesで状態管理を行いPiniaの必要がないかを検討
Piniaを使う場合はcomposablesは不要か、検討する必要がある

調査

https://qiita.com/tatsuki-tsuchiyama/items/b4ef100777954ea58821
Nuxtではpiniaはcomposablesディレクトリで管理するらしい

彼方からの呼び声彼方からの呼び声

Piniaの実装

yarn add pinia @pinia/nuxt

Nuxt設定ファイルに追加

nuxt.config.ts
  export default defineNuxtConfig({
    ...
+   modules: ['@pinia/nuxt'],
    ...
  })
彼方からの呼び声彼方からの呼び声

画面のルーティング変更

https://qiita.com/MS-0610/items/d58e90c0b520b68bfd57

https://zenn.dev/mm67/articles/nuxt3-pages-directory

Nuxt は内部で Vue Router を使用してウェブアプリケーション内でルートを作成するためのファイルに基づいたルーティングを提供してくれる

ディレクトリ構造

起動時の表示を変更する事なく、ディレクトリ構造を変更する

.
├── app.vue
└── pages
    └── index.vue

app.vueの変更

NuxtPageで子ページを読み込むように変更

app.vue
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

pages/index.vue

修正前に表示させていたコンテンツをここで記述する

pages/index.vue
<template>
  <NuxtWelcome />
</template>
彼方からの呼び声彼方からの呼び声

UI/UXの検討

個人開発で意識したUI/UXのデザインについて掲載していく

彼方からの呼び声彼方からの呼び声

ジャンルやカテゴリを検索する要素

コンテンツを検索ものではなく、コンテンツに付与されたTAGを対象に検索を行うコンポーネントを作成
普通の検索と分離させる必要があるのかと思われるが、ジャンルやカテゴリが増えた際に普通の検索と併用させると煩雑になる
コンテンツ(母体)に対して行う検索のコンポーネントは極力シンプルにしておきたい

こだわり

  • スマフォとPCで使用感が変わらないようにデザイン
  • スマフォファーストのデザインにしている