diff --git a/app/api/projects/[projectId]/board/comments/[commentId]/route.ts b/app/api/projects/[projectId]/board/comments/[commentId]/route.ts new file mode 100644 index 0000000..b933155 --- /dev/null +++ b/app/api/projects/[projectId]/board/comments/[commentId]/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createBoardService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string; commentId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { commentId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const service = createBoardService(); + try { + const comment = service.updateComment( + user.id, + Number(commentId), + String(body.bodyMd ?? '') + ); + return NextResponse.json({ comment }); + } catch (error) { + return handleApiError(error); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; commentId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { commentId } = await params; + + const service = createBoardService(); + try { + service.deleteComment(user.id, Number(commentId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/board/threads/[threadId]/comments/route.ts b/app/api/projects/[projectId]/board/threads/[threadId]/comments/route.ts new file mode 100644 index 0000000..0d4af9c --- /dev/null +++ b/app/api/projects/[projectId]/board/threads/[threadId]/comments/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createBoardService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string; threadId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { threadId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const service = createBoardService(); + try { + const comment = service.createComment( + user.id, + Number(threadId), + String(body.bodyMd ?? '') + ); + return NextResponse.json({ comment }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/board/threads/[threadId]/route.ts b/app/api/projects/[projectId]/board/threads/[threadId]/route.ts new file mode 100644 index 0000000..88bdf2a --- /dev/null +++ b/app/api/projects/[projectId]/board/threads/[threadId]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createBoardService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; threadId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { threadId } = await params; + + const service = createBoardService(); + try { + const thread = service.getThread(user.id, Number(threadId)); + const comments = service.listComments(user.id, Number(threadId)); + return NextResponse.json({ thread, comments }); + } catch (error) { + return handleApiError(error); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string; threadId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { threadId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const service = createBoardService(); + try { + const thread = service.updateThread(user.id, Number(threadId), { + title: typeof body.title === 'string' ? body.title : undefined, + bodyMd: typeof body.bodyMd === 'string' ? body.bodyMd : undefined, + category: + typeof body.category === 'string' + ? (body.category as never) + : undefined, + isPinned: typeof body.isPinned === 'number' ? body.isPinned : undefined, + isImportant: + typeof body.isImportant === 'number' ? body.isImportant : undefined, + }); + return NextResponse.json({ thread }); + } catch (error) { + return handleApiError(error); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; threadId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { threadId } = await params; + + const service = createBoardService(); + try { + service.deleteThread(user.id, Number(threadId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/board/threads/route.ts b/app/api/projects/[projectId]/board/threads/route.ts new file mode 100644 index 0000000..c9cbdb6 --- /dev/null +++ b/app/api/projects/[projectId]/board/threads/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createBoardService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1; + const search = request.nextUrl.searchParams.get('q') ?? undefined; + + const service = createBoardService(); + try { + return NextResponse.json( + service.listThreads(user.id, Number(projectId), { page, search }) + ); + } catch (error) { + return handleApiError(error); + } +} + +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; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const service = createBoardService(); + try { + const thread = service.createThread(user.id, Number(projectId), { + title: String(body.title ?? ''), + bodyMd: String(body.bodyMd ?? ''), + category: + typeof body.category === 'string' + ? (body.category as never) + : undefined, + }); + return NextResponse.json({ thread }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/projects/[projectId]/board/[threadId]/page.tsx b/app/projects/[projectId]/board/[threadId]/page.tsx new file mode 100644 index 0000000..49cdee2 --- /dev/null +++ b/app/projects/[projectId]/board/[threadId]/page.tsx @@ -0,0 +1,78 @@ +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 ( +
+
+ +
+ + ← 掲示板一覧へ + +
+
+

{thread.title}

+ {thread.category && ( + + {thread.category} + + )} +
+
+ +
+

+ 投稿: {thread.createdAt} / 更新: {thread.updatedAt} +

+
+ +
+

コメント ({comments.total})

+ {comments.items.map((comment) => ( +
+ +

{comment.createdAt}

+
+ ))} + +
+
+
+ ); +} diff --git a/app/projects/[projectId]/board/page.tsx b/app/projects/[projectId]/board/page.tsx new file mode 100644 index 0000000..4493d62 --- /dev/null +++ b/app/projects/[projectId]/board/page.tsx @@ -0,0 +1,77 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createBoardService, createProjectService } from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { ThreadForm } from '@/components/board/ThreadForm'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +export default async function BoardPage({ + params, + searchParams, +}: { + params: Promise<{ projectId: string }>; + searchParams: Promise<{ q?: string; page?: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/login'); + const { projectId } = await params; + const { q, page } = await searchParams; + + const projectService = createProjectService(); + let project: Awaited>; + try { + project = projectService.getProject(user.id, Number(projectId)); + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + const boardService = createBoardService(); + const { items } = boardService.listThreads(user.id, project.id, { + page: Number(page ?? '1') || 1, + search: q, + }); + + return ( +
+
+ +
+

掲示板

+ +
+ {items.length === 0 ? ( +

スレッドはありません。

+ ) : ( + items.map((thread) => ( + +
+

+ {thread.isPinned === 1 && '📌 '} + {thread.isImportant === 1 && '❗ '} + {thread.title} +

+ {thread.category && ( + + {thread.category} + + )} +
+

{thread.createdAt}

+
+ )) + )} +
+
+
+ ); +} diff --git a/components/board/CommentForm.tsx b/components/board/CommentForm.tsx new file mode 100644 index 0000000..c184282 --- /dev/null +++ b/components/board/CommentForm.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; + +export function CommentForm({ + projectId, + threadId, +}: { + projectId: number; + threadId: number; +}) { + const router = useRouter(); + const [bodyMd, setBodyMd] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + const res = await fetch( + `/api/projects/${projectId}/board/threads/${threadId}/comments`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bodyMd }), + } + ); + setLoading(false); + if (res.ok) { + setBodyMd(''); + router.refresh(); + } else { + const b = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(b?.error?.message ?? '投稿に失敗しました'); + } + } + + return ( +
+