feat(extra) : add catalog items and image export
This commit is contained in:
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
|
|||||||
const [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]);
|
const [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]);
|
||||||
if (!catalog || !content) notFound();
|
if (!catalog || !content) notFound();
|
||||||
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
|
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
|
||||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ id: section.id, title: section.title, rows: section.rows.map((row) => ({ id: row.id, mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, backgroundColor: row.backgroundColor, highlights: row.highlights, imageUrl: row.imageUrl, imageWidth: row.imageWidth, imageHeight: row.imageHeight })) })) }} /></main></div>;
|
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ id: section.id, title: section.title, rows: section.rows.map((row) => ({ id: row.id, mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, backgroundColor: row.backgroundColor, catalogItems: row.catalogItems.map(({ kind, key }) => ({ kind, key })), highlights: row.highlights, imageUrl: row.imageUrl, imageWidth: row.imageWidth, imageHeight: row.imageHeight })) })) }} /></main></div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import dynamic from "next/dynamic";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
ChartNoAxesCombinedIcon,
|
ChartNoAxesCombinedIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
ClipboardPasteIcon,
|
ClipboardPasteIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
@@ -175,6 +177,10 @@ type CatalogItem = {
|
|||||||
element?: string | null;
|
element?: string | null;
|
||||||
elementIconUrl?: string | null;
|
elementIconUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
type ExtraCatalogItem = {
|
||||||
|
kind: "character" | "weapon" | "artifact";
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
const teamMemberApplyOptions = [
|
const teamMemberApplyOptions = [
|
||||||
["characterKey", "Character"],
|
["characterKey", "Character"],
|
||||||
["weaponKey", "Weapon"],
|
["weaponKey", "Weapon"],
|
||||||
@@ -323,6 +329,7 @@ export interface GuideSectionEditorProps {
|
|||||||
body: string;
|
body: string;
|
||||||
percent: string | null;
|
percent: string | null;
|
||||||
backgroundColor: string | null;
|
backgroundColor: string | null;
|
||||||
|
catalogItems: ExtraCatalogItem[];
|
||||||
highlights: Array<{ text: string; color: string }>;
|
highlights: Array<{ text: string; color: string }>;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
imageWidth: number | null;
|
imageWidth: number | null;
|
||||||
@@ -3304,7 +3311,85 @@ function TeamEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExtraEditor({ guide, initial }: EditorBase) {
|
function ExtraCatalogItemsEditor({
|
||||||
|
value,
|
||||||
|
characters,
|
||||||
|
weapons,
|
||||||
|
artifacts,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: ExtraCatalogItem[];
|
||||||
|
characters: CatalogItem[];
|
||||||
|
weapons: CatalogItem[];
|
||||||
|
artifacts: CatalogItem[];
|
||||||
|
onChange: (value: ExtraCatalogItem[]) => void;
|
||||||
|
}) {
|
||||||
|
const [kind, setKind] = useState<ExtraCatalogItem["kind"]>("character");
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const catalogs = { character: characters, weapon: weapons, artifact: artifacts };
|
||||||
|
const labels = { character: "ตัวละคร", weapon: "อาวุธ", artifact: "อาร์ติแฟกต์" };
|
||||||
|
const items = catalogs[kind];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>ตัวละคร อาวุธ และอาร์ติแฟกต์</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{value.length ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{value.map((reference, index) => {
|
||||||
|
const item = catalogs[reference.kind].find(
|
||||||
|
(candidate) => candidate.key === reference.key,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card key={`${reference.kind}:${reference.key}`} size="sm">
|
||||||
|
<CardContent className="flex items-center gap-3">
|
||||||
|
{item ? (
|
||||||
|
<div className={cn("relative size-12 shrink-0 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}>
|
||||||
|
<Image src={item.imageUrl} alt="" fill className="object-contain" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{item?.name ?? reference.key}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{labels[reference.kind]}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => onChange(arrayMove(value, index, index - 1))} aria-label="เลื่อนขึ้น">
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" disabled={index === value.length - 1} onClick={() => onChange(arrayMove(value, index, index + 1))} aria-label="เลื่อนลง">
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} aria-label={`ลบ ${item?.name ?? reference.key}`}>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Select value={kind} onValueChange={(next) => { setKind(next as ExtraCatalogItem["kind"]); setKey(""); }}>
|
||||||
|
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectGroup>
|
||||||
|
<SelectItem value="character">ตัวละคร</SelectItem>
|
||||||
|
<SelectItem value="weapon">อาวุธ</SelectItem>
|
||||||
|
<SelectItem value="artifact">อาร์ติแฟกต์</SelectItem>
|
||||||
|
</SelectGroup></SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="min-w-64 flex-1">
|
||||||
|
<CatalogPicker items={items} value={key} onChange={setKey} placeholder={`เลือก${labels[kind]}`} showSelectedImage rarityBackground />
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" disabled={!key || value.some((item) => item.kind === kind && item.key === key)} onClick={() => { onChange([...value, { kind, key }]); setKey(""); }}>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
เพิ่มรายการ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorBase) {
|
||||||
const extraSensors = useSensors(
|
const extraSensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
@@ -3338,6 +3423,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
|||||||
? null
|
? null
|
||||||
: Number(row.percent),
|
: Number(row.percent),
|
||||||
backgroundColor: row.backgroundColor,
|
backgroundColor: row.backgroundColor,
|
||||||
|
catalogItems: row.catalogItems,
|
||||||
highlights: [],
|
highlights: [],
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
@@ -3369,6 +3455,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
|||||||
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,
|
||||||
|
catalogItems: row.catalogItems ?? [],
|
||||||
highlights: [],
|
highlights: [],
|
||||||
clientId: row.clientId || createClientId("extra-row"),
|
clientId: row.clientId || createClientId("extra-row"),
|
||||||
})),
|
})),
|
||||||
@@ -3621,6 +3708,28 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ExtraCatalogItemsEditor
|
||||||
|
value={row.catalogItems}
|
||||||
|
characters={characters}
|
||||||
|
weapons={weapons}
|
||||||
|
artifacts={artifacts}
|
||||||
|
onChange={(catalogItems) =>
|
||||||
|
setSections(
|
||||||
|
sections.map((item, index) =>
|
||||||
|
index === sectionIndex
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
rows: item.rows.map((candidate, inner) =>
|
||||||
|
inner === rowIndex
|
||||||
|
? { ...candidate, catalogItems }
|
||||||
|
: candidate,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>หัวข้อ</FieldLabel>
|
<FieldLabel>หัวข้อ</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -3657,6 +3766,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
|||||||
<FieldLabel>ข้อความ</FieldLabel>
|
<FieldLabel>ข้อความ</FieldLabel>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
value={row.body}
|
value={row.body}
|
||||||
|
showBubbleMenu={false}
|
||||||
invalid={extraIssue(
|
invalid={extraIssue(
|
||||||
"sections",
|
"sections",
|
||||||
sectionIndex,
|
sectionIndex,
|
||||||
@@ -3837,6 +3947,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
|||||||
body: "",
|
body: "",
|
||||||
percent: null,
|
percent: null,
|
||||||
backgroundColor: null,
|
backgroundColor: null,
|
||||||
|
catalogItems: [],
|
||||||
highlights: [],
|
highlights: [],
|
||||||
clientId: createClientId("extra-row"),
|
clientId: createClientId("extra-row"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
|
import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
|
||||||
|
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";
|
||||||
@@ -581,7 +582,7 @@ function ArtifactSectionContent({
|
|||||||
exporting = false,
|
exporting = false,
|
||||||
}: {
|
}: {
|
||||||
guide: PublicGuide;
|
guide: PublicGuide;
|
||||||
action?: React.ReactNode;
|
action?: ReactNode;
|
||||||
exporting?: boolean;
|
exporting?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const profile = guide.artifactProfile;
|
const profile = guide.artifactProfile;
|
||||||
@@ -1147,12 +1148,34 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
|
|||||||
|
|
||||||
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
|
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-6">
|
<ExtraSectionContent
|
||||||
<header className="text-center">
|
extra={extra}
|
||||||
|
action={
|
||||||
|
<GuideImageExport fileName={`${extra.title}.png`} captureClassName="p-0">
|
||||||
|
<ExtraSectionContent extra={extra} exporting />
|
||||||
|
</GuideImageExport>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtraSectionContent({
|
||||||
|
extra,
|
||||||
|
action,
|
||||||
|
exporting = false,
|
||||||
|
}: {
|
||||||
|
extra: PublicGuide["extras"][number];
|
||||||
|
action?: React.ReactNode;
|
||||||
|
exporting?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={cn("flex flex-col gap-6", exporting && "bg-background p-10")}>
|
||||||
|
<header className="flex flex-col items-center gap-3 text-center">
|
||||||
<p className="text-sm font-medium text-primary">คู่มือเสริม</p>
|
<p className="text-sm font-medium text-primary">คู่มือเสริม</p>
|
||||||
<h1 className="font-heading text-4xl font-semibold">{extra.title}</h1>
|
<h1 className="font-heading text-4xl font-semibold">{extra.title}</h1>
|
||||||
|
{action}
|
||||||
</header>
|
</header>
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className={cn("grid gap-5 md:grid-cols-2", exporting && "grid-cols-2")}>
|
||||||
{extra.rows.map((row) => (
|
{extra.rows.map((row) => (
|
||||||
<Card
|
<Card
|
||||||
key={row.id}
|
key={row.id}
|
||||||
@@ -1177,6 +1200,23 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{row.catalogItems.length ? (
|
||||||
|
<CardContent className="grid grid-cols-2 gap-2 pt-5">
|
||||||
|
{row.catalogItems.map((item) => (
|
||||||
|
<div key={`${item.kind}:${item.key}`} className="flex min-w-0 items-center gap-2 rounded-lg border bg-background/80 p-2">
|
||||||
|
{item.imageUrl ? (
|
||||||
|
<div className={cn("relative size-12 shrink-0 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}>
|
||||||
|
<Image src={item.imageUrl} alt="" fill className="object-contain" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{item.name}</p>
|
||||||
|
{!item.available ? <Badge variant="secondary">ไม่พบรายการ</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
) : null}
|
||||||
<CardHeader className="pt-5">
|
<CardHeader className="pt-5">
|
||||||
<CardTitle>{row.heading}</CardTitle>
|
<CardTitle>{row.heading}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface RichTextEditorProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
|
showBubbleMenu?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Format = "bold" | "italic" | "underline" | "strike";
|
type Format = "bold" | "italic" | "underline" | "strike";
|
||||||
@@ -145,6 +146,7 @@ export function RichTextEditor({
|
|||||||
placeholder = "เขียนข้อความ...",
|
placeholder = "เขียนข้อความ...",
|
||||||
className,
|
className,
|
||||||
invalid = false,
|
invalid = false,
|
||||||
|
showBubbleMenu = true,
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
const [linkOpen, setLinkOpen] = useState(false);
|
const [linkOpen, setLinkOpen] = useState(false);
|
||||||
const [linkText, setLinkText] = useState("");
|
const [linkText, setLinkText] = useState("");
|
||||||
@@ -241,9 +243,11 @@ export function RichTextEditor({
|
|||||||
<div className="border-b bg-muted/50 p-1">
|
<div className="border-b bg-muted/50 p-1">
|
||||||
<FormatControls editor={editor} onLink={openLinkDialog} />
|
<FormatControls editor={editor} onLink={openLinkDialog} />
|
||||||
</div>
|
</div>
|
||||||
<BubbleMenu editor={editor} className="rounded-lg border bg-popover p-1 shadow-md">
|
{showBubbleMenu ? (
|
||||||
<FormatControls editor={editor} onLink={openLinkDialog} />
|
<BubbleMenu editor={editor} className="rounded-lg border bg-popover p-1 shadow-md">
|
||||||
</BubbleMenu>
|
<FormatControls editor={editor} onLink={openLinkDialog} />
|
||||||
|
</BubbleMenu>
|
||||||
|
) : null}
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -661,6 +661,10 @@ export const extraSectionRows = guidesSchema.table(
|
|||||||
backgroundColor: varchar("background_color", { length: 7 }),
|
backgroundColor: varchar("background_color", { length: 7 }),
|
||||||
textColor: varchar("text_color", { length: 7 }),
|
textColor: varchar("text_color", { length: 7 }),
|
||||||
highlights: jsonb("highlights").$type<Array<{ text: string; color: string }>>().default([]).notNull(),
|
highlights: jsonb("highlights").$type<Array<{ text: string; color: string }>>().default([]).notNull(),
|
||||||
|
catalogItems: jsonb("catalog_items")
|
||||||
|
.$type<Array<{ kind: "character" | "weapon" | "artifact"; key: string }>>()
|
||||||
|
.default([])
|
||||||
|
.notNull(),
|
||||||
sortOrder: integer("sort_order").default(0).notNull(),
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)],
|
(table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)],
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "guides"."extra_section_row" ADD COLUMN "catalog_items" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
|||||||
"when": 1788535891970,
|
"when": 1788535891970,
|
||||||
"tag": "0027_condemned_skullbuster",
|
"tag": "0027_condemned_skullbuster",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788586391986,
|
||||||
|
"tag": "0028_unknown_scarlet_witch",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+25
-1
@@ -148,6 +148,15 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
|||||||
]);
|
]);
|
||||||
const extraIds = sections.filter((section) => section.kind === "extra").map((section) => section.id);
|
const extraIds = sections.filter((section) => section.kind === "extra").map((section) => section.id);
|
||||||
const extraRows = extraIds.length ? await getDb().select({ row: extraSectionRows, asset: media }).from(extraSectionRows).leftJoin(media, eq(media.id, extraSectionRows.mediaId)).where(inArray(extraSectionRows.sectionId, extraIds)).orderBy(asc(extraSectionRows.sortOrder)) : [];
|
const extraRows = extraIds.length ? await getDb().select({ row: extraSectionRows, asset: media }).from(extraSectionRows).leftJoin(media, eq(media.id, extraSectionRows.mediaId)).where(inArray(extraSectionRows.sectionId, extraIds)).orderBy(asc(extraSectionRows.sortOrder)) : [];
|
||||||
|
const extraCharacterKeys = [...new Set(extraRows.flatMap(({ row: extraRow }) => extraRow.catalogItems.filter((item) => item.kind === "character").map((item) => item.key)))];
|
||||||
|
const extraWeaponKeys = [...new Set(extraRows.flatMap(({ row: extraRow }) => extraRow.catalogItems.filter((item) => item.kind === "weapon").map((item) => item.key)))];
|
||||||
|
const extraArtifactKeys = [...new Set(extraRows.flatMap(({ row: extraRow }) => extraRow.catalogItems.filter((item) => item.kind === "artifact").map((item) => item.key)))];
|
||||||
|
const [extraCharacters, extraWeapons, extraArtifacts] = await Promise.all([
|
||||||
|
extraCharacterKeys.length ? getDb().select().from(catalogCharacters).where(inArray(catalogCharacters.key, extraCharacterKeys)) : [],
|
||||||
|
extraWeaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, extraWeaponKeys)) : [],
|
||||||
|
extraArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, extraArtifactKeys)) : [],
|
||||||
|
]);
|
||||||
|
const extraCatalogs = { character: extraCharacters, weapon: extraWeapons, artifact: extraArtifacts };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...row.guide,
|
...row.guide,
|
||||||
@@ -172,7 +181,22 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
|||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
|
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
|
||||||
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null, imageWidth: asset?.width ?? null, imageHeight: asset?.height ?? null })) })),
|
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({
|
||||||
|
...extraRow,
|
||||||
|
catalogItems: extraRow.catalogItems.map((reference) => {
|
||||||
|
const item = extraCatalogs[reference.kind].find((candidate) => candidate.key === reference.key);
|
||||||
|
return {
|
||||||
|
...reference,
|
||||||
|
name: item?.name ?? reference.key,
|
||||||
|
imageUrl: item ? publicMediaUrl(item.imageKey) : null,
|
||||||
|
rarity: item?.rarity ?? null,
|
||||||
|
available: Boolean(item),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
imageUrl: asset ? publicMediaUrl(asset.objectKey) : null,
|
||||||
|
imageWidth: asset?.width ?? null,
|
||||||
|
imageHeight: asset?.height ?? null,
|
||||||
|
})) })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,33 @@ describe("structured guide validation", () => {
|
|||||||
expect(result.data.sections[0].rows[0].body).toBe("<p><strong>Safe</strong></p>");
|
expect(result.data.sections[0].rows[0].body).toBe("<p><strong>Safe</strong></p>");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps ordered mixed catalog items in Extra rows and rejects duplicates", () => {
|
||||||
|
const row = {
|
||||||
|
mediaId: null,
|
||||||
|
heading: "Build options",
|
||||||
|
body: "",
|
||||||
|
percent: null,
|
||||||
|
backgroundColor: null,
|
||||||
|
highlights: [],
|
||||||
|
catalogItems: [
|
||||||
|
{ kind: "character", key: "10000089" },
|
||||||
|
{ kind: "weapon", key: "WEAPON_CLAYMORE" },
|
||||||
|
{ kind: "artifact", key: "ARTIFACT_SET" },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
const result = extraSectionSchema.safeParse({
|
||||||
|
expectedVersion: 1,
|
||||||
|
sections: [{ title: "Extra", rows: [row] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data.sections[0].rows[0].catalogItems).toEqual(row.catalogItems);
|
||||||
|
expect(extraSectionSchema.safeParse({
|
||||||
|
expectedVersion: 1,
|
||||||
|
sections: [{ title: "Extra", rows: [{ ...row, catalogItems: [row.catalogItems[0], row.catalogItems[0]] }] }],
|
||||||
|
}).success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Team Calc", () => {
|
describe("Team Calc", () => {
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ 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),
|
||||||
|
catalogItems: z.array(z.strictObject({
|
||||||
|
kind: z.enum(["character", "weapon", "artifact"]),
|
||||||
|
key: z.string().trim().min(1).max(64),
|
||||||
|
})).max(30).refine(
|
||||||
|
(items) => new Set(items.map((item) => `${item.kind}:${item.key}`)).size === items.length,
|
||||||
|
"รายการตัวละคร อาวุธ และอาร์ติแฟกต์ต้องไม่ซ้ำกัน",
|
||||||
|
).default([]),
|
||||||
highlights: z.array(z.strictObject({
|
highlights: z.array(z.strictObject({
|
||||||
text: z.string().trim().min(1).max(80),
|
text: z.string().trim().min(1).max(80),
|
||||||
color: hexColor,
|
color: hexColor,
|
||||||
|
|||||||
Reference in New Issue
Block a user