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:
@ -16,6 +16,8 @@ export function toPublicUser(user: User): PublicUser {
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
theme: user.theme,
|
||||
locale: user.locale,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
|
||||
5
lib/db/migrations/004_user_prefs.sql
Normal file
5
lib/db/migrations/004_user_prefs.sql
Normal file
@ -0,0 +1,5 @@
|
||||
-- 004_user_prefs.sql
|
||||
-- ユーザーごとのテーマ(dark/light)と言語(en/ja)設定を追加。
|
||||
|
||||
ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark';
|
||||
ALTER TABLE users ADD COLUMN locale TEXT NOT NULL DEFAULT 'en';
|
||||
99
lib/i18n/I18nProvider.tsx
Normal file
99
lib/i18n/I18nProvider.tsx
Normal 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
10
lib/i18n/constants.ts
Normal 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
172
lib/i18n/dictionary.ts
Normal 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
20
lib/i18n/server.ts
Normal 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;
|
||||
}
|
||||
@ -8,6 +8,8 @@
|
||||
|
||||
export type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
|
||||
export type UserStatus = 'active' | 'inactive';
|
||||
export type Theme = 'dark' | 'light';
|
||||
export type Locale = 'en' | 'ja';
|
||||
export type ProjectStatus = 'active' | 'on_hold' | 'completed' | 'archived';
|
||||
export type ProjectMemberRole = 'admin' | 'member' | 'guest';
|
||||
export type BoardCategory =
|
||||
@ -48,6 +50,8 @@ export interface User {
|
||||
avatarUrl: string | null;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
theme: Theme;
|
||||
locale: Locale;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ValidationError } from '@/lib/errors';
|
||||
import type { Locale, Theme } from '@/lib/types';
|
||||
import { VALID_LOCALES, VALID_THEMES } from '@/lib/i18n/constants';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
@ -20,6 +22,8 @@ export interface ProfileUpdateInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
theme?: Theme;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export function validateRegister(input: RegisterInput): void {
|
||||
@ -41,7 +45,9 @@ export function validateProfileUpdate(input: ProfileUpdateInput): void {
|
||||
if (
|
||||
input.name === undefined &&
|
||||
input.email === undefined &&
|
||||
input.avatarUrl === undefined
|
||||
input.avatarUrl === undefined &&
|
||||
input.theme === undefined &&
|
||||
input.locale === undefined
|
||||
) {
|
||||
throw new ValidationError('更新対象のフィールドを指定してください');
|
||||
}
|
||||
@ -56,6 +62,12 @@ export function validateProfileUpdate(input: ProfileUpdateInput): void {
|
||||
'avatarUrl'
|
||||
);
|
||||
}
|
||||
if (input.theme !== undefined && !VALID_THEMES.includes(input.theme)) {
|
||||
throw new ValidationError('無効なテーマです', 'theme');
|
||||
}
|
||||
if (input.locale !== undefined && !VALID_LOCALES.includes(input.locale)) {
|
||||
throw new ValidationError('無効な言語です', 'locale');
|
||||
}
|
||||
}
|
||||
|
||||
function validateName(name: string): void {
|
||||
|
||||
Reference in New Issue
Block a user