feat : create share function for comment
This commit is contained in:
@@ -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("หน้านี้แสดงข้อมูลที่บันทึกล่าสุด");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<GuidePageShell guide={guide} sections={guideSections} section={section} preview={preview}>
|
||||
{section ? (
|
||||
<GuideSection guide={guide} section={section} />
|
||||
) : (
|
||||
<GuideOverview guide={guide} sections={sections} baseHref={baseHref} />
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</GuidePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user