Compare commits
9 Commits
feature/ca
...
2588c0247b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2588c0247b | |||
| a845f51a35 | |||
| 9d7a23ea94 | |||
| 921cdc457d | |||
| 6a134a29bd | |||
| 91dd05ba7b | |||
| b2b3fb00b5 | |||
| 25d800a529 | |||
| c747978f3d |
41
app/api/projects/[projectId]/attachments/route.ts
Normal file
41
app/api/projects/[projectId]/attachments/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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})
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,20 +1,61 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { I18nProvider } from '@/lib/i18n/I18nProvider';
|
||||||
|
import { ServiceWorkerRegister } from '@/components/pwa/ServiceWorkerRegister';
|
||||||
|
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',
|
||||||
'プロジェクト単位で情報共有・タスク管理を行えるチームコラボレーションツール',
|
applicationName: 'Groupware',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
title: 'Groupware',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: '/icon.svg',
|
||||||
|
apple: '/icon-192.png',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: '#2563eb',
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ServiceWorkerRegister />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
51
app/manifest.ts
Normal file
51
app/manifest.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PWA マニフェスト。Next.js が /manifest.webmanifest を生成し、
|
||||||
|
* <link rel="manifest"> を自動で <head> に出力する。
|
||||||
|
*/
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'Groupware',
|
||||||
|
short_name: 'Groupware',
|
||||||
|
description: 'Project-based team collaboration tool',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
background_color: '#111827',
|
||||||
|
theme_color: '#2563eb',
|
||||||
|
lang: 'en',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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}%` }}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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}%` }}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 ? '投稿中...' : 'コメント投稿'}
|
||||||
|
|||||||
@ -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 ? '作成中...' : 'スレッド作成'}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
93
components/files/AttachmentList.tsx
Normal file
93
components/files/AttachmentList.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
components/files/AttachmentPicker.tsx
Normal file
166
components/files/AttachmentPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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-3 text-sm sm:gap-4">
|
||||||
<a href="/dashboard" className="text-gray-600 hover:underline">
|
<a
|
||||||
ダッシュボード
|
href="/dashboard"
|
||||||
|
className="hidden text-gray-600 hover:underline sm:inline 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>
|
||||||
|
|||||||
@ -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,26 @@ 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
|
||||||
|
data-testid="project-nav"
|
||||||
|
className="flex gap-4 overflow-x-auto whitespace-nowrap 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
24
components/layout/ThemeToggle.tsx
Normal file
24
components/layout/ThemeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
通知
|
通知
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
27
components/pwa/ServiceWorkerRegister.tsx
Normal file
27
components/pwa/ServiceWorkerRegister.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service Worker を登録する(本番環境のみ)。
|
||||||
|
* 開発環境では Next.js の HMR とSWキャッシュが衝突するため登録しない。
|
||||||
|
* 登録失敗はPWAがプログレッシブ拡張であるため無視する。
|
||||||
|
*/
|
||||||
|
export function ServiceWorkerRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') return;
|
||||||
|
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const register = () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => undefined);
|
||||||
|
};
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
register();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', register, { once: true });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
317
components/todo/TodoDialog.tsx
Normal file
317
components/todo/TodoDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export default tseslint.config(
|
|||||||
'playwright-report/**',
|
'playwright-report/**',
|
||||||
'test-results/**',
|
'test-results/**',
|
||||||
'next-env.d.ts',
|
'next-env.d.ts',
|
||||||
|
'scripts/**/*.mjs',
|
||||||
|
'public/**',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
22
lib/db/migrations/002_attachments.sql
Normal file
22
lib/db/migrations/002_attachments.sql
Normal 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';
|
||||||
4
lib/db/migrations/003_todo_tags.sql
Normal file
4
lib/db/migrations/003_todo_tags.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- 003_todo_tags.sql
|
||||||
|
-- ToDoアイテムにタグ列を追加(カンマ区切り、project_notes.tags と同じ方式)。
|
||||||
|
|
||||||
|
ALTER TABLE todo_items ADD COLUMN tags TEXT;
|
||||||
5
lib/db/migrations/004_user_prefs.sql
Normal file
5
lib/db/migrations/004_user_prefs.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- 004_user_prefs.sql
|
||||||
|
-- ユーザーごとのテーマ(dark/light)と言語(en/ja)設定を追加。
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark';
|
||||||
|
ALTER TABLE users ADD COLUMN locale TEXT NOT NULL DEFAULT 'en';
|
||||||
99
lib/i18n/I18nProvider.tsx
Normal file
99
lib/i18n/I18nProvider.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { dictionary, type MessageKey } from '@/lib/i18n/dictionary';
|
||||||
|
import { PREF_MAX_AGE } from '@/lib/i18n/constants';
|
||||||
|
import type { Locale, Theme } from '@/lib/types';
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
theme: Theme;
|
||||||
|
t: (key: MessageKey) => string;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
setTheme: (theme: Theme) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 言語とテーマのクライアント状態を提供するコンテキスト。
|
||||||
|
* - locale: 辞書の切り替え(t)と <html lang> の更新
|
||||||
|
* - setLocale/setTheme: Cookie を即時設定し /api/users/me で永続化、router.refresh でSSR再描画
|
||||||
|
*/
|
||||||
|
export function I18nProvider({
|
||||||
|
initialLocale,
|
||||||
|
initialTheme,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialLocale: Locale;
|
||||||
|
initialTheme: Theme;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||||
|
const [theme, setThemeState] = useState<Theme>(initialTheme);
|
||||||
|
|
||||||
|
const t = useCallback(
|
||||||
|
(key: MessageKey) => dictionary[locale][key] ?? dictionary.en[key] ?? key,
|
||||||
|
[locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
const persist = useCallback(async (body: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/users/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 永続化失敗時もローカルCookieで動作継続するが、ログは残す
|
||||||
|
console.warn('Failed to persist user prefs:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLocale = useCallback(
|
||||||
|
(next: Locale) => {
|
||||||
|
setLocaleState(next);
|
||||||
|
document.cookie = `locale=${next}; path=/; max-age=${PREF_MAX_AGE}; samesite=lax`;
|
||||||
|
void persist({ locale: next });
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
[persist, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTheme = useCallback(
|
||||||
|
async (next: Theme) => {
|
||||||
|
setThemeState(next);
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (next === 'dark') root.classList.add('dark');
|
||||||
|
else root.classList.remove('dark');
|
||||||
|
root.style.colorScheme = next;
|
||||||
|
document.cookie = `theme=${next}; path=/; max-age=${PREF_MAX_AGE}; samesite=lax`;
|
||||||
|
await persist({ theme: next });
|
||||||
|
},
|
||||||
|
[persist]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(
|
||||||
|
() => ({ locale, theme, t, setLocale, setTheme }),
|
||||||
|
[locale, theme, t, setLocale, setTheme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const ctx = useContext(I18nContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useI18n must be used within I18nProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
10
lib/i18n/constants.ts
Normal file
10
lib/i18n/constants.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { Locale, Theme } from '@/lib/types';
|
||||||
|
|
||||||
|
/** テーマ/言語のプリフ Cookie の有効期限(1年)。 */
|
||||||
|
export const PREF_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
/** 許容されるテーマ。 */
|
||||||
|
export const VALID_THEMES: Theme[] = ['dark', 'light'];
|
||||||
|
|
||||||
|
/** 許容される言語。 */
|
||||||
|
export const VALID_LOCALES: Locale[] = ['en', 'ja'];
|
||||||
172
lib/i18n/dictionary.ts
Normal file
172
lib/i18n/dictionary.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import type { Locale } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多言語辞書。en/ja の両方に同じキーを定義する。
|
||||||
|
* 共通クロム(ヘッダー/ナビ)、認証、プロフィール/設定、ページタイトル、
|
||||||
|
* 共通アクションラベルをカバーする。機能画面の本文は漸次追加する。
|
||||||
|
*/
|
||||||
|
const en = {
|
||||||
|
'app.name': 'Groupware',
|
||||||
|
'nav.dashboard': 'Dashboard',
|
||||||
|
'nav.overview': 'Overview',
|
||||||
|
'nav.board': 'Board',
|
||||||
|
'nav.notes': 'Notes',
|
||||||
|
'nav.chat': 'Chat',
|
||||||
|
'nav.todos': 'ToDo',
|
||||||
|
'nav.files': 'Files',
|
||||||
|
'nav.calendar': 'Calendar',
|
||||||
|
'nav.milestones': 'Milestones',
|
||||||
|
'nav.meetings': 'Meetings',
|
||||||
|
'nav.search': 'Search',
|
||||||
|
'nav.members': 'Members',
|
||||||
|
'nav.activity': 'Activity',
|
||||||
|
'nav.settings': 'Settings',
|
||||||
|
'header.logout': 'Logout',
|
||||||
|
'common.save': 'Save',
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
'common.create': 'Create',
|
||||||
|
'common.delete': 'Delete',
|
||||||
|
'common.submit': 'Submit',
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.back': 'Back',
|
||||||
|
'common.edit': 'Edit',
|
||||||
|
'theme.dark': 'Dark',
|
||||||
|
'theme.light': 'Light',
|
||||||
|
'language.english': 'English',
|
||||||
|
'language.japanese': '日本語',
|
||||||
|
'auth.login': 'Login',
|
||||||
|
'auth.register': 'Register',
|
||||||
|
'auth.displayName': 'Display name',
|
||||||
|
'auth.email': 'Email',
|
||||||
|
'auth.password': 'Password',
|
||||||
|
'auth.registerHere': 'New here? Register',
|
||||||
|
'auth.registerButton': 'Register',
|
||||||
|
'auth.loginButton': 'Login',
|
||||||
|
'auth.backToLogin': 'Back to login',
|
||||||
|
'auth.processing': 'Processing...',
|
||||||
|
'auth.failed': 'Operation failed',
|
||||||
|
'auth.projectName': 'Project name',
|
||||||
|
'auth.newProject': 'New project',
|
||||||
|
'project.description': 'Description (optional)',
|
||||||
|
'project.creating': 'Creating...',
|
||||||
|
'project.createFailed': 'Failed to create project',
|
||||||
|
'profile.title': 'Profile',
|
||||||
|
'profile.displayName': 'Display name',
|
||||||
|
'profile.email': 'Email',
|
||||||
|
'profile.avatarUrl': 'Avatar image URL',
|
||||||
|
'profile.saved': 'Profile updated',
|
||||||
|
'profile.backToDashboard': 'Back to dashboard',
|
||||||
|
'profile.role': 'Role',
|
||||||
|
'profile.theme': 'Theme',
|
||||||
|
'profile.language': 'Language',
|
||||||
|
'page.dashboard': 'Dashboard',
|
||||||
|
'page.board': 'Board',
|
||||||
|
'page.notes': 'Notes',
|
||||||
|
'page.chat': 'Chat',
|
||||||
|
'page.todos': 'ToDo / Kanban',
|
||||||
|
'page.files': 'Files',
|
||||||
|
'page.calendar': 'Calendar',
|
||||||
|
'page.milestones': 'Milestones',
|
||||||
|
'page.meetings': 'Meetings',
|
||||||
|
'page.search': 'Search',
|
||||||
|
'page.members': 'Members',
|
||||||
|
'page.activity': 'Activity',
|
||||||
|
'page.settings': 'Settings',
|
||||||
|
'page.profile': 'Profile',
|
||||||
|
'page.login': 'Login',
|
||||||
|
'dash.projects': 'Joined projects',
|
||||||
|
'dash.unreadNotifications': 'Unread notifications',
|
||||||
|
'dash.incompleteTodos': 'Incomplete ToDo',
|
||||||
|
'dash.overdueTasks': 'Overdue tasks',
|
||||||
|
'dash.upcomingMeetings': 'Upcoming meetings',
|
||||||
|
'dash.recentActivity': 'Recent activity',
|
||||||
|
'dash.empty': 'None',
|
||||||
|
'dash.noUnread': 'No unread',
|
||||||
|
'dash.openNotifications': 'Open notifications',
|
||||||
|
'dash.due': 'Due',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ja: typeof en = {
|
||||||
|
'app.name': 'シンプルグループウェア',
|
||||||
|
'nav.dashboard': 'ダッシュボード',
|
||||||
|
'nav.overview': '概要',
|
||||||
|
'nav.board': '掲示板',
|
||||||
|
'nav.notes': 'メモ',
|
||||||
|
'nav.chat': 'チャット',
|
||||||
|
'nav.todos': 'ToDo',
|
||||||
|
'nav.files': 'ファイル',
|
||||||
|
'nav.calendar': 'カレンダー',
|
||||||
|
'nav.milestones': 'マイルストーン',
|
||||||
|
'nav.meetings': 'ミーティング',
|
||||||
|
'nav.search': '検索',
|
||||||
|
'nav.members': 'メンバー',
|
||||||
|
'nav.activity': 'アクティビティ',
|
||||||
|
'nav.settings': '設定',
|
||||||
|
'header.logout': 'ログアウト',
|
||||||
|
'common.save': '保存',
|
||||||
|
'common.cancel': 'キャンセル',
|
||||||
|
'common.create': '作成',
|
||||||
|
'common.delete': '削除',
|
||||||
|
'common.submit': '送信',
|
||||||
|
'common.loading': '読み込み中...',
|
||||||
|
'common.back': '戻る',
|
||||||
|
'common.edit': '編集',
|
||||||
|
'theme.dark': 'ダーク',
|
||||||
|
'theme.light': 'ライト',
|
||||||
|
'language.english': 'English',
|
||||||
|
'language.japanese': '日本語',
|
||||||
|
'auth.login': 'ログイン',
|
||||||
|
'auth.register': '新規登録',
|
||||||
|
'auth.displayName': '表示名',
|
||||||
|
'auth.email': 'メールアドレス',
|
||||||
|
'auth.password': 'パスワード',
|
||||||
|
'auth.registerHere': '新規登録はこちら',
|
||||||
|
'auth.registerButton': '登録する',
|
||||||
|
'auth.loginButton': 'ログイン',
|
||||||
|
'auth.backToLogin': 'ログイン画面に戻る',
|
||||||
|
'auth.processing': '処理中...',
|
||||||
|
'auth.failed': '処理に失敗しました',
|
||||||
|
'auth.projectName': 'プロジェクト名',
|
||||||
|
'auth.newProject': '新規プロジェクト',
|
||||||
|
'project.description': '説明(任意)',
|
||||||
|
'project.creating': '作成中...',
|
||||||
|
'project.createFailed': 'プロジェクトの作成に失敗しました',
|
||||||
|
'profile.title': 'プロフィール',
|
||||||
|
'profile.displayName': '表示名',
|
||||||
|
'profile.email': 'メールアドレス',
|
||||||
|
'profile.avatarUrl': 'アイコン画像URL',
|
||||||
|
'profile.saved': 'プロフィールを更新しました',
|
||||||
|
'profile.backToDashboard': 'ダッシュボードへ',
|
||||||
|
'profile.role': 'ロール',
|
||||||
|
'profile.theme': 'テーマ',
|
||||||
|
'profile.language': '言語',
|
||||||
|
'page.dashboard': 'ダッシュボード',
|
||||||
|
'page.board': '掲示板',
|
||||||
|
'page.notes': 'メモ',
|
||||||
|
'page.chat': 'チャット',
|
||||||
|
'page.todos': 'ToDo / Kanban',
|
||||||
|
'page.files': 'ファイル',
|
||||||
|
'page.calendar': 'カレンダー',
|
||||||
|
'page.milestones': 'マイルストーン',
|
||||||
|
'page.meetings': 'ミーティング',
|
||||||
|
'page.search': '検索',
|
||||||
|
'page.members': 'メンバー',
|
||||||
|
'page.activity': 'アクティビティ',
|
||||||
|
'page.settings': '設定',
|
||||||
|
'page.profile': 'プロフィール',
|
||||||
|
'page.login': 'ログイン',
|
||||||
|
'dash.projects': '参加プロジェクト',
|
||||||
|
'dash.unreadNotifications': '未読通知',
|
||||||
|
'dash.incompleteTodos': '未完了ToDo',
|
||||||
|
'dash.overdueTasks': '期限切れタスク',
|
||||||
|
'dash.upcomingMeetings': '近日中のミーティング',
|
||||||
|
'dash.recentActivity': '最近のアクティビティ',
|
||||||
|
'dash.empty': 'ありません',
|
||||||
|
'dash.noUnread': '未読はありません',
|
||||||
|
'dash.openNotifications': '通知一覧を開く',
|
||||||
|
'dash.due': '期限',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dictionary: Record<Locale, typeof en> = { en, ja };
|
||||||
|
|
||||||
|
export type MessageKey = keyof typeof en;
|
||||||
20
lib/i18n/server.ts
Normal file
20
lib/i18n/server.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { dictionary, type MessageKey } from './dictionary';
|
||||||
|
import type { Locale, Theme } from '@/lib/types';
|
||||||
|
|
||||||
|
/** サーバー側で locale Cookie を読み解決する(既定 en)。 */
|
||||||
|
export async function getLocale(): Promise<Locale> {
|
||||||
|
const c = await cookies();
|
||||||
|
return c.get('locale')?.value === 'ja' ? 'ja' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** サーバー側で theme Cookie を読み解決する(既定 dark)。 */
|
||||||
|
export async function getTheme(): Promise<Theme> {
|
||||||
|
const c = await cookies();
|
||||||
|
return c.get('theme')?.value === 'light' ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** サーバー側で翻訳文字列を取得する。 */
|
||||||
|
export function translate(key: MessageKey, locale: Locale): string {
|
||||||
|
return dictionary[locale][key] ?? dictionary.en[key] ?? key;
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
export type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
|
export type 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';
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
// /login, /api, _next 静的 assets, favicon を除外
|
// /login, /api, _next 静的 assets, favicon, PWA静的リソース(manifest/SW/icon) を除外
|
||||||
'/((?!login|api|_next/static|_next/image|favicon.ico).*)',
|
'/((?!login|api|_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.webmanifest|icon\\.svg|icon-192\\.png|icon-512\\.png).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
5
public/icon.svg
Normal file
5
public/icon.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Groupware">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#2563eb"/>
|
||||||
|
<circle cx="256" cy="196" r="84" fill="#ffffff"/>
|
||||||
|
<path d="M128 432c0-70.7 57.3-128 128-128s128 57.3 128 128v24H128z" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 326 B |
82
public/sw.js
Normal file
82
public/sw.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// Groupware service worker: app-shell precache + network-first navigations +
|
||||||
|
// cache-first static assets for offline support and installability.
|
||||||
|
const CACHE = 'ogw-v1';
|
||||||
|
const PRECACHE_URLS = [
|
||||||
|
'/',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/icon.svg',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
// 一つでも欠けていても全体が失敗しないよう個別に追加
|
||||||
|
await Promise.allSettled(PRECACHE_URLS.map((u) => cache.add(u)));
|
||||||
|
await self.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
|
||||||
|
);
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
// クロスオリジンとAPIはキャッシュしない
|
||||||
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ナビゲーション: ネットワーク優先(オフライン時はキャッシュ/"/"にフォールバック)
|
||||||
|
if (req.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(req);
|
||||||
|
// エラーや認証リダイレクトの結果をキャッシュしない
|
||||||
|
if (res.ok && res.type === 'basic') {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
cache.put(req, res.clone());
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
const cached = await caches.match(req);
|
||||||
|
return cached || (await caches.match('/')) || Response.error();
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静的アセット: キャッシュ優先(stale-while-revalidate)
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cached = await caches.match(req);
|
||||||
|
const network = fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.status === 200 && res.type === 'basic') {
|
||||||
|
const cache = caches.open(CACHE);
|
||||||
|
cache.then((c) => c.put(req, res.clone()));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return cached || (await network) || Response.error();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
135
repositories/AttachmentRepository.ts
Normal file
135
repositories/AttachmentRepository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
26
scripts/generate-icons.mjs
Normal file
26
scripts/generate-icons.mjs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// One-off: rasterize public/icon.svg into 512 and 192 PNGs (any/maskable).
|
||||||
|
// Uses sharp (already a transitive dependency in this project). Run: node scripts/generate-icons.mjs
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const SVG = path.join(ROOT, 'public/icon.svg');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const svgBuf = fs.readFileSync(SVG);
|
||||||
|
const sizes = [512, 192];
|
||||||
|
for (const size of sizes) {
|
||||||
|
const out = path.join(ROOT, `public/icon-${size}.png`);
|
||||||
|
await sharp(svgBuf, { density: 384 })
|
||||||
|
.resize(size, size, { fit: 'contain' })
|
||||||
|
.png()
|
||||||
|
.toFile(out);
|
||||||
|
console.log(`wrote ${out} (${size}x${size})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
77
services/AttachmentService.ts
Normal file
77
services/AttachmentService.ts
Normal 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('プロジェクトに参加していません');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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('プロジェクトに参加していません');
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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('プロジェクトに参加していません');
|
||||||
|
|||||||
@ -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}',
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
15
tests/e2e/locale-ja.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"cookies": [
|
||||||
|
{
|
||||||
|
"name": "locale",
|
||||||
|
"value": "ja",
|
||||||
|
"domain": "localhost",
|
||||||
|
"path": "/",
|
||||||
|
"expires": -1,
|
||||||
|
"httpOnly": false,
|
||||||
|
"secure": false,
|
||||||
|
"sameSite": "Lax"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"origins": []
|
||||||
|
}
|
||||||
75
tests/e2e/pwa.spec.ts
Normal file
75
tests/e2e/pwa.spec.ts
Normal 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('pwa', () => {
|
||||||
|
test('manifest, service worker and icons are publicly served', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const manifestRes = await request.get('/manifest.webmanifest');
|
||||||
|
expect(manifestRes.ok()).toBeTruthy();
|
||||||
|
const manifest = (await manifestRes.json()) as {
|
||||||
|
name: string;
|
||||||
|
short_name: string;
|
||||||
|
display: string;
|
||||||
|
start_url: string;
|
||||||
|
icons: { sizes: string; type: string; purpose?: string }[];
|
||||||
|
};
|
||||||
|
expect(manifest.name).toBe('Groupware');
|
||||||
|
expect(manifest.short_name).toBe('Groupware');
|
||||||
|
expect(manifest.display).toBe('standalone');
|
||||||
|
expect(manifest.start_url).toBe('/');
|
||||||
|
expect(manifest.icons.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(manifest.icons.some((i) => i.sizes === '192x192')).toBeTruthy();
|
||||||
|
expect(manifest.icons.some((i) => i.sizes === '512x512')).toBeTruthy();
|
||||||
|
expect(manifest.icons.some((i) => i.purpose === 'maskable')).toBeTruthy();
|
||||||
|
|
||||||
|
const swRes = await request.get('/sw.js');
|
||||||
|
expect(swRes.ok()).toBeTruthy();
|
||||||
|
const swText = await swRes.text();
|
||||||
|
expect(swText).toContain('fetch');
|
||||||
|
expect(swText).toContain('install');
|
||||||
|
|
||||||
|
expect((await request.get('/icon-192.png')).ok()).toBeTruthy();
|
||||||
|
expect((await request.get('/icon-512.png')).ok()).toBeTruthy();
|
||||||
|
expect((await request.get('/icon.svg')).ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('project nav scrolls horizontally on mobile width (no wrap)', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
storageState: 'tests/e2e/locale-ja.json',
|
||||||
|
viewport: { width: 375, height: 812 },
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await setupOwner(page);
|
||||||
|
|
||||||
|
const nav = page.getByTestId('project-nav');
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
const scrollable = await nav.evaluate((el) => ({
|
||||||
|
scrollWidth: el.scrollWidth,
|
||||||
|
clientWidth: el.clientWidth,
|
||||||
|
}));
|
||||||
|
// ナビ項目が多数あるため、375px では横スクロールが発生する(折り返さない)
|
||||||
|
expect(scrollable.scrollWidth).toBeGreaterThan(scrollable.clientWidth);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user