feat(guides) : add safer publishing and discovery
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user