diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts index 0985338..e8c36e5 100644 --- a/app/api/users/me/route.ts +++ b/app/api/users/me/route.ts @@ -5,6 +5,8 @@ import { getDb } from '@/lib/db/sqlite'; import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; import { UnauthorizedError } from '@/lib/errors'; import { handleApiError, jsonError } from '@/lib/api/handleError'; +import type { Locale, Theme } from '@/lib/types'; +import { PREF_MAX_AGE } from '@/lib/i18n/constants'; export const runtime = 'nodejs'; @@ -21,6 +23,7 @@ export async function PATCH(request: NextRequest) { return jsonError(400, 'リクエスト本文が不正です'); } + // theme/locale は生値を Service に渡し、バリデータで不正値を弾く(→ ValidationError → 400) const authService = new AuthService(new UserRepository(getDb())); try { const updated = authService.updateProfile(currentUser.id, { @@ -28,8 +31,23 @@ export async function PATCH(request: NextRequest) { email: typeof body.email === 'string' ? body.email : undefined, avatarUrl: typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined, + theme: typeof body.theme === 'string' ? (body.theme as Theme) : undefined, + locale: + typeof body.locale === 'string' ? (body.locale as Locale) : undefined, }); - return NextResponse.json({ user: toPublicUser(updated) }); + const res = NextResponse.json({ user: toPublicUser(updated) }); + // 保存結果の theme/locale をCookieに反映(SSRでレイアウトが読めるように) + res.cookies.set('theme', updated.theme, { + path: '/', + maxAge: PREF_MAX_AGE, + sameSite: 'lax', + }); + res.cookies.set('locale', updated.locale, { + path: '/', + maxAge: PREF_MAX_AGE, + sameSite: 'lax', + }); + return res; } catch (error) { return handleApiError(error); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 7e2d6db..d9a4681 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -4,6 +4,7 @@ import { createDashboardService } from '@/lib/api/services'; import { Header } from '@/components/layout/Header'; import { CreateProjectForm } from '@/components/project/CreateProjectForm'; import { DashboardWidget } from '@/components/project/DashboardWidget'; +import { getLocale, translate } from '@/lib/i18n/server'; export const dynamic = 'force-dynamic'; @@ -15,20 +16,26 @@ export default async function DashboardPage() { const service = createDashboardService(); const dashboard = service.getPersonalDashboard(user.id); + const locale = await getLocale(); return ( -
+
-

ダッシュボード

+

+ {translate('page.dashboard', locale)} +

- + {dashboard.projects.map((p) => ( {p.name}({p.status}) @@ -36,35 +43,46 @@ export default async function DashboardPage() { - 通知一覧を開く + {translate('dash.openNotifications', locale)} - + {dashboard.incompleteTodos.map((t) => (

{t.title} - {t.dueDate ? `(期限: ${t.dueDate})` : ''} + {t.dueDate + ? `(${translate('dash.due', locale)}: ${t.dueDate})` + : ''}

))}
- + {dashboard.overdueTasks.map((t) => (

- {t.title}(期限: {t.dueDate}) + {t.title}({translate('dash.due', locale)}: {t.dueDate})

))}
- + {dashboard.upcomingMeetings.map((m) => (

{m.title}({m.startAt}) @@ -72,7 +90,10 @@ export default async function DashboardPage() { ))} - + {dashboard.recentActivity.map((l) => (

{l.action}({l.targetType}) diff --git a/app/globals.css b/app/globals.css index b5c61c9..29e8186 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,12 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* ルート要素の最低限のダーク/ライト背景フォールバック。 + 各コンポーネントは dark: ユーティリティで個別に調整している。 */ +html { + color-scheme: light dark; +} +body { + @apply bg-gray-50 text-gray-800 dark:bg-gray-900 dark:text-gray-100; +} diff --git a/app/layout.tsx b/app/layout.tsx index 0196109..f836cd7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,42 @@ import type { Metadata } from 'next'; +import { cookies } from 'next/headers'; import './globals.css'; +import { I18nProvider } from '@/lib/i18n/I18nProvider'; +import type { Locale, Theme } from '@/lib/types'; export const metadata: Metadata = { - title: 'シンプルグループウェア', - description: - 'プロジェクト単位で情報共有・タスク管理を行えるチームコラボレーションツール', + title: 'Groupware', + description: 'Project-based team collaboration tool', }; -export default function RootLayout({ +function resolveTheme(v: string | undefined): Theme { + return v === 'light' ? 'light' : 'dark'; // 既定は dark +} + +function resolveLocale(v: string | undefined): Locale { + return v === 'ja' ? 'ja' : 'en'; // 既定は en +} + +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const cookieStore = await cookies(); + const theme = resolveTheme(cookieStore.get('theme')?.value); + const locale = resolveLocale(cookieStore.get('locale')?.value); + return ( - - {children} + + + + {children} + + ); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 7946d9b..06adafa 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -2,11 +2,13 @@ import { useState, type FormEvent } from 'react'; import { useRouter } from 'next/navigation'; +import { useI18n } from '@/lib/i18n/I18nProvider'; type Mode = 'login' | 'register'; export default function LoginPage() { const router = useRouter(); + const { t } = useI18n(); const [mode, setMode] = useState('login'); const [name, setName] = useState(''); const [email, setEmail] = useState(''); @@ -38,22 +40,22 @@ export default function LoginPage() { const data = (await res.json().catch(() => null)) as { error?: { message?: string }; } | null; - setError(data?.error?.message ?? '処理に失敗しました'); + setError(data?.error?.message ?? t('auth.failed')); setLoading(false); } return ( -

-
+
+

- {mode === 'login' ? 'ログイン' : '新規登録'} + {mode === 'login' ? t('auth.login') : t('auth.register')}

{mode === 'register' && (
setName(e.target.value)} - className="mt-1 w-full rounded border px-3 py-2" + className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700" required />
)}
setEmail(e.target.value)} - className="mt-1 w-full rounded border px-3 py-2" + className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700" required />
setPassword(e.target.value)} - className="mt-1 w-full rounded border px-3 py-2" + className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700" required minLength={8} /> @@ -111,19 +113,23 @@ export default function LoginPage() { disabled={loading} className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50" > - {loading ? '処理中...' : mode === 'login' ? 'ログイン' : '登録する'} + {loading + ? t('auth.processing') + : mode === 'login' + ? t('auth.loginButton') + : t('auth.registerButton')}
diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx index bf57c68..00172a7 100644 --- a/app/notifications/page.tsx +++ b/app/notifications/page.tsx @@ -16,7 +16,7 @@ export default async function NotificationsPage() { const { items } = service.listUnread(user.id, 1); return ( -
+

通知

diff --git a/app/profile/page.tsx b/app/profile/page.tsx index cd9a30f..804b765 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -3,9 +3,11 @@ import { useEffect, useState, type FormEvent } from 'react'; import { useRouter } from 'next/navigation'; import type { PublicUser } from '@/lib/auth/getCurrentUser'; +import { useI18n } from '@/lib/i18n/I18nProvider'; export default function ProfilePage() { const router = useRouter(); + const { t, locale, theme, setLocale, setTheme } = useI18n(); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [name, setName] = useState(''); @@ -44,7 +46,7 @@ export default function ProfilePage() { const data = (await res.json().catch(() => null)) as { error?: { message?: string }; } | null; - setError(data?.error?.message ?? '更新に失敗しました'); + setError(data?.error?.message ?? t('auth.failed')); } } @@ -55,61 +57,63 @@ export default function ProfilePage() { if (loading) { return ( -
-

読み込み中...

+
+

+ {t('common.loading')} +

); } return ( -
-
+
+
-

プロフィール

+

{t('profile.title')}

setName(e.target.value)} - className="mt-1 w-full rounded border px-3 py-2" + className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700" />
setEmail(e.target.value)} - className="mt-1 w-full rounded border px-3 py-2" + className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700" />
setAvatarUrl(e.target.value)} - className="mt-1 w-full rounded border px-3 py-2" + className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700" />
@@ -119,26 +123,64 @@ export default function ProfilePage() {

)} {saved && ( -

プロフィールを更新しました

+

{t('profile.saved')}

)}
+
+
+ + +
+
+ + +
+
+ -

- ロール: {user?.role} +

+ {t('profile.role')}: {user?.role}

diff --git a/app/projects/[projectId]/activity/page.tsx b/app/projects/[projectId]/activity/page.tsx index 4b2cf38..9c58665 100644 --- a/app/projects/[projectId]/activity/page.tsx +++ b/app/projects/[projectId]/activity/page.tsx @@ -50,24 +50,28 @@ export default async function ProjectActivityPage({ const { items } = activityService.listByProject(project.id, 1); return ( -
+

アクティビティログ

{items.length === 0 ? ( -

アクティビティはありません。

+

+ アクティビティはありません。 +

) : ( -