Files
Ken Yasue 2017585f84 feat(m6): board with threads/comments, markdown, tests, e2e
- BoardRepository (thread/comment CRUD, soft-delete, search, pagination,
  pinned-first ordering) + BoardService (membership permission,
  author/admin edit/delete, comment->board_commented notification,
  board_posted/comment_added activity logs, category validation)
- APIs: threads list/create/detail/edit/delete, comments create/edit/delete
- MarkdownBody (react-markdown + remark-gfm + rehype-sanitize),
  ThreadForm, CommentForm, board list + thread detail screens, ProjectNav link
- Unit tests (BoardRepository, BoardService) + e2e board.spec
2026-06-25 01:28:08 +02:00

79 lines
2.7 KiB
TypeScript

import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createBoardService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { MarkdownBody } from '@/components/board/MarkdownBody';
import { CommentForm } from '@/components/board/CommentForm';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function ThreadDetailPage({
params,
}: {
params: Promise<{ projectId: string; threadId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId, threadId } = await params;
const boardService = createBoardService();
let thread;
let comments;
try {
thread = boardService.getThread(user.id, Number(threadId));
comments = boardService.listComments(user.id, Number(threadId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect(`/projects/${projectId}/board`);
}
throw error;
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={Number(projectId)} active="board" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<a
href={`/projects/${projectId}/board`}
className="text-sm text-blue-600 hover:underline"
>
</a>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{thread.title}</h1>
{thread.category && (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{thread.category}
</span>
)}
</div>
<div className="mt-4">
<MarkdownBody bodyMd={thread.bodyMd} />
</div>
<p className="mt-4 text-xs text-gray-400">
稿: {thread.createdAt} / : {thread.updatedAt}
</p>
</div>
<section className="space-y-4">
<h2 className="text-lg font-bold"> ({comments.total})</h2>
{comments.items.map((comment) => (
<div
key={comment.id}
className="rounded-lg border bg-white p-4 shadow-sm"
>
<MarkdownBody bodyMd={comment.bodyMd} />
<p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p>
</div>
))}
<CommentForm projectId={Number(projectId)} threadId={thread.id} />
</section>
</main>
</div>
);
}