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

View File

@ -5,6 +5,8 @@ import { getDb } from '@/lib/db/sqlite';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { UnauthorizedError } from '@/lib/errors'; import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError'; 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'; export const runtime = 'nodejs';
@ -21,6 +23,7 @@ export async function PATCH(request: NextRequest) {
return jsonError(400, 'リクエスト本文が不正です'); return jsonError(400, 'リクエスト本文が不正です');
} }
// theme/locale は生値を Service に渡し、バリデータで不正値を弾く(→ ValidationError → 400)
const authService = new AuthService(new UserRepository(getDb())); const authService = new AuthService(new UserRepository(getDb()));
try { try {
const updated = authService.updateProfile(currentUser.id, { const updated = authService.updateProfile(currentUser.id, {
@ -28,8 +31,23 @@ export async function PATCH(request: NextRequest) {
email: typeof body.email === 'string' ? body.email : undefined, email: typeof body.email === 'string' ? body.email : undefined,
avatarUrl: avatarUrl:
typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined, 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) { } catch (error) {
return handleApiError(error); return handleApiError(error);
} }

View File

@ -4,6 +4,7 @@ import { createDashboardService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { CreateProjectForm } from '@/components/project/CreateProjectForm'; import { CreateProjectForm } from '@/components/project/CreateProjectForm';
import { DashboardWidget } from '@/components/project/DashboardWidget'; import { DashboardWidget } from '@/components/project/DashboardWidget';
import { getLocale, translate } from '@/lib/i18n/server';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -15,20 +16,26 @@ export default async function DashboardPage() {
const service = createDashboardService(); const service = createDashboardService();
const dashboard = service.getPersonalDashboard(user.id); const dashboard = service.getPersonalDashboard(user.id);
const locale = await getLocale();
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<main className="mx-auto max-w-4xl space-y-6 p-6"> <main className="mx-auto max-w-4xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold">
{translate('page.dashboard', locale)}
</h1>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<DashboardWidget title="参加プロジェクト" empty="ありません"> <DashboardWidget
title={translate('dash.projects', locale)}
empty={translate('dash.empty', locale)}
>
{dashboard.projects.map((p) => ( {dashboard.projects.map((p) => (
<a <a
key={p.id} key={p.id}
href={`/projects/${p.id}`} href={`/projects/${p.id}`}
className="block rounded border px-3 py-2 text-sm hover:bg-gray-50" className="block rounded border px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
> >
{p.name}{p.status} {p.name}{p.status}
</a> </a>
@ -36,35 +43,46 @@ export default async function DashboardPage() {
</DashboardWidget> </DashboardWidget>
<DashboardWidget <DashboardWidget
title={`未読通知 (${dashboard.unreadNotificationCount})`} title={`${translate('dash.unreadNotifications', locale)} (${dashboard.unreadNotificationCount})`}
empty="未読はありません" empty={translate('dash.noUnread', locale)}
> >
<a <a
href="/notifications" href="/notifications"
className="text-sm text-blue-600 hover:underline" className="text-sm text-blue-600 hover:underline dark:text-blue-400"
> >
{translate('dash.openNotifications', locale)}
</a> </a>
</DashboardWidget> </DashboardWidget>
<DashboardWidget title="未完了ToDo" empty="ありません"> <DashboardWidget
title={translate('dash.incompleteTodos', locale)}
empty={translate('dash.empty', locale)}
>
{dashboard.incompleteTodos.map((t) => ( {dashboard.incompleteTodos.map((t) => (
<p key={t.id} className="text-sm"> <p key={t.id} className="text-sm">
{t.title} {t.title}
{t.dueDate ? `(期限: ${t.dueDate}` : ''} {t.dueDate
? `${translate('dash.due', locale)}: ${t.dueDate}`
: ''}
</p> </p>
))} ))}
</DashboardWidget> </DashboardWidget>
<DashboardWidget title="期限切れタスク" empty="ありません"> <DashboardWidget
title={translate('dash.overdueTasks', locale)}
empty={translate('dash.empty', locale)}
>
{dashboard.overdueTasks.map((t) => ( {dashboard.overdueTasks.map((t) => (
<p key={t.id} className="text-sm text-red-600"> <p key={t.id} className="text-sm text-red-600">
{t.title}: {t.dueDate} {t.title}{translate('dash.due', locale)}: {t.dueDate}
</p> </p>
))} ))}
</DashboardWidget> </DashboardWidget>
<DashboardWidget title="近日中のミーティング" empty="ありません"> <DashboardWidget
title={translate('dash.upcomingMeetings', locale)}
empty={translate('dash.empty', locale)}
>
{dashboard.upcomingMeetings.map((m) => ( {dashboard.upcomingMeetings.map((m) => (
<p key={m.id} className="text-sm"> <p key={m.id} className="text-sm">
{m.title}{m.startAt} {m.title}{m.startAt}
@ -72,7 +90,10 @@ export default async function DashboardPage() {
))} ))}
</DashboardWidget> </DashboardWidget>
<DashboardWidget title="最近のアクティビティ" empty="ありません"> <DashboardWidget
title={translate('dash.recentActivity', locale)}
empty={translate('dash.empty', locale)}
>
{dashboard.recentActivity.map((l) => ( {dashboard.recentActivity.map((l) => (
<p key={l.id} className="text-sm"> <p key={l.id} className="text-sm">
{l.action}{l.targetType} {l.action}{l.targetType}

View File

@ -1,3 +1,12 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ルート要素の最低限のダーク/ライト背景フォールバック。
各コンポーネントは dark: ユーティリティで個別に調整している。 */
html {
color-scheme: light dark;
}
body {
@apply bg-gray-50 text-gray-800 dark:bg-gray-900 dark:text-gray-100;
}

View File

@ -1,20 +1,42 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import './globals.css'; import './globals.css';
import { I18nProvider } from '@/lib/i18n/I18nProvider';
import type { Locale, Theme } from '@/lib/types';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'シンプルグループウェア', title: 'Groupware',
description: 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,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const cookieStore = await cookies();
const theme = resolveTheme(cookieStore.get('theme')?.value);
const locale = resolveLocale(cookieStore.get('locale')?.value);
return ( return (
<html lang="ja"> <html
<body>{children}</body> lang={locale}
className={theme === 'dark' ? 'dark' : ''}
style={{ colorScheme: theme }}
>
<body>
<I18nProvider initialLocale={locale} initialTheme={theme}>
{children}
</I18nProvider>
</body>
</html> </html>
); );
} }

View File

@ -2,11 +2,13 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useI18n } from '@/lib/i18n/I18nProvider';
type Mode = 'login' | 'register'; type Mode = 'login' | 'register';
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const [mode, setMode] = useState<Mode>('login'); const [mode, setMode] = useState<Mode>('login');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -38,22 +40,22 @@ export default function LoginPage() {
const data = (await res.json().catch(() => null)) as { const data = (await res.json().catch(() => null)) as {
error?: { message?: string }; error?: { message?: string };
} | null; } | null;
setError(data?.error?.message ?? '処理に失敗しました'); setError(data?.error?.message ?? t('auth.failed'));
setLoading(false); setLoading(false);
} }
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-8"> <main className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-8 dark:bg-gray-900">
<div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm"> <div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold">
{mode === 'login' ? 'ログイン' : '新規登録'} {mode === 'login' ? t('auth.login') : t('auth.register')}
</h1> </h1>
<form className="mt-6 space-y-4" onSubmit={onSubmit}> <form className="mt-6 space-y-4" onSubmit={onSubmit}>
{mode === 'register' && ( {mode === 'register' && (
<div> <div>
<label htmlFor="name" className="block text-sm font-medium"> <label htmlFor="name" className="block text-sm font-medium">
{t('auth.displayName')}
</label> </label>
<input <input
id="name" id="name"
@ -61,14 +63,14 @@ export default function LoginPage() {
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => 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 required
/> />
</div> </div>
)} )}
<div> <div>
<label htmlFor="email" className="block text-sm font-medium"> <label htmlFor="email" className="block text-sm font-medium">
{t('auth.email')}
</label> </label>
<input <input
id="email" id="email"
@ -77,13 +79,13 @@ export default function LoginPage() {
autoComplete="email" autoComplete="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => 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 required
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium"> <label htmlFor="password" className="block text-sm font-medium">
{t('auth.password')}
</label> </label>
<input <input
id="password" id="password"
@ -94,7 +96,7 @@ export default function LoginPage() {
} }
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => 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 required
minLength={8} minLength={8}
/> />
@ -111,19 +113,23 @@ export default function LoginPage() {
disabled={loading} disabled={loading}
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50" 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')}
</button> </button>
</form> </form>
<button <button
type="button" type="button"
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline" className="mt-4 w-full text-center text-sm text-blue-600 hover:underline dark:text-blue-400"
onClick={() => { onClick={() => {
setMode(mode === 'login' ? 'register' : 'login'); setMode(mode === 'login' ? 'register' : 'login');
setError(null); setError(null);
}} }}
> >
{mode === 'login' ? '新規登録はこちら' : 'ログイン画面に戻る'} {mode === 'login' ? t('auth.registerHere') : t('auth.backToLogin')}
</button> </button>
</div> </div>
</main> </main>

View File

@ -16,7 +16,7 @@ export default async function NotificationsPage() {
const { items } = service.listUnread(user.id, 1); const { items } = service.listUnread(user.id, 1);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<main className="mx-auto max-w-2xl space-y-6 p-6"> <main className="mx-auto max-w-2xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>

View File

@ -3,9 +3,11 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { PublicUser } from '@/lib/auth/getCurrentUser'; import type { PublicUser } from '@/lib/auth/getCurrentUser';
import { useI18n } from '@/lib/i18n/I18nProvider';
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
const { t, locale, theme, setLocale, setTheme } = useI18n();
const [user, setUser] = useState<PublicUser | null>(null); const [user, setUser] = useState<PublicUser | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [name, setName] = useState(''); const [name, setName] = useState('');
@ -44,7 +46,7 @@ export default function ProfilePage() {
const data = (await res.json().catch(() => null)) as { const data = (await res.json().catch(() => null)) as {
error?: { message?: string }; error?: { message?: string };
} | null; } | null;
setError(data?.error?.message ?? '更新に失敗しました'); setError(data?.error?.message ?? t('auth.failed'));
} }
} }
@ -55,61 +57,63 @@ export default function ProfilePage() {
if (loading) { if (loading) {
return ( return (
<main className="flex min-h-screen items-center justify-center"> <main className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<p>...</p> <p className="text-gray-500 dark:text-gray-400">
{t('common.loading')}
</p>
</main> </main>
); );
} }
return ( return (
<main className="min-h-screen bg-gray-50 p-8"> <main className="min-h-screen bg-gray-50 p-8 dark:bg-gray-900">
<div className="mx-auto max-w-md rounded-lg border bg-white p-8 shadow-sm"> <div className="mx-auto max-w-md rounded-lg border bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold">{t('profile.title')}</h1>
<button <button
type="button" type="button"
onClick={onLogout} onClick={onLogout}
className="text-sm text-blue-600 hover:underline" className="text-sm text-blue-600 hover:underline dark:text-blue-400"
> >
{t('header.logout')}
</button> </button>
</div> </div>
<form className="mt-6 space-y-4" onSubmit={onSubmit}> <form className="mt-6 space-y-4" onSubmit={onSubmit}>
<div> <div>
<label htmlFor="name" className="block text-sm font-medium"> <label htmlFor="name" className="block text-sm font-medium">
{t('profile.displayName')}
</label> </label>
<input <input
id="name" id="name"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => 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"
/> />
</div> </div>
<div> <div>
<label htmlFor="email" className="block text-sm font-medium"> <label htmlFor="email" className="block text-sm font-medium">
{t('profile.email')}
</label> </label>
<input <input
id="email" id="email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => 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"
/> />
</div> </div>
<div> <div>
<label htmlFor="avatarUrl" className="block text-sm font-medium"> <label htmlFor="avatarUrl" className="block text-sm font-medium">
URL {t('profile.avatarUrl')}
</label> </label>
<input <input
id="avatarUrl" id="avatarUrl"
type="url" type="url"
value={avatarUrl} value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)} onChange={(e) => 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"
/> />
</div> </div>
@ -119,26 +123,64 @@ export default function ProfilePage() {
</p> </p>
)} )}
{saved && ( {saved && (
<p className="text-sm text-green-600"></p> <p className="text-sm text-green-600">{t('profile.saved')}</p>
)} )}
<button <button
type="submit" type="submit"
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700" className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
> >
{t('common.save')}
</button> </button>
</form> </form>
<div className="mt-6 space-y-3 border-t pt-4 dark:border-gray-700">
<div>
<label htmlFor="theme-select" className="block text-sm font-medium">
{t('profile.theme')}
</label>
<select
id="theme-select"
value={theme}
onChange={(e) =>
void setTheme(e.target.value as 'dark' | 'light')
}
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
data-testid="profile-theme-select"
>
<option value="dark">{t('theme.dark')}</option>
<option value="light">{t('theme.light')}</option>
</select>
</div>
<div>
<label
htmlFor="locale-select"
className="block text-sm font-medium"
>
{t('profile.language')}
</label>
<select
id="locale-select"
value={locale}
onChange={(e) => setLocale(e.target.value as 'en' | 'ja')}
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
data-testid="profile-locale-select"
>
<option value="en">{t('language.english')}</option>
<option value="ja">{t('language.japanese')}</option>
</select>
</div>
</div>
<button <button
type="button" type="button"
onClick={() => router.push('/dashboard')} onClick={() => router.push('/dashboard')}
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline" className="mt-4 w-full text-center text-sm text-blue-600 hover:underline dark:text-blue-400"
> >
{t('profile.backToDashboard')}
</button> </button>
<p className="mt-2 text-center text-xs text-gray-500"> <p className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
: {user?.role} {t('profile.role')}: {user?.role}
</p> </p>
</div> </div>
</main> </main>

View File

@ -50,24 +50,28 @@ export default async function ProjectActivityPage({
const { items } = activityService.listByProject(project.id, 1); const { items } = activityService.listByProject(project.id, 1);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="activity" /> <ProjectNav projectId={project.id} active="activity" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-gray-400"></p> <p className="text-sm text-gray-400 dark:text-gray-500">
</p>
) : ( ) : (
<ul className="divide-y rounded-lg border bg-white shadow-sm"> <ul className="divide-y rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
{items.map((log) => ( {items.map((log) => (
<li key={log.id} className="p-4"> <li key={log.id} className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs"> <span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
{ACTION_LABELS[log.action] ?? log.action} {ACTION_LABELS[log.action] ?? log.action}
</span> </span>
<span className="text-xs text-gray-400">{log.createdAt}</span> <span className="text-xs text-gray-400 dark:text-gray-500">
{log.createdAt}
</span>
</div> </div>
<p className="mt-2 text-sm text-gray-700"> <p className="mt-2 text-sm text-gray-700 dark:text-gray-200">
{log.targetType} {log.targetType}
{log.targetId !== null ? ` #${log.targetId}` : ''} {log.targetId !== null ? ` #${log.targetId}` : ''}
</p> </p>

View File

@ -52,7 +52,7 @@ export default async function ThreadDetailPage({
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={Number(projectId)} active="board" /> <ProjectNav projectId={Number(projectId)} active="board" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
@ -62,11 +62,11 @@ export default async function ThreadDetailPage({
> >
</a> </a>
<div className="rounded-lg border bg-white p-6 shadow-sm"> <div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{thread.title}</h1> <h1 className="text-2xl font-bold">{thread.title}</h1>
{thread.category && ( {thread.category && (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs"> <span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
{thread.category} {thread.category}
</span> </span>
)} )}
@ -76,13 +76,13 @@ export default async function ThreadDetailPage({
</div> </div>
{attachments.thread.length > 0 && ( {attachments.thread.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<p className="mb-1 text-xs font-medium text-gray-500"> <p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
</p> </p>
<AttachmentList attachments={attachments.thread} /> <AttachmentList attachments={attachments.thread} />
</div> </div>
)} )}
<p className="mt-4 text-xs text-gray-400"> <p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
稿: {thread.createdAt} / : {thread.updatedAt} 稿: {thread.createdAt} / : {thread.updatedAt}
</p> </p>
</div> </div>
@ -92,7 +92,7 @@ export default async function ThreadDetailPage({
{comments.items.map((comment) => ( {comments.items.map((comment) => (
<div <div
key={comment.id} key={comment.id}
className="rounded-lg border bg-white p-4 shadow-sm" className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
> >
<MarkdownBody bodyMd={comment.bodyMd} /> <MarkdownBody bodyMd={comment.bodyMd} />
{commentAttachments.has(comment.id) && {commentAttachments.has(comment.id) &&
@ -103,7 +103,9 @@ export default async function ThreadDetailPage({
/> />
</div> </div>
)} )}
<p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p> <p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
{comment.createdAt}
</p>
</div> </div>
))} ))}
<CommentForm projectId={Number(projectId)} threadId={thread.id} /> <CommentForm projectId={Number(projectId)} threadId={thread.id} />

View File

@ -38,7 +38,7 @@ export default async function BoardPage({
}); });
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="board" /> <ProjectNav projectId={project.id} active="board" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
@ -46,27 +46,31 @@ export default async function BoardPage({
<ThreadForm projectId={project.id} /> <ThreadForm projectId={project.id} />
<section className="space-y-3"> <section className="space-y-3">
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-gray-400"></p> <p className="text-sm text-gray-400 dark:text-gray-500">
</p>
) : ( ) : (
items.map((thread) => ( items.map((thread) => (
<a <a
key={thread.id} key={thread.id}
href={`/projects/${project.id}/board/${thread.id}`} href={`/projects/${project.id}/board/${thread.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm hover:shadow-md" className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm hover:shadow-md"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800"> <h3 className="font-semibold text-gray-800 dark:text-gray-100">
{thread.isPinned === 1 && '📌 '} {thread.isPinned === 1 && '📌 '}
{thread.isImportant === 1 && '❗ '} {thread.isImportant === 1 && '❗ '}
{thread.title} {thread.title}
</h3> </h3>
{thread.category && ( {thread.category && (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs"> <span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
{thread.category} {thread.category}
</span> </span>
)} )}
</div> </div>
<p className="mt-1 text-xs text-gray-400">{thread.createdAt}</p> <p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{thread.createdAt}
</p>
</a> </a>
)) ))
)} )}

View File

@ -60,7 +60,7 @@ export default async function CalendarPage({
const events = scheduleService.getCalendarEvents(user.id, project.id, range); const events = scheduleService.getCalendarEvents(user.id, project.id, range);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="calendar" /> <ProjectNav projectId={project.id} active="calendar" />
<main className="mx-auto max-w-6xl space-y-6 p-6"> <main className="mx-auto max-w-6xl space-y-6 p-6">

View File

@ -29,7 +29,7 @@ export default async function ChatPage({
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="chat" /> <ProjectNav projectId={project.id} active="chat" />
<main className="mx-auto max-w-3xl space-y-4 p-6"> <main className="mx-auto max-w-3xl space-y-4 p-6">

View File

@ -39,7 +39,7 @@ export default async function FilesPage({
items.some((f) => f.uploaderId === user.id); items.some((f) => f.uploaderId === user.id);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="files" /> <ProjectNav projectId={project.id} active="files" />
<main className="mx-auto max-w-4xl space-y-6 p-6"> <main className="mx-auto max-w-4xl space-y-6 p-6">

View File

@ -36,7 +36,7 @@ export default async function MeetingsPage({
})); }));
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="meetings" /> <ProjectNav projectId={project.id} active="meetings" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
@ -44,23 +44,31 @@ export default async function MeetingsPage({
<MeetingForm projectId={project.id} members={members} /> <MeetingForm projectId={project.id} members={members} />
<section className="space-y-3"> <section className="space-y-3">
{meetings.length === 0 ? ( {meetings.length === 0 ? (
<p className="text-sm text-gray-400"></p> <p className="text-sm text-gray-400 dark:text-gray-500">
</p>
) : ( ) : (
meetings.map((m) => ( meetings.map((m) => (
<div <div
key={m.id} key={m.id}
className="rounded-lg border bg-white p-4 shadow-sm" className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
data-testid={`meeting-${m.id}`} data-testid={`meeting-${m.id}`}
> >
<h3 className="font-semibold text-gray-800">{m.title}</h3> <h3 className="font-semibold text-gray-800 dark:text-gray-100">
<p className="text-xs text-gray-500"> {m.title}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{m.startAt} {m.endAt} {m.startAt} {m.endAt}
</p> </p>
{m.location && ( {m.location && (
<p className="text-xs text-gray-500">: {m.location}</p> <p className="text-xs text-gray-500 dark:text-gray-400">
: {m.location}
</p>
)} )}
{m.minutesMd && ( {m.minutesMd && (
<p className="mt-2 text-sm text-gray-600"></p> <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
</p>
)} )}
</div> </div>
)) ))

View File

@ -38,7 +38,7 @@ export default async function ProjectMembersPage({
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="members" /> <ProjectNav projectId={project.id} active="members" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
@ -46,7 +46,7 @@ export default async function ProjectMembersPage({
{canManage && <AddMemberForm projectId={project.id} />} {canManage && <AddMemberForm projectId={project.id} />}
<section className="rounded-lg border bg-white shadow-sm"> <section className="rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
<ul className="divide-y"> <ul className="divide-y">
{members.map((member) => ( {members.map((member) => (
<li <li
@ -54,18 +54,20 @@ export default async function ProjectMembersPage({
className="flex items-center justify-between p-4" className="flex items-center justify-between p-4"
> >
<div> <div>
<p className="font-medium text-gray-800"> <p className="font-medium text-gray-800 dark:text-gray-100">
{member.user.name} {member.user.name}
{member.userId === user.id && ( {member.userId === user.id && (
<span className="ml-2 text-xs text-gray-400"> <span className="ml-2 text-xs text-gray-400 dark:text-gray-500">
() ()
</span> </span>
)} )}
</p> </p>
<p className="text-sm text-gray-500">{member.user.email}</p> <p className="text-sm text-gray-500 dark:text-gray-400">
{member.user.email}
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600 dark:text-gray-300">
{ROLE_LABEL[member.role] ?? member.role} {ROLE_LABEL[member.role] ?? member.role}
</span> </span>
{canManage && member.userId !== user.id && ( {canManage && member.userId !== user.id && (

View File

@ -41,7 +41,7 @@ export default async function MilestonesPage({
const milestones = scheduleService.getMilestones(user.id, project.id); const milestones = scheduleService.getMilestones(user.id, project.id);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="milestones" /> <ProjectNav projectId={project.id} active="milestones" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
@ -49,34 +49,38 @@ export default async function MilestonesPage({
<MilestoneForm projectId={project.id} /> <MilestoneForm projectId={project.id} />
<section className="space-y-3"> <section className="space-y-3">
{milestones.length === 0 ? ( {milestones.length === 0 ? (
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400 dark:text-gray-500">
</p> </p>
) : ( ) : (
milestones.map((m) => ( milestones.map((m) => (
<div <div
key={m.id} key={m.id}
className="rounded-lg border bg-white p-4 shadow-sm" className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
data-testid={`milestone-${m.id}`} data-testid={`milestone-${m.id}`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">{m.title}</h3> <h3 className="font-semibold text-gray-800 dark:text-gray-100">
<span className="text-xs text-gray-500"> {m.title}
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{m.status} {m.status}
{m.dueDate ? ` / 期限: ${m.dueDate}` : ''} {m.dueDate ? ` / 期限: ${m.dueDate}` : ''}
</span> </span>
</div> </div>
{m.description && ( {m.description && (
<p className="mt-1 text-sm text-gray-600">{m.description}</p> <p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
{m.description}
</p>
)} )}
<div className="mt-3"> <div className="mt-3">
<div className="flex items-center justify-between text-xs text-gray-500"> <div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span></span> <span></span>
<span data-testid={`milestone-progress-${m.id}`}> <span data-testid={`milestone-progress-${m.id}`}>
{m.progress}% {m.progress}%
</span> </span>
</div> </div>
<div className="mt-1 h-2 w-full rounded bg-gray-200"> <div className="mt-1 h-2 w-full rounded bg-gray-200 dark:bg-gray-700">
<div <div
className={`h-2 rounded ${progressColor(m.progress)}`} className={`h-2 rounded ${progressColor(m.progress)}`}
style={{ width: `${m.progress}%` }} style={{ width: `${m.progress}%` }}

View File

@ -31,7 +31,7 @@ export default async function NoteDetailPage({
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={Number(projectId)} active="notes" /> <ProjectNav projectId={Number(projectId)} active="notes" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
@ -41,18 +41,20 @@ export default async function NoteDetailPage({
> >
</a> </a>
<div className="rounded-lg border bg-white p-6 shadow-sm"> <div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold">
{note.isPinned === 1 && '📌 '} {note.isPinned === 1 && '📌 '}
{note.title} {note.title}
</h1> </h1>
{note.tags && ( {note.tags && (
<p className="mt-1 text-xs text-gray-500">{note.tags}</p> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{note.tags}
</p>
)} )}
<div className="mt-4"> <div className="mt-4">
<MarkdownBody bodyMd={note.bodyMd} /> <MarkdownBody bodyMd={note.bodyMd} />
</div> </div>
<p className="mt-4 text-xs text-gray-400"> <p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
: {note.createdAt} / : {note.updatedAt} : {note.createdAt} / : {note.updatedAt}
</p> </p>
</div> </div>

View File

@ -38,7 +38,7 @@ export default async function NotesPage({
}); });
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="notes" /> <ProjectNav projectId={project.id} active="notes" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
@ -46,24 +46,30 @@ export default async function NotesPage({
<NoteForm projectId={project.id} /> <NoteForm projectId={project.id} />
<section className="space-y-3"> <section className="space-y-3">
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-gray-400"></p> <p className="text-sm text-gray-400 dark:text-gray-500">
</p>
) : ( ) : (
items.map((note) => ( items.map((note) => (
<a <a
key={note.id} key={note.id}
href={`/projects/${project.id}/notes/${note.id}`} href={`/projects/${project.id}/notes/${note.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm hover:shadow-md" className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm hover:shadow-md"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800"> <h3 className="font-semibold text-gray-800 dark:text-gray-100">
{note.isPinned === 1 && '📌 '} {note.isPinned === 1 && '📌 '}
{note.title} {note.title}
</h3> </h3>
{note.tags && ( {note.tags && (
<span className="text-xs text-gray-500">{note.tags}</span> <span className="text-xs text-gray-500 dark:text-gray-400">
{note.tags}
</span>
)} )}
</div> </div>
<p className="mt-1 text-xs text-gray-400">{note.updatedAt}</p> <p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{note.updatedAt}
</p>
</a> </a>
)) ))
)} )}

View File

@ -31,18 +31,20 @@ export default async function ProjectOverviewPage({
const { project } = dashboard; const { project } = dashboard;
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="overview" /> <ProjectNav projectId={project.id} active="overview" />
<main className="mx-auto max-w-4xl space-y-6 p-6"> <main className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{project.name}</h1> <h1 className="text-2xl font-bold">{project.name}</h1>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs"> <span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
{project.status} {project.status}
</span> </span>
</div> </div>
{project.description && ( {project.description && (
<p className="text-gray-600">{project.description}</p> <p className="text-gray-600 dark:text-gray-300">
{project.description}
</p>
)} )}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@ -119,7 +121,7 @@ export default async function ProjectOverviewPage({
<p className="text-sm"> <p className="text-sm">
{m.title} - {m.progress}% {m.title} - {m.progress}%
</p> </p>
<div className="h-1.5 w-full rounded bg-gray-200"> <div className="h-1.5 w-full rounded bg-gray-200 dark:bg-gray-700">
<div <div
className="h-1.5 rounded bg-blue-500" className="h-1.5 rounded bg-blue-500"
style={{ width: `${m.progress}%` }} style={{ width: `${m.progress}%` }}

View File

@ -51,7 +51,7 @@ export default async function SearchPage({
: []; : [];
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="search" /> <ProjectNav projectId={project.id} active="search" />
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
@ -63,7 +63,9 @@ export default async function SearchPage({
/> />
<section className="space-y-2"> <section className="space-y-2">
{q && results.length === 0 && ( {q && results.length === 0 && (
<p className="text-sm text-gray-400"></p> <p className="text-sm text-gray-400 dark:text-gray-500">
</p>
)} )}
{results.map((r) => { {results.map((r) => {
const href = const href =
@ -73,16 +75,20 @@ export default async function SearchPage({
<a <a
key={`${r.type}-${r.id}`} key={`${r.type}-${r.id}`}
href={href} href={href}
className="block rounded border bg-white p-3 shadow-sm hover:shadow-md" className="block rounded border bg-white dark:bg-gray-800 p-3 shadow-sm hover:shadow-md"
data-testid={`search-result-${r.type}-${r.id}`} data-testid={`search-result-${r.type}-${r.id}`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-gray-800">{r.title}</span> <span className="font-medium text-gray-800 dark:text-gray-100">
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs"> {r.title}
</span>
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
{r.type} {r.type}
</span> </span>
</div> </div>
<p className="mt-1 text-xs text-gray-500">{r.snippet}</p> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{r.snippet}
</p>
</a> </a>
); );
})} })}

View File

@ -34,18 +34,18 @@ export default async function ProjectSettingsPage({
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="settings" /> <ProjectNav projectId={project.id} active="settings" />
<main className="mx-auto max-w-2xl space-y-6 p-6"> <main className="mx-auto max-w-2xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<div className="rounded-lg border bg-white p-6 shadow-sm"> <div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
<ProjectSettingsForm project={project} canManage={canManage} /> <ProjectSettingsForm project={project} canManage={canManage} />
</div> </div>
{canManage && ( {canManage && (
<div className="rounded-lg border border-red-200 bg-white p-6 shadow-sm"> <div className="rounded-lg border border-red-200 bg-white dark:bg-gray-800 p-6 shadow-sm">
<h2 className="text-sm font-semibold text-red-700"></h2> <h2 className="text-sm font-semibold text-red-700"></h2>
<p className="mt-1 text-sm text-gray-600"> <p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
</p> </p>
<DeleteProjectButton projectId={project.id} /> <DeleteProjectButton projectId={project.id} />

View File

@ -34,7 +34,7 @@ export default async function TodosPage({
const members = projectService.getMembers(user.id, project.id); const members = projectService.getMembers(user.id, project.id);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="todos" /> <ProjectNav projectId={project.id} active="todos" />
<main className="space-y-4 p-6"> <main className="space-y-4 p-6">

View File

@ -24,8 +24,8 @@ export function BackupCreateButton() {
} }
return ( return (
<div className="rounded-lg border bg-white p-4 shadow-sm"> <div className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600 dark:text-gray-300">
DBファイル + uploadsディレクトリをZIP化してバックアップを作成します DBファイル + uploadsディレクトリをZIP化してバックアップを作成します
</p> </p>
<button <button

View File

@ -58,7 +58,7 @@ export function ThreadForm({ projectId }: { projectId: number }) {
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm" className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
> >
<div className="flex gap-2"> <div className="flex gap-2">
<input <input

View File

@ -40,7 +40,7 @@ export function CalendarEventForm({
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end" className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row sm:items-end"
> >
<div className="flex-1"> <div className="flex-1">
<label className="block text-sm font-medium"></label> <label className="block text-sm font-medium"></label>

View File

@ -80,7 +80,7 @@ export function CalendarView({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 <h2
className="text-xl font-bold text-gray-800" className="text-xl font-bold text-gray-800 dark:text-gray-100"
data-testid="calendar-title" data-testid="calendar-title"
> >
{title} {title}
@ -95,7 +95,7 @@ export function CalendarView({
className={`px-3 py-1 text-sm ${ className={`px-3 py-1 text-sm ${
view === v.mode view === v.mode
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
data-testid={`calendar-view-${v.mode}`} data-testid={`calendar-view-${v.mode}`}
> >
@ -107,7 +107,7 @@ export function CalendarView({
<button <button
type="button" type="button"
onClick={goPrev} onClick={goPrev}
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100" className="rounded border bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="前へ" aria-label="前へ"
data-testid="calendar-prev" data-testid="calendar-prev"
> >
@ -116,7 +116,7 @@ export function CalendarView({
<button <button
type="button" type="button"
onClick={goToday} onClick={goToday}
className="rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100" className="rounded border bg-white dark:bg-gray-800 px-3 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
data-testid="calendar-today" data-testid="calendar-today"
> >
@ -124,7 +124,7 @@ export function CalendarView({
<button <button
type="button" type="button"
onClick={goNext} onClick={goNext}
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100" className="rounded border bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="次へ" aria-label="次へ"
data-testid="calendar-next" data-testid="calendar-next"
> >

View File

@ -37,14 +37,14 @@ export function DayView({
const isToday = key === todayKey; const isToday = key === todayKey;
return ( return (
<div className="rounded-lg border bg-white"> <div className="rounded-lg border bg-white dark:bg-gray-800">
<div className="flex items-center justify-between border-b p-3"> <div className="flex items-center justify-between border-b p-3">
<button <button
type="button" type="button"
onClick={() => onSelectDate(key)} onClick={() => onSelectDate(key)}
className="text-left" className="text-left"
> >
<p className="text-lg font-bold text-gray-800"> <p className="text-lg font-bold text-gray-800 dark:text-gray-100">
{day.getMonth() + 1}{day.getDate()}( {day.getMonth() + 1}{day.getDate()}(
{WEEKDAY_LABELS[day.getDay()]}) {WEEKDAY_LABELS[day.getDay()]})
</p> </p>
@ -58,10 +58,12 @@ export function DayView({
{allDay.length > 0 && ( {allDay.length > 0 && (
<div <div
className="border-b bg-gray-50 p-2" className="border-b bg-gray-50 dark:bg-gray-900 p-2"
data-testid="calendar-all-day-section" data-testid="calendar-all-day-section"
> >
<p className="mb-1 text-xs font-medium text-gray-500"></p> <p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{allDay.map((e) => ( {allDay.map((e) => (
<button <button
@ -70,8 +72,9 @@ export function DayView({
onClick={() => onSelectDate(key)} onClick={() => onSelectDate(key)}
title={e.title} title={e.title}
className={`max-w-full truncate rounded border px-2 py-0.5 text-xs ${ className={`max-w-full truncate rounded border px-2 py-0.5 text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600' SOURCE_COLORS[e.source] ??
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`} 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
data-testid={`calendar-event-${e.key}`} data-testid={`calendar-event-${e.key}`}
> >
{e.title} {e.title}
@ -90,7 +93,7 @@ export function DayView({
className="flex border-b last:border-b-0" className="flex border-b last:border-b-0"
data-testid={`calendar-hour-${String(h).padStart(2, '0')}`} data-testid={`calendar-hour-${String(h).padStart(2, '0')}`}
> >
<div className="w-16 shrink-0 border-r bg-gray-50 p-1 text-right text-xs text-gray-400"> <div className="w-16 shrink-0 border-r bg-gray-50 dark:bg-gray-900 p-1 text-right text-xs text-gray-400 dark:text-gray-500">
{String(h).padStart(2, '0')}:00 {String(h).padStart(2, '0')}:00
</div> </div>
<div className="flex-1 p-1"> <div className="flex-1 p-1">
@ -101,8 +104,9 @@ export function DayView({
onClick={() => onSelectDate(key)} onClick={() => onSelectDate(key)}
title={e.title} title={e.title}
className={`mb-1 block w-full truncate rounded border px-2 py-1 text-left text-xs ${ className={`mb-1 block w-full truncate rounded border px-2 py-1 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600' SOURCE_COLORS[e.source] ??
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`} 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
data-testid={`calendar-event-${e.key}`} data-testid={`calendar-event-${e.key}`}
> >
<span className="font-mono text-[10px] opacity-70"> <span className="font-mono text-[10px] opacity-70">

View File

@ -51,7 +51,7 @@ export function EventDetailDialog({
<div <div
ref={dialogRef} ref={dialogRef}
tabIndex={-1} tabIndex={-1}
className="mt-16 w-full max-w-lg rounded-lg bg-white shadow-xl focus:outline-none" className="mt-16 w-full max-w-lg rounded-lg bg-white dark:bg-gray-800 shadow-xl focus:outline-none"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@ -59,11 +59,13 @@ export function EventDetailDialog({
data-testid="calendar-detail-dialog" data-testid="calendar-detail-dialog"
> >
<div className="flex items-center justify-between border-b p-4"> <div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800">{dateLabel}</h2> <h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">
{dateLabel}
</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600" className="rounded p-1 text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600"
aria-label="閉じる" aria-label="閉じる"
data-testid="calendar-detail-close" data-testid="calendar-detail-close"
> >
@ -72,34 +74,35 @@ export function EventDetailDialog({
</div> </div>
<div className="max-h-[60vh] space-y-3 overflow-y-auto p-4"> <div className="max-h-[60vh] space-y-3 overflow-y-auto p-4">
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400 dark:text-gray-500">
</p> </p>
) : ( ) : (
events.map((e) => ( events.map((e) => (
<div <div
key={e.key} key={e.key}
className="rounded border border-gray-200 p-3" className="rounded border border-gray-200 dark:border-gray-700 p-3"
data-testid={`calendar-detail-${e.key}`} data-testid={`calendar-detail-${e.key}`}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="font-medium text-gray-800 break-words"> <p className="font-medium text-gray-800 dark:text-gray-100 break-words">
{e.title} {e.title}
</p> </p>
<span <span
className={`shrink-0 rounded px-2 py-0.5 text-xs ${ className={`shrink-0 rounded px-2 py-0.5 text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600' SOURCE_COLORS[e.source] ??
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`} }`}
> >
{SOURCE_LABELS[e.source] ?? e.source} {SOURCE_LABELS[e.source] ?? e.source}
</span> </span>
</div> </div>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatTime(e.startAt)} {formatTime(e.startAt)}
{e.endAt ? `${formatTime(e.endAt)}` : ''} {e.endAt ? `${formatTime(e.endAt)}` : ''}
</p> </p>
{e.description && ( {e.description && (
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600"> <p className="mt-2 whitespace-pre-wrap text-sm text-gray-600 dark:text-gray-300">
{e.description} {e.description}
</p> </p>
)} )}

View File

@ -35,7 +35,7 @@ export function MilestoneForm({ projectId }: { projectId: number }) {
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end" className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row sm:items-end"
> >
<div className="flex-1"> <div className="flex-1">
<label className="block text-sm font-medium"></label> <label className="block text-sm font-medium"></label>

View File

@ -28,8 +28,8 @@ export function MonthView({
onSelectDate: (dateKey: string) => void; onSelectDate: (dateKey: string) => void;
}) { }) {
return ( return (
<div className="overflow-hidden rounded-lg border bg-white"> <div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800">
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500"> <div className="grid grid-cols-7 border-b bg-gray-50 dark:bg-gray-900 text-center text-xs font-medium text-gray-500 dark:text-gray-400">
{WEEKDAY_LABELS.map((w) => ( {WEEKDAY_LABELS.map((w) => (
<div key={w} className="py-2"> <div key={w} className="py-2">
{w} {w}
@ -50,7 +50,9 @@ export function MonthView({
<div <div
key={key} key={key}
className={`min-h-[110px] border-r border-t p-1 last:border-r-0 ${ className={`min-h-[110px] border-r border-t p-1 last:border-r-0 ${
inMonth ? 'bg-white' : 'bg-gray-50' inMonth
? 'bg-white dark:bg-gray-800'
: 'bg-gray-50 dark:bg-gray-900'
}`} }`}
data-testid={`calendar-day-${key}`} data-testid={`calendar-day-${key}`}
> >
@ -61,8 +63,8 @@ export function MonthView({
isToday isToday
? 'bg-blue-600 font-bold text-white' ? 'bg-blue-600 font-bold text-white'
: inMonth : inMonth
? 'text-gray-700 hover:bg-gray-100' ? 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-gray-300 hover:bg-gray-100' : 'text-gray-300 dark:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
aria-label={`${key} の詳細を開く`} aria-label={`${key} の詳細を開く`}
> >
@ -76,8 +78,9 @@ export function MonthView({
onClick={() => onSelectDate(key)} onClick={() => onSelectDate(key)}
title={e.title} title={e.title}
className={`block w-full truncate rounded border px-1 text-left text-xs ${ className={`block w-full truncate rounded border px-1 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600' SOURCE_COLORS[e.source] ??
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`} 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
data-testid={`calendar-event-${e.key}`} data-testid={`calendar-event-${e.key}`}
> >
{e.title} {e.title}
@ -88,7 +91,7 @@ export function MonthView({
type="button" type="button"
onClick={() => onSelectDate(key)} onClick={() => onSelectDate(key)}
aria-label={`${key}の残り${hidden}件を開く`} aria-label={`${key}の残り${hidden}件を開く`}
className="block w-full truncate text-left text-xs text-gray-400 hover:text-gray-600" className="block w-full truncate text-left text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600"
> >
+{hidden} +{hidden}
</button> </button>

View File

@ -23,8 +23,8 @@ export function WeekView({
onSelectDate: (dateKey: string) => void; onSelectDate: (dateKey: string) => void;
}) { }) {
return ( return (
<div className="overflow-hidden rounded-lg border bg-white"> <div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800">
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500"> <div className="grid grid-cols-7 border-b bg-gray-50 dark:bg-gray-900 text-center text-xs font-medium text-gray-500 dark:text-gray-400">
{days.map((d) => { {days.map((d) => {
const key = toISODate(d); const key = toISODate(d);
return ( return (
@ -32,15 +32,17 @@ export function WeekView({
key={key} key={key}
type="button" type="button"
onClick={() => onSelectDate(key)} onClick={() => onSelectDate(key)}
className="py-2 hover:bg-gray-100" className="py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
data-testid={`calendar-weekday-${key}`} data-testid={`calendar-weekday-${key}`}
> >
<div className="text-gray-500">{WEEKDAY_LABELS[d.getDay()]}</div> <div className="text-gray-500 dark:text-gray-400">
{WEEKDAY_LABELS[d.getDay()]}
</div>
<div <div
className={`mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-sm ${ className={`mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-sm ${
key === todayKey key === todayKey
? 'bg-blue-600 font-bold text-white' ? 'bg-blue-600 font-bold text-white'
: 'text-gray-700' : 'text-gray-700 dark:text-gray-200'
}`} }`}
> >
{d.getDate()} {d.getDate()}
@ -61,7 +63,9 @@ export function WeekView({
> >
<div className="space-y-1"> <div className="space-y-1">
{dayEvents.length === 0 ? ( {dayEvents.length === 0 ? (
<p className="px-1 py-1 text-xs text-gray-300">-</p> <p className="px-1 py-1 text-xs text-gray-300 dark:text-gray-600">
-
</p>
) : ( ) : (
dayEvents.map((e) => ( dayEvents.map((e) => (
<button <button
@ -70,8 +74,9 @@ export function WeekView({
onClick={() => onSelectDate(key)} onClick={() => onSelectDate(key)}
title={e.title} title={e.title}
className={`block w-full truncate rounded border px-1 py-0.5 text-left text-xs ${ className={`block w-full truncate rounded border px-1 py-0.5 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600' SOURCE_COLORS[e.source] ??
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`} 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
data-testid={`calendar-event-${e.key}`} data-testid={`calendar-event-${e.key}`}
> >
<span className="font-mono text-[10px] opacity-70"> <span className="font-mono text-[10px] opacity-70">

View File

@ -94,7 +94,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
} }
return ( return (
<div className="flex h-[70vh] flex-col rounded-lg border bg-white shadow-sm"> <div className="flex h-[70vh] flex-col rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<ul className="space-y-2" data-testid="chat-messages"> <ul className="space-y-2" data-testid="chat-messages">
{messages.map((m) => ( {messages.map((m) => (
@ -106,8 +106,8 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
<span <span
className={`max-w-[80%] rounded-lg px-3 py-2 ${ className={`max-w-[80%] rounded-lg px-3 py-2 ${
m.body.includes(`@${userName}`) m.body.includes(`@${userName}`)
? 'bg-yellow-100 text-gray-800' ? 'bg-yellow-100 text-gray-800 dark:text-gray-100'
: 'bg-gray-100 text-gray-800' : 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
}`} }`}
> >
{m.body} {m.body}
@ -117,7 +117,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
<AttachmentList attachments={m.attachments} /> <AttachmentList attachments={m.attachments} />
</div> </div>
)} )}
<span className="mt-0.5 text-xs text-gray-400"> <span className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{m.createdAt} {m.createdAt}
</span> </span>
</li> </li>

View File

@ -34,7 +34,7 @@ export function AttachmentList({
return ( return (
<li <li
key={a.id} key={a.id}
className="overflow-hidden rounded border bg-gray-50" className="overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
data-testid={`attachment-${a.id}`} data-testid={`attachment-${a.id}`}
> >
{isImage ? ( {isImage ? (

View File

@ -98,7 +98,7 @@ export const AttachmentPicker = forwardRef<
return ( return (
<div className="space-y-2" data-testid="attachment-picker"> <div className="space-y-2" data-testid="attachment-picker">
<label className="inline-flex cursor-pointer items-center gap-1 rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"> <label className="inline-flex cursor-pointer items-center gap-1 rounded border bg-white dark:bg-gray-800 px-3 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
📎 📎
<input <input
ref={inputRef} ref={inputRef}
@ -111,7 +111,10 @@ export const AttachmentPicker = forwardRef<
/> />
</label> </label>
{loading && ( {loading && (
<p className="text-xs text-gray-500" data-testid="attachment-loading"> <p
className="text-xs text-gray-500 dark:text-gray-400"
data-testid="attachment-loading"
>
... ...
</p> </p>
)} )}
@ -128,7 +131,7 @@ export const AttachmentPicker = forwardRef<
return ( return (
<li <li
key={f.fileId} key={f.fileId}
className="relative overflow-hidden rounded border bg-gray-50" className="relative overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
data-testid={`attachment-picked-${f.fileId}`} data-testid={`attachment-picked-${f.fileId}`}
> >
{isImage ? ( {isImage ? (

View File

@ -20,7 +20,11 @@ export function FileList({
} }
if (files.length === 0) { if (files.length === 0) {
return <p className="text-sm text-gray-400"></p>; return (
<p className="text-sm text-gray-400 dark:text-gray-500">
</p>
);
} }
return ( return (
@ -36,7 +40,7 @@ export function FileList({
return ( return (
<li <li
key={file.id} key={file.id}
className="rounded-lg border bg-white p-3 shadow-sm" className="rounded-lg border bg-white dark:bg-gray-800 p-3 shadow-sm"
data-testid={`file-item-${file.id}`} data-testid={`file-item-${file.id}`}
> >
{isImage ? ( {isImage ? (
@ -56,25 +60,27 @@ export function FileList({
href={url} href={url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline" className="flex h-24 items-center justify-center rounded bg-gray-100 dark:bg-gray-700 text-sm text-blue-600 hover:underline"
> >
PDF PDF
</a> </a>
) : ( ) : (
<a <a
href={url} href={url}
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline" className="flex h-24 items-center justify-center rounded bg-gray-100 dark:bg-gray-700 text-sm text-blue-600 hover:underline"
> >
</a> </a>
)} )}
<p <p
className="mt-2 truncate text-xs text-gray-700" className="mt-2 truncate text-xs text-gray-700 dark:text-gray-200"
title={file.originalName} title={file.originalName}
> >
{file.originalName} {file.originalName}
</p> </p>
<p className="text-xs text-gray-400">{file.size} bytes</p> <p className="text-xs text-gray-400 dark:text-gray-500">
{file.size} bytes
</p>
{canDelete && ( {canDelete && (
<button <button
type="button" type="button"

View File

@ -33,7 +33,7 @@ export function Uploader({ projectId }: { projectId: number }) {
} }
return ( return (
<div className="rounded-lg border bg-white p-4 shadow-sm"> <div className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
<label className="block text-sm font-medium"> <label className="block text-sm font-medium">
</label> </label>
@ -46,7 +46,9 @@ export function Uploader({ projectId }: { projectId: number }) {
data-testid="file-input" data-testid="file-input"
/> />
{loading && ( {loading && (
<p className="mt-1 text-sm text-gray-500">...</p> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
...
</p>
)} )}
{error && ( {error && (
<p className="mt-1 text-sm text-red-600" role="alert"> <p className="mt-1 text-sm text-red-600" role="alert">

View File

@ -3,9 +3,12 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { PublicUser } from '@/lib/auth/getCurrentUser'; import type { PublicUser } from '@/lib/auth/getCurrentUser';
import { NotificationBadge } from '@/components/notifications/NotificationBadge'; import { NotificationBadge } from '@/components/notifications/NotificationBadge';
import { ThemeToggle } from '@/components/layout/ThemeToggle';
import { useI18n } from '@/lib/i18n/I18nProvider';
export function Header({ user }: { user: PublicUser }) { export function Header({ user }: { user: PublicUser }) {
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
async function handleLogout() { async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' }); await fetch('/api/auth/logout', { method: 'POST' });
@ -14,24 +17,34 @@ export function Header({ user }: { user: PublicUser }) {
} }
return ( return (
<header className="flex items-center justify-between border-b bg-white px-6 py-3"> <header className="flex items-center justify-between border-b bg-white px-6 py-3 dark:border-gray-700 dark:bg-gray-800">
<a href="/dashboard" className="font-bold text-gray-800"> <a
href="/dashboard"
className="font-bold text-gray-800 dark:text-gray-100"
>
{t('app.name')}
</a> </a>
<nav className="flex items-center gap-4 text-sm"> <nav className="flex items-center gap-4 text-sm">
<a href="/dashboard" className="text-gray-600 hover:underline"> <a
href="/dashboard"
className="text-gray-600 hover:underline dark:text-gray-300"
>
{t('nav.dashboard')}
</a> </a>
<ThemeToggle />
<NotificationBadge /> <NotificationBadge />
<a href="/profile" className="text-gray-600 hover:underline"> <a
{user.name} href="/profile"
className="text-gray-600 hover:underline dark:text-gray-300"
>
{user.name}
</a> </a>
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline dark:text-blue-400"
> >
{t('header.logout')}
</button> </button>
</nav> </nav>
</header> </header>

View File

@ -1,18 +1,23 @@
const NAV_ITEMS = [ 'use client';
{ href: '', label: '概要' },
{ href: '/board', label: '掲示板' }, import { useI18n } from '@/lib/i18n/I18nProvider';
{ href: '/notes', label: 'メモ' }, import type { MessageKey } from '@/lib/i18n/dictionary';
{ href: '/chat', label: 'チャット' },
{ href: '/todos', label: 'ToDo' }, const NAV_ITEMS: { href: string; key: MessageKey; activeKey: string }[] = [
{ href: '/files', label: 'ファイル' }, { href: '', key: 'nav.overview', activeKey: 'overview' },
{ href: '/calendar', label: 'カレンダー' }, { href: '/board', key: 'nav.board', activeKey: 'board' },
{ href: '/milestones', label: 'マイルストーン' }, { href: '/notes', key: 'nav.notes', activeKey: 'notes' },
{ href: '/meetings', label: 'ミーティング' }, { href: '/chat', key: 'nav.chat', activeKey: 'chat' },
{ href: '/search', label: '検索' }, { href: '/todos', key: 'nav.todos', activeKey: 'todos' },
{ href: '/members', label: 'メンバー' }, { href: '/files', key: 'nav.files', activeKey: 'files' },
{ href: '/activity', label: 'アクティビティ' }, { href: '/calendar', key: 'nav.calendar', activeKey: 'calendar' },
{ href: '/settings', label: '設定' }, { href: '/milestones', key: 'nav.milestones', activeKey: 'milestones' },
] as const; { href: '/meetings', key: 'nav.meetings', activeKey: 'meetings' },
{ href: '/search', key: 'nav.search', activeKey: 'search' },
{ href: '/members', key: 'nav.members', activeKey: 'members' },
{ href: '/activity', key: 'nav.activity', activeKey: 'activity' },
{ href: '/settings', key: 'nav.settings', activeKey: 'settings' },
];
/** /**
* プロジェクト内サブナビゲーション。 * プロジェクト内サブナビゲーション。
@ -38,38 +43,23 @@ export function ProjectNav({
| 'activity' | 'activity'
| 'settings'; | 'settings';
}) { }) {
const activeMap: Record<string, boolean> = { const { t } = useI18n();
overview: active === 'overview',
board: active === 'board',
notes: active === 'notes',
chat: active === 'chat',
todos: active === 'todos',
files: active === 'files',
calendar: active === 'calendar',
milestones: active === 'milestones',
meetings: active === 'meetings',
search: active === 'search',
members: active === 'members',
activity: active === 'activity',
settings: active === 'settings',
};
return ( return (
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm"> <nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm dark:border-gray-700 dark:bg-gray-800">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const key = item.href === '' ? 'overview' : item.href.slice(1);
const href = `/projects/${projectId}${item.href}`; const href = `/projects/${projectId}${item.href}`;
return ( return (
<a <a
key={item.href} key={item.href}
href={href} href={href}
className={ className={
activeMap[key] active === item.activeKey
? 'font-medium text-blue-600' ? 'font-medium text-blue-600 dark:text-blue-400'
: 'text-gray-600 hover:underline' : 'text-gray-600 hover:underline dark:text-gray-300'
} }
> >
{item.label} {t(item.key)}
</a> </a>
); );
})} })}

View File

@ -4,6 +4,8 @@
*/ */
export function Sidebar({ children }: { children: React.ReactNode }) { export function Sidebar({ children }: { children: React.ReactNode }) {
return ( return (
<aside className="w-48 shrink-0 border-r bg-gray-50 p-4">{children}</aside> <aside className="w-48 shrink-0 border-r bg-gray-50 dark:bg-gray-900 p-4">
{children}
</aside>
); );
} }

View File

@ -0,0 +1,24 @@
'use client';
import { useI18n } from '@/lib/i18n/I18nProvider';
/**
* ヘッダー等に置くテーマ切替ボタン。
* クリックで dark/light を即時切替(Cookie + ユーザー設定へ永続化)。
*/
export function ThemeToggle() {
const { theme, setTheme } = useI18n();
const next = theme === 'dark' ? 'light' : 'dark';
return (
<button
type="button"
onClick={() => void setTheme(next)}
aria-label={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
data-testid="theme-toggle"
className="rounded px-2 py-1 text-sm text-gray-600 hover:underline dark:text-gray-300"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}

View File

@ -69,7 +69,7 @@ export function MeetingForm({
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm" className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
data-testid="meeting-form" data-testid="meeting-form"
> >
<div> <div>

View File

@ -50,9 +50,11 @@ export function NoteEditor({
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm" className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
> >
<h2 className="text-sm font-semibold text-gray-700"></h2> <h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">
</h2>
<input <input
type="text" type="text"
value={title} value={title}

View File

@ -36,7 +36,7 @@ export function NoteForm({ projectId }: { projectId: number }) {
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm" className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
> >
<input <input
type="text" type="text"

View File

@ -23,7 +23,7 @@ export function MarkReadButton({ notificationId }: { notificationId: number }) {
type="button" type="button"
onClick={onRead} onClick={onRead}
disabled={busy} disabled={busy}
className="text-xs text-gray-500 hover:underline disabled:opacity-50" className="text-xs text-gray-500 dark:text-gray-400 hover:underline disabled:opacity-50"
> >
{busy ? '処理中...' : '既読にする'} {busy ? '処理中...' : '既読にする'}
</button> </button>

View File

@ -22,7 +22,10 @@ export function NotificationBadge() {
if (count === 0) { if (count === 0) {
return ( return (
<a href="/notifications" className="text-gray-600 hover:underline"> <a
href="/notifications"
className="text-gray-600 dark:text-gray-300 hover:underline"
>
</a> </a>
); );
@ -31,7 +34,7 @@ export function NotificationBadge() {
return ( return (
<a <a
href="/notifications" href="/notifications"
className="relative text-gray-600 hover:underline" className="relative text-gray-600 dark:text-gray-300 hover:underline"
data-notification-count={count} data-notification-count={count}
> >

View File

@ -18,11 +18,15 @@ export function NotificationList({
notifications: Notification[]; notifications: Notification[];
}) { }) {
if (notifications.length === 0) { if (notifications.length === 0) {
return <p className="text-sm text-gray-400"></p>; return (
<p className="text-sm text-gray-400 dark:text-gray-500">
</p>
);
} }
return ( return (
<ul className="divide-y rounded-lg border bg-white shadow-sm"> <ul className="divide-y rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
{notifications.map((notification) => ( {notifications.map((notification) => (
<li key={notification.id} className="p-4"> <li key={notification.id} className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -31,9 +35,13 @@ export function NotificationList({
</span> </span>
<MarkReadButton notificationId={notification.id} /> <MarkReadButton notificationId={notification.id} />
</div> </div>
<p className="mt-2 font-medium text-gray-800">{notification.title}</p> <p className="mt-2 font-medium text-gray-800 dark:text-gray-100">
{notification.title}
</p>
{notification.body && ( {notification.body && (
<p className="mt-1 text-sm text-gray-600">{notification.body}</p> <p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
{notification.body}
</p>
)} )}
{notification.projectId !== null && ( {notification.projectId !== null && (
<a <a

View File

@ -37,7 +37,7 @@ export function AddMemberForm({ projectId }: { projectId: number }) {
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex flex-col gap-2 rounded border bg-white p-4 sm:flex-row sm:items-end" className="flex flex-col gap-2 rounded border bg-white dark:bg-gray-800 p-4 sm:flex-row sm:items-end"
> >
<div className="flex-1"> <div className="flex-1">
<label htmlFor="member-email" className="block text-sm font-medium"> <label htmlFor="member-email" className="block text-sm font-medium">

View File

@ -2,9 +2,11 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useI18n } from '@/lib/i18n/I18nProvider';
export function CreateProjectForm() { export function CreateProjectForm() {
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -30,25 +32,25 @@ export function CreateProjectForm() {
const body = (await res.json().catch(() => null)) as { const body = (await res.json().catch(() => null)) as {
error?: { message?: string }; error?: { message?: string };
} | null; } | null;
setError(body?.error?.message ?? 'プロジェクトの作成に失敗しました'); setError(body?.error?.message ?? t('project.createFailed'));
setLoading(false); setLoading(false);
} }
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end" className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end dark:border-gray-700 dark:bg-gray-800"
> >
<div className="flex-1"> <div className="flex-1">
<label htmlFor="project-name" className="block text-sm font-medium"> <label htmlFor="project-name" className="block text-sm font-medium">
{t('auth.projectName')}
</label> </label>
<input <input
id="project-name" id="project-name"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => 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 required
maxLength={200} maxLength={200}
/> />
@ -58,14 +60,14 @@ export function CreateProjectForm() {
htmlFor="project-description" htmlFor="project-description"
className="block text-sm font-medium" className="block text-sm font-medium"
> >
{t('project.description')}
</label> </label>
<input <input
id="project-description" id="project-description"
type="text" type="text"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(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"
/> />
</div> </div>
<button <button
@ -73,7 +75,7 @@ export function CreateProjectForm() {
disabled={loading} disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50" className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
> >
{loading ? '作成中...' : '新規プロジェクト'} {loading ? t('project.creating') : t('auth.newProject')}
</button> </button>
{error && ( {error && (
<p className="text-sm text-red-600 sm:full" role="alert"> <p className="text-sm text-red-600 sm:full" role="alert">

View File

@ -12,12 +12,16 @@ export function DashboardWidget({
empty?: string; empty?: string;
}) { }) {
return ( return (
<section className="rounded-lg border bg-white p-4 shadow-sm"> <section className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
<h2 className="mb-3 text-sm font-semibold text-gray-700">{title}</h2> <h2 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
{title}
</h2>
{children ? ( {children ? (
<div className="space-y-2">{children}</div> <div className="space-y-2">{children}</div>
) : ( ) : (
<p className="text-sm text-gray-400">{empty ?? 'データがありません'}</p> <p className="text-sm text-gray-400 dark:text-gray-500">
{empty ?? 'データがありません'}
</p>
)} )}
</section> </section>
); );

View File

@ -11,27 +11,30 @@ const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800', active: 'bg-green-100 text-green-800',
on_hold: 'bg-yellow-100 text-yellow-800', on_hold: 'bg-yellow-100 text-yellow-800',
completed: 'bg-blue-100 text-blue-800', completed: 'bg-blue-100 text-blue-800',
archived: 'bg-gray-200 text-gray-700', archived: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200',
}; };
export function ProjectCard({ project }: { project: Project }) { export function ProjectCard({ project }: { project: Project }) {
return ( return (
<a <a
href={`/projects/${project.id}`} href={`/projects/${project.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm transition hover:shadow-md" className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm transition hover:shadow-md"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">{project.name}</h3> <h3 className="font-semibold text-gray-800 dark:text-gray-100">
{project.name}
</h3>
<span <span
className={`rounded px-2 py-0.5 text-xs ${ className={`rounded px-2 py-0.5 text-xs ${
STATUS_COLORS[project.status] ?? 'bg-gray-100 text-gray-700' STATUS_COLORS[project.status] ??
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200'
}`} }`}
> >
{STATUS_LABELS[project.status] ?? project.status} {STATUS_LABELS[project.status] ?? project.status}
</span> </span>
</div> </div>
{project.description && ( {project.description && (
<p className="mt-2 line-clamp-2 text-sm text-gray-600"> <p className="mt-2 line-clamp-2 text-sm text-gray-600 dark:text-gray-300">
{project.description} {project.description}
</p> </p>
)} )}

View File

@ -49,7 +49,7 @@ export function ProjectSettingsForm({
if (!canManage) { if (!canManage) {
return ( return (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500 dark:text-gray-400">
</p> </p>
); );

View File

@ -39,7 +39,7 @@ export function SearchForm({
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row" className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row"
data-testid="search-form" data-testid="search-form"
> >
<input <input

View File

@ -8,7 +8,7 @@ import { TodoDialog } from '@/components/todo/TodoDialog';
const PRIORITY_COLORS: Record<string, string> = { const PRIORITY_COLORS: Record<string, string> = {
high: 'bg-red-100 text-red-700', high: 'bg-red-100 text-red-700',
normal: 'bg-yellow-100 text-yellow-700', normal: 'bg-yellow-100 text-yellow-700',
low: 'bg-gray-100 text-gray-600', low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
}; };
export function KanbanBoard({ export function KanbanBoard({
@ -88,13 +88,13 @@ export function KanbanBoard({
return ( return (
<section <section
key={column.id} key={column.id}
className="w-64 shrink-0 rounded-lg bg-gray-100 p-3" className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, column)} onDrop={(e) => onDrop(e, column)}
data-testid={`kanban-column-${column.id}`} data-testid={`kanban-column-${column.id}`}
data-column-id={column.id} data-column-id={column.id}
> >
<h2 className="mb-2 text-sm font-semibold text-gray-700"> <h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
{column.name} ({colItems.length}) {column.name} ({colItems.length})
</h2> </h2>
<ul className="space-y-2"> <ul className="space-y-2">
@ -112,7 +112,7 @@ export function KanbanBoard({
draggable draggable
onDragStart={(e) => onDragStart(e, item.id)} onDragStart={(e) => onDragStart(e, item.id)}
onClick={() => setSelectedItem(item)} onClick={() => setSelectedItem(item)}
className={`cursor-grab rounded border bg-white p-2 shadow-sm ${ className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
draggingId === item.id ? 'opacity-50' : '' draggingId === item.id ? 'opacity-50' : ''
} ${item.completedAt ? 'opacity-60 line-through' : ''}`} } ${item.completedAt ? 'opacity-60 line-through' : ''}`}
data-testid={`todo-card-${item.id}`} data-testid={`todo-card-${item.id}`}
@ -146,17 +146,17 @@ export function KanbanBoard({
{item.priority} {item.priority}
</span> </span>
{item.dueDate && ( {item.dueDate && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500 dark:text-gray-400">
: {item.dueDate} : {item.dueDate}
</span> </span>
)} )}
{item.startDate && ( {item.startDate && (
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400 dark:text-gray-500">
: {item.startDate} : {item.startDate}
</span> </span>
)} )}
{assignee && ( {assignee && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500 dark:text-gray-400">
@{assignee.user.name} @{assignee.user.name}
</span> </span>
)} )}
@ -166,7 +166,7 @@ export function KanbanBoard({
{tags.map((t) => ( {tags.map((t) => (
<span <span
key={t} key={t}
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600" className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
> >
{t} {t}
</span> </span>

View File

@ -131,7 +131,7 @@ export function TodoDialog({
data-testid="todo-dialog-backdrop" data-testid="todo-dialog-backdrop"
> >
<div <div
className="mt-8 w-full max-w-xl rounded-lg bg-white shadow-xl" className="mt-8 w-full max-w-xl rounded-lg bg-white dark:bg-gray-800 shadow-xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@ -139,11 +139,13 @@ export function TodoDialog({
data-testid="todo-dialog" data-testid="todo-dialog"
> >
<div className="flex items-center justify-between border-b p-4"> <div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800">ToDoの編集</h2> <h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">
ToDoの編集
</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600" className="rounded p-1 text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600"
aria-label="閉じる" aria-label="閉じる"
data-testid="todo-dialog-close" data-testid="todo-dialog-close"
> >
@ -152,7 +154,9 @@ export function TodoDialog({
</div> </div>
{loading ? ( {loading ? (
<p className="p-6 text-sm text-gray-400">...</p> <p className="p-6 text-sm text-gray-400 dark:text-gray-500">
...
</p>
) : ( ) : (
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-4"> <div className="max-h-[70vh] space-y-3 overflow-y-auto p-4">
<div> <div>
@ -248,7 +252,7 @@ export function TodoDialog({
{tagList.map((t) => ( {tagList.map((t) => (
<span <span
key={t} key={t}
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600" className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs text-gray-600 dark:text-gray-300"
> >
{t} {t}
</span> </span>
@ -262,7 +266,9 @@ export function TodoDialog({
{attachments.length > 0 ? ( {attachments.length > 0 ? (
<AttachmentList attachments={attachments} /> <AttachmentList attachments={attachments} />
) : ( ) : (
<p className="text-xs text-gray-400"></p> <p className="text-xs text-gray-400 dark:text-gray-500">
</p>
)} )}
<div className="mt-2"> <div className="mt-2">
<AttachmentPicker <AttachmentPicker
@ -273,7 +279,7 @@ export function TodoDialog({
</div> </div>
</div> </div>
<div className="space-y-1 border-t pt-3 text-xs text-gray-500"> <div className="space-y-1 border-t pt-3 text-xs text-gray-500 dark:text-gray-400">
<p>: {current.completedAt ?? '未完了'}</p> <p>: {current.completedAt ?? '未完了'}</p>
<p>: {current.createdAt}</p> <p>: {current.createdAt}</p>
<p>: {current.updatedAt}</p> <p>: {current.updatedAt}</p>
@ -291,7 +297,7 @@ export function TodoDialog({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100" className="rounded border px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
> >
</button> </button>

View File

@ -84,11 +84,15 @@ interface User {
avatarUrl: string | null; // アイコン画像URL avatarUrl: string | null; // アイコン画像URL
role: UserRole; // 'system_admin' | 'project_admin' | 'member' | 'guest' role: UserRole; // 'system_admin' | 'project_admin' | 'member' | 'guest'
status: UserStatus; // 'active' | 'inactive' status: UserStatus; // 'active' | 'inactive'
theme: Theme; // 'dark' | 'light' (既定 dark)
locale: Locale; // 'en' | 'ja' (既定 en)
createdAt: string; // ISO8601 createdAt: string; // ISO8601
updatedAt: string; // ISO8601 updatedAt: string; // ISO8601
} }
type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest'; type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
type UserStatus = 'active' | 'inactive'; type UserStatus = 'active' | 'inactive';
type Theme = 'dark' | 'light';
type Locale = 'en' | 'ja';
``` ```
**制約**: emailは一意。passwordHashは平文保存しない。status='inactive'はログイン不可。 **制約**: emailは一意。passwordHashは平文保存しない。status='inactive'はログイン不可。

View File

@ -16,6 +16,8 @@ export function toPublicUser(user: User): PublicUser {
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
role: user.role, role: user.role,
status: user.status, status: user.status,
theme: user.theme,
locale: user.locale,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
}; };

View 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
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;
}

View File

@ -8,6 +8,8 @@
export type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest'; export type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
export type UserStatus = 'active' | 'inactive'; 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 ProjectStatus = 'active' | 'on_hold' | 'completed' | 'archived';
export type ProjectMemberRole = 'admin' | 'member' | 'guest'; export type ProjectMemberRole = 'admin' | 'member' | 'guest';
export type BoardCategory = export type BoardCategory =
@ -48,6 +50,8 @@ export interface User {
avatarUrl: string | null; avatarUrl: string | null;
role: UserRole; role: UserRole;
status: UserStatus; status: UserStatus;
theme: Theme;
locale: Locale;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@ -1,4 +1,6 @@
import { ValidationError } from '@/lib/errors'; 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 EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_NAME_LENGTH = 100; const MAX_NAME_LENGTH = 100;
@ -20,6 +22,8 @@ export interface ProfileUpdateInput {
name?: string; name?: string;
email?: string; email?: string;
avatarUrl?: string; avatarUrl?: string;
theme?: Theme;
locale?: Locale;
} }
export function validateRegister(input: RegisterInput): void { export function validateRegister(input: RegisterInput): void {
@ -41,7 +45,9 @@ export function validateProfileUpdate(input: ProfileUpdateInput): void {
if ( if (
input.name === undefined && input.name === undefined &&
input.email === undefined && input.email === undefined &&
input.avatarUrl === undefined input.avatarUrl === undefined &&
input.theme === undefined &&
input.locale === undefined
) { ) {
throw new ValidationError('更新対象のフィールドを指定してください'); throw new ValidationError('更新対象のフィールドを指定してください');
} }
@ -56,6 +62,12 @@ export function validateProfileUpdate(input: ProfileUpdateInput): void {
'avatarUrl' '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 { function validateName(name: string): void {

View File

@ -12,6 +12,9 @@ export default defineConfig({
use: { use: {
baseURL: 'http://localhost:3000', baseURL: 'http://localhost:3000',
trace: 'on-first-retry', trace: 'on-first-retry',
// E2Eは既存の日本語アサーションを維持するため既定で ja ロケールを使用。
// (既定UIは en だが、テストは ja Cookie を与えて日本語表示を検証する)
storageState: 'tests/e2e/locale-ja.json',
}, },
projects: [ projects: [
{ {

View File

@ -1,5 +1,5 @@
import type { SqliteDatabase } from '@/lib/db/sqlite'; import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { User, UserRole, UserStatus } from '@/lib/types'; import type { Locale, Theme, User, UserRole, UserStatus } from '@/lib/types';
interface UserRow { interface UserRow {
id: number; id: number;
@ -9,6 +9,8 @@ interface UserRow {
avatar_url: string | null; avatar_url: string | null;
role: string; role: string;
status: string; status: string;
theme: string;
locale: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -22,6 +24,8 @@ function mapUser(row: UserRow): User {
avatarUrl: row.avatar_url, avatarUrl: row.avatar_url,
role: row.role as UserRole, role: row.role as UserRole,
status: row.status as UserStatus, status: row.status as UserStatus,
theme: row.theme as Theme,
locale: row.locale as Locale,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@ -40,6 +44,8 @@ export interface UpdateUserInput {
avatarUrl?: string | null; avatarUrl?: string | null;
role?: UserRole; role?: UserRole;
status?: UserStatus; status?: UserStatus;
theme?: Theme;
locale?: Locale;
} }
/** /**
@ -114,6 +120,14 @@ export class UserRepository {
fields.push('status = @status'); fields.push('status = @status');
params.status = input.status; params.status = input.status;
} }
if (input.theme !== undefined) {
fields.push('theme = @theme');
params.theme = input.theme;
}
if (input.locale !== undefined) {
fields.push('locale = @locale');
params.locale = input.locale;
}
this.db.execute( this.db.execute(
`UPDATE users SET ${fields.join(', ')} WHERE id = @id`, `UPDATE users SET ${fields.join(', ')} WHERE id = @id`,

View File

@ -92,6 +92,8 @@ export class AuthService {
name: input.name, name: input.name,
email: input.email, email: input.email,
avatarUrl: input.avatarUrl, avatarUrl: input.avatarUrl,
theme: input.theme,
locale: input.locale,
}); });
if (!updated) { if (!updated) {
throw new NotFoundError('User', userId); throw new NotFoundError('User', userId);

View File

@ -1,6 +1,7 @@
import type { Config } from 'tailwindcss'; import type { Config } from 'tailwindcss';
const config: Config = { const config: Config = {
darkMode: 'class',
content: [ content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}',

View File

@ -22,12 +22,16 @@ test.describe('chat (SSE realtime)', () => {
const memberEmail = unique('member') + '@example.com'; const memberEmail = unique('member') + '@example.com';
// メンバーを先に登録(UIで作成+ログイン) // メンバーを先に登録(UIで作成+ログイン)
const memberContext = await browser.newContext(); const memberContext = await browser.newContext({
storageState: 'tests/e2e/locale-ja.json',
});
const memberPage = await memberContext.newPage(); const memberPage = await memberContext.newPage();
await registerAndLogin(memberPage, memberEmail, 'Member'); await registerAndLogin(memberPage, memberEmail, 'Member');
// オーナー登録+プロジェクト作成 // オーナー登録+プロジェクト作成
const ownerContext = await browser.newContext(); const ownerContext = await browser.newContext({
storageState: 'tests/e2e/locale-ja.json',
});
const ownerPage = await ownerContext.newPage(); const ownerPage = await ownerContext.newPage();
await registerAndLogin(ownerPage, ownerEmail, 'Owner'); await registerAndLogin(ownerPage, ownerEmail, 'Owner');
await ownerPage.getByLabel('プロジェクト名').fill(unique('Proj')); await ownerPage.getByLabel('プロジェクト名').fill(unique('Proj'));

15
tests/e2e/locale-ja.json Normal file
View File

@ -0,0 +1,15 @@
{
"cookies": [
{
"name": "locale",
"value": "ja",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
}
],
"origins": []
}

View File

@ -0,0 +1,75 @@
import { test, expect, type Page } from '@playwright/test';
function unique(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
}
async function setupOwner(page: Page): Promise<number> {
const email = unique('owner') + '@example.com';
await page.goto('/login');
await page.getByRole('button', { name: '新規登録はこちら' }).click();
await page.getByLabel('表示名').fill('Owner');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: '登録する' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
await page.getByRole('button', { name: '新規プロジェクト' }).click();
await expect(page).toHaveURL(/\/projects\/\d+$/);
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
}
test.describe('theme & i18n', () => {
test('a fresh visitor gets English locale and dark theme by default', async ({
browser,
}) => {
// storageState を持たない = 初回訪問者(既定 en / dark)
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
const className = await page.evaluate(
() => document.documentElement.className
);
expect(className).toContain('dark');
await ctx.close();
});
test('theme toggle switches dark<->light and persists', async ({ page }) => {
await setupOwner(page);
// 既定は dark
expect(
await page.evaluate(() => document.documentElement.className)
).toContain('dark');
await page.getByTestId('theme-toggle').click();
expect(
await page.evaluate(() => document.documentElement.className)
).not.toContain('dark');
// リロード後もライトが維持される(Cookie永続化)
await page.reload();
expect(
await page.evaluate(() => document.documentElement.className)
).not.toContain('dark');
});
test('language switch ja -> en updates chrome and persists', async ({
page,
}) => {
const projectId = await setupOwner(page);
await page.goto(`/projects/${projectId}`);
// E2E既定は ja → ヘッダーに ダッシュボード
await expect(
page.getByRole('link', { name: 'ダッシュボード' })
).toBeVisible();
// プロフィールで en に切替
await page.goto('/profile');
await page.getByTestId('profile-locale-select').selectOption('en');
// クロムが英語に切替
await page.goto(`/projects/${projectId}`);
await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
});
});

View File

@ -183,6 +183,7 @@ describe('Migrator', () => {
'001_initial.sql', '001_initial.sql',
'002_attachments.sql', '002_attachments.sql',
'003_todo_tags.sql', '003_todo_tags.sql',
'004_user_prefs.sql',
]); ]);
}); });
}); });

View File

@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('next/headers', () => ({
cookies: vi.fn(),
}));
import { cookies } from 'next/headers';
import { getLocale, getTheme, translate } from '@/lib/i18n/server';
const mockedCookies = vi.mocked(cookies);
function mockCookie(value: string | undefined) {
mockedCookies.mockResolvedValue({
get: () => (value === undefined ? undefined : { value }),
} as never);
}
function mockCookieByName(map: Record<string, string | undefined>) {
mockedCookies.mockResolvedValue({
get: (name: string) =>
map[name] === undefined ? undefined : { value: map[name] },
} as never);
}
describe('i18n/server', () => {
beforeEach(() => vi.clearAllMocks());
describe('getLocale', () => {
it('defaults to en when the cookie is absent', async () => {
mockCookie(undefined);
expect(await getLocale()).toBe('en');
});
it('returns ja when the cookie is ja', async () => {
mockCookieByName({ locale: 'ja' });
expect(await getLocale()).toBe('ja');
});
it('falls back to en for an unknown value', async () => {
mockCookieByName({ locale: 'fr' });
expect(await getLocale()).toBe('en');
});
});
describe('getTheme', () => {
it('defaults to dark when the cookie is absent', async () => {
mockCookie(undefined);
expect(await getTheme()).toBe('dark');
});
it('returns light when the cookie is light', async () => {
mockCookieByName({ theme: 'light' });
expect(await getTheme()).toBe('light');
});
it('falls back to dark for an unknown value', async () => {
mockCookieByName({ theme: 'pink' });
expect(await getTheme()).toBe('dark');
});
});
describe('translate', () => {
it('returns the localized string for each locale', () => {
expect(translate('nav.board', 'en')).toBe('Board');
expect(translate('nav.board', 'ja')).toBe('掲示板');
});
it('falls back to the english string then the key for unknown keys', () => {
// @ts-expect-error -- nonexistent key on purpose
expect(translate('nonexistent.key', 'en')).toBe('nonexistent.key');
});
});
});

View File

@ -123,5 +123,29 @@ describe('UserRepository', () => {
// UPDATE affects 0 rows; findById returns null // UPDATE affects 0 rows; findById returns null
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('defaults theme to dark and locale to en on create', () => {
const created = repo.create({
name: 'Pref',
email: 'p@example.com',
passwordHash: 'h',
});
expect(created.theme).toBe('dark');
expect(created.locale).toBe('en');
});
it('updates theme and locale', () => {
const created = repo.create({
name: 'Pref',
email: 'p@example.com',
passwordHash: 'h',
});
const updated = repo.update(created.id, {
theme: 'light',
locale: 'ja',
});
expect(updated?.theme).toBe('light');
expect(updated?.locale).toBe('ja');
});
}); });
}); });

View File

@ -3,7 +3,12 @@ import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite'; import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository'; import { UserRepository } from '@/repositories/UserRepository';
import { AuthService } from '@/services/AuthService'; import { AuthService } from '@/services/AuthService';
import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors'; import {
ConflictError,
UnauthorizedError,
NotFoundError,
ValidationError,
} from '@/lib/errors';
import { verifySessionToken } from '@/lib/auth/session'; import { verifySessionToken } from '@/lib/auth/session';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
@ -162,6 +167,38 @@ describe('AuthService', () => {
NotFoundError NotFoundError
); );
}); });
it('updates theme and locale', () => {
const created = authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
const updated = authService.updateProfile(created.id, {
theme: 'light',
locale: 'ja',
});
expect(updated.theme).toBe('light');
expect(updated.locale).toBe('ja');
});
it('rejects an invalid theme or locale', () => {
const created = authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
expect(() =>
authService.updateProfile(created.id, {
theme: 'pink' as never,
})
).toThrow(ValidationError);
expect(() =>
authService.updateProfile(created.id, {
locale: 'fr' as never,
})
).toThrow(ValidationError);
});
}); });
describe('createWithRole', () => { describe('createWithRole', () => {