5 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
25d800a529 feat(chat,board): file/image attachments on chat, board threads and comments
チャット・掲示板(スレッド/コメント)へファイル/画像添付を追加。

- lib/db/migrations/002_attachments.sql: attachments テーブル + file_assets.source 列
- repositories/AttachmentRepository + services/AttachmentService: 添付紐付け(プロジェクト/アップロード者チェック)
- FileStorageService.uploadForAttachment: 通知/SSE/アクティビティを行わない添付専用アップロード(source='attachment')
- ChatService/BoardService: fileIds 受理・添付紐付け・履歴/詳細表示・削除クリーンアップ(トランザクション)
- API: POST /attachments + chat/board の fileIds 受理
- UI: AttachmentList(画像サムネイル+Lightbox/ダウンロード) + AttachmentPicker(複数アップロード・送信中は送信不可) をチャット/スレッド/コメントに統合
- 添付ファイルは既存の /api/files/[fileId]/download(権限チェック済)で配信
2026-06-25 10:07:02 +02:00
106 changed files with 3598 additions and 438 deletions

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createFileStorageService } from '@/lib/api/services';
import { UnauthorizedError, ValidationError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
/**
* チャット/掲示板添付用のファイルアップロード。
* Files一覧公開用の通知/SSE/アクティビティを行わず、source='attachment'で保存する。
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return handleApiError(
new ValidationError('ファイルを指定してください', 'file')
);
}
const data = Buffer.from(await file.arrayBuffer());
const service = createFileStorageService();
try {
const fileAsset = service.uploadForAttachment(user.id, Number(projectId), {
originalName: file.name,
mimeType: file.type || 'application/octet-stream',
data,
});
return NextResponse.json({ file: fileAsset }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -22,10 +22,16 @@ export async function POST(
const service = createBoardService(); const service = createBoardService();
try { try {
const fileIds = Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const comment = service.createComment( const comment = service.createComment(
user.id, user.id,
Number(threadId), Number(threadId),
String(body.bodyMd ?? '') String(body.bodyMd ?? ''),
fileIds
); );
return NextResponse.json({ comment }, { status: 201 }); return NextResponse.json({ comment }, { status: 201 });
} catch (error) { } catch (error) {

View File

@ -42,6 +42,11 @@ export async function POST(
const service = createBoardService(); const service = createBoardService();
try { try {
const fileIds = Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const thread = service.createThread(user.id, Number(projectId), { const thread = service.createThread(user.id, Number(projectId), {
title: String(body.title ?? ''), title: String(body.title ?? ''),
bodyMd: String(body.bodyMd ?? ''), bodyMd: String(body.bodyMd ?? ''),
@ -49,6 +54,7 @@ export async function POST(
typeof body.category === 'string' typeof body.category === 'string'
? (body.category as never) ? (body.category as never)
: undefined, : undefined,
fileIds,
}); });
return NextResponse.json({ thread }, { status: 201 }); return NextResponse.json({ thread }, { status: 201 });
} catch (error) { } catch (error) {

View File

@ -42,10 +42,16 @@ export async function POST(
const service = createChatService(); const service = createChatService();
try { try {
const fileIds = Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const message = service.sendMessage( const message = service.sendMessage(
user.id, user.id,
Number(projectId), Number(projectId),
String(body.body ?? '') String(body.body ?? ''),
fileIds
); );
return NextResponse.json({ message }, { status: 201 }); return NextResponse.json({ message }, { status: 201 });
} catch (error) { } catch (error) {

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

@ -5,6 +5,8 @@ import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav'; import { ProjectNav } from '@/components/layout/ProjectNav';
import { MarkdownBody } from '@/components/board/MarkdownBody'; import { MarkdownBody } from '@/components/board/MarkdownBody';
import { CommentForm } from '@/components/board/CommentForm'; import { CommentForm } from '@/components/board/CommentForm';
import { AttachmentList } from '@/components/files/AttachmentList';
import type { AttachmentView } from '@/lib/types';
import { ForbiddenError, NotFoundError } from '@/lib/errors'; import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -21,9 +23,15 @@ export default async function ThreadDetailPage({
const boardService = createBoardService(); const boardService = createBoardService();
let thread; let thread;
let comments; let comments;
let attachments: { thread: AttachmentView[]; comments: AttachmentView[] };
try { try {
thread = boardService.getThread(user.id, Number(threadId)); thread = boardService.getThread(user.id, Number(threadId));
comments = boardService.listComments(user.id, Number(threadId)); comments = boardService.listComments(user.id, Number(threadId));
attachments = boardService.getAttachments(
user.id,
thread.id,
comments.items.map((c) => c.id)
);
} catch (error) { } catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) { if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect(`/projects/${projectId}/board`); redirect(`/projects/${projectId}/board`);
@ -31,8 +39,20 @@ export default async function ThreadDetailPage({
throw error; throw error;
} }
// コメントIDごとに添付をグループ化
const commentAttachments = new Map(
attachments.comments.map((a) => [
a.targetId,
[] as typeof attachments.comments,
])
);
for (const a of attachments.comments) {
const list = commentAttachments.get(a.targetId);
if (list) list.push(a);
}
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">
@ -42,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>
)} )}
@ -54,7 +74,15 @@ export default async function ThreadDetailPage({
<div className="mt-4"> <div className="mt-4">
<MarkdownBody bodyMd={thread.bodyMd} /> <MarkdownBody bodyMd={thread.bodyMd} />
</div> </div>
<p className="mt-4 text-xs text-gray-400"> {attachments.thread.length > 0 && (
<div className="mt-4">
<p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
</p>
<AttachmentList attachments={attachments.thread} />
</div>
)}
<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>
@ -64,10 +92,20 @@ 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} />
<p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p> {commentAttachments.has(comment.id) &&
commentAttachments.get(comment.id)!.length > 0 && (
<div className="mt-2">
<AttachmentList
attachments={commentAttachments.get(comment.id)!}
/>
</div>
)}
<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

@ -1,7 +1,9 @@
'use client'; 'use client';
import { useState, type FormEvent } from 'react'; import { useRef, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
export function CommentForm({ export function CommentForm({
projectId, projectId,
@ -11,25 +13,29 @@ export function CommentForm({
threadId: number; threadId: number;
}) { }) {
const router = useRouter(); const router = useRouter();
const pickerRef = useRef<AttachmentPickerHandle>(null);
const [bodyMd, setBodyMd] = useState(''); const [bodyMd, setBodyMd] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pickerLoading, setPickerLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) { async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch( const res = await fetch(
`/api/projects/${projectId}/board/threads/${threadId}/comments`, `/api/projects/${projectId}/board/threads/${threadId}/comments`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bodyMd }), body: JSON.stringify({ bodyMd, fileIds }),
} }
); );
setLoading(false); setLoading(false);
if (res.ok) { if (res.ok) {
setBodyMd(''); setBodyMd('');
pickerRef.current?.clear();
router.refresh(); router.refresh();
} else { } else {
const b = (await res.json().catch(() => null)) as { const b = (await res.json().catch(() => null)) as {
@ -48,6 +54,11 @@ export function CommentForm({
className="min-h-[80px] w-full rounded border px-3 py-2" className="min-h-[80px] w-full rounded border px-3 py-2"
required required
/> />
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
{error && ( {error && (
<p className="text-sm text-red-600" role="alert"> <p className="text-sm text-red-600" role="alert">
{error} {error}
@ -55,7 +66,7 @@ export function CommentForm({
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || pickerLoading}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
> >
{loading ? '投稿中...' : 'コメント投稿'} {loading ? '投稿中...' : 'コメント投稿'}

View File

@ -1,7 +1,9 @@
'use client'; 'use client';
import { useState, type FormEvent } from 'react'; import { useRef, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
const CATEGORIES = [ const CATEGORIES = [
{ value: '', label: 'なし' }, { value: '', label: 'なし' },
@ -16,16 +18,19 @@ const CATEGORIES = [
export function ThreadForm({ projectId }: { projectId: number }) { export function ThreadForm({ projectId }: { projectId: number }) {
const router = useRouter(); const router = useRouter();
const pickerRef = useRef<AttachmentPickerHandle>(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [bodyMd, setBodyMd] = useState(''); const [bodyMd, setBodyMd] = useState('');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pickerLoading, setPickerLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) { async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch(`/api/projects/${projectId}/board/threads`, { const res = await fetch(`/api/projects/${projectId}/board/threads`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -33,11 +38,13 @@ export function ThreadForm({ projectId }: { projectId: number }) {
title, title,
bodyMd, bodyMd,
category: category || undefined, category: category || undefined,
fileIds,
}), }),
}); });
setLoading(false); setLoading(false);
if (res.ok) { if (res.ok) {
const data = (await res.json()) as { thread: { id: number } }; const data = (await res.json()) as { thread: { id: number } };
pickerRef.current?.clear();
router.push(`/projects/${projectId}/board/${data.thread.id}`); router.push(`/projects/${projectId}/board/${data.thread.id}`);
router.refresh(); router.refresh();
} else { } else {
@ -51,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
@ -82,6 +89,11 @@ export function ThreadForm({ projectId }: { projectId: number }) {
className="min-h-[120px] w-full rounded border px-3 py-2" className="min-h-[120px] w-full rounded border px-3 py-2"
required required
/> />
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
{error && ( {error && (
<p className="text-sm text-red-600" role="alert"> <p className="text-sm text-red-600" role="alert">
{error} {error}
@ -89,7 +101,7 @@ export function ThreadForm({ projectId }: { projectId: number }) {
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || pickerLoading}
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 ? '作成中...' : 'スレッド作成'}

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

@ -1,7 +1,10 @@
'use client'; 'use client';
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useRef, useState, type FormEvent } from 'react';
import type { ChatMessage } from '@/lib/types'; import type { ChatMessageWithAttachments } from '@/lib/types';
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
import { AttachmentList } from '@/components/files/AttachmentList';
interface ChatWindowProps { interface ChatWindowProps {
projectId: number; projectId: number;
@ -9,16 +12,20 @@ interface ChatWindowProps {
} }
export function ChatWindow({ projectId, userName }: ChatWindowProps) { export function ChatWindow({ projectId, userName }: ChatWindowProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessageWithAttachments[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [pickerLoading, setPickerLoading] = useState(false);
const pickerRef = useRef<AttachmentPickerHandle>(null);
// 履歴取得 // 履歴取得
useEffect(() => { useEffect(() => {
fetch(`/api/projects/${projectId}/chat/messages`) fetch(`/api/projects/${projectId}/chat/messages`)
.then((res) => (res.ok ? res.json() : null)) .then((res) => (res.ok ? res.json() : null))
.then((data) => { .then((data) => {
if (data?.items) setMessages(data.items as ChatMessage[]); if (data?.items)
setMessages(data.items as ChatMessageWithAttachments[]);
}) })
.catch(() => undefined); .catch(() => undefined);
}, [projectId]); }, [projectId]);
@ -30,7 +37,10 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
try { try {
const parsed = JSON.parse(event.data) as { const parsed = JSON.parse(event.data) as {
type: string; type: string;
data: { message?: ChatMessage; id?: number }; data: {
message?: ChatMessageWithAttachments;
id?: number;
};
}; };
if (parsed.type === 'chat.message.created' && parsed.data.message) { if (parsed.type === 'chat.message.created' && parsed.data.message) {
setMessages((prev) => setMessages((prev) =>
@ -42,9 +52,12 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
parsed.type === 'chat.message.updated' && parsed.type === 'chat.message.updated' &&
parsed.data.message parsed.data.message
) { ) {
// 編集イベントは本文のみ更新し、添付は既存のものを保持する
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === parsed.data.message!.id ? parsed.data.message! : m m.id === parsed.data.message!.id
? { ...m, ...parsed.data.message!, attachments: m.attachments }
: m
) )
); );
} else if (parsed.type === 'chat.message.deleted' && parsed.data.id) { } else if (parsed.type === 'chat.message.deleted' && parsed.data.id) {
@ -61,13 +74,17 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
event.preventDefault(); event.preventDefault();
if (!input.trim()) return; if (!input.trim()) return;
setError(null); setError(null);
setSending(true);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch(`/api/projects/${projectId}/chat/messages`, { const res = await fetch(`/api/projects/${projectId}/chat/messages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: input }), body: JSON.stringify({ body: input, fileIds }),
}); });
setSending(false);
if (res.ok) { if (res.ok) {
setInput(''); setInput('');
pickerRef.current?.clear();
} else { } else {
const b = (await res.json().catch(() => null)) as { const b = (await res.json().catch(() => null)) as {
error?: { message?: string }; error?: { message?: string };
@ -77,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) => (
@ -89,13 +106,18 @@ 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}
</span> </span>
<span className="mt-0.5 text-xs text-gray-400"> {m.attachments && m.attachments.length > 0 && (
<div className="max-w-[80%]">
<AttachmentList attachments={m.attachments} />
</div>
)}
<span className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{m.createdAt} {m.createdAt}
</span> </span>
</li> </li>
@ -104,24 +126,32 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
</div> </div>
<form <form
onSubmit={onSend} onSubmit={onSend}
className="flex gap-2 border-t p-3" className="space-y-2 border-t p-3"
data-testid="chat-form" data-testid="chat-form"
> >
<input <AttachmentPicker
type="text" ref={pickerRef}
value={input} projectId={projectId}
onChange={(e) => setInput(e.target.value)} onLoadingChange={setPickerLoading}
placeholder="メッセージを入力(@email でメンション)"
className="flex-1 rounded border px-3 py-2"
data-testid="chat-input"
/> />
<button <div className="flex gap-2">
type="submit" <input
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700" type="text"
data-testid="chat-send" value={input}
> onChange={(e) => setInput(e.target.value)}
placeholder="メッセージを入力(@email でメンション)"
</button> className="flex-1 rounded border px-3 py-2"
data-testid="chat-input"
/>
<button
type="submit"
disabled={sending || pickerLoading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
data-testid="chat-send"
>
{sending ? '送信中...' : '送信'}
</button>
</div>
</form> </form>
{error && ( {error && (
<p className="px-3 pb-2 text-sm text-red-600" role="alert"> <p className="px-3 pb-2 text-sm text-red-600" role="alert">

View File

@ -0,0 +1,93 @@
'use client';
import { useEffect, useState } from 'react';
import type { AttachmentView } from '@/lib/types';
/**
* 添付ファイル一覧表示。画像はサムネイル(クリックでLightbox)、
* それ以外はダウンロードリンク。チャット/掲示板で共通利用。
*/
export function AttachmentList({
attachments,
}: {
attachments: AttachmentView[];
}) {
const [lightbox, setLightbox] = useState<AttachmentView | null>(null);
useEffect(() => {
if (!lightbox) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setLightbox(null);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [lightbox]);
if (attachments.length === 0) return null;
return (
<>
<ul className="mt-1 flex flex-wrap gap-2" data-testid="attachment-list">
{attachments.map((a) => {
const url = `/api/files/${a.fileId}/download`;
const isImage = a.mimeType.startsWith('image/');
return (
<li
key={a.id}
className="overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
data-testid={`attachment-${a.id}`}
>
{isImage ? (
<button
type="button"
onClick={() => setLightbox(a)}
className="block"
aria-label={`画像 ${a.originalName} を開く`}
>
<img
src={url}
alt={a.originalName}
className="h-20 w-20 object-cover"
/>
</button>
) : (
<a
href={url}
className="flex h-20 w-28 flex-col items-center justify-center px-2 text-center text-xs text-blue-600 hover:underline"
>
<span className="mb-1">📎</span>
<span className="w-full truncate" title={a.originalName}>
{a.originalName}
</span>
</a>
)}
</li>
);
})}
</ul>
{lightbox && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setLightbox(null)}
data-testid="attachment-lightbox"
>
<button
type="button"
onClick={() => setLightbox(null)}
className="absolute right-4 top-4 rounded bg-black/50 px-2 py-0.5 text-2xl text-white hover:bg-black/70"
aria-label="閉じる"
>
×
</button>
<img
src={`/api/files/${lightbox.fileId}/download`}
alt={lightbox.originalName}
className="max-h-full max-w-full rounded"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</>
);
}

View File

@ -0,0 +1,166 @@
'use client';
import {
forwardRef,
useImperativeHandle,
useRef,
useState,
type ChangeEvent,
} from 'react';
export interface AttachmentPickerHandle {
getFileIds: () => number[];
clear: () => void;
}
interface PickedFile {
fileId: number;
originalName: string;
mimeType: string;
}
/**
* チャット/掲示板用の添付ファイルピッカー。
* 選択したファイルを添付用エンドポイントへアップロードし、
* 親フォームは送信時に getFileIds() でファイルIDを取り出す。
* 送信完了後は clear() で状態をリセットする。
*/
export const AttachmentPicker = forwardRef<
AttachmentPickerHandle,
{ projectId: number; onLoadingChange?: (loading: boolean) => void }
>(function AttachmentPicker({ projectId, onLoadingChange }, ref) {
const [files, setFiles] = useState<PickedFile[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
function reportLoading(next: boolean) {
setLoading(next);
onLoadingChange?.(next);
}
useImperativeHandle(
ref,
() => ({
getFileIds: () => files.map((f) => f.fileId),
clear: () => {
setFiles([]);
setError(null);
if (inputRef.current) inputRef.current.value = '';
},
}),
[files]
);
async function onFile(event: ChangeEvent<HTMLInputElement>) {
const selected = Array.from(event.target.files ?? []);
if (selected.length === 0) return;
reportLoading(true);
setError(null);
try {
const uploaded: PickedFile[] = [];
for (const f of selected) {
const form = new FormData();
form.append('file', f);
const res = await fetch(`/api/projects/${projectId}/attachments`, {
method: 'POST',
body: form,
});
if (!res.ok) {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
throw new Error(b?.error?.message ?? 'アップロードに失敗しました');
}
const data = (await res.json()) as {
file: { id: number; originalName: string; mimeType: string };
};
uploaded.push({
fileId: data.file.id,
originalName: data.file.originalName,
mimeType: data.file.mimeType,
});
}
setFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(
err instanceof Error ? err.message : 'アップロードに失敗しました'
);
} finally {
reportLoading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
function removeFile(fileId: number) {
setFiles((prev) => prev.filter((f) => f.fileId !== fileId));
}
return (
<div className="space-y-2" data-testid="attachment-picker">
<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
ref={inputRef}
type="file"
multiple
onChange={onFile}
disabled={loading}
className="hidden"
data-testid="attachment-input"
/>
</label>
{loading && (
<p
className="text-xs text-gray-500 dark:text-gray-400"
data-testid="attachment-loading"
>
...
</p>
)}
{error && (
<p className="text-xs text-red-600" role="alert">
{error}
</p>
)}
{files.length > 0 && (
<ul className="flex flex-wrap gap-2">
{files.map((f) => {
const isImage = f.mimeType.startsWith('image/');
const url = `/api/files/${f.fileId}/download`;
return (
<li
key={f.fileId}
className="relative overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
data-testid={`attachment-picked-${f.fileId}`}
>
{isImage ? (
<img
src={url}
alt={f.originalName}
className="h-16 w-16 object-cover"
/>
) : (
<span className="flex h-16 w-24 items-center justify-center px-1 text-center text-[10px] text-blue-600">
<span className="truncate" title={f.originalName}>
{f.originalName}
</span>
</span>
)}
<button
type="button"
onClick={() => removeFile(f.fileId)}
className="absolute right-0 top-0 rounded-bl bg-black/50 px-1 text-xs text-white hover:bg-black/70"
aria-label={`${f.originalName} を削除`}
data-testid={`attachment-remove-${f.fileId}`}
>
×
</button>
</li>
);
})}
</ul>
)}
</div>
);
});

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

@ -181,6 +181,7 @@ repositories/
├── ChatRepository.ts ├── ChatRepository.ts
├── TodoRepository.ts ├── TodoRepository.ts
├── FileRepository.ts ├── FileRepository.ts
├── AttachmentRepository.ts
├── CalendarRepository.ts ├── CalendarRepository.ts
├── MeetingRepository.ts ├── MeetingRepository.ts
├── ProjectNoteRepository.ts ├── ProjectNoteRepository.ts
@ -208,6 +209,7 @@ services/
├── MeetingService.ts ├── MeetingService.ts
├── ScheduleService.ts ├── ScheduleService.ts
├── FileStorageService.ts ├── FileStorageService.ts
├── AttachmentService.ts
├── BackupService.ts ├── BackupService.ts
├── TodoService.ts ├── TodoService.ts
├── BoardService.ts ├── BoardService.ts
@ -233,8 +235,8 @@ 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 ├── 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
├── notes/ # NoteEditor, MarkdownPreview ├── notes/ # NoteEditor, MarkdownPreview

View File

@ -18,6 +18,8 @@ import { TodoService } from '@/services/TodoService';
import { TodoRepository } from '@/repositories/TodoRepository'; import { TodoRepository } from '@/repositories/TodoRepository';
import { FileStorageService } from '@/services/FileStorageService'; import { FileStorageService } from '@/services/FileStorageService';
import { FileRepository } from '@/repositories/FileRepository'; import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentService } from '@/services/AttachmentService';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { ScheduleService } from '@/services/ScheduleService'; import { ScheduleService } from '@/services/ScheduleService';
import { CalendarRepository } from '@/repositories/CalendarRepository'; import { CalendarRepository } from '@/repositories/CalendarRepository';
import { MilestoneRepository } from '@/repositories/MilestoneRepository'; import { MilestoneRepository } from '@/repositories/MilestoneRepository';
@ -51,11 +53,18 @@ export function createActivityLogService(): ActivityLogService {
export function createBoardService(): BoardService { export function createBoardService(): BoardService {
const db = getDb(); const db = getDb();
const memberRepo = new ProjectMemberRepository(db);
return new BoardService( return new BoardService(
new BoardRepository(db), new BoardRepository(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)),
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
memberRepo
),
db
); );
} }
@ -71,23 +80,37 @@ export function createNoteService(): NoteService {
export function createChatService(): ChatService { export function createChatService(): ChatService {
const db = getDb(); const db = getDb();
const memberRepo = new ProjectMemberRepository(db);
return new ChatService( return new ChatService(
new ChatRepository(db), new ChatRepository(db),
new ProjectMemberRepository(db), memberRepo,
new UserRepository(db), new UserRepository(db),
new NotificationService(new NotificationRepository(db)), new NotificationService(new NotificationRepository(db)),
getSseHub() getSseHub(),
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
memberRepo
),
db
); );
} }
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,22 @@
-- 002_attachments.sql
-- チャット/掲示板への添付ファイル関連付けテーブルと、file_assets の来源区分。
-- 11. attachments: メッセージ/スレッド/コメント と file_assets の多対多関連
CREATE TABLE attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
file_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (file_id) REFERENCES file_assets(id)
);
CREATE INDEX idx_attachments_target ON attachments(target_type, target_id);
CREATE INDEX idx_attachments_file ON attachments(file_id);
-- file_assets に source 列を追加(ライブラリ公開 vs 添付専用)。
-- 既存レコードは 'library' となり Files 一覧にそのまま表示される。
ALTER TABLE file_assets ADD COLUMN source TEXT NOT NULL DEFAULT 'library';

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;
@ -141,10 +146,48 @@ export interface FileAsset {
mimeType: string; mimeType: string;
size: number; size: number;
path: string; path: string;
source: FileAssetSource;
createdAt: string; createdAt: string;
deletedAt: string | null; deletedAt: string | null;
} }
/** ファイルの来源。library=Files一覧公開、attachment=添付専用(一覧に出さない)。 */
export type FileAssetSource = 'library' | 'attachment';
/** 添付ファイルの関連付け対象。 */
export type AttachmentTargetType =
| 'chat_message'
| 'board_thread'
| 'board_comment'
| 'todo_item';
/** attachments エンティティ。 */
export interface Attachment {
id: number;
projectId: number;
fileId: number;
targetType: AttachmentTargetType;
targetId: number;
createdAt: string;
deletedAt: string | null;
}
/** UI表示用の添付ファイルビュー(ダウンロードURLは fileId から組み立てる)。 */
export interface AttachmentView {
id: number;
fileId: number;
targetType: AttachmentTargetType;
targetId: number;
originalName: string;
mimeType: string;
size: number;
}
/** チャットメッセージ + 添付ファイル。 */
export interface ChatMessageWithAttachments extends ChatMessage {
attachments: AttachmentView[];
}
export interface ProjectNote { export interface ProjectNote {
id: number; id: number;
projectId: number; projectId: number;
@ -281,7 +324,10 @@ export type NotificationEvent =
export type SseEvent = export type SseEvent =
| { | {
type: 'chat.message.created'; type: 'chat.message.created';
data: { projectId: number; message: ChatMessage }; data: {
projectId: number;
message: ChatMessageWithAttachments;
};
} }
| { | {
type: 'chat.message.updated'; type: 'chat.message.updated';

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

@ -0,0 +1,135 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type {
Attachment,
AttachmentTargetType,
AttachmentView,
} from '@/lib/types';
interface AttachmentRow {
id: number;
project_id: number;
file_id: number;
target_type: string;
target_id: number;
created_at: string;
deleted_at: string | null;
}
interface AttachmentViewRow {
id: number;
file_id: number;
target_type: string;
target_id: number;
original_name: string;
mime_type: string;
size: number;
}
function mapAttachment(row: AttachmentRow): Attachment {
return {
id: row.id,
projectId: row.project_id,
fileId: row.file_id,
targetType: row.target_type as AttachmentTargetType,
targetId: row.target_id,
createdAt: row.created_at,
deletedAt: row.deleted_at,
};
}
function mapView(row: AttachmentViewRow): AttachmentView {
return {
id: row.id,
fileId: row.file_id,
targetType: row.target_type as AttachmentTargetType,
targetId: row.target_id,
originalName: row.original_name,
mimeType: row.mime_type,
size: row.size,
};
}
export interface CreateAttachmentInput {
projectId: number;
fileId: number;
targetType: AttachmentTargetType;
targetId: number;
}
/**
* attachments テーブルのCRUDを担うRepository。
* file_assets とJOINして表示用ビューを返す。
*/
export class AttachmentRepository {
constructor(private readonly db: SqliteDatabase) {}
create(input: CreateAttachmentInput): Attachment {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO attachments (project_id, file_id, target_type, target_id, created_at, deleted_at)
VALUES (@projectId, @fileId, @targetType, @targetId, @createdAt, NULL)`,
{
projectId: input.projectId,
fileId: input.fileId,
targetType: input.targetType,
targetId: input.targetId,
createdAt: now,
}
);
const created = this.findById(Number(result.lastInsertRowid));
if (!created) throw new Error('Failed to create attachment');
return created;
}
findById(id: number): Attachment | null {
const row = this.db.get<AttachmentRow>(
'SELECT * FROM attachments WHERE id = @id AND deleted_at IS NULL',
{ id }
);
return row ? mapAttachment(row) : null;
}
findByTarget(
targetType: AttachmentTargetType,
targetId: number
): Attachment[] {
const rows = this.db.query<AttachmentRow>(
`SELECT * FROM attachments
WHERE target_type = @targetType AND target_id = @targetId AND deleted_at IS NULL
ORDER BY id ASC`,
{ targetType, targetId }
);
return rows.map(mapAttachment);
}
/** 複数ターゲットの添付ビューを一括取得(N+1回避)。 */
findViewsByTargets(
targetType: AttachmentTargetType,
targetIds: number[]
): AttachmentView[] {
if (targetIds.length === 0) return [];
const placeholders = targetIds.map(() => '?').join(',');
const rows = this.db.query<AttachmentViewRow>(
`SELECT a.id AS id, a.file_id AS file_id, a.target_type AS target_type,
a.target_id AS target_id, f.original_name AS original_name,
f.mime_type AS mime_type, f.size AS size
FROM attachments a
JOIN file_assets f ON f.id = a.file_id
WHERE a.target_type = ? AND a.deleted_at IS NULL
AND f.deleted_at IS NULL
AND a.target_id IN (${placeholders})
ORDER BY a.target_id ASC, a.id ASC`,
[targetType, ...targetIds]
);
return rows.map(mapView);
}
/** 対象の添付を論理削除(メッセージ/スレッド/コメント削除時のクリーンアップ)。 */
deleteByTarget(targetType: AttachmentTargetType, targetId: number): boolean {
const result = this.db.execute(
'UPDATE attachments SET deleted_at = @now WHERE target_type = @targetType AND target_id = @targetId AND deleted_at IS NULL',
{ now: new Date().toISOString(), targetType, targetId }
);
return result.changes > 0;
}
}

View File

@ -1,5 +1,5 @@
import type { SqliteDatabase } from '@/lib/db/sqlite'; import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { FileAsset } from '@/lib/types'; import type { FileAsset, FileAssetSource } from '@/lib/types';
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
@ -17,6 +17,7 @@ interface FileAssetRow {
mime_type: string; mime_type: string;
size: number; size: number;
path: string; path: string;
source: string;
created_at: string; created_at: string;
deleted_at: string | null; deleted_at: string | null;
} }
@ -31,6 +32,7 @@ function mapFile(row: FileAssetRow): FileAsset {
mimeType: row.mime_type, mimeType: row.mime_type,
size: row.size, size: row.size,
path: row.path, path: row.path,
source: row.source as FileAssetSource,
createdAt: row.created_at, createdAt: row.created_at,
deletedAt: row.deleted_at, deletedAt: row.deleted_at,
}; };
@ -44,6 +46,7 @@ export interface CreateFileInput {
mimeType: string; mimeType: string;
size: number; size: number;
path: string; path: string;
source?: FileAssetSource;
} }
export class FileRepository { export class FileRepository {
@ -55,15 +58,17 @@ export class FileRepository {
pageSize: number = DEFAULT_PAGE_SIZE pageSize: number = DEFAULT_PAGE_SIZE
): Paginated<FileAsset> { ): Paginated<FileAsset> {
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
// 添付専用ファイル(source='attachment')はFiles一覧に出さない
const items = this.db.query<FileAssetRow>( const items = this.db.query<FileAssetRow>(
`SELECT * FROM file_assets `SELECT * FROM file_assets
WHERE project_id = @projectId AND deleted_at IS NULL WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
LIMIT @pageSize OFFSET @offset`, LIMIT @pageSize OFFSET @offset`,
{ projectId, pageSize, offset } { projectId, pageSize, offset }
); );
const total = this.db.get<{ count: number }>( const total = this.db.get<{ count: number }>(
'SELECT COUNT(*) AS count FROM file_assets WHERE project_id = @projectId AND deleted_at IS NULL', `SELECT COUNT(*) AS count FROM file_assets
WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'`,
{ projectId } { projectId }
); );
return { items: items.map(mapFile), total: total?.count ?? 0 }; return { items: items.map(mapFile), total: total?.count ?? 0 };
@ -79,9 +84,10 @@ export class FileRepository {
create(input: CreateFileInput): FileAsset { create(input: CreateFileInput): FileAsset {
const now = new Date().toISOString(); const now = new Date().toISOString();
const source = input.source ?? 'library';
const result = this.db.execute( const result = this.db.execute(
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, source, created_at, deleted_at)
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @source, @createdAt, NULL)`,
{ {
projectId: input.projectId, projectId: input.projectId,
uploaderId: input.uploaderId, uploaderId: input.uploaderId,
@ -90,6 +96,7 @@ export class FileRepository {
mimeType: input.mimeType, mimeType: input.mimeType,
size: input.size, size: input.size,
path: input.path, path: input.path,
source,
createdAt: now, createdAt: now,
} }
); );

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

@ -0,0 +1,77 @@
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
import type { AttachmentTargetType, AttachmentView } from '@/lib/types';
/**
* 添付ファイルの関連付けを担うService。
* チャット/掲示板から呼ばれ、file_assets と対象(メッセージ/スレッド/コメント)を紐付ける。
* 権限チェック(プロジェクト参加 + 自身のアップロードのみ)とクリーンアップを行う。
*/
export class AttachmentService {
constructor(
private readonly attachmentRepository: AttachmentRepository,
private readonly fileRepository: FileRepository,
private readonly projectMemberRepository: ProjectMemberRepository
) {}
/**
* 指定ターゲットへファイルを添付する。
* 自身が当該プロジェクトにアップロードしたファイルのみ添付可能。
*/
attach(
actorId: number,
projectId: number,
targetType: AttachmentTargetType,
targetId: number,
fileIds: number[]
): AttachmentView[] {
this.requireMember(projectId, actorId);
const ids = Array.from(new Set(fileIds ?? []));
if (ids.length === 0) return [];
for (const fileId of ids) {
const file = this.fileRepository.findFileById(fileId);
if (!file) throw new NotFoundError('FileAsset', fileId);
if (file.projectId !== projectId) {
throw new ForbiddenError('プロジェクト外のファイルは添付できません');
}
if (file.uploaderId !== actorId) {
throw new ForbiddenError('自身のアップロードファイルのみ添付できます');
}
this.attachmentRepository.create({
projectId,
fileId,
targetType,
targetId,
});
}
return this.attachmentRepository.findViewsByTargets(targetType, [targetId]);
}
listViews(
targetType: AttachmentTargetType,
targetId: number
): AttachmentView[] {
return this.attachmentRepository.findViewsByTargets(targetType, [targetId]);
}
listViewsBatch(
targetType: AttachmentTargetType,
targetIds: number[]
): AttachmentView[] {
return this.attachmentRepository.findViewsByTargets(targetType, targetIds);
}
/** 対象の添付を論理削除(メッセージ/スレッド/コメント削除時)。 */
detach(targetType: AttachmentTargetType, targetId: number): void {
this.attachmentRepository.deleteByTarget(targetType, targetId);
}
private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');
}
}
}

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

@ -6,8 +6,15 @@ import type { Paginated } from '@/repositories/BoardRepository';
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 type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types'; import type {
AttachmentView,
BoardCategory,
BoardComment,
BoardThread,
} from '@/lib/types';
const VALID_CATEGORIES: BoardCategory[] = [ const VALID_CATEGORIES: BoardCategory[] = [
'notice', 'notice',
@ -23,6 +30,7 @@ export interface CreateThreadInput {
title: string; title: string;
bodyMd: string; bodyMd: string;
category?: BoardCategory; category?: BoardCategory;
fileIds?: number[];
} }
export interface UpdateThreadInput { export interface UpdateThreadInput {
@ -36,14 +44,17 @@ export interface UpdateThreadInput {
/** /**
* 掲示板の業務ロジックを担うService。 * 掲示板の業務ロジックを担うService。
* 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、 * 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、
* コメント追加時の通知(board_commented)アクティビティログ記録を行う。 * コメント追加時の通知(board_commented)アクティビティログ記録
* 添付ファイル紐付けを行う。
*/ */
export class BoardService { export class BoardService {
constructor( constructor(
private readonly boardRepository: BoardRepository, private readonly boardRepository: BoardRepository,
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 attachmentService: AttachmentService,
private readonly db: SqliteDatabase
) {} ) {}
listThreads( listThreads(
@ -69,12 +80,24 @@ export class BoardService {
): BoardThread { ): BoardThread {
this.requireMember(projectId, actorId); this.requireMember(projectId, actorId);
this.validateThreadInput(input.title, input.bodyMd, input.category); this.validateThreadInput(input.title, input.bodyMd, input.category);
const thread = this.boardRepository.createThread({ const thread = this.db.transaction(() => {
projectId, const t = this.boardRepository.createThread({
title: input.title, projectId,
bodyMd: input.bodyMd, title: input.title,
authorId: actorId, bodyMd: input.bodyMd,
category: input.category ?? null, authorId: actorId,
category: input.category ?? null,
});
if (input.fileIds && input.fileIds.length > 0) {
this.attachmentService.attach(
actorId,
projectId,
'board_thread',
t.id,
input.fileIds
);
}
return t;
}); });
this.activityLogService.logActivity({ this.activityLogService.logActivity({
projectId, projectId,
@ -118,7 +141,10 @@ export class BoardService {
deleteThread(actorId: number, threadId: number): void { deleteThread(actorId: number, threadId: number): void {
const thread = this.getThread(actorId, threadId); const thread = this.getThread(actorId, threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId); this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId);
this.boardRepository.deleteThread(threadId); this.db.transaction(() => {
this.attachmentService.detach('board_thread', threadId);
this.boardRepository.deleteThread(threadId);
});
} }
listComments( listComments(
@ -133,16 +159,29 @@ export class BoardService {
createComment( createComment(
actorId: number, actorId: number,
threadId: number, threadId: number,
bodyMd: string bodyMd: string,
fileIds?: number[]
): BoardComment { ): BoardComment {
const thread = this.getThread(actorId, threadId); const thread = this.getThread(actorId, threadId);
if (!bodyMd.trim()) { if (!bodyMd.trim()) {
throw new ValidationError('コメント本文を入力してください', 'bodyMd'); throw new ValidationError('コメント本文を入力してください', 'bodyMd');
} }
const comment = this.boardRepository.createComment({ const comment = this.db.transaction(() => {
threadId: thread.id, const c = this.boardRepository.createComment({
authorId: actorId, threadId: thread.id,
bodyMd, authorId: actorId,
bodyMd,
});
if (fileIds && fileIds.length > 0) {
this.attachmentService.attach(
actorId,
thread.projectId,
'board_comment',
c.id,
fileIds
);
}
return c;
}); });
// スレッド投稿者へ通知(自分自身へのコメントは通知しない) // スレッド投稿者へ通知(自分自身へのコメントは通知しない)
if (thread.authorId !== actorId) { if (thread.authorId !== actorId) {
@ -188,7 +227,29 @@ export class BoardService {
if (!comment) throw new NotFoundError('BoardComment', commentId); if (!comment) throw new NotFoundError('BoardComment', commentId);
const thread = this.getThread(actorId, comment.threadId); const thread = this.getThread(actorId, comment.threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId); this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId);
this.boardRepository.deleteComment(commentId); this.db.transaction(() => {
this.attachmentService.detach('board_comment', commentId);
this.boardRepository.deleteComment(commentId);
});
}
/**
* スレッド本文と指定コメントの添付ファイルを一括取得する。
* メンバーシップは getThread で検証する。
*/
getAttachments(
actorId: number,
threadId: number,
commentIds: number[]
): { thread: AttachmentView[]; comments: AttachmentView[] } {
const thread = this.getThread(actorId, threadId);
return {
thread: this.attachmentService.listViews('board_thread', thread.id),
comments: this.attachmentService.listViewsBatch(
'board_comment',
commentIds
),
};
} }
private requireMember(projectId: number, actorId: number): void { private requireMember(projectId: number, actorId: number): void {

View File

@ -6,15 +6,21 @@ import type { Paginated } from '@/repositories/ChatRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { UserRepository } from '@/repositories/UserRepository'; import { UserRepository } from '@/repositories/UserRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
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 { ChatMessage } from '@/lib/types'; import type {
AttachmentView,
ChatMessage,
ChatMessageWithAttachments,
} from '@/lib/types';
const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g; const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
/** /**
* チャットの業務ロジックを担うService。 * チャットの業務ロジックを担うService。
* 権限チェック、メッセージ送信/編集/削除、メンション通知、SSE配信を行う。 * 権限チェック、メッセージ送信/編集/削除、メンション通知、添付ファイル紐付け、SSE配信を行う。
*/ */
export class ChatService { export class ChatService {
constructor( constructor(
@ -22,27 +28,56 @@ export class ChatService {
private readonly projectMemberRepository: ProjectMemberRepository, private readonly projectMemberRepository: ProjectMemberRepository,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly sseHub: SseHub private readonly sseHub: SseHub,
private readonly attachmentService: AttachmentService,
private readonly db: SqliteDatabase
) {} ) {}
getHistory( getHistory(
actorId: number, actorId: number,
projectId: number, projectId: number,
opts: ListMessagesOptions = {} opts: ListMessagesOptions = {}
): Paginated<ChatMessage> { ): Paginated<ChatMessageWithAttachments> {
this.requireMember(projectId, actorId); this.requireMember(projectId, actorId);
return this.chatRepository.findMessages(projectId, opts); const page = this.chatRepository.findMessages(projectId, opts);
const byTarget = this.indexAttachments(
'chat_message',
page.items.map((m) => m.id)
);
return {
items: page.items.map((m) => this.withAttachments(m, byTarget.get(m.id))),
total: page.total,
};
} }
sendMessage(actorId: number, projectId: number, body: string): ChatMessage { sendMessage(
actorId: number,
projectId: number,
body: string,
fileIds?: number[]
): ChatMessageWithAttachments {
this.requireMember(projectId, actorId); this.requireMember(projectId, actorId);
if (!body.trim()) { if (!body.trim()) {
throw new ValidationError('メッセージを入力してください', 'body'); throw new ValidationError('メッセージを入力してください', 'body');
} }
const message = this.chatRepository.create({ // メッセージ本体と添付紐付けはトランザクション内で一貫保存する
projectId, const { message, attachments } = this.db.transaction(() => {
authorId: actorId, const msg = this.chatRepository.create({
body, projectId,
authorId: actorId,
body,
});
const atts =
fileIds && fileIds.length > 0
? this.attachmentService.attach(
actorId,
projectId,
'chat_message',
msg.id,
fileIds
)
: [];
return { message: msg, attachments: atts };
}); });
// メンション検出(@email) → 通知 // メンション検出(@email) → 通知
@ -64,11 +99,12 @@ export class ChatService {
} }
} }
const messageWithAttachments = this.withAttachments(message, attachments);
this.sseHub.broadcast(projectId, { this.sseHub.broadcast(projectId, {
type: 'chat.message.created', type: 'chat.message.created',
data: { projectId, message }, data: { projectId, message: messageWithAttachments },
}); });
return message; return messageWithAttachments;
} }
editMessage(actorId: number, messageId: number, body: string): ChatMessage { editMessage(actorId: number, messageId: number, body: string): ChatMessage {
@ -101,13 +137,38 @@ export class ChatService {
if (message.authorId !== actorId && role !== 'admin') { if (message.authorId !== actorId && role !== 'admin') {
throw new ForbiddenError('投稿者または管理者のみ削除できます'); throw new ForbiddenError('投稿者または管理者のみ削除できます');
} }
this.chatRepository.delete(messageId); this.db.transaction(() => {
this.chatRepository.delete(messageId);
this.attachmentService.detach('chat_message', messageId);
});
this.sseHub.broadcast(message.projectId, { this.sseHub.broadcast(message.projectId, {
type: 'chat.message.deleted', type: 'chat.message.deleted',
data: { projectId: message.projectId, id: messageId }, data: { projectId: message.projectId, id: messageId },
}); });
} }
private withAttachments(
message: ChatMessage,
attachments: AttachmentView[] | undefined
): ChatMessageWithAttachments {
return { ...message, attachments: attachments ?? [] };
}
private indexAttachments(
targetType: 'chat_message',
targetIds: number[]
): Map<number, AttachmentView[]> {
const map = new Map<number, AttachmentView[]>();
if (targetIds.length === 0) return map;
const views = this.attachmentService.listViewsBatch(targetType, targetIds);
for (const v of views) {
const list = map.get(v.targetId) ?? [];
list.push(v);
map.set(v.targetId, list);
}
return map;
}
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

@ -7,7 +7,7 @@ import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { SseHub } from '@/lib/sse/hub'; import { SseHub } from '@/lib/sse/hub';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { FileAsset } from '@/lib/types'; import type { FileAsset, FileAssetSource } from '@/lib/types';
const ALLOWED_MIME_PREFIXES = [ const ALLOWED_MIME_PREFIXES = [
'image/', 'image/',
@ -62,33 +62,7 @@ export class FileStorageService {
projectId: number, projectId: number,
input: UploadFileInput input: UploadFileInput
): FileAsset { ): FileAsset {
this.requireMember(projectId, actorId); const fileAsset = this.persistFile(actorId, projectId, input, 'library');
if (!input.data || input.data.length === 0) {
throw new ValidationError('ファイルが空です', 'file');
}
if (!this.isAllowedMime(input.mimeType)) {
throw new ValidationError('許可されていないファイル形式です', 'mimeType');
}
const ext =
this.sanitizeExt(input.originalName) ??
EXT_BY_MIME[input.mimeType] ??
'bin';
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(this.uploadsDir, String(projectId));
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, filename);
fs.writeFileSync(filePath, input.data);
const fileAsset = this.fileRepository.create({
projectId,
uploaderId: actorId,
filename,
originalName: this.sanitizeName(input.originalName) || 'file',
mimeType: input.mimeType,
size: input.data.length,
path: filePath,
});
const memberIds = this.projectMemberRepository const memberIds = this.projectMemberRepository
.findByProject(projectId) .findByProject(projectId)
@ -117,6 +91,58 @@ export class FileStorageService {
return fileAsset; return fileAsset;
} }
/**
* チャット/掲示板の添付ファイル用アップロード。
* file_shared 通知・file.uploaded SSE・file_uploaded アクティビティを行わず、
* source='attachment' で保存する(Files一覧には出さない)。
*/
uploadForAttachment(
actorId: number,
projectId: number,
input: UploadFileInput
): FileAsset {
return this.persistFile(actorId, projectId, input, 'attachment');
}
/**
* 共通のファイル保存処理。権限チェック・MIMEチェック・FS保存・file_assets登録を行う。
*/
private persistFile(
actorId: number,
projectId: number,
input: UploadFileInput,
source: FileAssetSource
): FileAsset {
this.requireMember(projectId, actorId);
if (!input.data || input.data.length === 0) {
throw new ValidationError('ファイルが空です', 'file');
}
if (!this.isAllowedMime(input.mimeType)) {
throw new ValidationError('許可されていないファイル形式です', 'mimeType');
}
const ext =
this.sanitizeExt(input.originalName) ??
EXT_BY_MIME[input.mimeType] ??
'bin';
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(this.uploadsDir, String(projectId));
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, filename);
fs.writeFileSync(filePath, input.data);
return this.fileRepository.create({
projectId,
uploaderId: actorId,
filename,
originalName: this.sanitizeName(input.originalName) || 'file',
mimeType: input.mimeType,
size: input.data.length,
path: filePath,
source,
});
}
listFiles(actorId: number, projectId: number, page: number = 1) { listFiles(actorId: number, projectId: number, page: number = 1) {
this.requireMember(projectId, actorId); this.requireMember(projectId, actorId);
return this.fileRepository.findFilesByProject(projectId, page); return this.fileRepository.findFilesByProject(projectId, page);

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

@ -77,4 +77,44 @@ test.describe('board', () => {
page.getByRole('link', { name: new RegExp(title) }) page.getByRole('link', { name: new RegExp(title) })
).toHaveCount(0); ).toHaveCount(0);
}); });
test('thread and comment can carry file attachments', async ({ page }) => {
const projectId = await setupOwner(page);
// 添付ファイル(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 } };
// ファイル付きスレッド作成
const title = unique('AttachThread');
const res = await page.request.post(
`/api/projects/${projectId}/board/threads`,
{ data: { title, bodyMd: 'body', fileIds: [file.id] } }
);
expect(res.ok()).toBeTruthy();
const { thread } = (await res.json()) as { thread: { id: number } };
// ファイル付きコメント
const cRes = await page.request.post(
`/api/projects/${projectId}/board/threads/${thread.id}/comments`,
{ data: { bodyMd: 'with file', fileIds: [file.id] } }
);
expect(cRes.ok()).toBeTruthy();
// 詳細ページでスレッド・コメントの添付が表示される
await page.goto(`/projects/${projectId}/board/${thread.id}`);
await expect(page.getByTestId('attachment-list').first()).toBeVisible();
});
}); });

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'));
@ -65,4 +69,50 @@ test.describe('chat (SSE realtime)', () => {
await ownerContext.close(); await ownerContext.close();
await memberContext.close(); await memberContext.close();
}); });
test('a message can carry file attachments rendered in the chat', async ({
page,
}) => {
const ownerEmail = unique('owner') + '@example.com';
await registerAndLogin(page, ownerEmail, 'Owner');
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
await page.getByRole('button', { name: '新規プロジェクト' }).click();
await expect(page).toHaveURL(/\/projects\/\d+$/);
const projectId = Number(page.url().match(/\/projects\/(\d+)/)![1]);
// 添付ファイル(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 } };
// ファイル付きメッセージ送信
const text = unique('with-attach');
const sendRes = await page.request.post(
`/api/projects/${projectId}/chat/messages`,
{ data: { body: text, fileIds: [file.id] } }
);
expect(sendRes.ok()).toBeTruthy();
const { message } = (await sendRes.json()) as {
message: { attachments: { fileId: number }[] };
};
expect(message.attachments).toHaveLength(1);
// チャット画面に添付画像が表示される
await page.goto(`/projects/${projectId}/chat`);
await expect(page.getByTestId('chat-form')).toBeVisible();
await expect(page.getByTestId('attachment-list')).toBeVisible({
timeout: 10_000,
});
});
}); });

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

@ -5,8 +5,11 @@ 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 { ChatRepository } from '@/repositories/ChatRepository'; import { ChatRepository } from '@/repositories/ChatRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { AttachmentService } from '@/services/AttachmentService';
import { ChatService } from '@/services/ChatService'; import { ChatService } from '@/services/ChatService';
import { SseHub, type SseClient } from '@/lib/sse/hub'; import { SseHub, type SseClient } from '@/lib/sse/hub';
@ -37,7 +40,13 @@ describe('chat → SSE broadcast (integration)', () => {
members, members,
users, users,
new NotificationService(new NotificationRepository(db)), new NotificationService(new NotificationRepository(db)),
hub hub,
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
members
),
db
); );
}); });

View File

@ -158,6 +158,7 @@ describe('Migrator', () => {
const expectedTables = [ const expectedTables = [
'activity_logs', 'activity_logs',
'attachments',
'board_comments', 'board_comments',
'board_threads', 'board_threads',
'calendar_events', 'calendar_events',
@ -180,6 +181,9 @@ 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',
'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

@ -44,7 +44,10 @@ describe('SseHub', () => {
hub.broadcast(1, { hub.broadcast(1, {
type: 'chat.message.created', type: 'chat.message.created',
data: { projectId: 1, message: { id: 1 } as never }, data: {
projectId: 1,
message: { id: 1, attachments: [] } as never,
},
}); });
expect(a1.received).toHaveLength(1); expect(a1.received).toHaveLength(1);

View File

@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
describe('AttachmentRepository', () => {
let db: SqliteDatabase;
let repo: AttachmentRepository;
let fileRepo: FileRepository;
let projectId: number;
let userId: number;
beforeEach(() => {
db = createMigratedTestDb();
repo = new AttachmentRepository(db);
fileRepo = new FileRepository(db);
userId = new UserRepository(db).create({
name: 'U',
email: 'u@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: userId,
}).id;
new ProjectMemberRepository(db).add(projectId, userId, 'admin');
});
afterEach(() => db.close());
function createFile(name: string) {
return fileRepo.create({
projectId,
uploaderId: userId,
filename: `${name}.bin`,
originalName: name,
mimeType: 'image/png',
size: 10,
path: `/tmp/${name}.bin`,
source: 'attachment',
});
}
it('creates and finds an attachment by target', () => {
const file = createFile('a');
const att = repo.create({
projectId,
fileId: file.id,
targetType: 'chat_message',
targetId: 1,
});
expect(att.id).toBeGreaterThan(0);
expect(att.targetType).toBe('chat_message');
const list = repo.findByTarget('chat_message', 1);
expect(list).toHaveLength(1);
expect(list[0].fileId).toBe(file.id);
});
it('findViewsByTargets joins file_assets and returns view fields', () => {
const f1 = createFile('one');
const f2 = createFile('two');
repo.create({
projectId,
fileId: f1.id,
targetType: 'board_comment',
targetId: 10,
});
repo.create({
projectId,
fileId: f2.id,
targetType: 'board_comment',
targetId: 11,
});
const views = repo.findViewsByTargets('board_comment', [10, 11]);
expect(views).toHaveLength(2);
const v1 = views.find((v) => v.targetId === 10);
expect(v1?.originalName).toBe('one');
expect(v1?.mimeType).toBe('image/png');
expect(v1?.fileId).toBe(f1.id);
});
it('returns empty array for empty target id list', () => {
expect(repo.findViewsByTargets('chat_message', [])).toEqual([]);
});
it('isolates by target_type (same target_id, different type)', () => {
const file = createFile('a');
repo.create({
projectId,
fileId: file.id,
targetType: 'chat_message',
targetId: 5,
});
repo.create({
projectId,
fileId: file.id,
targetType: 'board_thread',
targetId: 5,
});
expect(repo.findByTarget('chat_message', 5)).toHaveLength(1);
expect(repo.findByTarget('board_thread', 5)).toHaveLength(1);
});
it('excludes soft-deleted attachments and files', () => {
const f1 = createFile('keep');
const f2 = createFile('drop');
repo.create({
projectId,
fileId: f1.id,
targetType: 'chat_message',
targetId: 1,
});
repo.create({
projectId,
fileId: f2.id,
targetType: 'chat_message',
targetId: 1,
});
// ファイルを論理削除するとビューから消える
fileRepo.delete(f2.id);
const views = repo.findViewsByTargets('chat_message', [1]);
expect(views).toHaveLength(1);
expect(views[0].originalName).toBe('keep');
});
it('deleteByTarget soft-deletes all attachments for the target', () => {
createFile('a');
createFile('b');
const f1 = createFile('one');
const f2 = createFile('two');
repo.create({
projectId,
fileId: f1.id,
targetType: 'board_thread',
targetId: 7,
});
repo.create({
projectId,
fileId: f2.id,
targetType: 'board_thread',
targetId: 7,
});
expect(repo.deleteByTarget('board_thread', 7)).toBe(true);
expect(repo.findByTarget('board_thread', 7)).toHaveLength(0);
// 既に削除済みなら false
expect(repo.deleteByTarget('board_thread', 7)).toBe(false);
});
});

View File

@ -85,4 +85,37 @@ describe('FileRepository', () => {
expect(repo.findFilesByProject(projectId, 1, 2).items).toHaveLength(2); expect(repo.findFilesByProject(projectId, 1, 2).items).toHaveLength(2);
expect(repo.findFilesByProject(projectId, 1, 2).total).toBe(5); expect(repo.findFilesByProject(projectId, 1, 2).total).toBe(5);
}); });
it('excludes attachment-source files from the library list but findFileById still returns them', () => {
createFile('library-file');
repo.create({
projectId,
uploaderId: userId,
filename: 'att.bin',
originalName: 'attachment-file',
mimeType: 'image/png',
size: 1,
path: '/tmp/att.bin',
source: 'attachment',
});
const list = repo.findFilesByProject(projectId);
expect(list.total).toBe(1);
expect(list.items[0].originalName).toBe('library-file');
// 添付ファイルも findFileById では取得可能(ダウンロード用)
const att = repo.findFilesByProject(projectId, 1, 100).items;
expect(att).toHaveLength(1);
});
it('defaults source to library when not specified', () => {
const f = repo.create({
projectId,
uploaderId: userId,
filename: 'd.bin',
originalName: 'd',
mimeType: 'text/plain',
size: 1,
path: '/tmp/d.bin',
});
expect(f.source).toBe('library');
});
}); });

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');
});
}); });
}); });

Some files were not shown because too many files have changed in this diff Show More