feat(guides) : add safer publishing and discovery
CI / Verify (push) Successful in 1m35s
CI / Build immutable images and deploy (push) Successful in 5m27s

This commit is contained in:
2026-09-19 13:32:57 +07:00 Unverified
parent 6698dc1302
commit 9896e8a4ad
27 changed files with 3213 additions and 108 deletions
+34 -3
View File
@@ -19,28 +19,41 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { cn } from "@/lib/utils";
export function GuideVisibilityControl({
guideId,
guideName,
isPublic,
version,
className,
}: {
guideId: string;
guideName: string;
isPublic: boolean;
version: number;
className?: string;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const [publicationNote, setPublicationNote] = useState("");
const [error, setError] = useState<string | null>(null);
const updateVisibility = (nextPublic: boolean) => {
startTransition(async () => {
const result = await setStructuredGuideVisibility(guideId, nextPublic);
setError(null);
const result = await setStructuredGuideVisibility(guideId, {
isPublic: nextPublic,
expectedVersion: version,
publicationNote,
});
if (result.status !== "saved") {
toast.error(result.message ?? "เปลี่ยนสถานะ Guide ไม่สำเร็จ");
const message = result.message ?? "เปลี่ยนสถานะ Guide ไม่สำเร็จ";
setError(message);
toast.error(message);
return;
}
toast.success(nextPublic ? "เผยแพร่ Guide แล้ว" : "เปลี่ยน Guide เป็น Private แล้ว");
@@ -78,12 +91,30 @@ export function GuideVisibilityControl({
: "Guide จะแสดงบนหน้าเว็บไซต์และทุกคนจะเปิดดูผ่านลิงก์สาธารณะได้"}
</AlertDialogDescription>
</AlertDialogHeader>
{!isPublic ? (
<Textarea
value={publicationNote}
onChange={(event) => setPublicationNote(event.target.value)}
maxLength={500}
placeholder="สรุปสิ่งที่อัปเดต (ไม่บังคับ)"
aria-label="สรุปสิ่งที่อัปเดต"
/>
) : null}
{error ? (
<Alert variant="destructive">
<AlertTitle>ยังเผยแพร่ไม่ได้</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>ยกเลิก</AlertDialogCancel>
<AlertDialogAction
variant={isPublic ? "destructive" : "default"}
disabled={pending}
onClick={() => updateVisibility(!isPublic)}
onClick={(event) => {
event.preventDefault();
updateVisibility(!isPublic);
}}
>
{pending && <Spinner data-icon="inline-start" />}
{isPublic ? "ยืนยัน Private" : "ยืนยัน Public"}
@@ -258,6 +258,7 @@ export function StructuredDashboard({
guideId={guide.id}
guideName={guide.name}
isPublic={guide.isPublic}
version={guide.version}
/>
<DeleteConfirmationDialog
showSuccessToast={false}
+91 -5
View File
@@ -4,8 +4,9 @@ import { searchItems } from "@/lib/search";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SearchIcon, UsersIcon } from "lucide-react";
import { useState } from "react";
import { useMemo } from "react";
import {
Card,
@@ -27,6 +28,14 @@ import {
InputGroupInput,
} from "@/components/ui/input-group";
import { useVisitorPresence } from "@/hooks/use-presence";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type GuideCard = {
id: string;
@@ -36,15 +45,48 @@ type GuideCard = {
coverUrl: string;
version: number;
aliases: string[];
element: string | null;
weaponType: string | null;
rarity: number | null;
updatedAt: Date;
publishedAt: Date | null;
publishedCatalogVersion: string | null;
};
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const [query, setQuery] = useState("");
const searchParams = useSearchParams();
const query = searchParams.get("q") ?? "";
const presence = useVisitorPresence(null);
const search = searchItems(guides, query,
const elements = useMemo(() => [...new Set(guides.flatMap((guide) => guide.element ? [guide.element] : []))].sort(), [guides]);
const weapons = useMemo(() => [...new Set(guides.flatMap((guide) => guide.weaponType ? [guide.weaponType] : []))].sort(), [guides]);
const rarities = useMemo(() => [...new Set(guides.flatMap((guide) => guide.rarity ? [guide.rarity] : []))].sort((a, b) => b - a), [guides]);
const elementParam = searchParams.get("element");
const weaponParam = searchParams.get("weapon");
const rarityParam = searchParams.get("rarity");
const element = elementParam && elements.includes(elementParam) ? elementParam : "all";
const weapon = weaponParam && weapons.includes(weaponParam) ? weaponParam : "all";
const rarity = rarityParam && rarities.includes(Number(rarityParam)) ? rarityParam : "all";
const sort = searchParams.get("sort") === "recent" ? "recent" : "manual";
const matchingGuides = guides.filter((guide) =>
(element === "all" || guide.element === element) &&
(weapon === "all" || guide.weaponType === weapon) &&
(rarity === "all" || guide.rarity === Number(rarity)),
);
const search = searchItems(matchingGuides, query,
(guide) => `${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`,
(guide) => [guide.name, ...guide.aliases]);
const filtered = search.items;
const filtered = sort === "recent"
? [...search.items].sort((left, right) =>
new Date(right.publishedAt ?? right.updatedAt).getTime() - new Date(left.publishedAt ?? left.updatedAt).getTime())
: search.items;
function setParam(name: string, value: string) {
const next = new URLSearchParams(searchParams.toString());
if (!value || value === "all" || (name === "sort" && value === "manual")) next.delete(name);
else next.set(name, value);
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
return (
<div className="flex flex-col gap-8">
<InputGroup className="h-11 max-w-2xl">
@@ -53,11 +95,17 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
onChange={(event) => setParam("q", event.target.value)}
placeholder="ค้นหาไกด์…"
aria-label="ค้นหาไกด์"
/>
</InputGroup>
<div className="flex flex-wrap gap-2" aria-label="ตัวกรองไกด์">
<GuideFilter label="ธาตุ" value={element} options={elements} onChange={(value) => setParam("element", value)} />
<GuideFilter label="อาวุธ" value={weapon} options={weapons} onChange={(value) => setParam("weapon", value)} />
<GuideFilter label="ดาว" value={rarity} options={rarities.map(String)} onChange={(value) => setParam("rarity", value)} />
<GuideFilter label="เรียงตาม" value={sort} options={["manual", "recent"]} optionLabels={{ manual: "แนะนำ", recent: "อัปเดตล่าสุด" }} includeAll={false} onChange={(value) => setParam("sort", value)} />
</div>
{filtered.length ? (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
@@ -92,6 +140,12 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
<CardDescription className="line-clamp-3">
{guide.overview}
</CardDescription>
<div className="flex flex-wrap gap-2 pt-2">
{guide.publishedCatalogVersion ? <Badge variant="outline">v{guide.publishedCatalogVersion}</Badge> : null}
<Badge variant="secondary">
อัปเดต {new Date(guide.publishedAt ?? guide.updatedAt).toLocaleDateString("th-TH")}
</Badge>
</div>
</CardHeader>
</Card>
</Link>
@@ -108,3 +162,35 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
</div>
);
}
function GuideFilter({
label,
value,
options,
optionLabels = {},
includeAll = true,
onChange,
}: {
label: string;
value: string;
options: string[];
optionLabels?: Record<string, string>;
includeAll?: boolean;
onChange: (value: string) => void;
}) {
return (
<Select value={value} onValueChange={(next) => next && onChange(next)}>
<SelectTrigger aria-label={label}>
<SelectValue>{optionLabels[value] ?? (value === "all" ? label : value)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{includeAll ? <SelectItem value="all">{label}: ทั้งหมด</SelectItem> : null}
{options.map((option) => (
<SelectItem key={option} value={option}>{optionLabels[option] ?? option}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
+24 -27
View File
@@ -4,6 +4,7 @@ import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -56,33 +57,11 @@ import {
} from "@/lib/guides/talents";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { normalizeExtraRichText } from "@/lib/rich-text";
import { publicGuideSections } from "@/lib/guides/public-sections";
import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
if (slug === "weapon") return guide.weapons.length > 0;
if (slug === "artifact")
return (
guide.artifactGroups.some((condition) => condition.tiers.some((group) => group.rows.length > 0)) ||
Boolean(
guide.artifactProfile &&
(guide.artifactProfile.recommendedConditions.some((condition) => condition.sands.length || condition.goblet.length || condition.circlet.length || condition.substats.length) ||
guide.artifactProfile.sands.length ||
guide.artifactProfile.goblet.length ||
guide.artifactProfile.circlet.length ||
guide.artifactProfile.substats.length ||
guide.artifactProfile.targetConditions.some((group) => group.rows.length) ||
guide.artifactProfile.talents.length),
)
);
if (slug === "constellations") return guide.constellationGroups.some((group) => group.rows.length > 0);
if (slug === "team") return guide.teams.length > 0;
return guide.extras.some(
(extra) => extra.slug === slug && extra.rows.length > 0,
);
}
export function publicSections(guide: PublicGuide) {
return guide.sections.filter((section) => hasSection(guide, section.slug));
return publicGuideSections(guide);
}
function publicSectionTitle(slug: string, fallback: string): string {
@@ -96,11 +75,14 @@ function publicSectionTitle(slug: string, fallback: string): string {
export function StructuredGuideShell({
guide,
section,
preview = false,
}: {
guide: PublicGuide;
section?: string;
preview?: boolean;
}) {
const sections = publicSections(guide);
const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`;
const currentPageTitle = section
? (sections.find((item) => item.slug === section)?.title ?? section)
: "Overview";
@@ -108,9 +90,17 @@ export function StructuredGuideShell({
<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-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6 lg:px-8">
<Button variant="ghost" size="icon" render={<Link href="/" />} nativeButton={false} aria-label="กลับหน้ารวม">
<Button variant="ghost" size="icon" render={<Link href={preview ? `/admin/${guide.characterKey}` : "/"} />} nativeButton={false} aria-label={preview ? "กลับหน้าแก้ไข" : "กลับหน้ารวม"}>
<ArrowLeftIcon />
</Button>
<div className="relative size-16 overflow-hidden rounded-lg border">
@@ -137,7 +127,7 @@ export function StructuredGuideShell({
className="min-h-11 w-full rounded-xl border bg-card p-2"
>
<Link
href={`/${guide.slug}`}
href={baseHref}
className={cn(
"flex min-h-8 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
@@ -158,7 +148,7 @@ export function StructuredGuideShell({
{sections.map((item) => (
<Link
key={item.id}
href={`/${guide.slug}/${item.slug}`}
href={`${baseHref}/${item.slug}`}
className={cn(
"flex min-h-8 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
@@ -212,6 +202,13 @@ function GuideOverview({
<p className="text-sm font-medium text-primary">ภาพรวมแนวทาง</p>
<h1 className="font-heading text-4xl font-bold">{guide.name}</h1>
<p className="text-lg font-semibold">{guide.overview}</p>
<div className="flex flex-wrap justify-center gap-2">
{guide.publishedCatalogVersion ? <Badge variant="outline">เวอร์ชัน {guide.publishedCatalogVersion}</Badge> : null}
<Badge variant="secondary">
อัปเดต {new Date(guide.publishedAt ?? guide.updatedAt).toLocaleDateString("th-TH")}
</Badge>
</div>
{guide.publicationNote ? <p className="text-sm text-muted-foreground">{guide.publicationNote}</p> : null}
</div>
<div className="grid items-start gap-6 lg:grid-cols-2">
<div className="order-1 relative aspect-square w-full overflow-hidden rounded-xl border bg-muted lg:col-start-1 lg:row-start-1">