feat : comment feature
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <div className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10"><CommentBoardSkeleton /></div>;
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
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 <div className="flex min-h-svh flex-col bg-background">
|
||||
<SiteHeader editHref={`/admin/${guide.characterKey}`} guideId={guide.id} />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-10">
|
||||
<header className="flex items-center gap-3"><Image src={guide.coverUrl} alt="" width={64} height={64} className="size-16 rounded-lg object-cover" />
|
||||
<div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">ความคิดเห็น</h1></div>
|
||||
</header>
|
||||
<ScrollableNav ariaLabel={`เมนูคู่มือ ${guide.name}`} activeKey="comment" className="min-h-11 w-full rounded-xl border bg-card p-1 sm:p-2">
|
||||
{sections.map((section) => <Link key={section.id} href={`/${guide.slug}${section.slug ? `/${section.slug}` : ""}`} prefetch={false}
|
||||
aria-current={section.slug === "comment" ? "page" : undefined}
|
||||
className={cn("flex min-h-11 shrink-0 items-center gap-2 rounded-md px-3 text-sm", section.slug === "comment" ? "bg-primary/15 font-semibold text-primary" : "text-muted-foreground hover:bg-muted")}>
|
||||
{navigationIconPath(section.slug || "overview") && <Image src={navigationIconPath(section.slug || "overview")!} alt="" width={20} height={20} className="size-5 object-contain" />}
|
||||
{section.title}
|
||||
</Link>)}
|
||||
</ScrollableNav>
|
||||
<Suspense fallback={<CommentBoardSkeleton />}><CommentBoardContent target={target} /></Suspense>
|
||||
</main><SiteFooter />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <div className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10"><CommentBoardSkeleton /></div>;
|
||||
}
|
||||
@@ -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 <><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
|
||||
<header><h1 className="font-heading text-3xl font-semibold">ความคิดเห็น</h1><p className="mt-2 text-muted-foreground">ตอบกลับและจัดการความคิดเห็นจากทุกไกด์</p></header>
|
||||
<AdminCommentInbox key={initialTarget ?? "all"} initialTarget={initialTarget} initial={initial} targets={targets} />
|
||||
</main></>;
|
||||
}
|
||||
@@ -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">) {
|
||||
</main>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
<AdminUnreadCommentsProvider>{children}</AdminUnreadCommentsProvider>
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <div className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10"><CommentBoardSkeleton /></div>;
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>> }) {
|
||||
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 <div className="flex min-h-svh flex-col bg-background"><SiteHeader editHref="/admin/stygian" />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-10">
|
||||
<header className="flex items-center gap-3"><Image src="/icon/nav/Comment.webp" alt="" width={48} height={48} className="size-12 object-contain" />
|
||||
<div><p className="text-sm text-muted-foreground">Stygian · {selected.challengeName}</p><h1 className="font-heading text-3xl font-semibold">ความคิดเห็น</h1></div>
|
||||
</header>
|
||||
<nav aria-label="เมนู Stygian" className="flex flex-wrap gap-2"><Link href={`/stygian?schedule=${selected.scheduleId}`} className={buttonVariants({ variant: "outline" })}>ไกด์</Link>
|
||||
<Link href={`/stygian/comment?schedule=${selected.scheduleId}`} aria-current="page" className={buttonVariants({ variant: "secondary" })}>ความคิดเห็น</Link></nav>
|
||||
<nav aria-label="รอบ Stygian" className="flex flex-wrap gap-2">{schedules.map((schedule) => <Link key={schedule.scheduleId}
|
||||
href={`/stygian/comment?schedule=${schedule.scheduleId}`} prefetch={false} aria-current={schedule.scheduleId === selected.scheduleId ? "page" : undefined}
|
||||
className={buttonVariants({ variant: schedule.scheduleId === selected.scheduleId ? "default" : "outline" })}>{schedule.challengeName}</Link>)}</nav>
|
||||
<Suspense key={target} fallback={<CommentBoardSkeleton />}><CommentBoardContent target={target} /></Suspense>
|
||||
</main><SiteFooter /></div>;
|
||||
}
|
||||
@@ -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 ? <div className="flex flex-col gap-2"><p className="text-sm font-medium">เลือกเวอร์ชัน</p><nav className="flex flex-wrap gap-2" aria-label="เวอร์ชัน Stygian">{schedules.map((entry) =>
|
||||
<Link key={entry.scheduleId} href={`/stygian?schedule=${entry.scheduleId}`} aria-current={entry.scheduleId === selectedId ? "page" : undefined}
|
||||
className={buttonVariants({ variant: entry.scheduleId === selectedId ? "default" : "outline", size: "sm", className: "max-sm:min-h-11" })}>{entry.challengeName}</Link>)}</nav></div> : null}
|
||||
{schedule && <nav aria-label="Stygian navigation" className="flex flex-wrap gap-2">
|
||||
<Link href={`/stygian?schedule=${schedule.scheduleId}`} aria-current="page" className={buttonVariants({ variant: "secondary" })}>Guide</Link>
|
||||
<a href="#comments" className={buttonVariants({ variant: "outline" })}>
|
||||
<Image src="/icon/nav/Comment.webp" alt="" width={20} height={20} className="size-5 object-contain" />ความคิดเห็น
|
||||
</a>
|
||||
</nav>}
|
||||
{schedule && catalog ? <StygianGuide schedule={schedule} catalog={catalog} /> : (
|
||||
<Empty className="border"><EmptyHeader><EmptyTitle>ยังไม่มีไกด์ Stygian</EmptyTitle><EmptyDescription>กำลังเตรียมข้อมูล กลับมาอ่านได้ภายหลัง หรือเลือกไกด์ตัวละครระหว่างรอ</EmptyDescription></EmptyHeader><Link href="/" className={buttonVariants({ variant: "outline" })}>เลือกไกด์ตัวละคร</Link></Empty>
|
||||
)}
|
||||
{schedule && <section id="comments" aria-label="ความคิดเห็น Stygian" className="scroll-mt-24 border-t pt-8">
|
||||
<Suspense key={`stygian:${schedule.scheduleId}`} fallback={<CommentBoardSkeleton />}>
|
||||
<CommentBoardContent target={`stygian:${schedule.scheduleId}`} />
|
||||
</Suspense>
|
||||
</section>}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user