feat : create share function for comment

This commit is contained in:
2026-10-08 18:06:16 +07:00 Unverified
parent 4e99b36026
commit c8a986c973
4 changed files with 218 additions and 143 deletions
+8 -26
View File
@@ -1,18 +1,11 @@
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 { GuidePageShell } from "@/components/public/guide-page-shell";
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 }> };
@@ -25,22 +18,11 @@ export default async function GuideCommentPage({ params }: Props) {
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>;
return (
<GuidePageShell guide={guide} sections={guide.sections} section="comment">
<Suspense fallback={<CommentBoardSkeleton />}>
<CommentBoardContent target={target} />
</Suspense>
</GuidePageShell>
);
}
@@ -0,0 +1,60 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { GuidePageShell } from "./guide-page-shell";
vi.mock("@/components/public/site-header", () => ({ SiteHeader: () => null }));
vi.mock("@/components/live-refresh", () => ({ LiveRefresh: () => null }));
const guide = {
id: "test-guide",
slug: "raiden",
name: "Raiden",
characterKey: "raiden-key",
coverUrl: "/cover.png",
};
const sections = [
{ id: "weapon", slug: "weapon", title: "Weapon" },
{ id: "extra", slug: "rotation", title: "Rotation" },
];
describe("shared guide navigation", () => {
it("gives comments the home button and guide tabs around their content", () => {
const html = renderToStaticMarkup(
<GuidePageShell guide={guide} sections={sections} section="comment">
<section>Comment board</section>
</GuidePageShell>,
);
expect(html).toMatch(/<a\b[^>]*aria-label="กลับหน้ารวม"[^>]*href="\/"/);
expect(html).toMatch(/<a\b[^>]*aria-current="page"[^>]*href="\/raiden\/comment"/);
expect(html).toContain('href="/raiden/weapon"');
expect(html).toContain('href="/raiden/rotation"');
expect(html).toContain("Weapons");
expect(html).toContain("Rotation");
expect(html).toContain("<section>Comment board</section>");
});
it("keeps overview active and includes a single comments link", () => {
const html = renderToStaticMarkup(
<GuidePageShell guide={guide} sections={sections}>Overview content</GuidePageShell>,
);
expect(html).toMatch(/<a\b[^>]*aria-current="page"[^>]*href="\/raiden"/);
expect(html.match(/href="\/raiden\/comment"/g)).toHaveLength(1);
});
it("keeps preview links under admin and omits comments", () => {
const html = renderToStaticMarkup(
<GuidePageShell guide={guide} sections={sections} section="weapon" preview>
Preview content
</GuidePageShell>,
);
expect(html).toMatch(/<a\b[^>]*aria-label="กลับหน้าแก้ไข"[^>]*href="\/admin\/raiden-key"/);
expect(html).toMatch(/<a\b[^>]*aria-current="page"[^>]*href="\/admin\/raiden-key\/preview\/weapon"/);
expect(html).toContain('href="/admin/raiden-key/preview/rotation"');
expect(html).not.toContain("/comment");
expect(html).toContain("หน้านี้แสดงข้อมูลที่บันทึกล่าสุด");
});
});
+139
View File
@@ -0,0 +1,139 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ArrowLeftIcon } from "lucide-react";
import Image from "@/components/ui/resilient-image";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { SiteHeader } from "@/components/public/site-header";
import { SiteFooter } from "@/components/public/site-footer";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { LiveRefresh } from "@/components/live-refresh";
import type { PublicGuideSummary } from "@/lib/guides/queries";
import { commentNavigationSection } from "@/lib/comments/navigation";
import { navigationIconPath } from "@/lib/navigation";
import { cn } from "@/lib/utils";
export function publicSectionTitle(slug: string, fallback: string): string {
if (slug === "weapon") return "Weapons";
if (slug === "artifact") return "Artifacts";
if (slug === "constellations") return "Constellations";
if (slug === "team") return "Team calc";
if (slug === "comment") return "Comment";
return fallback;
}
export function GuidePageShell({
guide,
sections: guideSections,
section,
preview = false,
children,
}: {
guide: Pick<PublicGuideSummary, "id" | "slug" | "name" | "characterKey" | "coverUrl">;
sections: Pick<PublicGuideSummary["sections"][number], "id" | "slug" | "title">[];
section?: string;
preview?: boolean;
children: ReactNode;
}) {
const sections = preview ? guideSections : [...guideSections, commentNavigationSection(guide.id)];
const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`;
const currentPageTitle = section
? publicSectionTitle(section, sections.find((item) => item.slug === section)?.title ?? section)
: "Overview";
return (
<div className="flex min-h-svh flex-col bg-background">
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
<SiteHeader editHref={`/admin/${guide.characterKey}`} guideId={guide.id} />
{preview ? (
<div className="mx-auto w-full max-w-7xl px-4 pt-4 sm:px-6 lg:px-8">
<Alert>
<AlertTitle>Preview</AlertTitle>
<AlertDescription>หน้านี้แสดงข้อมูลที่บันทึกล่าสุดและเปิดดูได้เฉพาะผู้ดูแล</AlertDescription>
</Alert>
</div>
) : null}
<div className="sticky top-0 z-40 bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-12 w-full max-w-7xl items-center gap-3 px-4 sm:min-h-16 sm:px-6 lg:px-8">
<Button variant="ghost" size="icon" className="max-sm:size-11" render={<Link href={preview ? `/admin/${guide.characterKey}` : "/"} />} nativeButton={false} aria-label={preview ? "กลับหน้าแก้ไข" : "กลับหน้ารวม"}>
<ArrowLeftIcon />
</Button>
<div className="relative size-10 shrink-0 overflow-hidden rounded-lg border sm:size-16">
<Image
src={guide.coverUrl}
alt={guide.name}
fill
sizes="(min-width: 640px) 64px, 40px"
className="object-cover"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
<p className="truncate text-sm text-muted-foreground">
{guide.name}
</p>
<p className="max-w-full truncate font-heading text-lg font-semibold sm:text-2xl">
{currentPageTitle}
</p>
</div>
</div>
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
<ScrollableNav
ariaLabel={`เมนูคู่มือ ${guide.name}`}
activeKey={section ?? "overview"}
className="min-h-11 w-full rounded-xl border bg-card p-1 sm:p-2"
>
<Link
href={baseHref}
aria-current={!section ? "page" : undefined}
className={cn(
"flex min-h-11 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
!section
? "bg-primary/15 font-semibold text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
prefetch={false}
>
<Image
src={navigationIconPath("overview")!}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
Overview
</Link>
{sections.map((item) => (
<Link
key={item.id}
href={`${baseHref}/${item.slug}`}
aria-current={section === item.slug ? "page" : undefined}
className={cn(
"flex min-h-11 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
section === item.slug
? "bg-primary/15 font-semibold text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
prefetch={false}
>
{navigationIconPath(item.slug) ? (
<Image
src={navigationIconPath(item.slug)!}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
) : null}
{publicSectionTitle(item.slug, item.title)}
</Link>
))}
</ScrollableNav>
</div>
</div>
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
{children}
</main>
<SiteFooter />
</div>
);
}
+11 -117
View File
@@ -1,13 +1,11 @@
import Image from "@/components/ui/resilient-image";
import localFont from "next/font/local";
import Link from "next/link";
import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react";
import { ArrowUpRightIcon } from "lucide-react";
import { Fragment, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ComparisonContext } from "@/components/public/comparison-context";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
@@ -25,9 +23,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { SiteFooter } from "@/components/public/site-footer";
import { SiteHeader } from "@/components/public/site-header";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { GuidePageShell, publicSectionTitle } from "@/components/public/guide-page-shell";
import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { ExportText } from "@/components/public/export-language";
@@ -40,7 +36,6 @@ import { LunarisText } from "@/components/public/lunaris-text";
import { ConditionSwitcher } from "@/components/public/condition-switcher";
import { MaterialPoster } from "@/components/public/material-poster";
import { RichTextContent } from "@/components/rich-text-content";
import { LiveRefresh } from "@/components/live-refresh";
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
import { elementIconFilter } from "@/lib/catalog/element";
import type { PublicGuide } from "@/lib/guides/queries";
@@ -76,15 +71,6 @@ export function publicSections(guide: PublicGuide) {
return publicGuideSections(guide);
}
function publicSectionTitle(slug: string, fallback: string): string {
if (slug === "weapon") return "Weapons";
if (slug === "artifact") return "Artifacts";
if (slug === "constellations") return "Constellations";
if (slug === "team") return "Team calc";
if (slug === "comment") return "Comment";
return fallback;
}
export function StructuredGuideShell({
guide,
section,
@@ -94,109 +80,17 @@ export function StructuredGuideShell({
section?: string;
preview?: boolean;
}) {
const sections = preview ? publicSections(guide) : [...publicSections(guide), commentNavigationSection(guide.id)];
const guideSections = publicSections(guide);
const sections = preview ? guideSections : [...guideSections, commentNavigationSection(guide.id)];
const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`;
const currentPageTitle = section
? publicSectionTitle(section, sections.find((item) => item.slug === section)?.title ?? section)
: "Overview";
return (
<div className="flex min-h-svh flex-col bg-background">
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
<SiteHeader editHref={`/admin/${guide.characterKey}`} guideId={guide.id} />
{preview ? (
<div className="mx-auto w-full max-w-7xl px-4 pt-4 sm:px-6 lg:px-8">
<Alert>
<AlertTitle>Preview</AlertTitle>
<AlertDescription>หน้านี้แสดงข้อมูลที่บันทึกล่าสุดและเปิดดูได้เฉพาะผู้ดูแล</AlertDescription>
</Alert>
</div>
) : null}
<div className="sticky top-0 z-40 bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-12 w-full max-w-7xl items-center gap-3 px-4 sm:min-h-16 sm:px-6 lg:px-8">
<Button variant="ghost" size="icon" className="max-sm:size-11" render={<Link href={preview ? `/admin/${guide.characterKey}` : "/"} />} nativeButton={false} aria-label={preview ? "กลับหน้าแก้ไข" : "กลับหน้ารวม"}>
<ArrowLeftIcon />
</Button>
<div className="relative size-10 shrink-0 overflow-hidden rounded-lg border sm:size-16">
<Image
src={guide.coverUrl}
alt={guide.name}
fill
sizes="(min-width: 640px) 64px, 40px"
className="object-cover"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
<p className="truncate text-sm text-muted-foreground">
{guide.name}
</p>
<p className="max-w-full truncate font-heading text-lg font-semibold sm:text-2xl">
{currentPageTitle}
</p>
</div>
</div>
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
<ScrollableNav
ariaLabel={`เมนูคู่มือ ${guide.name}`}
activeKey={section ?? "overview"}
className="min-h-11 w-full rounded-xl border bg-card p-1 sm:p-2"
>
<Link
href={baseHref}
aria-current={!section ? "page" : undefined}
className={cn(
"flex min-h-11 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
!section
? "bg-primary/15 font-semibold text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
prefetch={false}
>
<Image
src={navigationIconPath("overview")!}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
Overview
</Link>
{sections.map((item) => (
<Link
key={item.id}
href={`${baseHref}/${item.slug}`}
aria-current={section === item.slug ? "page" : undefined}
className={cn(
"flex min-h-11 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
section === item.slug
? "bg-primary/15 font-semibold text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
prefetch={false}
>
{navigationIconPath(item.slug) ? (
<Image
src={navigationIconPath(item.slug)!}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
) : null}
{publicSectionTitle(item.slug, item.title)}
</Link>
))}
</ScrollableNav>
</div>
</div>
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
{section ? (
<GuideSection guide={guide} section={section} />
) : (
<GuideOverview guide={guide} sections={sections} baseHref={baseHref} />
)}
</main>
<SiteFooter />
</div>
<GuidePageShell guide={guide} sections={guideSections} section={section} preview={preview}>
{section ? (
<GuideSection guide={guide} section={section} />
) : (
<GuideOverview guide={guide} sections={sections} baseHref={baseHref} />
)}
</GuidePageShell>
);
}