feat(ux): dark/light theme + en/ja i18n + user preferences

UX全体にテーマと言語設定を追加。

- lib/db/migrations/004_user_prefs.sql: users に theme/locale 列を追加(既定 dark/en)
- lib/i18n/: dictionary(en/ja) + I18nProvider(クライアントcontext: locale/theme/t/setLocale/setTheme) + server.ts(SSR用 getLocale/getTheme/translate) + constants.ts
- app/layout.tsx: theme/locale Cookie を読み <html class/lang> をSSR、I18nProvider でラップ(フラッシュなし)
- tailwind darkMode:'class' + 全画面に dark: バリアントを一括付与(Nodeスクリプト lookbehind で安全に変換)
- components/layout/ThemeToggle: 即時クラス切替+Cookie+永続化
- app/profile: テーマ/言語セレクタ、chrome翻訳(Header/ProjectNav/login/dashboard/profile)
- PATCH /api/users/me: theme/locale を受理(バリデーション→400)、Cookieを設定
- E2E: locale=ja storageState で既存JAアサーションを維持、theme-i18n.spec で既定en/darkと切替を検証
This commit is contained in:
Ken Yasue
2026-06-25 11:37:44 +02:00
parent 6a134a29bd
commit 921cdc457d
74 changed files with 1092 additions and 279 deletions

99
lib/i18n/I18nProvider.tsx Normal file
View File

@ -0,0 +1,99 @@
'use client';
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useRouter } from 'next/navigation';
import { dictionary, type MessageKey } from '@/lib/i18n/dictionary';
import { PREF_MAX_AGE } from '@/lib/i18n/constants';
import type { Locale, Theme } from '@/lib/types';
interface I18nContextValue {
locale: Locale;
theme: Theme;
t: (key: MessageKey) => string;
setLocale: (locale: Locale) => void;
setTheme: (theme: Theme) => Promise<void>;
}
const I18nContext = createContext<I18nContextValue | null>(null);
/**
* 言語とテーマのクライアント状態を提供するコンテキスト。
* - locale: 辞書の切り替え(t)と <html lang> の更新
* - setLocale/setTheme: Cookie を即時設定し /api/users/me で永続化、router.refresh でSSR再描画
*/
export function I18nProvider({
initialLocale,
initialTheme,
children,
}: {
initialLocale: Locale;
initialTheme: Theme;
children: ReactNode;
}) {
const router = useRouter();
const [locale, setLocaleState] = useState<Locale>(initialLocale);
const [theme, setThemeState] = useState<Theme>(initialTheme);
const t = useCallback(
(key: MessageKey) => dictionary[locale][key] ?? dictionary.en[key] ?? key,
[locale]
);
const persist = useCallback(async (body: Record<string, unknown>) => {
try {
await fetch('/api/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (error) {
// 永続化失敗時もローカルCookieで動作継続するが、ログは残す
console.warn('Failed to persist user prefs:', error);
}
}, []);
const setLocale = useCallback(
(next: Locale) => {
setLocaleState(next);
document.cookie = `locale=${next}; path=/; max-age=${PREF_MAX_AGE}; samesite=lax`;
void persist({ locale: next });
router.refresh();
},
[persist, router]
);
const setTheme = useCallback(
async (next: Theme) => {
setThemeState(next);
const root = document.documentElement;
if (next === 'dark') root.classList.add('dark');
else root.classList.remove('dark');
root.style.colorScheme = next;
document.cookie = `theme=${next}; path=/; max-age=${PREF_MAX_AGE}; samesite=lax`;
await persist({ theme: next });
},
[persist]
);
const value = useMemo<I18nContextValue>(
() => ({ locale, theme, t, setLocale, setTheme }),
[locale, theme, t, setLocale, setTheme]
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error('useI18n must be used within I18nProvider');
}
return ctx;
}

10
lib/i18n/constants.ts Normal file
View File

@ -0,0 +1,10 @@
import type { Locale, Theme } from '@/lib/types';
/** テーマ/言語のプリフ Cookie の有効期限(1年)。 */
export const PREF_MAX_AGE = 60 * 60 * 24 * 365;
/** 許容されるテーマ。 */
export const VALID_THEMES: Theme[] = ['dark', 'light'];
/** 許容される言語。 */
export const VALID_LOCALES: Locale[] = ['en', 'ja'];

172
lib/i18n/dictionary.ts Normal file
View File

@ -0,0 +1,172 @@
import type { Locale } from '@/lib/types';
/**
* 多言語辞書。en/ja の両方に同じキーを定義する。
* 共通クロム(ヘッダー/ナビ)、認証、プロフィール/設定、ページタイトル、
* 共通アクションラベルをカバーする。機能画面の本文は漸次追加する。
*/
const en = {
'app.name': 'Groupware',
'nav.dashboard': 'Dashboard',
'nav.overview': 'Overview',
'nav.board': 'Board',
'nav.notes': 'Notes',
'nav.chat': 'Chat',
'nav.todos': 'ToDo',
'nav.files': 'Files',
'nav.calendar': 'Calendar',
'nav.milestones': 'Milestones',
'nav.meetings': 'Meetings',
'nav.search': 'Search',
'nav.members': 'Members',
'nav.activity': 'Activity',
'nav.settings': 'Settings',
'header.logout': 'Logout',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.create': 'Create',
'common.delete': 'Delete',
'common.submit': 'Submit',
'common.loading': 'Loading...',
'common.back': 'Back',
'common.edit': 'Edit',
'theme.dark': 'Dark',
'theme.light': 'Light',
'language.english': 'English',
'language.japanese': '日本語',
'auth.login': 'Login',
'auth.register': 'Register',
'auth.displayName': 'Display name',
'auth.email': 'Email',
'auth.password': 'Password',
'auth.registerHere': 'New here? Register',
'auth.registerButton': 'Register',
'auth.loginButton': 'Login',
'auth.backToLogin': 'Back to login',
'auth.processing': 'Processing...',
'auth.failed': 'Operation failed',
'auth.projectName': 'Project name',
'auth.newProject': 'New project',
'project.description': 'Description (optional)',
'project.creating': 'Creating...',
'project.createFailed': 'Failed to create project',
'profile.title': 'Profile',
'profile.displayName': 'Display name',
'profile.email': 'Email',
'profile.avatarUrl': 'Avatar image URL',
'profile.saved': 'Profile updated',
'profile.backToDashboard': 'Back to dashboard',
'profile.role': 'Role',
'profile.theme': 'Theme',
'profile.language': 'Language',
'page.dashboard': 'Dashboard',
'page.board': 'Board',
'page.notes': 'Notes',
'page.chat': 'Chat',
'page.todos': 'ToDo / Kanban',
'page.files': 'Files',
'page.calendar': 'Calendar',
'page.milestones': 'Milestones',
'page.meetings': 'Meetings',
'page.search': 'Search',
'page.members': 'Members',
'page.activity': 'Activity',
'page.settings': 'Settings',
'page.profile': 'Profile',
'page.login': 'Login',
'dash.projects': 'Joined projects',
'dash.unreadNotifications': 'Unread notifications',
'dash.incompleteTodos': 'Incomplete ToDo',
'dash.overdueTasks': 'Overdue tasks',
'dash.upcomingMeetings': 'Upcoming meetings',
'dash.recentActivity': 'Recent activity',
'dash.empty': 'None',
'dash.noUnread': 'No unread',
'dash.openNotifications': 'Open notifications',
'dash.due': 'Due',
};
const ja: typeof en = {
'app.name': 'シンプルグループウェア',
'nav.dashboard': 'ダッシュボード',
'nav.overview': '概要',
'nav.board': '掲示板',
'nav.notes': 'メモ',
'nav.chat': 'チャット',
'nav.todos': 'ToDo',
'nav.files': 'ファイル',
'nav.calendar': 'カレンダー',
'nav.milestones': 'マイルストーン',
'nav.meetings': 'ミーティング',
'nav.search': '検索',
'nav.members': 'メンバー',
'nav.activity': 'アクティビティ',
'nav.settings': '設定',
'header.logout': 'ログアウト',
'common.save': '保存',
'common.cancel': 'キャンセル',
'common.create': '作成',
'common.delete': '削除',
'common.submit': '送信',
'common.loading': '読み込み中...',
'common.back': '戻る',
'common.edit': '編集',
'theme.dark': 'ダーク',
'theme.light': 'ライト',
'language.english': 'English',
'language.japanese': '日本語',
'auth.login': 'ログイン',
'auth.register': '新規登録',
'auth.displayName': '表示名',
'auth.email': 'メールアドレス',
'auth.password': 'パスワード',
'auth.registerHere': '新規登録はこちら',
'auth.registerButton': '登録する',
'auth.loginButton': 'ログイン',
'auth.backToLogin': 'ログイン画面に戻る',
'auth.processing': '処理中...',
'auth.failed': '処理に失敗しました',
'auth.projectName': 'プロジェクト名',
'auth.newProject': '新規プロジェクト',
'project.description': '説明(任意)',
'project.creating': '作成中...',
'project.createFailed': 'プロジェクトの作成に失敗しました',
'profile.title': 'プロフィール',
'profile.displayName': '表示名',
'profile.email': 'メールアドレス',
'profile.avatarUrl': 'アイコン画像URL',
'profile.saved': 'プロフィールを更新しました',
'profile.backToDashboard': 'ダッシュボードへ',
'profile.role': 'ロール',
'profile.theme': 'テーマ',
'profile.language': '言語',
'page.dashboard': 'ダッシュボード',
'page.board': '掲示板',
'page.notes': 'メモ',
'page.chat': 'チャット',
'page.todos': 'ToDo / Kanban',
'page.files': 'ファイル',
'page.calendar': 'カレンダー',
'page.milestones': 'マイルストーン',
'page.meetings': 'ミーティング',
'page.search': '検索',
'page.members': 'メンバー',
'page.activity': 'アクティビティ',
'page.settings': '設定',
'page.profile': 'プロフィール',
'page.login': 'ログイン',
'dash.projects': '参加プロジェクト',
'dash.unreadNotifications': '未読通知',
'dash.incompleteTodos': '未完了ToDo',
'dash.overdueTasks': '期限切れタスク',
'dash.upcomingMeetings': '近日中のミーティング',
'dash.recentActivity': '最近のアクティビティ',
'dash.empty': 'ありません',
'dash.noUnread': '未読はありません',
'dash.openNotifications': '通知一覧を開く',
'dash.due': '期限',
};
export const dictionary: Record<Locale, typeof en> = { en, ja };
export type MessageKey = keyof typeof en;

20
lib/i18n/server.ts Normal file
View File

@ -0,0 +1,20 @@
import { cookies } from 'next/headers';
import { dictionary, type MessageKey } from './dictionary';
import type { Locale, Theme } from '@/lib/types';
/** サーバー側で locale Cookie を読み解決する(既定 en)。 */
export async function getLocale(): Promise<Locale> {
const c = await cookies();
return c.get('locale')?.value === 'ja' ? 'ja' : 'en';
}
/** サーバー側で theme Cookie を読み解決する(既定 dark)。 */
export async function getTheme(): Promise<Theme> {
const c = await cookies();
return c.get('theme')?.value === 'light' ? 'light' : 'dark';
}
/** サーバー側で翻訳文字列を取得する。 */
export function translate(key: MessageKey, locale: Locale): string {
return dictionary[locale][key] ?? dictionary.en[key] ?? key;
}