feat: add guide section header notes
This commit is contained in:
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
|
||||
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
|
||||
if (!catalog || !content) notFound();
|
||||
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={{ weapons: content.weapons, 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, constellations: content.constellations, 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, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></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, weapons: content.weapons, 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, constellations: content.constellations, 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, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ export async function GET(
|
||||
Promise.all(guide.weapons.map((row) => pngDataUrl(row.weapon.imageUrl))),
|
||||
]);
|
||||
const weaponHeights = guide.weapons.map((row) => Math.max(126, 126 + (row.note ? Math.ceil(row.note.length / 52) * 22 : 0)));
|
||||
const height = Math.max(360, 190 + weaponHeights.reduce((sum, rowHeight) => sum + rowHeight, 0) + Math.max(0, guide.weapons.length - 1) * 14);
|
||||
const noteHeight = guide.note ? Math.max(24, Math.ceil(guide.note.length / 70) * 22) : 0;
|
||||
const height = Math.max(360, 190 + noteHeight + weaponHeights.reduce((sum, rowHeight) => sum + rowHeight, 0) + Math.max(0, guide.weapons.length - 1) * 14);
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
@@ -78,6 +79,7 @@ export async function GET(
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ display: "flex", color: "#f0b72f", fontSize: 16, fontWeight: 700 }}>อาวุธที่แนะนำ</div>
|
||||
<div style={{ display: "flex", fontSize: 38, fontWeight: 700 }}>อาวุธ</div>
|
||||
{guide.note ? <div style={{ display: "flex", maxWidth: 760, color: "#b9b9c2", fontSize: 16, lineHeight: 1.4, textAlign: "center" }}>{guide.note}</div> : null}
|
||||
</div>
|
||||
{guide.weapons.map((row, index) => (
|
||||
<div
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface GuideSectionEditorProps {
|
||||
catalogConstellations: CatalogConstellation[];
|
||||
catalogTalents: CatalogTalent[];
|
||||
initial: {
|
||||
sectionNote: string | null;
|
||||
weapons: WeaponValue[];
|
||||
artifactGroups: ArtifactValue[];
|
||||
artifactProfile: ArtifactProfile;
|
||||
@@ -325,8 +326,9 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
const [note, setNote] = useState(initial.sectionNote ?? "");
|
||||
const [rows, setRows] = useState(initial.weapons.map((row) => ({ clientId: row.id, weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "", note: row.note ?? "" })));
|
||||
const value = { rows: rows.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent), note: row.note.trim() || null })) };
|
||||
const value = { note: note.trim() || null, rows: rows.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent), note: row.note.trim() || null })) };
|
||||
const validation = weaponSectionSchema.safeParse({ ...value, expectedVersion: guide.version });
|
||||
const autosave = useAutosave({ value, valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }) });
|
||||
const handleWeaponDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
@@ -341,6 +343,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Weapon tier list</CardTitle><CardDescription>ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Field><FieldLabel htmlFor="weapon-section-note">Header note (optional)</FieldLabel><Textarea id="weapon-section-note" value={note} onChange={(event) => setNote(event.target.value)} /></Field>
|
||||
<DndContext sensors={weaponSensors} collisionDetection={closestCenter} onDragEnd={handleWeaponDragEnd}>
|
||||
<SortableContext items={rows.map((row) => row.clientId)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -536,6 +539,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
}
|
||||
|
||||
function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBase) {
|
||||
const [note, setNote] = useState(initial.sectionNote ?? "");
|
||||
const [rows, setRows] = useState(() => {
|
||||
const source = catalogConstellations.length ? catalogConstellations : Array.from({ length: 6 }, (_, index) => ({ level: index + 1, name: `C${index + 1}`, description: initial.constellations.find((row) => row.level === index + 1)?.detail ?? "", iconUrl: null }));
|
||||
return source.map((constellation) => {
|
||||
@@ -543,14 +547,17 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
|
||||
return { ...constellation, cumulativePercent: saved?.cumulativePercent ?? "", isGreat: saved?.isGreat ?? false };
|
||||
});
|
||||
});
|
||||
const value = { rows: rows.filter((row) => row.description.trim() && row.cumulativePercent !== "").map((row) => ({ level: row.level, detail: row.description, cumulativePercent: Number(row.cumulativePercent), isGreat: row.isGreat })) };
|
||||
const value = { note: note.trim() || null, rows: rows.filter((row) => row.description.trim() && row.cumulativePercent !== "").map((row) => ({ level: row.level, detail: row.description, cumulativePercent: Number(row.cumulativePercent), isGreat: row.isGreat })) };
|
||||
const validation = constellationSectionSchema.safeParse({ ...value, expectedVersion: guide.version });
|
||||
const autosave = useAutosave({ value, valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion) => saveConstellationSection(guide.id, { ...next, expectedVersion }) });
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Constellations</CardTitle><CardDescription>ชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100%</CardDescription></CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||
{rows.map((row, index) => <Card key={row.level} size="sm"><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="number" min="0" step="0.01" value={row.cumulativePercent} onChange={(event) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, isGreat } : item))} aria-label={`Mark C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Field><FieldLabel htmlFor="constellation-section-note">Header note (optional)</FieldLabel><Textarea id="constellation-section-note" value={note} onChange={(event) => setNote(event.target.value)} /></Field>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{rows.map((row, index) => <Card key={row.level} size="sm"><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="number" min="0" step="0.01" value={row.cumulativePercent} onChange={(event) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, isGreat } : item))} aria-label={`Mark C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end"><AutosaveStatus state={autosave} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} /></CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -212,11 +212,13 @@ function metric(value: string | null) {
|
||||
}
|
||||
|
||||
function WeaponSection({ guide }: { guide: PublicGuide }) {
|
||||
const note = guide.sections.find((section) => section.slug === "weapon")?.note;
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<header className="flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-sm font-medium text-primary">อาวุธที่แนะนำ</p>
|
||||
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">อาวุธ</h1>
|
||||
{note ? <p className="max-w-3xl whitespace-pre-wrap text-muted-foreground">{note}</p> : null}
|
||||
<a
|
||||
href={`/api/guides/${guide.id}/weapons/image`}
|
||||
download
|
||||
@@ -508,11 +510,13 @@ function ArtifactStat({ iconSrc, label, values }: { iconSrc: string; label: stri
|
||||
}
|
||||
|
||||
function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
const note = guide.sections.find((section) => section.slug === "constellations")?.note;
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<header className="text-center">
|
||||
<header className="flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-sm font-medium text-primary">กลุ่มดาว</p>
|
||||
<h1 className="font-heading text-4xl font-semibold">กลุ่มดาว</h1>
|
||||
{note ? <p className="max-w-3xl whitespace-pre-wrap text-muted-foreground">{note}</p> : null}
|
||||
</header>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{guide.constellations.map((row) => (
|
||||
|
||||
@@ -512,6 +512,7 @@ export const guideSections = guidesSchema.table(
|
||||
kind: varchar("kind", { length: 32 }).notNull(),
|
||||
slug: varchar("slug", { length: 120 }).notNull(),
|
||||
title: text("title").notNull(),
|
||||
note: text("note"),
|
||||
enabled: boolean("enabled").default(true).notNull(),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "guides"."guide_section" ADD COLUMN "note" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1788192979231,
|
||||
"tag": "0016_uneven_infant_terrible",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1788245483347,
|
||||
"tag": "0017_needy_hulk",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export async function saveWeapons(guideId: string, input: unknown) {
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "weapon")));
|
||||
await tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId));
|
||||
if (data.rows.length) await tx.insert(weaponRecommendations).values(data.rows.map((row, sortOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, guideId, sortOrder })));
|
||||
return updated;
|
||||
@@ -135,6 +136,7 @@ export async function saveConstellations(guideId: string, input: unknown) {
|
||||
const data = constellationSectionSchema.parse(input);
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "constellations")));
|
||||
await tx.delete(constellationRecommendations).where(eq(constellationRecommendations.guideId, guideId));
|
||||
if (data.rows.length) await tx.insert(constellationRecommendations).values(data.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), guideId })));
|
||||
return updated;
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function getPublicTeamExport(teamId: string) {
|
||||
|
||||
export async function getPublicWeaponExport(guideId: string) {
|
||||
const [row] = await getDb()
|
||||
.select({ guide: guides })
|
||||
.select({ guide: guides, note: guideSections.note })
|
||||
.from(guides)
|
||||
.innerJoin(
|
||||
guideSections,
|
||||
@@ -208,6 +208,7 @@ export async function getPublicWeaponExport(guideId: string) {
|
||||
|
||||
return {
|
||||
guideName: row.guide.name,
|
||||
note: row.note,
|
||||
weapons: weapons.map(({ recommendation, weapon }) => ({
|
||||
...recommendation,
|
||||
weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) },
|
||||
|
||||
@@ -13,14 +13,21 @@ describe("structured guide validation", () => {
|
||||
});
|
||||
|
||||
it("requires at least one weapon percentage", () => {
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null, note: null }] }).success).toBe(false);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }).success).toBe(true);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null, note: null }] }).success).toBe(false);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("limits an optional note on each weapon", () => {
|
||||
const row = { weaponKey: "1", refinement: 1, overallPercent: 100, personalPercent: null };
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ ...row, note: null }] }).success).toBe(true);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ ...row, note: "x".repeat(2_001) }] }).success).toBe(false);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ ...row, note: null }] }).success).toBe(true);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ ...row, note: "x".repeat(2_001) }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("limits weapon and constellation header notes", () => {
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", rows: [] }).success).toBe(true);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), rows: [] }).success).toBe(false);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", rows: [] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), rows: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("limits artifact main stats to two per slot", () => {
|
||||
@@ -70,8 +77,8 @@ describe("structured guide validation", () => {
|
||||
|
||||
it("persists whether a constellation percentage is marked as great", () => {
|
||||
const row = { level: 1, detail: "Test constellation", cumulativePercent: 120, isGreat: true };
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, rows: [row] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, rows: [{ ...row, isGreat: "yes" }] }).success).toBe(false);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [row] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ ...row, isGreat: "yes" }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires four unique team members and non-zero damage", () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const updateGuideSchema = z.strictObject({
|
||||
|
||||
export const weaponSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
rows: z.array(z.strictObject({
|
||||
weaponKey: catalogKey,
|
||||
refinement: z.number().int().min(1).max(5),
|
||||
@@ -78,6 +79,7 @@ export const artifactSectionSchema = z.strictObject({
|
||||
|
||||
export const constellationSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
rows: z.array(z.strictObject({
|
||||
level: z.number().int().min(1).max(6),
|
||||
detail: z.string().trim().min(1).max(2_000),
|
||||
|
||||
Reference in New Issue
Block a user