Agent Grow Tech Notes
🐈

TypeScript & GraphQL でToDoアプリを開発する #7

に公開

⬅️前回の記事はこちら

https://zenn.dev/agent_grow/articles/939d9197dd87c5

⭐️日本語/英語切り替え機能を実装

frontend/に国際化(i18n: internationalization)パッケージをインストール

% npm install @nuxtjs/i18n

frontend/にi18n/locales/en.jsonを作成

en.json
{
    "title": "To-Do",
    "new_task": "Enter a new task",
    "add": "Add",
    "edit": "Edit",
    "delete": "Delete",
    "save": "Save",
    "cancel": "Cancel",
    "loading": "Loading...",
    "error": "Error"
}

frontend/i18n/locales/にja.jsonを作成

ja.json
{
    "title": "やることリスト",
    "new_task": "新しいタスクを入力",
    "add": "追加",
    "edit": "編集",
    "delete": "削除",
    "save": "保存",
    "cancel": "キャンセル",
    "loading": "読み込み中...",
    "error": "エラー"
}

frontend/i18n/にi18n.config.tsを作成

i18n.config.ts
import { createI18n } from 'vue-i18n'

export default defineI18nConfig(() => ({
    legacy: false,
    globalInjection: true,
    locale: 'ja',
    fallbackLocale: 'ja'
}))

nuxt.config.tsに modules: i18n: を追記

nuxt.config.ts
  modules: ['@nuxtjs/i18n'],

  i18n: {
    locales: [
      { code: 'ja', iso: 'ja-JP', file: 'ja.json', name: '日本語' },
      { code: 'en', iso: 'en-US', file: 'en.json', name: 'English' },
    ],
    lazy: true,
    langDir: 'locales/',
    defaultLocale: 'ja',
    vueI18n: './i18n.config.ts',
  },

index.vueを修正

index.vue
  <template>
    <!-- メインの表示領域 -->
    <v-container class="py-10 todo-container">

      <!-- タスク追加フォーム -->
      <v-card elevation="4" class="pa-4 mb-4">
        <v-card-title class="d-flex justify-space-between align-center text-h6">   // 【修正】スタイルの適用
          {{ t('title') }}   // 【修正】多言語対応
          <div class="d-flex align-center gap-2">
            <v-btn icon @click="toggleLocale">   // 【修正】toggleLocaleの追記
                <v-icon>{{ locale === 'ja' ? 'mdi-earth' : 'mdi-translate' }}</v-icon>
            </v-btn>
            <v-btn icon @click="toggleTheme">
                <v-icon>{{ theme.global.name.value === 'dark' ? 'mdi-white-balance-sunny' : 'mdi-weather-night' }}</v-icon>
            </v-btn>
          </div>
        </v-card-title>
        <v-form @submit.prevent="createTodo" class="d-flex gap-2 mt-2">
          <v-text-field
            v-model="newTitle"
            :label="t('new_task')"
            dense
            hide-details
            variant="outlined"
          />
          <v-btn color="primary" type="submit">{{ t('add') }}</v-btn>   // 【修正】多言語対応
        </v-form>
      </v-card>
  
      <!-- 読み込み中 / エラー表示 -->
      <v-alert v-if="pending" type="info" variant="text" class="mb-4">{{ t('loading') }}</v-alert>   // 【修正】多言語対応
      <v-alert v-else-if="error" type="error" class="mb-4">{{ t('error') }}: {{ error.message }}</v-alert>   // 【修正】多言語対応
  
      <!-- タスクリストの表示 -->
      <v-card
        v-for="todo in todos"
        :key="todo.id"
        elevation="2"
        class="pa-3 mb-3"
      >
        <div class="d-flex align-center justify-space-between w-100">

          <!-- 編集モードではないときの表示 -->
          <template v-if="editingId !== todo.id">
            <div class="d-flex align-center gap-3">

              <!-- 完了状態のチェックボックス -->
              <v-checkbox
                :model-value="todo.completed"
                @update:model-value="() => toggleCompleted(todo)"
                density="compact"
                hide-details
              />

              <!-- タイトル(完了時は取り消し線) -->
              <span :class="{ 'text-decoration-line-through': todo.completed }">
                {{ todo.title }}
              </span>
            </div>
  
            <!-- 編集 / 削除 メニュー -->
            <v-menu
              :opened="menuOpenId === todo.id"
              @update:opened="(val: boolean) => (menuOpenId = val ? todo.id : null)"
              location="bottom"
            >
              <template #activator="{ props }">
                <v-btn icon v-bind="props"><v-icon>mdi-dots-horizontal</v-icon></v-btn>
              </template>
              <v-list>
                <v-list-item @click="startEdit(todo)">
                  <v-list-item-title>{{ t('edit') }}</v-list-item-title>   // 【修正】多言語対応
                </v-list-item>
                <v-list-item @click="deleteTodo(todo.id)">
                  <v-list-item-title>{{ t('delete') }}</v-list-item-title>   // 【修正】多言語対応
                </v-list-item>
              </v-list>
            </v-menu>
          </template>
  
          <!-- 編集モードのときの表示 -->
          <template v-else>
            <v-text-field
              v-model="editedTitle"
              density="compact"
              hide-details
              class="flex-grow-1"
            />
            <div class="d-flex gap-2">
              <v-btn size="small" color="primary" @click="saveEdit(todo.id)">{{ t('save') }}</v-btn>   // 【修正】多言語対応
              <v-btn size="small" color="grey" @click="cancelEdit">{{ t('cancel') }}</v-btn>   // 【修正】多言語対応
            </div>
          </template>
        </div>
      </v-card>
    </v-container>
  </template>

<script setup lang="ts">
import { ref } from 'vue'
import { useTodos } from '@/composables/useTodos'
import { CREATE_TODO, UPDATE_TODO, DELETE_TODO } from '@/graphql/mutations'
import { useNuxtApp } from '#app'
import { useTheme } from 'vuetify'
import { useI18n } from 'vue-i18n'   // 【修正】多言語対応

const theme = useTheme()
const { locale, t } = useI18n()   // 【修正】多言語対応

const toggleTheme = () => {
    theme.global.name.value = theme.global.name.value === 'dark' ? 'light' : 'dark'
}

const toggleLocale = () => {   // 【修正】多言語対応
    locale.value = locale.value === 'ja' ? 'en' : 'ja'
}


/**
 * Todoの型定義
 */
interface Todo {
  id: number
  title: string
  completed: boolean
}

/**
 * Composableから状態取得
 */
const { todos, pending, error, refresh } = useTodos()
const { $apollo } = useNuxtApp()

/**
 * 入力および編集中の状態管理
 */
const newTitle = ref('')
const editedTitle = ref('')
const editingId = ref<number | null>(null)
const menuOpenId = ref<number | null>(null)

/**
 * タスクを新規作成する処理
 */
const createTodo = async () => {
  if (!newTitle.value.trim()) return
  await $apollo.mutate({
    mutation: CREATE_TODO,
    variables: { title: newTitle.value }
  })
  newTitle.value = ''
  refresh()
}

/**
 * 完了状態を切り替える処理
 */
const toggleCompleted = async (todo: Todo) => {
  await $apollo.mutate({
    mutation: UPDATE_TODO,
    variables: { id: todo.id, completed: !todo.completed }
  })
  refresh()
}

/**
 * タスクを削除する処理
 */
const deleteTodo = async (id: number) => {
  await $apollo.mutate({
    mutation: DELETE_TODO,
    variables: { id }
  })
  refresh()
  closeMenu()
}

/**
 * 編集モードに切り替える処理
 */
const startEdit = (todo: Todo) => {
  editingId.value = todo.id
  editedTitle.value = todo.title
  closeMenu()
}

/**
 * 編集をキャンセルする処理
 */
const cancelEdit = () => {
  editingId.value = null
  editedTitle.value = ''
}

/**
 * 編集内容を保存する処理
 */
const saveEdit = async (id: number) => {
  if (!editedTitle.value.trim()) return
  await $apollo.mutate({
    mutation: UPDATE_TODO,
    variables: { id, title: editedTitle.value }
  })
  editingId.value = null
  editedTitle.value = ''
  refresh()
}

/**
 * メニューを閉じる処理
 */
const closeMenu = () => {
  menuOpenId.value = null
}
</script>

<style scoped>
/* 完了タスクの打ち消し線 */
.text-decoration-line-through {
  text-decoration: line-through;
}

/* コンポーネント間の余白調整 */
.gap-2 {
  gap: 8px;
}

.gap-3 {
  gap: 12px;
}

/* 中央寄せ・幅制限 */
.todo-container {
  max-width: 600px;
  margin: 0 auto;
}
</style>

動作確認

🚀日本語/英語 切り替え機能の実装が完了!!!

おわりに

日本語/英語 切り替え機能の実装 お疲れさまでした。
また、全7記事にわたる「TypeScript & GraphQL でToDoアプリを開発する」にお付き合いいただきありがとうございました。
ToDoアプリというシンプルな題材を通して、各技術の基本的な使い方を習得できたと思います。

Agent Grow Tech Notes
Agent Grow Tech Notes

Discussion