From 5812d9971569cd16f80f8e2cda07500ede10fedc Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 8 Oct 2026 01:40:08 +0700 Subject: [PATCH] feat : comment feature --- README.md | 46 + app/[character]/comment/loading.tsx | 5 + app/[character]/comment/page.tsx | 46 + app/admin/comments/loading.tsx | 5 + app/admin/comments/page.tsx | 17 + app/admin/layout.tsx | 3 +- app/api/admin/comments/route.ts | 13 + app/api/admin/comments/unread/route.ts | 21 + app/api/comments/[id]/[action]/route.ts | 41 + app/api/comments/[id]/route.ts | 32 + app/api/comments/events/route.test.ts | 42 + app/api/comments/events/route.ts | 25 + app/api/comments/images/[id]/route.ts | 13 + app/api/comments/push-subscription/route.ts | 45 + app/api/comments/route.ts | 27 + app/stygian/comment/loading.tsx | 5 + app/stygian/comment/page.tsx | 36 + app/stygian/page.tsx | 15 + components/admin/admin-header.tsx | 5 + components/admin/editor-presence.tsx | 4 + components/admin/structured-dashboard.tsx | 4 +- components/comments/admin-comment-inbox.tsx | 57 + components/comments/admin-unread-comments.tsx | 37 + components/comments/client.ts | 26 + components/comments/comment-board-content.tsx | 7 + components/comments/comment-board.tsx | 62 + components/comments/comment-card.tsx | 353 ++ components/comments/comment-composer.tsx | 143 + components/comments/comment-image.tsx | 9 + components/comments/comment-push-prompt.tsx | 72 + components/comments/comment-skeleton.tsx | 31 + components/comments/use-comment-events.ts | 56 + components/comments/use-comment-feed.ts | 70 + components/commission/push-client.ts | 3 + components/public/structured-guide.tsx | 11 +- components/ui/accordion.tsx | 5 +- db/schema.ts | 96 + drizzle/0073_unusual_darkhawk.sql | 81 + drizzle/0074_tough_cerise.sql | 13 + drizzle/0075_fancy_ravenous.sql | 17 + drizzle/0076_free_jazinda.sql | 10 + drizzle/0077_reflective_chimera.sql | 8 + drizzle/meta/0073_snapshot.json | 4822 ++++++++++++++++ drizzle/meta/0074_snapshot.json | 4822 ++++++++++++++++ drizzle/meta/0075_snapshot.json | 4822 ++++++++++++++++ drizzle/meta/0076_snapshot.json | 4897 ++++++++++++++++ drizzle/meta/0077_snapshot.json | 4952 +++++++++++++++++ drizzle/meta/_journal.json | 35 + lib/audit-log.ts | 6 + lib/comments/events.test.ts | 17 + lib/comments/events.ts | 15 + lib/comments/navigation.ts | 3 + lib/comments/publish.ts | 87 + lib/comments/push.ts | 42 + lib/comments/repository.ts | 263 + lib/comments/types.ts | 33 + lib/comments/unread.ts | 30 + lib/comments/validation.test.ts | 52 + lib/comments/validation.ts | 58 + lib/navigation.ts | 1 + lib/security/headers.ts | 3 +- public/commission-push-sw.js | 2 +- public/icon/nav/Comment.webp | Bin 0 -> 2052 bytes tests/comment-notification-worker.test.ts | 33 + tests/comments.integration.test.ts | 411 ++ 65 files changed, 27013 insertions(+), 10 deletions(-) create mode 100644 app/[character]/comment/loading.tsx create mode 100644 app/[character]/comment/page.tsx create mode 100644 app/admin/comments/loading.tsx create mode 100644 app/admin/comments/page.tsx create mode 100644 app/api/admin/comments/route.ts create mode 100644 app/api/admin/comments/unread/route.ts create mode 100644 app/api/comments/[id]/[action]/route.ts create mode 100644 app/api/comments/[id]/route.ts create mode 100644 app/api/comments/events/route.test.ts create mode 100644 app/api/comments/events/route.ts create mode 100644 app/api/comments/images/[id]/route.ts create mode 100644 app/api/comments/push-subscription/route.ts create mode 100644 app/api/comments/route.ts create mode 100644 app/stygian/comment/loading.tsx create mode 100644 app/stygian/comment/page.tsx create mode 100644 components/comments/admin-comment-inbox.tsx create mode 100644 components/comments/admin-unread-comments.tsx create mode 100644 components/comments/client.ts create mode 100644 components/comments/comment-board-content.tsx create mode 100644 components/comments/comment-board.tsx create mode 100644 components/comments/comment-card.tsx create mode 100644 components/comments/comment-composer.tsx create mode 100644 components/comments/comment-image.tsx create mode 100644 components/comments/comment-push-prompt.tsx create mode 100644 components/comments/comment-skeleton.tsx create mode 100644 components/comments/use-comment-events.ts create mode 100644 components/comments/use-comment-feed.ts create mode 100644 drizzle/0073_unusual_darkhawk.sql create mode 100644 drizzle/0074_tough_cerise.sql create mode 100644 drizzle/0075_fancy_ravenous.sql create mode 100644 drizzle/0076_free_jazinda.sql create mode 100644 drizzle/0077_reflective_chimera.sql create mode 100644 drizzle/meta/0073_snapshot.json create mode 100644 drizzle/meta/0074_snapshot.json create mode 100644 drizzle/meta/0075_snapshot.json create mode 100644 drizzle/meta/0076_snapshot.json create mode 100644 drizzle/meta/0077_snapshot.json create mode 100644 lib/comments/events.test.ts create mode 100644 lib/comments/events.ts create mode 100644 lib/comments/navigation.ts create mode 100644 lib/comments/publish.ts create mode 100644 lib/comments/push.ts create mode 100644 lib/comments/repository.ts create mode 100644 lib/comments/types.ts create mode 100644 lib/comments/unread.ts create mode 100644 lib/comments/validation.test.ts create mode 100644 lib/comments/validation.ts create mode 100644 public/icon/nav/Comment.webp create mode 100644 tests/comment-notification-worker.test.ts create mode 100644 tests/comments.integration.test.ts diff --git a/README.md b/README.md index 6868b1e..63cb565 100644 --- a/README.md +++ b/README.md @@ -158,12 +158,58 @@ with the latest version reported by Lunaris. - Enable the Discord Message Content intent and grant the bot View Channel and Send Messages in the configured channels. +### Guide comments + +Comment tables live in the dedicated PostgreSQL `comments` schema: `comment`, +`thread`, `revision`, `attachment`, `revision_attachment`, `reaction`, +`push_subscription`, and `read_state`. + +Character guides have a Comment section at `/{slug}/comment`. Stygian comments +appear at the bottom of `/stygian?schedule={id}` and remain accessible at +`/stygian/comment?schedule={id}`; both share the selected schedule's discussion. Readers can +browse anonymously; posting, replies, likes, and dislikes require an account. +Replies expand inline with connector lines; replies to replies identify their recipient. +The Thai comment UI supports image paste (Ctrl+V) with previews and loading skeletons. +Comment boards and the admin inbox receive Redis-backed SSE change events; +reads after each event recheck authorization. Reconnecting refreshes missed updates. +Signed-in authors can enable browser notifications after posting. New replies notify +only the direct parent author, excluding self-replies. Notification clicks open the +root thread, load the relevant reply page, and highlight/focus the exact reply. +This uses the existing `WEB_PUSH_PUBLIC_KEY`, `WEB_PUSH_PRIVATE_KEY`, and +`WEB_PUSH_SUBJECT` configuration, with subscriptions in `comments.push_subscription`. +Likes, dislikes, and admin hearts update optimistically and save in the background; +a failed save rolls the reaction back and shows an error. + +Each comment or reply supports 4,000 characters and up to five JPEG, PNG, or +WebP images, each no larger than 10 MiB. Image bytes are uploaded to the +S3 bucket through Bun’s `S3Client`; PostgreSQL stores only object keys, metadata, +and revision references. Browsers load images directly from `S3_PUBLIC_URL`. Videos are unsupported. Authors can edit +or delete their comments, and the “แก้ไขแล้ว” link exposes previous text and images. +Deletion permanently removes the comment and every descendant reply, including their +revision history, attachment metadata, and reactions. Their S3 images are also deleted. + +`/admin/comments` provides the cross-guide inbox, guide/schedule filtering, +visible/hidden and unanswered filters, replies, reactions, admin hearts, and +hide/restore moderation. Conversation buttons appear only on comments with replies; +the dialog shows a thread skeleton while loading. Admin-only unread badges update +via SSE, cap at `99+`, and link to the guide inbox. Opening the inbox marks its +selected guide (or all guides) read for that admin. Hiding a root also hides its replies. Moderation actions +are recorded in the activity log. Comment reads and legacy attachment redirects +are not cached; guide content caches are independent of discussions. + +The comment migration must run before deploying these pages. For PostgreSQL +integration verification, point `DATABASE_INTEGRATION_URL` at a migrated test or +development database and run `bun run test tests/comments.integration.test.ts`. +The suite creates isolated fixtures and removes them afterward; image storage is +mocked while decoding and database operations are exercised. + ### Media visibility | Upload path | Visibility | | --- | --- | | Presigned staged upload | Private in S3. `/media/[id]` allows public access only while a published guide references the object; otherwise administrator authorization is required. | | Direct guide image upload | Public CDN object, including uploads made for drafts. | +| Comment attachment | Stored in Bun S3 and loaded directly from `S3_PUBLIC_URL`. Hiding removes image links from public comment responses; existing URLs remain accessible until the S3 object is deleted. `/api/comments/images/[id]` only redirects old links after a visibility check. | | Commission attachment | Served from `S3_PUBLIC_URL`; anyone with the URL can view it. | Keep sensitive material out of the direct upload path and commission attachments. diff --git a/app/[character]/comment/loading.tsx b/app/[character]/comment/loading.tsx new file mode 100644 index 0000000..f64d7a0 --- /dev/null +++ b/app/[character]/comment/loading.tsx @@ -0,0 +1,5 @@ +import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; + +export default function Loading() { + return
; +} diff --git a/app/[character]/comment/page.tsx b/app/[character]/comment/page.tsx new file mode 100644 index 0000000..482c4f1 --- /dev/null +++ b/app/[character]/comment/page.tsx @@ -0,0 +1,46 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import Image from "@/components/ui/resilient-image"; +import { SiteHeader } from "@/components/public/site-header"; +import { SiteFooter } from "@/components/public/site-footer"; +import { ScrollableNav } from "@/components/public/scrollable-nav"; +import { CommentBoardContent } from "@/components/comments/comment-board-content"; +import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; +import { getCachedPublicGuideSummary } from "@/lib/cache/public"; +import { commentNavigationSection } from "@/lib/comments/navigation"; +import { navigationIconPath } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; + +export const instant = false; +type Props = { params: Promise<{ character: string }> }; +export async function generateMetadata({ params }: Props): Promise { + const guide = await getCachedPublicGuideSummary((await params).character); + return { title: guide ? `${guide.name}` : "ไม่พบไกด์" }; +} +export default async function GuideCommentPage({ params }: Props) { + await connection(); + const guide = await getCachedPublicGuideSummary((await params).character); + if (!guide) notFound(); + const target = `guide:${guide.id}`; + const sections = [{ id: "overview", slug: "", title: "Overview" }, ...guide.sections, commentNavigationSection(guide.id)]; + return
+ +
+
+

{guide.name}

ความคิดเห็น

+
+ + {sections.map((section) => + {navigationIconPath(section.slug || "overview") && } + {section.title} + )} + + }> +
+
; +} diff --git a/app/admin/comments/loading.tsx b/app/admin/comments/loading.tsx new file mode 100644 index 0000000..f64d7a0 --- /dev/null +++ b/app/admin/comments/loading.tsx @@ -0,0 +1,5 @@ +import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; + +export default function Loading() { + return
; +} diff --git a/app/admin/comments/page.tsx b/app/admin/comments/page.tsx new file mode 100644 index 0000000..ac32f0a --- /dev/null +++ b/app/admin/comments/page.tsx @@ -0,0 +1,17 @@ +import { connection } from "next/server"; +import { redirect } from "next/navigation"; +import { AdminHeader } from "@/components/admin/admin-header"; +import { AdminCommentInbox } from "@/components/comments/admin-comment-inbox"; +import { getCommentViewer, listComments, listCommentTargets } from "@/lib/comments/repository"; + +export default async function AdminCommentsPage({ searchParams }: { searchParams: Promise<{ target?: string }> }) { + await connection(); + const viewer = await getCommentViewer(); + if (!viewer?.admin) redirect("/auth/login?next=%2Fadmin%2Fcomments"); + const initialTarget = (await searchParams).target; + const [initial, targets] = await Promise.all([listComments({ viewer, inbox: true, target: initialTarget }), listCommentTargets()]); + return <>
+

ความคิดเห็น

ตอบกลับและจัดการความคิดเห็นจากทุกไกด์

+ +
; +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 7ec6001..d8304f5 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; +import { AdminUnreadCommentsProvider } from "@/components/comments/admin-unread-comments"; import { TooltipProvider } from "@/components/ui/tooltip"; export default function AdminLayout({ children }: LayoutProps<"/admin">) { @@ -12,7 +13,7 @@ export default function AdminLayout({ children }: LayoutProps<"/admin">) { } > - {children} + {children} ); diff --git a/app/api/admin/comments/route.ts b/app/api/admin/comments/route.ts new file mode 100644 index 0000000..b8f5384 --- /dev/null +++ b/app/api/admin/comments/route.ts @@ -0,0 +1,13 @@ +import { listComments, requireCommentAdmin, requireCommentViewer } from "@/lib/comments/repository"; +import { errorResponse } from "@/lib/security/http"; + +export async function GET(request: Request) { + try { + const viewer = await requireCommentViewer(); + requireCommentAdmin(viewer); + const query = new URL(request.url).searchParams; + return Response.json(await listComments({ viewer, inbox: true, target: query.get("target") || undefined, + status: query.get("status") || undefined, unanswered: query.get("unanswered") === "true", cursor: query.get("cursor") }), + { headers: { "Cache-Control": "private, no-store" } }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/admin/comments/unread/route.ts b/app/api/admin/comments/unread/route.ts new file mode 100644 index 0000000..837e720 --- /dev/null +++ b/app/api/admin/comments/unread/route.ts @@ -0,0 +1,21 @@ +import * as z from "zod"; +import { requireCommentViewer } from "@/lib/comments/repository"; +import { markCommentsRead, unreadCommentCounts } from "@/lib/comments/unread"; +import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +export async function GET() { + try { return Response.json(await unreadCommentCounts(await requireCommentViewer()), { headers: { "Cache-Control": "private, no-store" } }); } + catch (cause) { return errorResponse(cause); } +} +export async function POST(request: Request) { + try { + requireSameOrigin(request); + const viewer = await requireCommentViewer(); + await limitRequest("comment-read", viewer.id, 120); + const body = z.object({ target: z.string().max(80) }).safeParse(await readJson(request, 1024)); + if (!body.success) throw new HttpError(400, "invalid-target"); + await markCommentsRead(viewer, body.data.target); + return new Response(null, { status: 204 }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/comments/[id]/[action]/route.ts b/app/api/comments/[id]/[action]/route.ts new file mode 100644 index 0000000..3c3a4bc --- /dev/null +++ b/app/api/comments/[id]/[action]/route.ts @@ -0,0 +1,41 @@ +import { commentHistory, getCommentViewer, listComments, mutateComment, requireCommentViewer } from "@/lib/comments/repository"; +import { heartSchema, moderationSchema, reactionSchema } from "@/lib/comments/validation"; +import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +type Context = { params: Promise<{ id: string; action: string }> }; +export async function GET(request: Request, context: Context) { + try { + const { id, action } = await context.params; + const viewer = await getCommentViewer(); + const cursor = new URL(request.url).searchParams.get("cursor"); + const result = action === "history" ? await commentHistory(id, viewer, cursor) + : action === "replies" ? await listComments({ viewer, rootId: id, cursor, target: new URL(request.url).searchParams.get("target") ?? undefined }) + : null; + if (!result) throw new HttpError(404, "not-found"); + return Response.json(result, { headers: { "Cache-Control": "private, no-store" } }); + } catch (cause) { return errorResponse(cause); } +} +export async function PUT(request: Request, context: Context) { + try { + requireSameOrigin(request); + const viewer = await requireCommentViewer(); + await limitRequest("comment-mutate", viewer.id, 60); + const { id, action } = await context.params; + const body = await readJson(request); + if (action === "reaction") { + const value = reactionSchema.safeParse(body); + if (!value.success) throw new HttpError(400, "invalid-reaction"); + await mutateComment(id, viewer, "reaction", value.data.value); + } else if (action === "moderation") { + const value = moderationSchema.safeParse(body); + if (!value.success) throw new HttpError(400, "invalid-moderation"); + await mutateComment(id, viewer, "moderation", value.data.hidden); + } else if (action === "heart") { + const value = heartSchema.safeParse(body); + if (!value.success) throw new HttpError(400, "invalid-heart"); + await mutateComment(id, viewer, "heart", value.data.hearted); + } else throw new HttpError(404, "not-found"); + return Response.json({ ok: true }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/comments/[id]/route.ts b/app/api/comments/[id]/route.ts new file mode 100644 index 0000000..534403a --- /dev/null +++ b/app/api/comments/[id]/route.ts @@ -0,0 +1,32 @@ +import { authorizeComment, commentId, getCommentViewer, listComments, mutateComment, requireCommentViewer } from "@/lib/comments/repository"; +import { publishComment } from "@/lib/comments/publish"; +import { errorResponse, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +type Context = { params: Promise<{ id: string }> }; +export async function GET(_request: Request, context: Context) { + try { + const viewer = await getCommentViewer(); + const id = commentId((await context.params).id); + const authorized = await authorizeComment(id, viewer); + const result = await listComments({ viewer, target: authorized.destination.target, id }); + return Response.json({ item: result.items[0], viewer }, { headers: { "Cache-Control": "private, no-store" } }); + } catch (cause) { return errorResponse(cause); } +} +export async function PATCH(request: Request, context: Context) { + try { + requireSameOrigin(request); + const viewer = await requireCommentViewer(); + await limitRequest("comment-publish", viewer.id, 20); + return Response.json(await publishComment(request, viewer, { id: commentId((await context.params).id) })); + } catch (cause) { return errorResponse(cause); } +} +export async function DELETE(request: Request, context: Context) { + try { + requireSameOrigin(request); + const viewer = await requireCommentViewer(); + await limitRequest("comment-mutate", viewer.id, 60); + await mutateComment((await context.params).id, viewer, "delete"); + return new Response(null, { status: 204 }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/comments/events/route.test.ts b/app/api/comments/events/route.test.ts new file mode 100644 index 0000000..12382f3 --- /dev/null +++ b/app/api/comments/events/route.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { HttpError } from "@/lib/security/http"; +const mocks = vi.hoisted(() => ({ viewer: vi.fn(), target: vi.fn(), stream: vi.fn(), limit: vi.fn() })); +vi.mock("@/lib/comments/repository", () => ({ + getCommentViewer: mocks.viewer, getCommentTarget: mocks.target, + requireCommentAdmin: (viewer: { admin: boolean } | null) => { if (!viewer?.admin) throw new HttpError(403, "forbidden"); }, +})); +vi.mock("@/lib/events/redis-stream", () => ({ createRedisNamedEventResponse: mocks.stream })); +vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: mocks.limit, trustedClientAddress: () => "127.0.0.1" })); +import { GET } from "./route"; + +describe("comment event authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); mocks.viewer.mockResolvedValue(null); mocks.target.mockResolvedValue({ target: "stygian:123" }); + mocks.stream.mockResolvedValue(new Response("event: changed\ndata: changed\n\n", { headers: { "Content-Type": "text/event-stream" } })); + }); + it("allows anonymous subscribers for a readable target", async () => { + const request = new Request("https://guide.test/api/comments/events?target=stygian:123"); + expect((await GET(request)).status).toBe(200); + expect(mocks.target).toHaveBeenCalledWith("stygian:123", null); + expect(mocks.stream).toHaveBeenCalledWith("comments:stygian:123", "changed", request.signal); + }); + it("does not subscribe to an inaccessible guide", async () => { + mocks.target.mockRejectedValue(new HttpError(404, "guide-not-found")); + expect((await GET(new Request("https://guide.test/api/comments/events?target=guide:private"))).status).toBe(404); + expect(mocks.stream).not.toHaveBeenCalled(); + }); + it("rejects anonymous admin inbox subscriptions", async () => { + expect((await GET(new Request("https://guide.test/api/comments/events?scope=admin"))).status).toBe(403); + expect(mocks.stream).not.toHaveBeenCalled(); + }); + it("allows verified admin inbox subscriptions", async () => { + mocks.viewer.mockResolvedValue({ id: "admin", admin: true }); + const request = new Request("https://guide.test/api/comments/events?scope=admin"); + expect((await GET(request)).status).toBe(200); + expect(mocks.stream).toHaveBeenCalledWith("comments:admin", "changed", request.signal); + }); + it("requires a target for public subscriptions", async () => { + expect((await GET(new Request("https://guide.test/api/comments/events"))).status).toBe(400); + expect(mocks.stream).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/comments/events/route.ts b/app/api/comments/events/route.ts new file mode 100644 index 0000000..1e467bc --- /dev/null +++ b/app/api/comments/events/route.ts @@ -0,0 +1,25 @@ +import { createRedisNamedEventResponse } from "@/lib/events/redis-stream"; +import { getCommentTarget, getCommentViewer, requireCommentAdmin } from "@/lib/comments/repository"; +import { errorResponse, HttpError } from "@/lib/security/http"; +import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit"; + +export async function GET(request: Request) { + try { + await limitRequest("stream-open", trustedClientAddress(request.headers), 60); + const query = new URL(request.url).searchParams; + const viewer = await getCommentViewer(); + let topic: string; + if (query.get("scope") === "admin") { + requireCommentAdmin(viewer); + topic = "comments:admin"; + } else { + const target = query.get("target"); + if (!target) throw new HttpError(400, "target-required"); + const destination = await getCommentTarget(target, viewer); + topic = `comments:${destination.target}`; + } + // Events contain no comment text, images, or author information. + // Every refresh rechecks current guide visibility and viewer permissions. + return await createRedisNamedEventResponse(topic, "changed", request.signal); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/comments/images/[id]/route.ts b/app/api/comments/images/[id]/route.ts new file mode 100644 index 0000000..49c9433 --- /dev/null +++ b/app/api/comments/images/[id]/route.ts @@ -0,0 +1,13 @@ +import { commentImage, getCommentViewer } from "@/lib/comments/repository"; +import { publicMediaUrl } from "@/lib/media/storage"; +import { errorResponse } from "@/lib/security/http"; + +export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { + try { + const image = await commentImage((await context.params).id, await getCommentViewer()); + // Keep old attachment links working without proxying image bytes through the app. + return new Response(null, { status: 307, headers: { + Location: publicMediaUrl(image.objectKey), "Cache-Control": "private, no-store", + } }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/comments/push-subscription/route.ts b/app/api/comments/push-subscription/route.ts new file mode 100644 index 0000000..e3091a3 --- /dev/null +++ b/app/api/comments/push-subscription/route.ts @@ -0,0 +1,45 @@ +import { and, eq } from "drizzle-orm"; +import * as z from "zod"; +import { getDb } from "@/db"; +import { commentPushSubscriptions } from "@/db/schema"; +import { pushPublicKey, validPushEndpoint } from "@/lib/commission/push"; +import { requireCommentViewer } from "@/lib/comments/repository"; +import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +const subscriptionSchema = z.object({ + endpoint: z.string().max(2048), + keys: z.object({ p256dh: z.string().regex(/^[A-Za-z0-9_-]{50,200}$/), auth: z.string().regex(/^[A-Za-z0-9_-]{10,100}$/) }), +}); +export async function GET() { + try { + await requireCommentViewer(); + const publicKey = pushPublicKey(); + if (!publicKey) throw new HttpError(503, "push-not-configured"); + return Response.json({ publicKey }, { headers: { "Cache-Control": "private, no-store" } }); + } catch (cause) { return errorResponse(cause); } +} +export async function POST(request: Request) { + try { + requireSameOrigin(request); + const viewer = await requireCommentViewer(); + if (!pushPublicKey()) throw new HttpError(503, "push-not-configured"); + await limitRequest("comment-push-subscription", viewer.id, 60); + const parsed = subscriptionSchema.safeParse(await readJson(request, 4096)); + if (!parsed.success || !validPushEndpoint(parsed.data.endpoint)) throw new HttpError(400, "invalid-push-subscription"); + const { endpoint, keys } = parsed.data; + await getDb().insert(commentPushSubscriptions).values({ endpoint, userId: viewer.id, ...keys }) + .onConflictDoUpdate({ target: commentPushSubscriptions.endpoint, set: { userId: viewer.id, ...keys } }); + return new Response(null, { status: 204 }); + } catch (cause) { return errorResponse(cause); } +} +export async function DELETE(request: Request) { + try { + requireSameOrigin(request); + const viewer = await requireCommentViewer(); + const parsed = z.object({ endpoint: z.string().max(2048) }).safeParse(await readJson(request, 4096)); + if (!parsed.success) throw new HttpError(400, "invalid-push-subscription"); + await getDb().delete(commentPushSubscriptions).where(and(eq(commentPushSubscriptions.endpoint, parsed.data.endpoint), eq(commentPushSubscriptions.userId, viewer.id))); + return new Response(null, { status: 204 }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts new file mode 100644 index 0000000..4588d74 --- /dev/null +++ b/app/api/comments/route.ts @@ -0,0 +1,27 @@ +import { after } from "next/server"; +import { sendCommentReplyPush } from "@/lib/comments/push"; +import { getCommentViewer, listComments, requireCommentViewer } from "@/lib/comments/repository"; +import { publishComment } from "@/lib/comments/publish"; +import { errorResponse, HttpError, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +export async function GET(request: Request) { + try { + const query = new URL(request.url).searchParams; + const target = query.get("target"); + if (!target) throw new HttpError(400, "target-required"); + return Response.json(await listComments({ viewer: await getCommentViewer(), target, cursor: query.get("cursor"), sort: query.get("sort") ?? undefined }), { headers: { "Cache-Control": "private, no-store" } }); + } catch (cause) { return errorResponse(cause); } +} +export async function POST(request: Request) { + try { + requireSameOrigin(request); + const viewer = await requireCommentViewer(); + await limitRequest("comment-publish", viewer.id, 20); + const target = new URL(request.url).searchParams.get("target"); + if (!target) throw new HttpError(400, "target-required"); + const result = await publishComment(request, viewer, { target }); + after(async () => { await sendCommentReplyPush(result.id).catch(() => console.error("Comment reply notification failed")); }); + return Response.json(result, { status: 201 }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/stygian/comment/loading.tsx b/app/stygian/comment/loading.tsx new file mode 100644 index 0000000..f64d7a0 --- /dev/null +++ b/app/stygian/comment/loading.tsx @@ -0,0 +1,5 @@ +import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; + +export default function Loading() { + return
; +} diff --git a/app/stygian/comment/page.tsx b/app/stygian/comment/page.tsx new file mode 100644 index 0000000..c9e8d68 --- /dev/null +++ b/app/stygian/comment/page.tsx @@ -0,0 +1,36 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import Image from "@/components/ui/resilient-image"; +import { SiteHeader } from "@/components/public/site-header"; +import { SiteFooter } from "@/components/public/site-footer"; +import { buttonVariants } from "@/components/ui/button"; +import { CommentBoardContent } from "@/components/comments/comment-board-content"; +import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; +import { getCachedStygianSchedules } from "@/lib/cache/stygian"; + +export const instant = false; +export const metadata: Metadata = { title: "Stygian" }; +export default async function StygianCommentPage({ searchParams }: { searchParams: Promise> }) { + await connection(); + const query = await searchParams; + const schedules = await getCachedStygianSchedules(); + const raw = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule; + const selected = raw ? schedules.find((entry) => entry.scheduleId === Number(raw)) : schedules[0]; + if (!selected) notFound(); + const target = `stygian:${selected.scheduleId}`; + return
+
+
+

Stygian · {selected.challengeName}

ความคิดเห็น

+
+ + + }> +
; +} diff --git a/app/stygian/page.tsx b/app/stygian/page.tsx index becda36..fb47d5a 100644 --- a/app/stygian/page.tsx +++ b/app/stygian/page.tsx @@ -1,10 +1,14 @@ import Link from "next/link"; +import { Suspense } from "react"; +import Image from "@/components/ui/resilient-image"; import { connection } from "next/server"; import { SiteFooter } from "@/components/public/site-footer"; import { SiteHeader } from "@/components/public/site-header"; import { StygianGuide } from "@/components/public/stygian-guide"; import { LiveRefresh } from "@/components/live-refresh"; +import { CommentBoardContent } from "@/components/comments/comment-board-content"; +import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; import { buttonVariants } from "@/components/ui/button"; import { getCachedStygianSchedule, getCachedStygianSchedules } from "@/lib/cache/stygian"; @@ -33,9 +37,20 @@ export default async function StygianPage({ searchParams }: { searchParams: Prom {schedules.length ?

เลือกเวอร์ชัน

: null} + {schedule && } {schedule && catalog ? : ( ยังไม่มีไกด์ Stygianกำลังเตรียมข้อมูล กลับมาอ่านได้ภายหลัง หรือเลือกไกด์ตัวละครระหว่างรอเลือกไกด์ตัวละคร )} + {schedule &&
+ }> + + +
} diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 6a48382..969b917 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -13,6 +13,7 @@ import { SwordsIcon, } from "lucide-react"; +import { AdminUnreadCount } from "@/components/comments/admin-unread-comments"; import { Button } from "@/components/ui/button"; import { SignOutButton } from "@/components/auth/sign-out-button"; import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt"; @@ -33,6 +34,10 @@ export function AdminHeader() { ไกด์เกนชินไม่ใช่เกมมือถือ