feat : update
This commit is contained in:
@@ -21,7 +21,6 @@ const sections = [
|
|||||||
"artifact",
|
"artifact",
|
||||||
"constellations",
|
"constellations",
|
||||||
"team",
|
"team",
|
||||||
"extra",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const instant = false;
|
export const instant = false;
|
||||||
@@ -34,9 +33,22 @@ export default async function StructuredEditorPage({
|
|||||||
await connection();
|
await connection();
|
||||||
if (!(await getAdminSession())) redirect("/admin/login");
|
if (!(await getAdminSession())) redirect("/admin/login");
|
||||||
const { character, page } = await params;
|
const { character, page } = await params;
|
||||||
if (!sections.includes(page as (typeof sections)[number])) notFound();
|
|
||||||
const guide = await getGuideByCharacterKey(character);
|
const guide = await getGuideByCharacterKey(character);
|
||||||
if (!guide) notFound();
|
if (!guide) notFound();
|
||||||
|
const extraSections = guide.sections.filter((section) => section.kind === "extra");
|
||||||
|
if (page === "extra") {
|
||||||
|
redirect(
|
||||||
|
extraSections[0]
|
||||||
|
? `/admin/${character}/${extraSections[0].slug}`
|
||||||
|
: `/admin/${character}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fixedSection = sections.includes(page as (typeof sections)[number]);
|
||||||
|
const extraSection = extraSections.find((section) => section.slug === page);
|
||||||
|
if (!fixedSection && !extraSection) notFound();
|
||||||
|
const editorSection = fixedSection
|
||||||
|
? page as (typeof sections)[number]
|
||||||
|
: "extra" as const;
|
||||||
const [catalog, content] = await Promise.all([
|
const [catalog, content] = await Promise.all([
|
||||||
getActiveCatalog(),
|
getActiveCatalog(),
|
||||||
getAdminGuideContent(guide.slug),
|
getAdminGuideContent(guide.slug),
|
||||||
@@ -65,18 +77,20 @@ export default async function StructuredEditorPage({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-muted-foreground">{guide.name}</p>
|
<p className="text-sm text-muted-foreground">{guide.name}</p>
|
||||||
<h1 className="whitespace-nowrap font-heading text-3xl font-semibold">
|
<h1 className="whitespace-nowrap font-heading text-3xl font-semibold">
|
||||||
{guide.sections.find((section) => section.slug === page)?.title ??
|
{extraSection?.title ?? guide.sections.find((section) => section.slug === page)?.title ?? page}
|
||||||
page}
|
|
||||||
</h1>
|
</h1>
|
||||||
<EditorPresence guideId={guide.id} />
|
<EditorPresence guideId={guide.id} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<GuideSectionNav
|
<GuideSectionNav
|
||||||
|
guideId={guide.id}
|
||||||
characterKey={guide.characterKey}
|
characterKey={guide.characterKey}
|
||||||
current={page as (typeof sections)[number]}
|
current={page}
|
||||||
|
extras={extraSections.map(({ id, slug, title }) => ({ id, slug, title }))}
|
||||||
/>
|
/>
|
||||||
<GuideSectionEditor
|
<GuideSectionEditor
|
||||||
section={page as (typeof sections)[number]}
|
section={editorSection}
|
||||||
|
extraSectionId={extraSection?.id}
|
||||||
guide={guide}
|
guide={guide}
|
||||||
weapons={catalog.weapons}
|
weapons={catalog.weapons}
|
||||||
artifacts={catalog.artifacts}
|
artifacts={catalog.artifacts}
|
||||||
@@ -119,7 +133,7 @@ export default async function StructuredEditorPage({
|
|||||||
damage: member.damage,
|
damage: member.damage,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
extras: content.extras.map((section) => ({
|
extras: content.extras.filter((section) => !extraSection || section.id === extraSection.id).map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
title: section.title,
|
title: section.title,
|
||||||
rows: section.rows.map((row) => ({
|
rows: section.rows.map((row) => ({
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
|
|
||||||
|
import { AdminHeader } from "@/components/admin/admin-header";
|
||||||
|
import { EditorPresence } from "@/components/admin/editor-presence";
|
||||||
|
import { ExtraOverview } from "@/components/admin/guide-extra-controls";
|
||||||
|
import { GuideOverviewForm } from "@/components/admin/guide-overview-form";
|
||||||
|
import { GuideSectionNav } from "@/components/admin/guide-section-nav";
|
||||||
|
import { GuideVisibilityControl } from "@/components/admin/guide-visibility-control";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { AdminHeader } from "@/components/admin/admin-header";
|
|
||||||
import { GuideOverviewForm } from "@/components/admin/guide-overview-form";
|
|
||||||
import { GuideVisibilityControl } from "@/components/admin/guide-visibility-control";
|
|
||||||
import { GuideSectionNav } from "@/components/admin/guide-section-nav";
|
|
||||||
import { EditorPresence } from "@/components/admin/editor-presence";
|
|
||||||
import { getAdminSession } from "@/lib/auth/server";
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
import { getGuideByCharacterKey } from "@/lib/guides/queries";
|
import { getGuideByCharacterKey } from "@/lib/guides/queries";
|
||||||
import { navigationIconPath } from "@/lib/navigation";
|
import { navigationIconPath } from "@/lib/navigation";
|
||||||
@@ -28,5 +29,46 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
|
|||||||
const { character } = await params;
|
const { character } = await params;
|
||||||
const guide = await getGuideByCharacterKey(character);
|
const guide = await getGuideByCharacterKey(character);
|
||||||
if (!guide) notFound();
|
if (!guide) notFound();
|
||||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8"><header className="flex flex-wrap items-center gap-4"><Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button><div className="relative size-16 overflow-hidden rounded-lg border"><Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p><h1 className="font-heading text-3xl font-semibold">{guide.name}</h1><EditorPresence guideId={guide.id} /></div><GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} /></header><GuideSectionNav characterKey={guide.characterKey} current="overview" /><GuideOverviewForm guide={guide} /><div className="grid gap-4 md:grid-cols-2">{sections.map(({ slug, title, description }) => <Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} prefetch className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader>{navigationIconPath(slug) ? <Image src={navigationIconPath(slug)!} alt="" width={40} height={40} className="size-10 object-contain" /> : null}<CardTitle>{title}</CardTitle><CardDescription>{description}</CardDescription><CardAction><ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" /></CardAction></CardHeader></Card></Link>)}<Link href={`/admin/${guide.characterKey}/extra`} prefetch className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><Image src={navigationIconPath("extra")!} alt="" width={40} height={40} className="size-10 object-contain" /><CardTitle>Extra sections</CardTitle><CardDescription>ส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์</CardDescription><CardAction><ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" /></CardAction></CardHeader></Card></Link></div></main></div>;
|
const extras = guide.sections
|
||||||
|
.filter((section) => section.kind === "extra")
|
||||||
|
.map(({ id, slug, title }) => ({ id, slug, title }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-svh">
|
||||||
|
<AdminHeader />
|
||||||
|
<main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||||
|
<header className="flex flex-wrap items-center gap-4">
|
||||||
|
<Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard">
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</Button>
|
||||||
|
<div className="relative size-16 overflow-hidden rounded-lg border">
|
||||||
|
<Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p>
|
||||||
|
<h1 className="font-heading text-3xl font-semibold">{guide.name}</h1>
|
||||||
|
<EditorPresence guideId={guide.id} />
|
||||||
|
</div>
|
||||||
|
<GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} />
|
||||||
|
</header>
|
||||||
|
<GuideSectionNav guideId={guide.id} characterKey={guide.characterKey} current="overview" extras={extras} />
|
||||||
|
<GuideOverviewForm guide={guide} />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{sections.map(({ slug, title, description }) => (
|
||||||
|
<Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} prefetch className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<Card className="h-full transition-colors hover:bg-muted/50">
|
||||||
|
<CardHeader>
|
||||||
|
{navigationIconPath(slug) ? <Image src={navigationIconPath(slug)!} alt="" width={40} height={40} className="size-10 object-contain" /> : null}
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
<CardAction><ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" /></CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ExtraOverview guideId={guide.id} characterKey={guide.characterKey} initialVersion={guide.version} initialExtras={extras} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ import { requireAdmin } from "@/lib/auth/server";
|
|||||||
import { builtInTemplates } from "@/lib/templates";
|
import { builtInTemplates } from "@/lib/templates";
|
||||||
import { persistTemplateApplication } from "@/lib/templates/persist";
|
import { persistTemplateApplication } from "@/lib/templates/persist";
|
||||||
import {
|
import {
|
||||||
|
createExtraSection,
|
||||||
createGuide,
|
createGuide,
|
||||||
|
deleteExtraSection,
|
||||||
GuideVersionConflictError,
|
GuideVersionConflictError,
|
||||||
permanentlyDeleteGuide,
|
permanentlyDeleteGuide,
|
||||||
|
reorderExtraSections,
|
||||||
restoreGuide,
|
restoreGuide,
|
||||||
runCatalogSync,
|
runCatalogSync,
|
||||||
saveArtifacts,
|
saveArtifacts,
|
||||||
saveConstellations,
|
saveConstellations,
|
||||||
|
saveExtraSection,
|
||||||
saveExtraSections,
|
saveExtraSections,
|
||||||
saveTeams,
|
saveTeams,
|
||||||
setGuideVisibility,
|
setGuideVisibility,
|
||||||
@@ -274,6 +278,64 @@ export async function saveExtraSectionList(guideId: string, input: unknown) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createGuideExtra(guideId: string, input: unknown) {
|
||||||
|
await requireAdmin();
|
||||||
|
try {
|
||||||
|
const section = await createExtraSection(z.string().uuid().parse(guideId), input);
|
||||||
|
updateTag(pageCacheTag(guideId));
|
||||||
|
return {
|
||||||
|
status: "saved" as const,
|
||||||
|
version: section.version,
|
||||||
|
section: { id: section.id, slug: section.slug, title: section.title },
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
return {
|
||||||
|
status: "error" as const,
|
||||||
|
message: cause instanceof z.ZodError
|
||||||
|
? cause.issues.map((issue) => issue.message).join(" • ")
|
||||||
|
: cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: "สร้าง Extra section ไม่สำเร็จ",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGuideExtra(
|
||||||
|
guideId: string,
|
||||||
|
sectionId: string,
|
||||||
|
input: unknown,
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsedGuideId = z.string().uuid().parse(guideId);
|
||||||
|
const parsedSectionId = z.string().uuid().parse(sectionId);
|
||||||
|
const result = await runGuideAction(() =>
|
||||||
|
saveExtraSection(parsedGuideId, parsedSectionId, input),
|
||||||
|
);
|
||||||
|
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGuideExtra(guideId: string, sectionId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
const result = await runGuideAction(() =>
|
||||||
|
deleteExtraSection(
|
||||||
|
z.string().uuid().parse(guideId),
|
||||||
|
z.string().uuid().parse(sectionId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderGuideExtras(guideId: string, input: unknown) {
|
||||||
|
await requireAdmin();
|
||||||
|
const result = await runGuideAction(() =>
|
||||||
|
reorderExtraSections(z.string().uuid().parse(guideId), input),
|
||||||
|
);
|
||||||
|
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function moveGuideToTrash(guideId: string) {
|
export async function moveGuideToTrash(guideId: string) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
return runGuideAction(() => trashGuide(guideId));
|
return runGuideAction(() => trashGuide(guideId));
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
ArrowUpRightIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState, type ComponentProps } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createGuideExtra,
|
||||||
|
reorderGuideExtras,
|
||||||
|
} from "@/app/admin/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
type ExtraLink = { id: string; slug: string; title: string };
|
||||||
|
|
||||||
|
export function AddExtraButton({
|
||||||
|
guideId,
|
||||||
|
characterKey,
|
||||||
|
variant = "ghost",
|
||||||
|
size = "sm",
|
||||||
|
label = "เพิ่ม Extra",
|
||||||
|
}: {
|
||||||
|
guideId: string;
|
||||||
|
characterKey: string;
|
||||||
|
variant?: ComponentProps<typeof Button>["variant"];
|
||||||
|
size?: ComponentProps<typeof Button>["size"];
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
if (!trimmedTitle) return;
|
||||||
|
const browserWindow = window as Window & { __guideHasUnsavedChanges?: boolean };
|
||||||
|
if (
|
||||||
|
browserWindow.__guideHasUnsavedChanges &&
|
||||||
|
!window.confirm("ข้อมูลยังบันทึกไม่เสร็จ ต้องการสร้าง Extra ใหม่หรือไม่?")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(true);
|
||||||
|
const result = await createGuideExtra(guideId, { title: trimmedTitle });
|
||||||
|
setPending(false);
|
||||||
|
if (result.status === "error") {
|
||||||
|
toast.error(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
setTitle("");
|
||||||
|
toast.success("สร้าง Extra แล้ว");
|
||||||
|
router.push(`/admin/${characterKey}/${result.section.slug}`);
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
aria-label={label || "เพิ่ม Extra"}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>เพิ่ม Extra section</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
ตั้งชื่อส่วนเสริม ระบบจะสร้างและเปิดหน้าแก้ไขให้ทันที
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void create();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="new-extra-title">ชื่อ Extra</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="new-extra-title"
|
||||||
|
value={title}
|
||||||
|
maxLength={160}
|
||||||
|
autoFocus
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="submit" disabled={!title.trim() || pending}>
|
||||||
|
{pending ? <Spinner data-icon="inline-start" /> : <PlusIcon data-icon="inline-start" />}
|
||||||
|
สร้าง Extra
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtraOverview({
|
||||||
|
guideId,
|
||||||
|
characterKey,
|
||||||
|
initialVersion,
|
||||||
|
initialExtras,
|
||||||
|
}: {
|
||||||
|
guideId: string;
|
||||||
|
characterKey: string;
|
||||||
|
initialVersion: number;
|
||||||
|
initialExtras: ExtraLink[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [extras, setExtras] = useState(initialExtras);
|
||||||
|
const [version, setVersion] = useState(initialVersion);
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const move = async (index: number, direction: -1 | 1) => {
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= extras.length) return;
|
||||||
|
const previous = extras;
|
||||||
|
const next = [...extras];
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
setExtras(next);
|
||||||
|
setPendingId(extras[index].id);
|
||||||
|
const result = await reorderGuideExtras(guideId, {
|
||||||
|
expectedVersion: version,
|
||||||
|
sectionIds: next.map((extra) => extra.id),
|
||||||
|
});
|
||||||
|
setPendingId(null);
|
||||||
|
if (result.status !== "saved") {
|
||||||
|
setExtras(previous);
|
||||||
|
toast.error(result.message);
|
||||||
|
if (result.status === "conflict") window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.version) setVersion(result.version);
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-heading text-xl font-semibold">Extra sections</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">แต่ละ Extra มีหน้าแก้ไขและบันทึกแยกจากกัน</p>
|
||||||
|
</div>
|
||||||
|
<AddExtraButton
|
||||||
|
guideId={guideId}
|
||||||
|
characterKey={characterKey}
|
||||||
|
variant="outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{extras.length ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{extras.map((extra, index) => (
|
||||||
|
<Card key={extra.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{extra.title}</CardTitle>
|
||||||
|
<CardDescription>Extra section {index + 1}</CardDescription>
|
||||||
|
<CardAction className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={index === 0 || pendingId !== null}
|
||||||
|
aria-label={`เลื่อน ${extra.title} ขึ้น`}
|
||||||
|
onClick={() => void move(index, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={index === extras.length - 1 || pendingId !== null}
|
||||||
|
aria-label={`เลื่อน ${extra.title} ลง`}
|
||||||
|
onClick={() => void move(index, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon />
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
render={<Link href={`/admin/${characterKey}/${extra.slug}`} />}
|
||||||
|
nativeButton={false}
|
||||||
|
>
|
||||||
|
แก้ไข Extra
|
||||||
|
<ArrowUpRightIcon data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>ยังไม่มี Extra</CardTitle>
|
||||||
|
<CardDescription>กดเพิ่ม Extra เพื่อสร้างส่วนเสริมแรกของ Guide นี้</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ChartNoAxesCombinedIcon,
|
ChartNoAxesCombinedIcon,
|
||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
@@ -43,9 +44,10 @@ import {
|
|||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deleteGuideExtra,
|
||||||
saveArtifactSection,
|
saveArtifactSection,
|
||||||
saveConstellationSection,
|
saveConstellationSection,
|
||||||
saveExtraSectionList,
|
saveGuideExtra,
|
||||||
saveTeamSection,
|
saveTeamSection,
|
||||||
saveWeaponSection,
|
saveWeaponSection,
|
||||||
} from "@/app/admin/actions";
|
} from "@/app/admin/actions";
|
||||||
@@ -317,6 +319,7 @@ function hasIssuePrefix(
|
|||||||
|
|
||||||
export interface GuideSectionEditorProps {
|
export interface GuideSectionEditorProps {
|
||||||
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
|
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
|
||||||
|
extraSectionId?: string;
|
||||||
guide: Guide;
|
guide: Guide;
|
||||||
weapons: CatalogItem[];
|
weapons: CatalogItem[];
|
||||||
artifacts: CatalogItem[];
|
artifacts: CatalogItem[];
|
||||||
@@ -340,6 +343,7 @@ export interface GuideSectionEditorProps {
|
|||||||
body: string;
|
body: string;
|
||||||
percent: string | null;
|
percent: string | null;
|
||||||
backgroundColor: string | null;
|
backgroundColor: string | null;
|
||||||
|
textColor: string | null;
|
||||||
sheet: ExtraSheet | null;
|
sheet: ExtraSheet | null;
|
||||||
catalogItems: ExtraCatalogItem[];
|
catalogItems: ExtraCatalogItem[];
|
||||||
highlights: Array<{ text: string; color: string }>;
|
highlights: Array<{ text: string; color: string }>;
|
||||||
@@ -3523,7 +3527,9 @@ function ExtraCatalogItemsEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorBase) {
|
function ExtraEditor({ guide, extraSectionId, initial, characters, weapons, artifacts }: EditorBase) {
|
||||||
|
const router = useRouter();
|
||||||
|
const sectionId = extraSectionId ?? initial.extras[0]?.id ?? "";
|
||||||
const extraSensors = useSensors(
|
const extraSensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
@@ -3557,6 +3563,7 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
|
|||||||
? null
|
? null
|
||||||
: Number(row.percent),
|
: Number(row.percent),
|
||||||
backgroundColor: row.backgroundColor,
|
backgroundColor: row.backgroundColor,
|
||||||
|
textColor: row.textColor,
|
||||||
sheet: row.sheet,
|
sheet: row.sheet,
|
||||||
catalogItems: row.catalogItems,
|
catalogItems: row.catalogItems,
|
||||||
highlights: [],
|
highlights: [],
|
||||||
@@ -3575,8 +3582,11 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
|
|||||||
valid: validation.success,
|
valid: validation.success,
|
||||||
initialVersion: guide.version,
|
initialVersion: guide.version,
|
||||||
persist: (next, expectedVersion) =>
|
persist: (next, expectedVersion) =>
|
||||||
saveExtraSectionList(guide.id, { ...next, expectedVersion }),
|
saveGuideExtra(guide.id, sectionId, {
|
||||||
storageKey: adminDraftKey(guide.id, "extra"),
|
section: next.sections[0],
|
||||||
|
expectedVersion,
|
||||||
|
}),
|
||||||
|
storageKey: adminDraftKey(guide.id, `extra:${sectionId}`),
|
||||||
localDraft,
|
localDraft,
|
||||||
parseLocalDraft: (candidate) =>
|
parseLocalDraft: (candidate) =>
|
||||||
parseEditorDraft<typeof localDraft>(candidate, ["sections"]),
|
parseEditorDraft<typeof localDraft>(candidate, ["sections"]),
|
||||||
@@ -3590,6 +3600,7 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
|
|||||||
id: row.id || createClientId("extra-row-record"),
|
id: row.id || createClientId("extra-row-record"),
|
||||||
body: normalizeExtraRichText(row.body, row.highlights ?? []),
|
body: normalizeExtraRichText(row.body, row.highlights ?? []),
|
||||||
backgroundColor: row.backgroundColor ?? null,
|
backgroundColor: row.backgroundColor ?? null,
|
||||||
|
textColor: row.textColor ?? null,
|
||||||
sheet: row.sheet ?? null,
|
sheet: row.sheet ?? null,
|
||||||
catalogItems: row.catalogItems ?? [],
|
catalogItems: row.catalogItems ?? [],
|
||||||
highlights: [],
|
highlights: [],
|
||||||
@@ -3600,14 +3611,6 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
|
|||||||
setOpenSectionIds(restoredSections.map((section) => section.clientId));
|
setOpenSectionIds(restoredSections.map((section) => section.clientId));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const handleSectionDragEnd = ({ active, over }: DragEndEvent) => {
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
setSections((current) => {
|
|
||||||
const from = current.findIndex((section) => section.clientId === active.id);
|
|
||||||
const to = current.findIndex((section) => section.clientId === over.id);
|
|
||||||
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const handleRowDragEnd = (sectionId: string, { active, over }: DragEndEvent) => {
|
const handleRowDragEnd = (sectionId: string, { active, over }: DragEndEvent) => {
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
setSections((current) => current.map((section) => {
|
setSections((current) => current.map((section) => {
|
||||||
@@ -3622,36 +3625,21 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Extra sections</CardTitle>
|
<CardTitle>{sections[0]?.title ?? "Extra section"}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์
|
เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์
|
||||||
ระบบจะบันทึกอัตโนมัติ
|
ระบบจะบันทึกอัตโนมัติ
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-5">
|
<CardContent className="flex flex-col gap-5">
|
||||||
<DndContext
|
<Accordion
|
||||||
sensors={extraSensors}
|
multiple
|
||||||
collisionDetection={closestCenter}
|
value={openSectionIds}
|
||||||
onDragEnd={handleSectionDragEnd}
|
onValueChange={setOpenSectionIds}
|
||||||
|
className="gap-5"
|
||||||
>
|
>
|
||||||
<Accordion
|
|
||||||
multiple
|
|
||||||
value={openSectionIds}
|
|
||||||
onValueChange={setOpenSectionIds}
|
|
||||||
className="gap-5"
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={sections.map((section) => section.clientId)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{sections.map((section, sectionIndex) => (
|
{sections.map((section, sectionIndex) => (
|
||||||
<SortableExtraItem
|
<AccordionItem key={section.clientId} value={section.clientId} className="border-0">
|
||||||
key={section.clientId}
|
|
||||||
id={section.clientId}
|
|
||||||
label={`ลากเพื่อจัดลำดับ ${section.title || `Extra section ${sectionIndex + 1}`}`}
|
|
||||||
>
|
|
||||||
{(sectionDragHandle) => (
|
|
||||||
<AccordionItem value={section.clientId} className="border-0">
|
|
||||||
<Card
|
<Card
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -3676,18 +3664,24 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
|
|||||||
/>
|
/>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardAction className="flex items-center gap-1">
|
<CardAction className="flex items-center gap-1">
|
||||||
{sectionDragHandle}
|
|
||||||
<DeleteConfirmationDialog
|
<DeleteConfirmationDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button type="button" variant="ghost" size="icon-sm" />
|
<Button type="button" variant="ghost" size="icon-sm" />
|
||||||
}
|
}
|
||||||
title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`}
|
title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`}
|
||||||
description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide"
|
description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide"
|
||||||
onConfirm={() =>
|
showSuccessToast={false}
|
||||||
setSections(
|
onConfirm={async () => {
|
||||||
sections.filter((_, index) => index !== sectionIndex),
|
const result = await deleteGuideExtra(guide.id, section.id);
|
||||||
)
|
if (result.status !== "saved") {
|
||||||
}
|
toast.error(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.removeItem(adminDraftKey(guide.id, `extra:${section.id}`));
|
||||||
|
toast.success("ลบ Extra แล้ว");
|
||||||
|
router.push(`/admin/${guide.characterKey}`);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
<span className="sr-only">ลบส่วน</span>
|
<span className="sr-only">ลบส่วน</span>
|
||||||
@@ -4168,6 +4162,7 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
|
|||||||
body: "",
|
body: "",
|
||||||
percent: null,
|
percent: null,
|
||||||
backgroundColor: null,
|
backgroundColor: null,
|
||||||
|
textColor: null,
|
||||||
sheet: null,
|
sheet: null,
|
||||||
catalogItems: [],
|
catalogItems: [],
|
||||||
highlights: [],
|
highlights: [],
|
||||||
@@ -4187,33 +4182,10 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)}
|
|
||||||
</SortableExtraItem>
|
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</Accordion>
|
||||||
</Accordion>
|
|
||||||
</DndContext>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex-wrap justify-between gap-3">
|
<CardFooter className="flex-wrap justify-end gap-3">
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
const clientId = createClientId("extra-section");
|
|
||||||
setSections([
|
|
||||||
...sections,
|
|
||||||
{
|
|
||||||
id: createClientId("extra-section-record"),
|
|
||||||
title: "Extra",
|
|
||||||
rows: [],
|
|
||||||
clientId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setOpenSectionIds([...openSectionIds, clientId]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon data-icon="inline-start" />
|
|
||||||
เพิ่ม Extra section
|
|
||||||
</Button>
|
|
||||||
<AutosaveStatus
|
<AutosaveStatus
|
||||||
state={autosave.state}
|
state={autosave.state}
|
||||||
issues={
|
issues={
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
|
|
||||||
|
import { AddExtraButton } from "@/components/admin/guide-extra-controls";
|
||||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||||
import { navigationIconPath } from "@/lib/navigation";
|
import { navigationIconPath } from "@/lib/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -13,15 +14,18 @@ const sections = [
|
|||||||
{ slug: "artifact", label: "Artifacts & Stats" },
|
{ slug: "artifact", label: "Artifacts & Stats" },
|
||||||
{ slug: "constellations", label: "Constellations" },
|
{ slug: "constellations", label: "Constellations" },
|
||||||
{ slug: "team", label: "Team Calc" },
|
{ slug: "team", label: "Team Calc" },
|
||||||
{ slug: "extra", label: "Extra" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function GuideSectionNav({
|
export function GuideSectionNav({
|
||||||
|
guideId,
|
||||||
characterKey,
|
characterKey,
|
||||||
current,
|
current,
|
||||||
|
extras,
|
||||||
}: {
|
}: {
|
||||||
|
guideId: string;
|
||||||
characterKey: string;
|
characterKey: string;
|
||||||
current: "overview" | (typeof sections)[number]["slug"];
|
current: string;
|
||||||
|
extras: Array<{ id: string; slug: string; title: string }>;
|
||||||
}) {
|
}) {
|
||||||
const confirmNavigation = (event: MouseEvent<HTMLAnchorElement>) => {
|
const confirmNavigation = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
const browserWindow = window as Window & { __guideHasUnsavedChanges?: boolean };
|
const browserWindow = window as Window & { __guideHasUnsavedChanges?: boolean };
|
||||||
@@ -64,6 +68,27 @@ export function GuideSectionNav({
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{extras.map((extra) => (
|
||||||
|
<Link
|
||||||
|
key={extra.id}
|
||||||
|
href={`/admin/${characterKey}/${extra.slug}`}
|
||||||
|
prefetch
|
||||||
|
onClick={confirmNavigation}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-8 shrink-0 items-center gap-2 rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
current === extra.slug ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image src={navigationIconPath("extra")!} alt="" width={20} height={20} className="size-5 object-contain" />
|
||||||
|
{extra.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<AddExtraButton
|
||||||
|
guideId={guideId}
|
||||||
|
characterKey={characterKey}
|
||||||
|
size="icon-sm"
|
||||||
|
label=""
|
||||||
|
/>
|
||||||
</ScrollableNav>
|
</ScrollableNav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Kbd } from "@/components/ui/kbd";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardAction,
|
CardAction,
|
||||||
@@ -26,7 +25,7 @@ import {
|
|||||||
import { SiteFooter } from "@/components/public/site-footer";
|
import { SiteFooter } from "@/components/public/site-footer";
|
||||||
import { SiteHeader } from "@/components/public/site-header";
|
import { SiteHeader } from "@/components/public/site-header";
|
||||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||||
import { TeamSearch } from "@/components/public/team-search";
|
import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
|
||||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||||
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
||||||
import { ConstellationImage } from "@/components/public/constellation-image";
|
import { ConstellationImage } from "@/components/public/constellation-image";
|
||||||
@@ -709,62 +708,14 @@ function ArtifactSectionContent({
|
|||||||
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
|
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex -space-x-3">
|
<TeamArtifactIcon
|
||||||
<a
|
first={row.first}
|
||||||
href={lunarisItemUrl("artifact", row.first.key)}
|
second={row.second}
|
||||||
target="_blank"
|
eager={exporting}
|
||||||
rel="noreferrer"
|
linked={!exporting}
|
||||||
tabIndex={exporting ? -1 : undefined}
|
sizes="(min-width: 640px) 4rem, 3.5rem"
|
||||||
aria-label={`เปิดข้อมูลอาร์ติแฟกต์ ${row.first.name} บน Lunaris`}
|
className={exporting ? "size-16 shrink-0" : "size-14 shrink-0 sm:size-16"}
|
||||||
className={cn(
|
/>
|
||||||
"relative overflow-hidden rounded-lg border",
|
|
||||||
exporting ? "size-16" : "size-14 sm:size-16",
|
|
||||||
rarityGradientClass(row.first.rarity),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={row.first.imageUrl}
|
|
||||||
alt={row.first.name}
|
|
||||||
fill
|
|
||||||
loading={exporting ? "eager" : undefined}
|
|
||||||
sizes="(min-width: 640px) 4rem, 3.5rem"
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
{!exporting ? (
|
|
||||||
<Kbd className="absolute top-0 right-0 h-3 min-w-3 bg-transparent p-0 text-white">
|
|
||||||
<ArrowUpRightIcon aria-hidden="true" />
|
|
||||||
</Kbd>
|
|
||||||
) : null}
|
|
||||||
</a>
|
|
||||||
{row.second ? (
|
|
||||||
<a
|
|
||||||
href={lunarisItemUrl("artifact", row.second.key)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
tabIndex={exporting ? -1 : undefined}
|
|
||||||
aria-label={`เปิดข้อมูลอาร์ติแฟกต์ ${row.second.name} บน Lunaris`}
|
|
||||||
className={cn(
|
|
||||||
"relative overflow-hidden rounded-lg border",
|
|
||||||
exporting ? "size-16" : "size-14 sm:size-16",
|
|
||||||
rarityGradientClass(row.second.rarity),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={row.second.imageUrl}
|
|
||||||
alt={row.second.name}
|
|
||||||
fill
|
|
||||||
loading={exporting ? "eager" : undefined}
|
|
||||||
sizes="(min-width: 640px) 4rem, 3.5rem"
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
{!exporting ? (
|
|
||||||
<Kbd className="absolute top-0 right-0 h-3 min-w-3 bg-transparent p-0 text-white">
|
|
||||||
<ArrowUpRightIcon aria-hidden="true" />
|
|
||||||
</Kbd>
|
|
||||||
) : null}
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{artifactTierCount(row.second?.name ?? null)}{" "}
|
{artifactTierCount(row.second?.name ?? null)}{" "}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function ArtifactHalf({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TeamArtifactIcon({
|
export function TeamArtifactIcon({
|
||||||
first,
|
first,
|
||||||
second,
|
second,
|
||||||
eager = false,
|
eager = false,
|
||||||
|
|||||||
+132
-1
@@ -1,6 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import { getDb } from "@/db";
|
import { getDb } from "@/db";
|
||||||
import {
|
import {
|
||||||
@@ -26,8 +26,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
artifactSectionSchema,
|
artifactSectionSchema,
|
||||||
constellationSectionSchema,
|
constellationSectionSchema,
|
||||||
|
createExtraSectionSchema,
|
||||||
createGuideSchema,
|
createGuideSchema,
|
||||||
extraSectionSchema,
|
extraSectionSchema,
|
||||||
|
reorderExtraSectionsSchema,
|
||||||
|
saveExtraSectionSchema,
|
||||||
slugFromCharacterName,
|
slugFromCharacterName,
|
||||||
teamSectionSchema,
|
teamSectionSchema,
|
||||||
updateGuideSchema,
|
updateGuideSchema,
|
||||||
@@ -222,6 +225,134 @@ export async function saveExtraSections(guideId: string, input: unknown) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createExtraSection(guideId: string, input: unknown) {
|
||||||
|
const data = createExtraSectionSchema.parse(input);
|
||||||
|
return getDb().transaction(async (tx) => {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ slug: guideSections.slug, sortOrder: guideSections.sortOrder })
|
||||||
|
.from(guideSections)
|
||||||
|
.where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")))
|
||||||
|
.orderBy(asc(guideSections.sortOrder));
|
||||||
|
if (existing.length >= 20) throw new Error("Guide มี Extra sections ได้สูงสุด 20 ส่วน");
|
||||||
|
|
||||||
|
const [updated] = await tx
|
||||||
|
.update(guides)
|
||||||
|
.set({ version: sql`${guides.version} + 1` })
|
||||||
|
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
|
||||||
|
.returning();
|
||||||
|
if (!updated) throw new Error("ไม่พบ Guide");
|
||||||
|
|
||||||
|
const suffix = existing.reduce((highest, section) => {
|
||||||
|
const match = /^extra-(\d+)$/u.exec(section.slug);
|
||||||
|
return match ? Math.max(highest, Number(match[1])) : highest;
|
||||||
|
}, 0) + 1;
|
||||||
|
const sortOrder = Math.max(3, ...existing.map((section) => section.sortOrder)) + 1;
|
||||||
|
const [section] = await tx
|
||||||
|
.insert(guideSections)
|
||||||
|
.values({
|
||||||
|
guideId,
|
||||||
|
kind: "extra",
|
||||||
|
slug: `extra-${suffix}`,
|
||||||
|
title: data.title,
|
||||||
|
enabled: true,
|
||||||
|
sortOrder,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
await tx.insert(extraSections).values({ sectionId: section.id });
|
||||||
|
await emitGuideEvents(tx, guideId, updated.version);
|
||||||
|
return { ...section, version: updated.version };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveExtraSection(
|
||||||
|
guideId: string,
|
||||||
|
sectionId: string,
|
||||||
|
input: unknown,
|
||||||
|
) {
|
||||||
|
const data = saveExtraSectionSchema.parse(input);
|
||||||
|
return getDb().transaction(async (tx) => {
|
||||||
|
const [target] = await tx
|
||||||
|
.select({ id: guideSections.id })
|
||||||
|
.from(guideSections)
|
||||||
|
.where(and(
|
||||||
|
eq(guideSections.id, sectionId),
|
||||||
|
eq(guideSections.guideId, guideId),
|
||||||
|
eq(guideSections.kind, "extra"),
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
if (!target) throw new Error("ไม่พบ Extra section");
|
||||||
|
|
||||||
|
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||||
|
await tx
|
||||||
|
.update(guideSections)
|
||||||
|
.set({ title: data.section.title })
|
||||||
|
.where(eq(guideSections.id, sectionId));
|
||||||
|
await tx.delete(extraSectionRows).where(eq(extraSectionRows.sectionId, sectionId));
|
||||||
|
if (data.section.rows.length) {
|
||||||
|
await tx.insert(extraSectionRows).values(data.section.rows.map((row, sortOrder) => ({
|
||||||
|
...row,
|
||||||
|
body: normalizeExtraRichText(row.body, row.highlights),
|
||||||
|
highlights: [],
|
||||||
|
percent: row.percent?.toString() ?? null,
|
||||||
|
sectionId,
|
||||||
|
sortOrder,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteExtraSection(guideId: string, sectionId: string) {
|
||||||
|
return getDb().transaction(async (tx) => {
|
||||||
|
const [target] = await tx
|
||||||
|
.select({ id: guideSections.id })
|
||||||
|
.from(guideSections)
|
||||||
|
.where(and(
|
||||||
|
eq(guideSections.id, sectionId),
|
||||||
|
eq(guideSections.guideId, guideId),
|
||||||
|
eq(guideSections.kind, "extra"),
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
if (!target) throw new Error("ไม่พบ Extra section");
|
||||||
|
|
||||||
|
const [updated] = await tx
|
||||||
|
.update(guides)
|
||||||
|
.set({ version: sql`${guides.version} + 1` })
|
||||||
|
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
|
||||||
|
.returning();
|
||||||
|
if (!updated) throw new Error("ไม่พบ Guide");
|
||||||
|
await tx.delete(guideSections).where(eq(guideSections.id, sectionId));
|
||||||
|
await emitGuideEvents(tx, guideId, updated.version);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderExtraSections(guideId: string, input: unknown) {
|
||||||
|
const data = reorderExtraSectionsSchema.parse(input);
|
||||||
|
return getDb().transaction(async (tx) => {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ id: guideSections.id })
|
||||||
|
.from(guideSections)
|
||||||
|
.where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
|
||||||
|
const existingIds = new Set(existing.map((section) => section.id));
|
||||||
|
if (
|
||||||
|
existingIds.size !== data.sectionIds.length ||
|
||||||
|
data.sectionIds.some((sectionId) => !existingIds.has(sectionId))
|
||||||
|
) {
|
||||||
|
throw new Error("รายการ Extra sections ไม่ตรงกับข้อมูลล่าสุด");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||||
|
for (const [index, sectionId] of data.sectionIds.entries()) {
|
||||||
|
await tx
|
||||||
|
.update(guideSections)
|
||||||
|
.set({ sortOrder: 4 + index })
|
||||||
|
.where(eq(guideSections.id, sectionId));
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function trashGuide(guideId: string) {
|
export async function trashGuide(guideId: string) {
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
|
const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
|||||||
sections,
|
sections,
|
||||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
||||||
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
|
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
|
||||||
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
|
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
|
||||||
artifactProfile: profile[0] ?? null,
|
artifactProfile: profile[0] ?? null,
|
||||||
constellationGroups: constellationGroups.map((group) => ({
|
constellationGroups: constellationGroups.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { calculateTeamMetrics } from "@/lib/guides/calculations";
|
import { calculateTeamMetrics } from "@/lib/guides/calculations";
|
||||||
import { createExtraSheet } from "@/lib/guides/extra-sheet";
|
import { createExtraSheet } from "@/lib/guides/extra-sheet";
|
||||||
import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, extraSectionSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas";
|
import {
|
||||||
|
artifactSectionSchema,
|
||||||
|
constellationSectionSchema,
|
||||||
|
createExtraSectionSchema,
|
||||||
|
createGuideSchema,
|
||||||
|
extraSectionSchema,
|
||||||
|
reorderExtraSectionsSchema,
|
||||||
|
saveExtraSectionSchema,
|
||||||
|
teamSectionSchema,
|
||||||
|
updateGuideSchema,
|
||||||
|
weaponSectionSchema,
|
||||||
|
} from "@/lib/guides/schemas";
|
||||||
import {
|
import {
|
||||||
recommendedStatElement,
|
recommendedStatElement,
|
||||||
recommendedStatIconName,
|
recommendedStatIconName,
|
||||||
@@ -245,6 +256,29 @@ describe("structured guide validation", () => {
|
|||||||
sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }],
|
sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }],
|
||||||
}).success).toBe(false);
|
}).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("validates targeted Extra create, save, and reorder inputs", () => {
|
||||||
|
const section = {
|
||||||
|
title: "Build notes",
|
||||||
|
rows: [{
|
||||||
|
mediaId: null,
|
||||||
|
heading: "Rotation",
|
||||||
|
body: "",
|
||||||
|
percent: null,
|
||||||
|
backgroundColor: null,
|
||||||
|
highlights: [],
|
||||||
|
catalogItems: [],
|
||||||
|
sheet: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const firstId = "550e8400-e29b-41d4-a716-446655440000";
|
||||||
|
const secondId = "d9428888-122b-11e1-b85c-61cd3cbb3210";
|
||||||
|
|
||||||
|
expect(createExtraSectionSchema.parse({ title: " Build notes " }).title).toBe("Build notes");
|
||||||
|
expect(saveExtraSectionSchema.safeParse({ expectedVersion: 2, section }).success).toBe(true);
|
||||||
|
expect(reorderExtraSectionsSchema.safeParse({ expectedVersion: 2, sectionIds: [firstId, secondId] }).success).toBe(true);
|
||||||
|
expect(reorderExtraSectionsSchema.safeParse({ expectedVersion: 2, sectionIds: [firstId, firstId] }).success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Team Calc", () => {
|
describe("Team Calc", () => {
|
||||||
|
|||||||
+24
-4
@@ -146,6 +146,7 @@ const extraRowSchema = z.strictObject({
|
|||||||
body: z.string().trim().max(10_000),
|
body: z.string().trim().max(10_000),
|
||||||
percent: percent.nullable(),
|
percent: percent.nullable(),
|
||||||
backgroundColor: hexColor.nullable().default(null),
|
backgroundColor: hexColor.nullable().default(null),
|
||||||
|
textColor: hexColor.nullable().default(null),
|
||||||
catalogItems: z.array(z.strictObject({
|
catalogItems: z.array(z.strictObject({
|
||||||
kind: z.enum(["character", "weapon", "artifact"]),
|
kind: z.enum(["character", "weapon", "artifact"]),
|
||||||
key: z.string().trim().min(1).max(64),
|
key: z.string().trim().min(1).max(64),
|
||||||
@@ -168,12 +169,31 @@ const extraRowSchema = z.strictObject({
|
|||||||
highlights: [],
|
highlights: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const extraSectionItemSchema = z.strictObject({
|
||||||
|
title: z.string().trim().min(1).max(160),
|
||||||
|
rows: z.array(extraRowSchema).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
export const extraSectionSchema = z.strictObject({
|
export const extraSectionSchema = z.strictObject({
|
||||||
expectedVersion: z.number().int().positive(),
|
expectedVersion: z.number().int().positive(),
|
||||||
sections: z.array(z.strictObject({
|
sections: z.array(extraSectionItemSchema).max(20),
|
||||||
title: z.string().trim().min(1).max(160),
|
});
|
||||||
rows: z.array(extraRowSchema).max(50),
|
|
||||||
})).max(20),
|
export const createExtraSectionSchema = z.strictObject({
|
||||||
|
title: z.string().trim().min(1).max(160),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveExtraSectionSchema = z.strictObject({
|
||||||
|
expectedVersion: z.number().int().positive(),
|
||||||
|
section: extraSectionItemSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reorderExtraSectionsSchema = z.strictObject({
|
||||||
|
expectedVersion: z.number().int().positive(),
|
||||||
|
sectionIds: z.array(z.string().uuid()).max(20).refine(
|
||||||
|
(sectionIds) => new Set(sectionIds).size === sectionIds.length,
|
||||||
|
"Extra sections ต้องไม่ซ้ำกัน",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type WeaponSectionInput = z.output<typeof weaponSectionSchema>;
|
export type WeaponSectionInput = z.output<typeof weaponSectionSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user