เลือกส่วนที่ต้องการแก้ไข
diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 49bdeac..adf3dc6 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -21,7 +21,6 @@ const sections = [ "artifact", "constellations", "team", - "extra", ] as const; export const instant = false; @@ -34,9 +33,22 @@ export default async function StructuredEditorPage({ await connection(); if (!(await getAdminSession())) redirect("/admin/login"); const { character, page } = await params; - if (!sections.includes(page as (typeof sections)[number])) notFound(); const guide = await getGuideByCharacterKey(character); 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([ getActiveCatalog(), getAdminGuideContent(guide.slug), @@ -65,18 +77,20 @@ export default async function StructuredEditorPage({
{guide.name}
เลือกส่วนที่ต้องการแก้ไข
เลือกส่วนที่ต้องการแก้ไข
+แต่ละ Extra มีหน้าแก้ไขและบันทึกแยกจากกัน
+
{artifactTierCount(row.second?.name ?? null)}{" "}
diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx
index 90999da..47528a2 100644
--- a/components/public/team-search.tsx
+++ b/components/public/team-search.tsx
@@ -75,7 +75,7 @@ function ArtifactHalf({
);
}
-function TeamArtifactIcon({
+export function TeamArtifactIcon({
first,
second,
eager = false,
diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts
index ca3d2f7..716c2ac 100644
--- a/lib/guides/mutations.ts
+++ b/lib/guides/mutations.ts
@@ -1,6 +1,6 @@
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 {
@@ -26,8 +26,11 @@ import {
import {
artifactSectionSchema,
constellationSectionSchema,
+ createExtraSectionSchema,
createGuideSchema,
extraSectionSchema,
+ reorderExtraSectionsSchema,
+ saveExtraSectionSchema,
slugFromCharacterName,
teamSectionSchema,
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) {
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();
diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts
index d9f593c..b76644c 100644
--- a/lib/guides/queries.ts
+++ b/lib/guides/queries.ts
@@ -173,7 +173,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
sections,
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) } })) })),
- 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,
constellationGroups: constellationGroups.map((group) => ({
...group,
diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts
index 84f705c..22fc5a4 100644
--- a/lib/guides/schemas.test.ts
+++ b/lib/guides/schemas.test.ts
@@ -2,7 +2,18 @@ import { describe, expect, it } from "vitest";
import { calculateTeamMetrics } from "@/lib/guides/calculations";
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 {
recommendedStatElement,
recommendedStatIconName,
@@ -245,6 +256,29 @@ describe("structured guide validation", () => {
sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }],
}).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", () => {
diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts
index c0666a2..fa1652f 100644
--- a/lib/guides/schemas.ts
+++ b/lib/guides/schemas.ts
@@ -146,6 +146,7 @@ const extraRowSchema = z.strictObject({
body: z.string().trim().max(10_000),
percent: percent.nullable(),
backgroundColor: hexColor.nullable().default(null),
+ textColor: hexColor.nullable().default(null),
catalogItems: z.array(z.strictObject({
kind: z.enum(["character", "weapon", "artifact"]),
key: z.string().trim().min(1).max(64),
@@ -168,12 +169,31 @@ const extraRowSchema = z.strictObject({
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({
expectedVersion: z.number().int().positive(),
- sections: z.array(z.strictObject({
- title: z.string().trim().min(1).max(160),
- rows: z.array(extraRowSchema).max(50),
- })).max(20),
+ sections: z.array(extraSectionItemSchema).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