4 Commits

Author SHA1 Message Date
921cdc457d 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と切替を検証
2026-06-25 11:37:44 +02:00
6a134a29bd Merge branch 'feature/todo-metadata-edit-dialog' - todo metadata + edit dialog
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 10:52:20 +02:00
91dd05ba7b feat(todo): richer metadata (tags, startDate, files) + edit/detail dialog
ToDoアイテムのメタデータを拡充し、編集/詳細ダイアログを追加。

- lib/db/migrations/003_todo_tags.sql: todo_items に tags 列を追加
- TodoRepository/TodoService: startDate(従来ハードコードNULL)とtagsをcreate/updateで永続化、fileIdsで添付紐付け(トランザクション)、deleteItemで添付クリーンアップ、getItem/getItemAttachments を追加
- API: GET /todos/items/[itemId] ({item, attachments}) を追加、POST/PATCH で startDate/tags/fileIds を受理、PATCH は null(クリア)と undefined(更新しない)を区別
- UI: TodoDialog(タイトル/説明/担当/優先度/開始日/期限/タグ/添付を編集、完了日時・タイムスタンプは読み取り専用)、KanbanBoard のカードクリックでダイアログを開きメタデータを表示
- MilestoneRepository の todo マッピングに tags を追加し as never キャストを修正
- docs/functional-design.md に tags / file_assets.source / attachments テーブルを追記
2026-06-25 10:51:50 +02:00
b2b3fb00b5 Merge branch 'feature/chat-board-attachments' - file/image attachments for chat & board
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 10:07:40 +02:00
85 changed files with 1964 additions and 342 deletions

View File

@ -6,6 +6,23 @@ import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { itemId } = await params;
const service = createTodoService();
try {
const item = service.getItem(user.id, Number(itemId));
const attachments = service.getItemAttachments(user.id, Number(itemId));
return NextResponse.json({ item, attachments });
} catch (error) {
return handleApiError(error);
}
}
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> } { params }: { params: Promise<{ projectId: string; itemId: string }> }
@ -35,23 +52,29 @@ export async function PATCH(
); );
return NextResponse.json({ item }); return NextResponse.json({ item });
} }
// nullable フィールドは absent(undefined=更新しない) と null(クリア) を区別する
const nullableString = (v: unknown): string | null | undefined =>
v === undefined ? undefined : v === null ? null : String(v);
const nullableNumber = (v: unknown): number | null | undefined =>
v === undefined ? undefined : v === null ? null : Number(v);
const item = service.updateItem(user.id, Number(itemId), { const item = service.updateItem(user.id, Number(itemId), {
title: typeof body.title === 'string' ? body.title : undefined, title: typeof body.title === 'string' ? body.title : undefined,
description: description: nullableString(body.description),
typeof body.description === 'string' ? body.description : undefined, assigneeId: nullableNumber(body.assigneeId),
assigneeId:
body.assigneeId === null || body.assigneeId === undefined
? undefined
: Number(body.assigneeId),
priority: priority:
typeof body.priority === 'string' typeof body.priority === 'string'
? (body.priority as 'low' | 'normal' | 'high') ? (body.priority as 'low' | 'normal' | 'high')
: undefined, : undefined,
dueDate: typeof body.dueDate === 'string' ? body.dueDate : undefined, startDate: nullableString(body.startDate),
milestoneId: dueDate: nullableString(body.dueDate),
body.milestoneId === null || body.milestoneId === undefined tags: nullableString(body.tags),
? undefined milestoneId: nullableNumber(body.milestoneId),
: Number(body.milestoneId), fileIds: Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: undefined,
}); });
return NextResponse.json({ item }); return NextResponse.json({ item });
} catch (error) { } catch (error) {

View File

@ -38,6 +38,11 @@ export async function POST(
} }
const service = createTodoService(); const service = createTodoService();
try { try {
const fileIds = Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const item = service.createItem(user.id, Number(projectId), { const item = service.createItem(user.id, Number(projectId), {
title: String(body.title ?? ''), title: String(body.title ?? ''),
columnId: Number(body.columnId ?? 0), columnId: Number(body.columnId ?? 0),
@ -51,7 +56,11 @@ export async function POST(
typeof body.priority === 'string' typeof body.priority === 'string'
? (body.priority as 'low' | 'normal' | 'high') ? (body.priority as 'low' | 'normal' | 'high')
: undefined, : undefined,
startDate:
typeof body.startDate === 'string' ? body.startDate : undefined,
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null, dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
tags: typeof body.tags === 'string' ? body.tags : undefined,
fileIds,
}); });
return NextResponse.json({ item }, { status: 201 }); return NextResponse.json({ item }, { status: 201 });
} catch (error) { } catch (error) {

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

@ -31,9 +31,10 @@ export default async function TodosPage({
const todoService = createTodoService(); const todoService = createTodoService();
const columns = todoService.getColumns(user.id, project.id); const columns = todoService.getColumns(user.id, project.id);
const items = todoService.getItems(user.id, project.id); const items = todoService.getItems(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">
@ -42,6 +43,7 @@ export default async function TodosPage({
projectId={project.id} projectId={project.id}
columns={columns} columns={columns}
initialItems={items} initialItems={items}
members={members}
/> />
</main> </main>
</div> </div>

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

@ -2,24 +2,29 @@
import { useCallback, useState, type DragEvent } from 'react'; import { useCallback, useState, type DragEvent } from 'react';
import type { TodoColumn, TodoItem } from '@/lib/types'; import type { TodoColumn, TodoItem } from '@/lib/types';
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
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({
projectId, projectId,
columns, columns,
initialItems, initialItems,
members,
}: { }: {
projectId: number; projectId: number;
columns: TodoColumn[]; columns: TodoColumn[];
initialItems: TodoItem[]; initialItems: TodoItem[];
members: ProjectMemberWithUser[];
}) { }) {
const [items, setItems] = useState<TodoItem[]>(initialItems); const [items, setItems] = useState<TodoItem[]>(initialItems);
const [draggingId, setDraggingId] = useState<number | null>(null); const [draggingId, setDraggingId] = useState<number | null>(null);
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
const reload = useCallback(async () => { const reload = useCallback(async () => {
const res = await fetch(`/api/projects/${projectId}/todos/items`); const res = await fetch(`/api/projects/${projectId}/todos/items`);
@ -44,7 +49,7 @@ export function KanbanBoard({
setDraggingId(null); setDraggingId(null);
return; return;
} }
// 楽観的にローカル更新したあとAPIで移動 // 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す)
setItems((prev) => setItems((prev) =>
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i)) prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i))
); );
@ -52,7 +57,9 @@ export function KanbanBoard({
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }), body: JSON.stringify({ columnId: column.id, orderIndex: 0 }),
}).then(() => reload()); })
.catch(() => undefined)
.finally(() => reload());
setDraggingId(null); setDraggingId(null);
} }
@ -81,58 +88,109 @@ 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">
{colItems.map((item) => ( {colItems.map((item) => {
<li const tags = item.tags
key={item.id} ?.split(',')
draggable .map((t) => t.trim())
onDragStart={(e) => onDragStart(e, item.id)} .filter(Boolean);
className={`cursor-grab rounded border bg-white p-2 shadow-sm ${ const assignee = members.find(
draggingId === item.id ? 'opacity-50' : '' (m) => m.user.id === item.assigneeId
} ${item.completedAt ? 'opacity-60 line-through' : ''}`} );
data-testid={`todo-card-${item.id}`} return (
> <li
<div className="flex items-start justify-between gap-2"> key={item.id}
<span className="text-sm">{item.title}</span> draggable
<button onDragStart={(e) => onDragStart(e, item.id)}
type="button" onClick={() => setSelectedItem(item)}
onClick={() => toggleComplete(item.id)} className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
className="text-xs text-blue-600 hover:underline" draggingId === item.id ? 'opacity-50' : ''
data-testid={`todo-complete-${item.id}`} } ${item.completedAt ? 'opacity-60 line-through' : ''}`}
> data-testid={`todo-card-${item.id}`}
{item.completedAt ? '戻す' : '完了'} >
</button> <div className="flex items-start justify-between gap-2">
</div> <span
<div className="mt-1 flex gap-1"> className="text-sm hover:text-blue-600 hover:underline"
<span data-testid={`todo-open-${item.id}`}
className={`rounded px-1.5 py-0.5 text-xs ${ >
PRIORITY_COLORS[item.priority] ?? PRIORITY_COLORS.normal {item.title}
}`}
>
{item.priority}
</span>
{item.dueDate && (
<span className="text-xs text-gray-500">
: {item.dueDate}
</span> </span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleComplete(item.id);
}}
className="text-xs text-blue-600 hover:underline"
data-testid={`todo-complete-${item.id}`}
>
{item.completedAt ? '戻す' : '完了'}
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
PRIORITY_COLORS[item.priority] ??
PRIORITY_COLORS.normal
}`}
>
{item.priority}
</span>
{item.dueDate && (
<span className="text-xs text-gray-500 dark:text-gray-400">
: {item.dueDate}
</span>
)}
{item.startDate && (
<span className="text-xs text-gray-400 dark:text-gray-500">
: {item.startDate}
</span>
)}
{assignee && (
<span className="text-xs text-gray-500 dark:text-gray-400">
@{assignee.user.name}
</span>
)}
</div>
{tags && tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tags.map((t) => (
<span
key={t}
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}
</span>
))}
</div>
)} )}
</div> </li>
</li> );
))} })}
</ul> </ul>
<NewTaskInput onAdd={(title) => addTask(column.id, title)} /> <NewTaskInput onAdd={(title) => addTask(column.id, title)} />
</section> </section>
); );
})} })}
{selectedItem && (
<TodoDialog
projectId={projectId}
item={selectedItem}
members={members}
onClose={() => setSelectedItem(null)}
onSaved={reload}
/>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,317 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { AttachmentView, TodoItem, TodoPriority } from '@/lib/types';
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
import { AttachmentList } from '@/components/files/AttachmentList';
import {
AttachmentPicker,
type AttachmentPickerHandle,
} from '@/components/files/AttachmentPicker';
const PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
function parseTags(tags: string | null): string[] {
if (!tags) return [];
return tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
/**
* ToDo編集/詳細ダイアログ。
* カードクリックで開き、タイトル/説明/担当/優先度/開始日/期限/タグ/添付を
* 編集・閲覧できる。完了日(完了時刻)・作成/更新日時は読み取り専用で表示。
*/
export function TodoDialog({
projectId,
item,
members,
onClose,
onSaved,
}: {
projectId: number;
item: TodoItem;
members: ProjectMemberWithUser[];
onClose: () => void;
onSaved: () => void | Promise<void>;
}) {
const [title, setTitle] = useState(item.title);
const [description, setDescription] = useState(item.description ?? '');
const [assigneeId, setAssigneeId] = useState<string>(
item.assigneeId ? String(item.assigneeId) : ''
);
const [priority, setPriority] = useState<TodoPriority>(item.priority);
const [startDate, setStartDate] = useState(item.startDate ?? '');
const [dueDate, setDueDate] = useState(item.dueDate ?? '');
const [tags, setTags] = useState(item.tags ?? '');
const [attachments, setAttachments] = useState<AttachmentView[]>([]);
const [current, setCurrent] = useState<TodoItem>(item);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pickerLoading, setPickerLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pickerRef = useRef<AttachmentPickerHandle>(null);
// 開封時に最新のアイテム+添付を取得
useEffect(() => {
let alive = true;
setLoading(true);
fetch(`/api/projects/${projectId}/todos/items/${item.id}`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!alive || !data) return;
const fetched = data.item as TodoItem;
setTitle(fetched.title);
setDescription(fetched.description ?? '');
setAssigneeId(fetched.assigneeId ? String(fetched.assigneeId) : '');
setPriority(fetched.priority);
setStartDate(fetched.startDate ?? '');
setDueDate(fetched.dueDate ?? '');
setTags(fetched.tags ?? '');
setAttachments((data.attachments as AttachmentView[]) ?? []);
setCurrent(fetched);
})
.catch(() => undefined)
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [projectId, item.id]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
async function onSave() {
setSaving(true);
setError(null);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch(
`/api/projects/${projectId}/todos/items/${item.id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
assigneeId: assigneeId ? Number(assigneeId) : null,
priority,
startDate: startDate || null,
dueDate: dueDate || null,
tags: tags || null,
fileIds,
}),
}
);
setSaving(false);
if (res.ok) {
pickerRef.current?.clear();
await onSaved();
onClose();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '保存に失敗しました');
}
}
const tagList = parseTags(tags);
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
onClick={onClose}
data-testid="todo-dialog-backdrop"
>
<div
className="mt-8 w-full max-w-xl rounded-lg bg-white dark:bg-gray-800 shadow-xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`ToDo ${item.title} の編集`}
data-testid="todo-dialog"
>
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">
ToDoの編集
</h2>
<button
type="button"
onClick={onClose}
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="閉じる"
data-testid="todo-dialog-close"
>
×
</button>
</div>
{loading ? (
<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>
<label className="block text-sm font-medium"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-title"
/>
</div>
<div>
<label className="block text-sm font-medium"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 min-h-[80px] w-full rounded border px-3 py-2"
data-testid="todo-description"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium"></label>
<select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-assignee"
>
<option value=""></option>
{members.map((m) => (
<option key={m.user.id} value={String(m.user.id)}>
{m.user.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium"></label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as TodoPriority)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-priority"
>
{PRIORITIES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-start-date"
/>
</div>
<div>
<label className="block text-sm font-medium">
(deadline)
</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-due-date"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium">
()
</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="frontend, urgent"
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-tags"
/>
{tagList.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tagList.map((t) => (
<span
key={t}
className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs text-gray-600 dark:text-gray-300"
>
{t}
</span>
))}
</div>
)}
</div>
<div>
<p className="mb-1 text-sm font-medium"></p>
{attachments.length > 0 ? (
<AttachmentList attachments={attachments} />
) : (
<p className="text-xs text-gray-400 dark:text-gray-500">
</p>
)}
<div className="mt-2">
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
</div>
</div>
<div className="space-y-1 border-t pt-3 text-xs text-gray-500 dark:text-gray-400">
<p>: {current.completedAt ?? '未完了'}</p>
<p>: {current.createdAt}</p>
<p>: {current.updatedAt}</p>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
)}
<div className="flex justify-end gap-2 border-t p-4">
<button
type="button"
onClick={onClose}
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
type="button"
onClick={onSave}
disabled={saving || pickerLoading || loading}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
data-testid="todo-save"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
}

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'はログイン不可。
@ -200,6 +204,7 @@ interface TodoItem {
completedAt: string | null; completedAt: string | null;
orderIndex: number; orderIndex: number;
milestoneId: number | null; // FK milestones.id milestoneId: number | null; // FK milestones.id
tags: string | null; // カンマ区切りのタグ(project_notes.tags と同じ方式)
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
@ -219,9 +224,31 @@ interface FileAsset {
mimeType: string; // MIMEタイプアップロード時チェック mimeType: string; // MIMEタイプアップロード時チェック
size: number; // バイト数 size: number; // バイト数
path: string; // ローカルパスuploads/... path: string; // ローカルパスuploads/...
source: FileAssetSource; // 'library'(Files一覧公開) | 'attachment'(添付専用)
createdAt: string; createdAt: string;
deletedAt: string | null; deletedAt: string | null;
} }
type FileAssetSource = 'library' | 'attachment';
```
### attachments
```typescript
// file_assets とチャット/掲示板/ToDo を多対多で紐付ける
interface Attachment {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
fileId: number; // FK file_assets.id
targetType: AttachmentTargetType;
targetId: number; // targetType に応じた chat_message/board_thread/board_comment/todo_item の id
createdAt: string;
deletedAt: string | null;
}
type AttachmentTargetType =
| 'chat_message'
| 'board_thread'
| 'board_comment'
| 'todo_item';
``` ```
### project_notes ### project_notes
@ -410,6 +437,7 @@ erDiagram
integer assignee_id FK integer assignee_id FK
integer milestone_id FK integer milestone_id FK
text due_date text due_date
text tags
} }
meetings { meetings {
integer id PK integer id PK

View File

@ -235,7 +235,7 @@ components/
├── project/ # ProjectCard, DashboardWidget ├── project/ # ProjectCard, DashboardWidget
├── board/ # ThreadList, ThreadForm, CommentList ├── board/ # ThreadList, ThreadForm, CommentList
├── chat/ # ChatWindow, MessageInput, MessageList ├── chat/ # ChatWindow, MessageInput, MessageList
├── todo/ # KanbanBoard, KanbanColumn, TodoCard ├── todo/ # KanbanBoard, TodoDialog
├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker ├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker
├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm ├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm
├── meetings/ # MeetingForm, ConflictWarning ├── meetings/ # MeetingForm, ConflictWarning

View File

@ -98,12 +98,19 @@ export function createChatService(): ChatService {
export function createTodoService(): TodoService { export function createTodoService(): TodoService {
const db = getDb(); const db = getDb();
const memberRepo = new ProjectMemberRepository(db);
return new TodoService( return new TodoService(
new TodoRepository(db), new TodoRepository(db),
new ProjectMemberRepository(db), memberRepo,
new NotificationService(new NotificationRepository(db)), new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db)), new ActivityLogService(new ActivityLogRepository(db)),
getSseHub() getSseHub(),
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
memberRepo
),
db
); );
} }

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,4 @@
-- 003_todo_tags.sql
-- ToDoアイテムにタグ列を追加(カンマ区切り、project_notes.tags と同じ方式)。
ALTER TABLE todo_items ADD COLUMN tags TEXT;

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;
} }
@ -127,6 +131,7 @@ export interface TodoItem {
completedAt: string | null; completedAt: string | null;
orderIndex: number; orderIndex: number;
milestoneId: number | null; milestoneId: number | null;
tags: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
@ -153,7 +158,8 @@ export type FileAssetSource = 'library' | 'attachment';
export type AttachmentTargetType = export type AttachmentTargetType =
| 'chat_message' | 'chat_message'
| 'board_thread' | 'board_thread'
| 'board_comment'; | 'board_comment'
| 'todo_item';
/** attachments エンティティ。 */ /** attachments エンティティ。 */
export interface Attachment { export interface Attachment {

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,10 @@
import type { SqliteDatabase } from '@/lib/db/sqlite'; import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { Milestone, MilestoneStatus, TodoItem } from '@/lib/types'; import type {
Milestone,
MilestoneStatus,
TodoItem,
TodoPriority,
} from '@/lib/types';
interface MilestoneRow { interface MilestoneRow {
id: number; id: number;
@ -27,6 +32,7 @@ interface TodoItemRow {
completed_at: string | null; completed_at: string | null;
order_index: number; order_index: number;
milestone_id: number | null; milestone_id: number | null;
tags: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted_at: string | null; deleted_at: string | null;
@ -55,12 +61,13 @@ function mapTodo(row: TodoItemRow): TodoItem {
description: row.description, description: row.description,
assigneeId: row.assignee_id, assigneeId: row.assignee_id,
creatorId: row.creator_id, creatorId: row.creator_id,
priority: row.priority as never, priority: row.priority as TodoPriority,
startDate: row.start_date, startDate: row.start_date,
dueDate: row.due_date, dueDate: row.due_date,
completedAt: row.completed_at, completedAt: row.completed_at,
orderIndex: row.order_index, orderIndex: row.order_index,
milestoneId: row.milestone_id, milestoneId: row.milestone_id,
tags: row.tags,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
deletedAt: row.deleted_at, deletedAt: row.deleted_at,

View File

@ -24,6 +24,7 @@ interface TodoItemRow {
completed_at: string | null; completed_at: string | null;
order_index: number; order_index: number;
milestone_id: number | null; milestone_id: number | null;
tags: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted_at: string | null; deleted_at: string | null;
@ -55,6 +56,7 @@ function mapItem(row: TodoItemRow): TodoItem {
completedAt: row.completed_at, completedAt: row.completed_at,
orderIndex: row.order_index, orderIndex: row.order_index,
milestoneId: row.milestone_id, milestoneId: row.milestone_id,
tags: row.tags,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
deletedAt: row.deleted_at, deletedAt: row.deleted_at,
@ -75,7 +77,9 @@ export interface CreateItemInput {
description?: string | null; description?: string | null;
assigneeId?: number | null; assigneeId?: number | null;
priority?: TodoPriority; priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
tags?: string | null;
orderIndex: number; orderIndex: number;
} }
@ -84,7 +88,9 @@ export interface UpdateItemInput {
description?: string | null; description?: string | null;
assigneeId?: number | null; assigneeId?: number | null;
priority?: TodoPriority; priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
tags?: string | null;
completedAt?: string | null; completedAt?: string | null;
columnId?: number; columnId?: number;
orderIndex?: number; orderIndex?: number;
@ -193,8 +199,8 @@ export class TodoRepository {
createItem(input: CreateItemInput): TodoItem { createItem(input: CreateItemInput): TodoItem {
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = this.db.execute( const result = this.db.execute(
`INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at) `INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, tags, created_at, updated_at, deleted_at)
VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, NULL, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`, VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, @startDate, @dueDate, NULL, @orderIndex, @milestoneId, @tags, @createdAt, @updatedAt, NULL)`,
{ {
projectId: input.projectId, projectId: input.projectId,
columnId: input.columnId, columnId: input.columnId,
@ -203,9 +209,11 @@ export class TodoRepository {
assigneeId: input.assigneeId ?? null, assigneeId: input.assigneeId ?? null,
creatorId: input.creatorId, creatorId: input.creatorId,
priority: input.priority ?? 'normal', priority: input.priority ?? 'normal',
startDate: input.startDate ?? null,
dueDate: input.dueDate ?? null, dueDate: input.dueDate ?? null,
orderIndex: input.orderIndex, orderIndex: input.orderIndex,
milestoneId: null, milestoneId: null,
tags: input.tags ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }
@ -237,10 +245,18 @@ export class TodoRepository {
fields.push('priority = @priority'); fields.push('priority = @priority');
params.priority = input.priority; params.priority = input.priority;
} }
if (input.startDate !== undefined) {
fields.push('start_date = @startDate');
params.startDate = input.startDate;
}
if (input.dueDate !== undefined) { if (input.dueDate !== undefined) {
fields.push('due_date = @dueDate'); fields.push('due_date = @dueDate');
params.dueDate = input.dueDate; params.dueDate = input.dueDate;
} }
if (input.tags !== undefined) {
fields.push('tags = @tags');
params.tags = input.tags;
}
if (input.completedAt !== undefined) { if (input.completedAt !== undefined) {
fields.push('completed_at = @completedAt'); fields.push('completed_at = @completedAt');
params.completedAt = input.completedAt; params.completedAt = input.completedAt;

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

@ -2,9 +2,16 @@ import { TodoRepository } from '@/repositories/TodoRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { AttachmentService } from '@/services/AttachmentService';
import { SseHub } from '@/lib/sse/hub'; import { SseHub } from '@/lib/sse/hub';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { TodoColumn, TodoItem, TodoPriority } from '@/lib/types'; import type {
AttachmentView,
TodoColumn,
TodoItem,
TodoPriority,
} from '@/lib/types';
const STANDARD_COLUMNS = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done']; const STANDARD_COLUMNS = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'];
const VALID_PRIORITIES: TodoPriority[] = ['low', 'normal', 'high']; const VALID_PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
@ -15,7 +22,10 @@ export interface CreateItemInput {
description?: string; description?: string;
assigneeId?: number | null; assigneeId?: number | null;
priority?: TodoPriority; priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
tags?: string | null;
fileIds?: number[];
} }
export interface UpdateItemRequest { export interface UpdateItemRequest {
@ -23,16 +33,20 @@ export interface UpdateItemRequest {
description?: string | null; description?: string | null;
assigneeId?: number | null; assigneeId?: number | null;
priority?: TodoPriority; priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
tags?: string | null;
columnId?: number; columnId?: number;
orderIndex?: number; orderIndex?: number;
milestoneId?: number | null; milestoneId?: number | null;
fileIds?: number[];
} }
/** /**
* ToDo/Kanbanの業務ロジックを担うService。 * ToDo/Kanbanの業務ロジックを担うService。
* 標準カラム初期生成、権限チェック、タスクCRUD/移動/完了、 * 標準カラム初期生成、権限チェック、タスクCRUD/移動/完了、
* 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、SSE配信を行う。 * 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、
* 添付ファイル紐付け、SSE配信を行う。
*/ */
export class TodoService { export class TodoService {
constructor( constructor(
@ -40,7 +54,9 @@ export class TodoService {
private readonly projectMemberRepository: ProjectMemberRepository, private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly activityLogService: ActivityLogService, private readonly activityLogService: ActivityLogService,
private readonly sseHub: SseHub private readonly sseHub: SseHub,
private readonly attachmentService: AttachmentService,
private readonly db: SqliteDatabase
) {} ) {}
getColumns(actorId: number, projectId: number): TodoColumn[] { getColumns(actorId: number, projectId: number): TodoColumn[] {
@ -119,20 +135,33 @@ export class TodoService {
if (input.priority && !VALID_PRIORITIES.includes(input.priority)) { if (input.priority && !VALID_PRIORITIES.includes(input.priority)) {
throw new ValidationError('無効な優先度です', 'priority'); throw new ValidationError('無効な優先度です', 'priority');
} }
const orderIndex = // タスク本体と添付紐付けはトランザクション内で一貫保存する
input.columnId !== undefined const item = this.db.transaction(() => {
? this.todoRepository.maxItemOrderIndex(input.columnId) + 1 const orderIndex =
: 0; this.todoRepository.maxItemOrderIndex(input.columnId) + 1;
const item = this.todoRepository.createItem({ const created = this.todoRepository.createItem({
projectId, projectId,
columnId: input.columnId, columnId: input.columnId,
title: input.title, title: input.title,
creatorId: actorId, creatorId: actorId,
description: input.description ?? null, description: input.description ?? null,
assigneeId: input.assigneeId ?? null, assigneeId: input.assigneeId ?? null,
priority: input.priority, priority: input.priority,
dueDate: input.dueDate ?? null, startDate: input.startDate ?? null,
orderIndex, dueDate: input.dueDate ?? null,
tags: input.tags ?? null,
orderIndex,
});
if (input.fileIds && input.fileIds.length > 0) {
this.attachmentService.attach(
actorId,
projectId,
'todo_item',
created.id,
input.fileIds
);
}
return created;
}); });
if (input.assigneeId && input.assigneeId !== actorId) { if (input.assigneeId && input.assigneeId !== actorId) {
@ -155,7 +184,19 @@ export class TodoService {
throw new ValidationError('無効な優先度です', 'priority'); throw new ValidationError('無効な優先度です', 'priority');
} }
const previousAssignee = item.assigneeId; const previousAssignee = item.assigneeId;
const updated = this.todoRepository.updateItem(itemId, input); const updated = this.db.transaction(() => {
const u = this.todoRepository.updateItem(itemId, input);
if (u && input.fileIds && input.fileIds.length > 0) {
this.attachmentService.attach(
actorId,
item.projectId,
'todo_item',
itemId,
input.fileIds
);
}
return u;
});
if (!updated) throw new NotFoundError('TodoItem', itemId); if (!updated) throw new NotFoundError('TodoItem', itemId);
if ( if (
@ -220,10 +261,29 @@ export class TodoService {
if (item.creatorId !== actorId && role !== 'admin') { if (item.creatorId !== actorId && role !== 'admin') {
throw new ForbiddenError('作成者または管理者のみ削除できます'); throw new ForbiddenError('作成者または管理者のみ削除できます');
} }
this.todoRepository.deleteItem(itemId); this.db.transaction(() => {
this.attachmentService.detach('todo_item', itemId);
this.todoRepository.deleteItem(itemId);
});
this.broadcastTodo(item.projectId); this.broadcastTodo(item.projectId);
} }
/** 単一のToDoアイテムを取得(メンバーシップチェック付き)。 */
getItem(actorId: number, itemId: number): TodoItem {
const item = this.todoRepository.findItemById(itemId);
if (!item) throw new NotFoundError('TodoItem', itemId);
this.requireMember(item.projectId, actorId);
return item;
}
/** ToDoに紐付く添付ファイル一覧を取得(メンバーシップチェック付き)。 */
getItemAttachments(actorId: number, itemId: number): AttachmentView[] {
const item = this.todoRepository.findItemById(itemId);
if (!item) throw new NotFoundError('TodoItem', itemId);
this.requireMember(item.projectId, actorId);
return this.attachmentService.listViews('todo_item', itemId);
}
private requireMember(projectId: number, actorId: number): void { private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) { if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません'); throw new ForbiddenError('プロジェクトに参加していません');

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

@ -76,4 +76,99 @@ test.describe('todo / kanban', () => {
page.getByTestId(`kanban-column-${done.id}`).getByText(title) page.getByTestId(`kanban-column-${done.id}`).getByText(title)
).toBeVisible(); ).toBeVisible();
}); });
test('edit dialog shows and persists metadata + file attachments', async ({
page,
}) => {
const projectId = await setupOwner(page);
const colsRes = await page.request.get(
`/api/projects/${projectId}/todos/columns`
);
const cols = (
(await colsRes.json()) as { columns: { id: number; name: string }[] }
).columns;
const backlog = cols.find((c) => c.name === 'Backlog')!;
// オーナー(担当者候補)のuser idを取得
const members = (
(await (
await page.request.get(`/api/projects/${projectId}/members`)
).json()) as { members: { user: { id: number; name: string } }[] }
).members;
const ownerId = members[0].user.id;
// タスク作成(API)
const title = unique('Task');
const createRes = await page.request.post(
`/api/projects/${projectId}/todos/items`,
{ data: { title, columnId: backlog.id } }
);
const { item } = (await createRes.json()) as { item: { id: number } };
// 添付ファイル(1x1 PNG)をアップロード
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
'base64'
);
const upRes = await page.request.post(
`/api/projects/${projectId}/attachments`,
{
multipart: {
file: { name: 'pic.png', mimeType: 'image/png', buffer: png },
},
}
);
expect(upRes.ok()).toBeTruthy();
const { file } = (await upRes.json()) as { file: { id: number } };
// ダイアログを開いてメタデータを編集
await page.goto(`/projects/${projectId}/todos`);
await page.getByTestId(`todo-card-${item.id}`).click();
await expect(page.getByTestId('todo-dialog')).toBeVisible();
await page.getByTestId('todo-description').fill('detailed desc');
await page.getByTestId('todo-tags').fill('frontend, urgent');
await page.getByTestId('todo-start-date').fill('2026-07-01');
await page.getByTestId('todo-due-date').fill('2026-07-31');
await page.getByTestId('todo-assignee').selectOption(String(ownerId));
await page.getByTestId('todo-save').click();
await expect(page.getByTestId('todo-dialog')).toBeHidden();
// カードにタグが表示される
await expect(page.getByText('frontend')).toBeVisible();
// APIでファイルを添付付け
const attachRes = await page.request.patch(
`/api/projects/${projectId}/todos/items/${item.id}`,
{ data: { fileIds: [file.id] } }
);
expect(attachRes.ok()).toBeTruthy();
// GET でアイテム+添付が取得できる
const getRes = await page.request.get(
`/api/projects/${projectId}/todos/items/${item.id}`
);
const body = (await getRes.json()) as {
item: {
description: string;
tags: string;
startDate: string;
dueDate: string;
assigneeId: number;
};
attachments: { fileId: number }[];
};
expect(body.item.description).toBe('detailed desc');
expect(body.item.tags).toBe('frontend, urgent');
expect(body.item.startDate).toBe('2026-07-01');
expect(body.item.dueDate).toBe('2026-07-31');
expect(body.item.assigneeId).toBe(ownerId);
expect(body.attachments).toHaveLength(1);
// ダイアログ再オープンで添付画像が表示される
await page.reload();
await page.getByTestId(`todo-card-${item.id}`).click();
await expect(page.getByTestId('todo-dialog')).toBeVisible();
await expect(page.getByTestId('attachment-list')).toBeVisible();
});
}); });

View File

@ -182,6 +182,8 @@ describe('Migrator', () => {
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([ expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
'001_initial.sql', '001_initial.sql',
'002_attachments.sql', '002_attachments.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

@ -114,6 +114,49 @@ describe('TodoRepository', () => {
expect(updated?.completedAt).toBeTruthy(); expect(updated?.completedAt).toBeTruthy();
}); });
it('persists startDate and tags on create', () => {
const item = repo.createItem({
projectId,
columnId,
title: 't',
creatorId: userId,
orderIndex: 0,
startDate: '2026-07-01',
tags: 'frontend,urgent',
});
const found = repo.findItemById(item.id);
expect(found?.startDate).toBe('2026-07-01');
expect(found?.tags).toBe('frontend,urgent');
});
it('updates startDate and tags', () => {
const item = repo.createItem({
projectId,
columnId,
title: 't',
creatorId: userId,
orderIndex: 0,
});
const updated = repo.updateItem(item.id, {
startDate: '2026-08-01',
tags: 'backend',
});
expect(updated?.startDate).toBe('2026-08-01');
expect(updated?.tags).toBe('backend');
});
it('defaults startDate and tags to null when omitted', () => {
const item = repo.createItem({
projectId,
columnId,
title: 't',
creatorId: userId,
orderIndex: 0,
});
expect(item.startDate).toBeNull();
expect(item.tags).toBeNull();
});
it('isolates items by project', () => { it('isolates items by project', () => {
const p2 = new ProjectRepository(db).create({ const p2 = new ProjectRepository(db).create({
name: 'P2', name: 'P2',

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', () => {

View File

@ -5,10 +5,13 @@ import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository'; import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { TodoRepository } from '@/repositories/TodoRepository'; import { TodoRepository } from '@/repositories/TodoRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository';
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository'; import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { AttachmentService } from '@/services/AttachmentService';
import { TodoService } from '@/services/TodoService'; import { TodoService } from '@/services/TodoService';
import { SseHub } from '@/lib/sse/hub'; import { SseHub } from '@/lib/sse/hub';
import { ForbiddenError, NotFoundError } from '@/lib/errors'; import { ForbiddenError, NotFoundError } from '@/lib/errors';
@ -24,7 +27,13 @@ function makeService(db: SqliteDatabase) {
members, members,
new NotificationService(new NotificationRepository(db)), new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db)), new ActivityLogService(new ActivityLogRepository(db)),
hub hub,
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
members
),
db
), ),
members, members,
users, users,
@ -161,4 +170,142 @@ describe('TodoService', () => {
NotFoundError NotFoundError
); );
}); });
it('createItem persists startDate/tags and attaches files', () => {
const col = service.getColumns(authorId, projectId)[0];
const fileRepo = new FileRepository(db);
const f1 = fileRepo.create({
projectId,
uploaderId: authorId,
filename: 'a.png',
originalName: 'a.png',
mimeType: 'image/png',
size: 1,
path: '/tmp/a.png',
source: 'attachment',
});
const item = service.createItem(authorId, projectId, {
title: 'rich',
columnId: col.id,
startDate: '2026-07-01',
dueDate: '2026-07-31',
tags: 'frontend,urgent',
fileIds: [f1.id],
});
expect(item.startDate).toBe('2026-07-01');
expect(item.tags).toBe('frontend,urgent');
expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
});
it('updateItem updates startDate/tags and attaches new files', () => {
const col = service.getColumns(authorId, projectId)[0];
const item = service.createItem(authorId, projectId, {
title: 't',
columnId: col.id,
});
const fileRepo = new FileRepository(db);
const f1 = fileRepo.create({
projectId,
uploaderId: authorId,
filename: 'b.png',
originalName: 'b.png',
mimeType: 'image/png',
size: 1,
path: '/tmp/b.png',
source: 'attachment',
});
const updated = service.updateItem(authorId, item.id, {
description: 'desc',
startDate: '2026-08-01',
tags: 'backend',
fileIds: [f1.id],
});
expect(updated.description).toBe('desc');
expect(updated.startDate).toBe('2026-08-01');
expect(updated.tags).toBe('backend');
expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
});
it('deleteItem detaches its attachments', () => {
const col = service.getColumns(authorId, projectId)[0];
const fileRepo = new FileRepository(db);
const f1 = fileRepo.create({
projectId,
uploaderId: authorId,
filename: 'a.png',
originalName: 'a.png',
mimeType: 'image/png',
size: 1,
path: '/tmp/a.png',
source: 'attachment',
});
const item = service.createItem(authorId, projectId, {
title: 't',
columnId: col.id,
fileIds: [f1.id],
});
expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
service.deleteItem(authorId, item.id);
expect(
new AttachmentRepository(db).findByTarget('todo_item', item.id)
).toHaveLength(0);
});
it('getItem and getItemAttachments enforce membership', () => {
const col = service.getColumns(authorId, projectId)[0];
const item = service.createItem(authorId, projectId, {
title: 't',
columnId: col.id,
});
expect(service.getItem(authorId, item.id).id).toBe(item.id);
expect(() => service.getItem(outsiderId, item.id)).toThrow(ForbiddenError);
expect(() => service.getItemAttachments(outsiderId, item.id)).toThrow(
ForbiddenError
);
});
it('only the creator or an admin can delete an item (member-but-not-creator cannot)', () => {
const col = service.getColumns(authorId, projectId)[0];
// authorId(admin)が作成したアイテムを memberId(非管理者・非作成者)は削除できない
const item = service.createItem(authorId, projectId, {
title: 't',
columnId: col.id,
});
expect(() => service.deleteItem(memberId, item.id)).toThrow(ForbiddenError);
// admin(作成者)は削除可
service.deleteItem(authorId, item.id);
expect(() => service.getItem(authorId, item.id)).toThrow(NotFoundError);
});
it('deleteColumn: admin can delete, non-admin member cannot', () => {
const col = service.getColumns(authorId, projectId)[0];
// memberId(非管理者)はカラム削除不可
expect(() => service.deleteColumn(memberId, col.id)).toThrow(
ForbiddenError
);
// authorId(admin)は削除可
service.deleteColumn(authorId, col.id);
expect(() => service.updateColumn(authorId, col.id, { name: 'x' })).toThrow(
NotFoundError
);
});
it('clears startDate/tags/assigneeId via null on update', () => {
const col = service.getColumns(authorId, projectId)[0];
const item = service.createItem(authorId, projectId, {
title: 't',
columnId: col.id,
startDate: '2026-07-01',
tags: 'a,b',
assigneeId: memberId,
});
const cleared = service.updateItem(authorId, item.id, {
startDate: null,
tags: null,
assigneeId: null,
});
expect(cleared.startDate).toBeNull();
expect(cleared.tags).toBeNull();
expect(cleared.assigneeId).toBeNull();
});
}); });