diff --git a/Plan.md b/Plan.md
index 567a373..9a54203 100644
--- a/Plan.md
+++ b/Plan.md
@@ -27,8 +27,8 @@ Remove Guide moves content to a recoverable Trash before permanent deletion.
- Constellations: C1–C6 description and cumulative performance against C0=100%.
- Team Calc: multiple four-character teams, raw damage, calculated member
shares, rotation duration, total DPS, and combo text.
-- Extra: multiple sections of image-first rows with heading, short body, and an
- optional percentage.
+- Extra: multiple sections of image-first rows with heading, short body, an
+ optional percentage, and an optional editable mini spreadsheet.
## Catalog and media
diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx
index 22dfffd..8d8f841 100644
--- a/app/admin/[character]/[page]/page.tsx
+++ b/app/admin/[character]/[page]/page.tsx
@@ -8,19 +8,134 @@ import { GuideSectionNav } from "@/components/admin/guide-section-nav";
import { AdminHeader } from "@/components/admin/admin-header";
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
-import { getActiveCatalog, getAdminGuideContent, getGuideByCharacterKey } from "@/lib/guides/queries";
+import {
+ getActiveCatalog,
+ getAdminGuideContent,
+ getGuideByCharacterKey,
+} from "@/lib/guides/queries";
-const sections = ["weapon", "artifact", "constellations", "team", "extra"] as const;
+const sections = [
+ "weapon",
+ "artifact",
+ "constellations",
+ "team",
+ "extra",
+] as const;
-export default async function StructuredEditorPage({ params }: { params: Promise<{ character: string; page: string }> }) {
+export default async function StructuredEditorPage({
+ params,
+}: {
+ params: Promise<{ character: string; page: string }>;
+}) {
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 [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]);
+ const [catalog, content] = await Promise.all([
+ getActiveCatalog(),
+ getAdminGuideContent(guide.slug),
+ ]);
if (!catalog || !content) notFound();
- const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
- return
} nativeButton={false} aria-label="กลับหน้า Guide">{guide.name}
{guide.sections.find((section) => section.slug === page)?.title ?? page}
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, textColor: row.textColor, catalogItems: row.catalogItems.map(({ kind, key }) => ({ kind, key })), highlights: row.highlights, imageUrl: row.imageUrl, imageWidth: row.imageWidth, imageHeight: row.imageHeight })) })) }} /> ;
+ const guideCharacter = catalog.characters.find(
+ (item) => item.key === guide.characterKey,
+ );
+ return (
+
+
+
+
+ }
+ nativeButton={false}
+ aria-label="กลับหน้า Guide"
+ >
+
+
+
+
{guide.name}
+
+ {guide.sections.find((section) => section.slug === page)?.title ??
+ page}
+
+
+
+
+ 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,
+ textColor: row.textColor,
+ sheet: row.sheet,
+ catalogItems: row.catalogItems.map(({ kind, key }) => ({
+ kind,
+ key,
+ })),
+ highlights: row.highlights,
+ imageUrl: row.imageUrl,
+ imageWidth: row.imageWidth,
+ imageHeight: row.imageHeight,
+ })),
+ })),
+ }}
+ />
+
+
+ );
}
diff --git a/app/globals.css b/app/globals.css
index e9fe78f..ffed109 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -121,6 +121,22 @@
}
}
+.extra-sheet-grid {
+ --background-color: var(--background);
+ --text-color: var(--foreground);
+ --readonly-text-color: var(--muted-foreground);
+ --outline-color: var(--ring);
+ --outline-background-color: color-mix(in oklch, var(--ring) 20%, transparent);
+ --border-color: var(--border);
+ --header-background-color: var(--muted);
+ --elevation: 0 4px 12px color-mix(in oklch, var(--background) 70%, transparent);
+}
+
+.extra-sheet-grid .Spreadsheet__header--selected {
+ background: var(--accent);
+ color: var(--accent-foreground);
+}
+
@media (pointer: coarse) {
[data-slot="button"],
[data-slot="toggle-group-item"],
diff --git a/bun.lock b/bun.lock
index 9a530a3..118ba50 100644
--- a/bun.lock
+++ b/bun.lock
@@ -34,7 +34,9 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"react-resizable-panels": "^4.12.3",
+ "react-spreadsheet": "0.10.1",
"recharts": "3.8.0",
+ "scheduler": "^0.27.0",
"server-only": "^0.0.1",
"shadcn": "^4.19.0",
"sharp": "^0.35.4",
@@ -740,10 +742,14 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+ "bahttext": ["bahttext@1.1.0", "", {}, "sha512-3g7DbmCwYhLnnN5hJFB+Mtbst4q2gfXpAdqOMK6tz6FmgtlrhX1n1uhuzkIhEr+WcnFR6eRvebIE5msGuEtA7Q=="],
+
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="],
+ "bessel": ["bessel@1.0.2", "", {}, "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw=="],
+
"better-auth": ["better-auth@1.7.2", "", { "dependencies": { "@better-auth/core": "1.7.2", "@better-auth/drizzle-adapter": "1.7.2", "@better-auth/kysely-adapter": "1.7.2", "@better-auth/memory-adapter": "1.7.2", "@better-auth/mongo-adapter": "1.7.2", "@better-auth/prisma-adapter": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A=="],
"better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="],
@@ -778,8 +784,12 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+ "chevrotain": ["chevrotain@7.1.2", "", { "dependencies": { "regexp-to-ast": "0.5.0" } }, "sha512-9bQsXVQ7UAvzMs7iUBBJ9Yv//exOy7bIR3PByOEk4M64vIE/LsiOiX7VIkMF/vEMlrSStwsaE884Bp9CpjtC5g=="],
+
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+ "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
+
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
@@ -1014,6 +1024,8 @@
"fast-equals": ["fast-equals@5.4.2", "", {}, "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ=="],
+ "fast-formula-parser": ["fast-formula-parser@1.0.19", "", { "dependencies": { "bahttext": "^1.1.0", "bessel": "^1.0.2", "chevrotain": "^7.0.1", "jstat": "^1.9.3" } }, "sha512-8Roq7V1XjuYj3cWZ9tILyqBHGjS7NkejRaHxSWePu0qjIcvimZwe6WfeA9che03dgWrykZqrw691qg3lwiIkMg=="],
+
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -1250,6 +1262,8 @@
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
+ "jstat": ["jstat@1.9.6", "", {}, "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug=="],
+
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -1502,6 +1516,8 @@
"react-resizable-panels": ["react-resizable-panels@4.12.3", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-GHMJWnDXui/3RX4bT+cgBP+N3N2nkwcoZATr/2xLFpqQQe7TlBrE0cGM0dUa9ceT7A90tUrSRsiqgRG+g0/2AA=="],
+ "react-spreadsheet": ["react-spreadsheet@0.10.1", "", { "dependencies": { "array.prototype.flatmap": "^1.2.5", "classnames": "^2.3.1", "fast-formula-parser": "^1.0.19", "use-context-selector": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0", "scheduler": ">=0.19.0" } }, "sha512-w+KqCCA5H81FQ7dEBMOz1cZm9I8PSx9yNs+XwjYMjBfCDsOBBljiau93US8GQTtMbXtsExX8k3qD7hZLTp3UHw=="],
+
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="],
@@ -1516,6 +1532,8 @@
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
+ "regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
+
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -1722,6 +1740,8 @@
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+ "use-context-selector": ["use-context-selector@2.0.0", "", { "peerDependencies": { "react": ">=18.0.0", "scheduler": ">=0.19.0" } }, "sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g=="],
+
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
diff --git a/components/admin/extra-sheet-editor.tsx b/components/admin/extra-sheet-editor.tsx
new file mode 100644
index 0000000..3c5ea5b
--- /dev/null
+++ b/components/admin/extra-sheet-editor.tsx
@@ -0,0 +1,303 @@
+"use client";
+
+import {
+ ArrowDownIcon,
+ ArrowLeftIcon,
+ ArrowRightIcon,
+ ArrowUpIcon,
+ Columns3Icon,
+ PlusIcon,
+ Rows3Icon,
+ Trash2Icon,
+} from "lucide-react";
+import Spreadsheet, { type Point } from "react-spreadsheet";
+import { useMemo, useState } from "react";
+
+import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import {
+ addExtraSheetColumn,
+ addExtraSheetRow,
+ deleteExtraSheetColumn,
+ deleteExtraSheetRow,
+ extraSheetToMatrix,
+ matrixToExtraSheet,
+ moveExtraSheetColumn,
+ moveExtraSheetRow,
+ nextExtraSheetColumnLabel,
+ type ExtraSheet,
+ type ExtraSheetMatrix,
+} from "@/lib/guides/extra-sheet";
+
+type DeleteTarget = "column" | "row" | null;
+
+function LiteralDataViewer({ cell }: { cell?: { value?: unknown } }) {
+ return (
+
+ {String(cell?.value ?? "")}
+
+ );
+}
+
+export function ExtraSheetEditor({
+ value,
+ createId,
+ onChange,
+ onRemove,
+}: {
+ value: ExtraSheet;
+ createId: (prefix: string) => string;
+ onChange: (value: ExtraSheet) => void;
+ onRemove: () => void;
+}) {
+ const [active, setActive] = useState({ row: 0, column: 0 });
+ const [labelDialogOpen, setLabelDialogOpen] = useState(false);
+ const [renaming, setRenaming] = useState(false);
+ const [columnLabel, setColumnLabel] = useState("");
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [removeOpen, setRemoveOpen] = useState(false);
+ const matrix = useMemo(() => extraSheetToMatrix(value), [value]);
+ const activeColumn = value.columns[active.column];
+ const activeRow = value.rows[active.row];
+ const normalizedLabel = columnLabel.trim().toLocaleLowerCase();
+ const labelInvalid = !normalizedLabel || value.columns.some((column, index) =>
+ index !== (renaming ? active.column : -1) &&
+ column.label.toLocaleLowerCase() === normalizedLabel,
+ );
+
+ const openAddColumn = () => {
+ setRenaming(false);
+ setColumnLabel(nextExtraSheetColumnLabel(value.columns));
+ setLabelDialogOpen(true);
+ };
+ const openRenameColumn = () => {
+ if (!activeColumn) return;
+ setRenaming(true);
+ setColumnLabel(activeColumn.label);
+ setLabelDialogOpen(true);
+ };
+ const saveColumnLabel = () => {
+ if (labelInvalid) return;
+ if (renaming) {
+ onChange({
+ ...value,
+ columns: value.columns.map((column, index) =>
+ index === active.column ? { ...column, label: columnLabel.trim() } : column,
+ ),
+ });
+ } else {
+ const column = value.columns.length;
+ onChange(addExtraSheetColumn(
+ value,
+ columnLabel.trim(),
+ createId("extra-sheet-column"),
+ ));
+ setActive((current) => ({ ...current, column }));
+ }
+ setLabelDialogOpen(false);
+ };
+
+ return (
+
+
+
+
+
+ }
+ >
+
+ แถว {active.row + 1}
+
+
+
+ จัดการแถว {active.row + 1}
+ {
+ onChange(moveExtraSheetRow(value, active.row, active.row - 1));
+ setActive((current) => ({ ...current, row: current.row - 1 }));
+ }}
+ >
+ ย้ายขึ้น
+
+ {
+ onChange(moveExtraSheetRow(value, active.row, active.row + 1));
+ setActive((current) => ({ ...current, row: current.row + 1 }));
+ }}
+ >
+ ย้ายลง
+
+
+
+
+ setDeleteTarget("row")}
+ >
+ ลบแถว
+
+
+
+
+
+ }
+ >
+
+ {activeColumn?.label ?? "คอลัมน์"}
+
+
+
+ {activeColumn?.label}
+ แก้ไขชื่อ
+ {
+ onChange(moveExtraSheetColumn(value, active.column, active.column - 1));
+ setActive((current) => ({ ...current, column: current.column - 1 }));
+ }}
+ >
+ ย้ายไปทางซ้าย
+
+ {
+ onChange(moveExtraSheetColumn(value, active.column, active.column + 1));
+ setActive((current) => ({ ...current, column: current.column + 1 }));
+ }}
+ >
+ ย้ายไปทางขวา
+
+
+
+
+ setDeleteTarget("column")}
+ >
+ ลบคอลัมน์
+
+
+
+
+
+
+
+
+ column.label)}
+ DataViewer={LiteralDataViewer}
+ onActivate={setActive}
+ onChange={(next) => onChange(matrixToExtraSheet(
+ value,
+ next as ExtraSheetMatrix,
+ createId,
+ ))}
+ />
+
+
+
+
+
{ if (!open) setDeleteTarget(null); }}
+ title={deleteTarget === "column" ? `ลบ ${activeColumn?.label}?` : `ลบแถว ${active.row + 1}?`}
+ description="ข้อมูลในรายการนี้จะถูกลบออกจากตาราง"
+ showSuccessToast={false}
+ onConfirm={() => {
+ if (deleteTarget === "column") {
+ onChange(deleteExtraSheetColumn(value, active.column));
+ setActive((current) => ({ ...current, column: Math.max(0, current.column - 1) }));
+ } else if (deleteTarget === "row") {
+ onChange(deleteExtraSheetRow(value, active.row));
+ setActive((current) => ({ ...current, row: Math.max(0, current.row - 1) }));
+ }
+ setDeleteTarget(null);
+ }}
+ />
+ {
+ onRemove();
+ setRemoveOpen(false);
+ }}
+ />
+
+ );
+}
diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx
index 84209a3..cee1b1a 100644
--- a/components/admin/guide-section-editor.tsx
+++ b/components/admin/guide-section-editor.tsx
@@ -164,6 +164,7 @@ import {
} from "@/lib/guides/percent-input";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { normalizeExtraRichText } from "@/lib/rich-text";
+import { createExtraSheet, type ExtraSheet } from "@/lib/guides/extra-sheet";
import { cn } from "@/lib/utils";
type CatalogItem = {
@@ -276,6 +277,13 @@ const RichTextEditor = dynamic(
loading: () => ,
},
);
+const ExtraSheetEditor = dynamic(
+ () => import("@/components/admin/extra-sheet-editor").then((module) => module.ExtraSheetEditor),
+ {
+ ssr: false,
+ loading: () => ,
+ },
+);
function createClientId(prefix: string): string {
const browserCrypto = globalThis.crypto;
@@ -329,6 +337,7 @@ export interface GuideSectionEditorProps {
body: string;
percent: string | null;
backgroundColor: string | null;
+ sheet: ExtraSheet | null;
catalogItems: ExtraCatalogItem[];
highlights: Array<{ text: string; color: string }>;
imageUrl: string | null;
@@ -3508,6 +3517,7 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
? null
: Number(row.percent),
backgroundColor: row.backgroundColor,
+ sheet: row.sheet,
catalogItems: row.catalogItems,
highlights: [],
})),
@@ -3540,6 +3550,7 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
id: row.id || createClientId("extra-row-record"),
body: normalizeExtraRichText(row.body, row.highlights ?? []),
backgroundColor: row.backgroundColor ?? null,
+ sheet: row.sheet ?? null,
catalogItems: row.catalogItems ?? [],
highlights: [],
clientId: row.clientId || createClientId("extra-row"),
@@ -3881,6 +3892,71 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB
}
/>
+
+ ตารางข้อมูล (optional)
+ {row.sheet ? (
+
+ setSections(
+ sections.map((item, index) =>
+ index === sectionIndex
+ ? {
+ ...item,
+ rows: item.rows.map((candidate, inner) =>
+ inner === rowIndex
+ ? { ...candidate, sheet }
+ : candidate,
+ ),
+ }
+ : item,
+ ),
+ )
+ }
+ onRemove={() =>
+ setSections(
+ sections.map((item, index) =>
+ index === sectionIndex
+ ? {
+ ...item,
+ rows: item.rows.map((candidate, inner) =>
+ inner === rowIndex
+ ? { ...candidate, sheet: null }
+ : candidate,
+ ),
+ }
+ : item,
+ ),
+ )
+ }
+ />
+ ) : (
+
+ )}
+
Percent (optional)
-
-
+
) : null}
-
);
}
diff --git a/components/public/guide-export-branding.tsx b/components/public/guide-export-branding.tsx
new file mode 100644
index 0000000..08b0ced
--- /dev/null
+++ b/components/public/guide-export-branding.tsx
@@ -0,0 +1,37 @@
+import Image from "next/image";
+
+export function GuideExportBranding({
+ backgroundUrl,
+ variant = "standard",
+}: {
+ backgroundUrl: string;
+ variant?: "standard" | "constellation";
+}) {
+ const constellation = variant === "constellation";
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx
index 096b749..c3d376c 100644
--- a/components/public/structured-guide.tsx
+++ b/components/public/structured-guide.tsx
@@ -15,11 +15,20 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
import { SiteFooter } from "@/components/public/site-footer";
import { SiteHeader } from "@/components/public/site-header";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { TeamSearch } from "@/components/public/team-search";
import { GuideImageExport } from "@/components/public/guide-image-export";
+import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ConstellationImage } from "@/components/public/constellation-image";
import { RichTextContent } from "@/components/rich-text-content";
import { LiveRefresh } from "@/components/live-refresh";
@@ -358,25 +367,9 @@ function WeaponSectionContent({
)}
>
{exporting ? (
- <>
-
-
-
- >
+
) : null}
{exporting ? (
- <>
-
-
-
- >
+
) : null}
{exporting ? (
- <>
-
-
-
- >
+
) : null}
คู่มือเสริม
@@ -1243,7 +1204,10 @@ function ExtraSectionContent({
{extra.rows.map((row) => (
{row.imageUrl ? (
@@ -1297,6 +1261,40 @@ function ExtraSectionContent({
{row.percent ? {metric(row.percent)} : null}
+ {row.sheet ? (
+
+
+
+
+ {row.sheet.columns.map((column) => (
+
+ {column.label}
+
+ ))}
+
+
+
+ {row.sheet.rows.map((sheetRow) => (
+
+ {row.sheet!.columns.map((column) => (
+
+ {sheetRow.cells[column.id] ?? ""}
+
+ ))}
+
+ ))}
+
+
+
+ ) : null}
))}
diff --git a/db/schema.ts b/db/schema.ts
index faccc84..3c133af 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -17,6 +17,7 @@ import {
} from "drizzle-orm/pg-core";
import type { CatalogConstellation, CatalogTalent } from "@/lib/catalog/lunaris";
+import type { ExtraSheet } from "@/lib/guides/extra-sheet";
const authSchema = pgSchema("auth");
const mediaSchema = pgSchema("media");
@@ -665,6 +666,7 @@ export const extraSectionRows = guidesSchema.table(
.$type>()
.default([])
.notNull(),
+ sheet: jsonb("sheet").$type(),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)],
diff --git a/drizzle/0029_rare_multiple_man.sql b/drizzle/0029_rare_multiple_man.sql
new file mode 100644
index 0000000..20e4cd1
--- /dev/null
+++ b/drizzle/0029_rare_multiple_man.sql
@@ -0,0 +1 @@
+ALTER TABLE "guides"."extra_section_row" ADD COLUMN "sheet" jsonb;
\ No newline at end of file
diff --git a/drizzle/meta/0029_snapshot.json b/drizzle/meta/0029_snapshot.json
new file mode 100644
index 0000000..2ffa604
--- /dev/null
+++ b/drizzle/meta/0029_snapshot.json
@@ -0,0 +1,3456 @@
+{
+ "id": "46d8b8cd-5cc2-435e-82ae-e891b24e19d1",
+ "prevId": "bf1a7c7a-d041-412f-ac75-8751b0630818",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_issuer_unique": {
+ "name": "account_issuer_unique",
+ "columns": [
+ {
+ "expression": "issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.artifact_group": {
+ "name": "artifact_group",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "artifact_group_order_idx": {
+ "name": "artifact_group_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "artifact_group_guide_id_guide_id_fk": {
+ "name": "artifact_group_guide_id_guide_id_fk",
+ "tableFrom": "artifact_group",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.artifact_profile": {
+ "name": "artifact_profile",
+ "schema": "guides",
+ "columns": {
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "sands": {
+ "name": "sands",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "goblet": {
+ "name": "goblet",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "circlet": {
+ "name": "circlet",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "substats": {
+ "name": "substats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "targets": {
+ "name": "targets",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "talents": {
+ "name": "talents",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "artifact_profile_guide_id_guide_id_fk": {
+ "name": "artifact_profile_guide_id_guide_id_fk",
+ "tableFrom": "artifact_profile",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.artifact_recommendation": {
+ "name": "artifact_recommendation",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_artifact_key": {
+ "name": "first_artifact_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "second_artifact_key": {
+ "name": "second_artifact_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "percent": {
+ "name": "percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "artifact_recommendation_order_idx": {
+ "name": "artifact_recommendation_order_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "artifact_recommendation_group_id_artifact_group_id_fk": {
+ "name": "artifact_recommendation_group_id_artifact_group_id_fk",
+ "tableFrom": "artifact_recommendation",
+ "tableTo": "artifact_group",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.block": {
+ "name": "block",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schema_version": {
+ "name": "schema_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "responsive_layout": {
+ "name": "responsive_layout",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "block_page_order_idx": {
+ "name": "block_page_order_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "block_page_id_page_id_fk": {
+ "name": "block_page_id_page_id_fk",
+ "tableFrom": "block",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "catalog.artifact": {
+ "name": "artifact",
+ "schema": "catalog",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_key": {
+ "name": "image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "catalog_artifact_name_idx": {
+ "name": "catalog_artifact_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "artifact_key_pk": {
+ "name": "artifact_key_pk",
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "catalog.character": {
+ "name": "character",
+ "schema": "catalog",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "element": {
+ "name": "element",
+ "type": "varchar(48)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weapon_type": {
+ "name": "weapon_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_key": {
+ "name": "image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "element_icon_key": {
+ "name": "element_icon_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "coop_image_key": {
+ "name": "coop_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name_card_image_key": {
+ "name": "name_card_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "constellations": {
+ "name": "constellations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "talents": {
+ "name": "talents",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "catalog_character_name_idx": {
+ "name": "catalog_character_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "character_key_pk": {
+ "name": "character_key_pk",
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "catalog.weapon": {
+ "name": "weapon",
+ "schema": "catalog",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weapon_type": {
+ "name": "weapon_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_key": {
+ "name": "image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "catalog_weapon_name_idx": {
+ "name": "catalog_weapon_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "weapon_key_pk": {
+ "name": "weapon_key_pk",
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.character": {
+ "name": "character",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name_th": {
+ "name": "name_th",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name_en": {
+ "name": "name_en",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "portrait_media_id": {
+ "name": "portrait_media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "element": {
+ "name": "element",
+ "type": "varchar(48)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(80)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "visible": {
+ "name": "visible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "character_slug_unique": {
+ "name": "character_slug_unique",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "character_directory_idx": {
+ "name": "character_directory_idx",
+ "columns": [
+ {
+ "expression": "visible",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "character_portrait_media_id_media_id_fk": {
+ "name": "character_portrait_media_id_media_id_fk",
+ "tableFrom": "character",
+ "tableTo": "media",
+ "schemaTo": "media",
+ "columnsFrom": [
+ "portrait_media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.constellation_condition": {
+ "name": "constellation_condition",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "constellation_condition_order_idx": {
+ "name": "constellation_condition_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "constellation_condition_guide_id_guide_id_fk": {
+ "name": "constellation_condition_guide_id_guide_id_fk",
+ "tableFrom": "constellation_condition",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.constellation_recommendation": {
+ "name": "constellation_recommendation",
+ "schema": "guides",
+ "columns": {
+ "condition_id": {
+ "name": "condition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cumulative_percent": {
+ "name": "cumulative_percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_great": {
+ "name": "is_great",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "constellation_recommendation_condition_id_constellation_condition_id_fk": {
+ "name": "constellation_recommendation_condition_id_constellation_condition_id_fk",
+ "tableFrom": "constellation_recommendation",
+ "tableTo": "constellation_condition",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "condition_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "constellation_recommendation_condition_id_level_pk": {
+ "name": "constellation_recommendation_condition_id_level_pk",
+ "columns": [
+ "condition_id",
+ "level"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.data_source_version": {
+ "name": "data_source_version",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "data_source_id": {
+ "name": "data_source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rows": {
+ "name": "rows",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "evaluation": {
+ "name": "evaluation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "data_source_version_unique": {
+ "name": "data_source_version_unique",
+ "columns": [
+ {
+ "expression": "data_source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "data_source_version_data_source_id_data_source_id_fk": {
+ "name": "data_source_version_data_source_id_data_source_id_fk",
+ "tableFrom": "data_source_version",
+ "tableTo": "data_source",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "data_source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "data_source_version_created_by_id_user_id_fk": {
+ "name": "data_source_version_created_by_id_user_id_fk",
+ "tableFrom": "data_source_version",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.data_source": {
+ "name": "data_source",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fields": {
+ "name": "fields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "current_version": {
+ "name": "current_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "data_source_name_idx": {
+ "name": "data_source_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.extra_section_row": {
+ "name": "extra_section_row",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heading": {
+ "name": "heading",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "percent": {
+ "name": "percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_color": {
+ "name": "background_color",
+ "type": "varchar(7)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "text_color": {
+ "name": "text_color",
+ "type": "varchar(7)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "highlights": {
+ "name": "highlights",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "catalog_items": {
+ "name": "catalog_items",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "sheet": {
+ "name": "sheet",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "extra_section_row_order_idx": {
+ "name": "extra_section_row_order_idx",
+ "columns": [
+ {
+ "expression": "section_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "extra_section_row_section_id_extra_section_section_id_fk": {
+ "name": "extra_section_row_section_id_extra_section_section_id_fk",
+ "tableFrom": "extra_section_row",
+ "tableTo": "extra_section",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "section_id"
+ ],
+ "columnsTo": [
+ "section_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "extra_section_row_media_id_media_id_fk": {
+ "name": "extra_section_row_media_id_media_id_fk",
+ "tableFrom": "extra_section_row",
+ "tableTo": "media",
+ "schemaTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.extra_section": {
+ "name": "extra_section",
+ "schema": "guides",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "extra_section_section_id_guide_section_id_fk": {
+ "name": "extra_section_section_id_guide_section_id_fk",
+ "tableFrom": "extra_section",
+ "tableTo": "guide_section",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "section_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.guide_section": {
+ "name": "guide_section",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "guide_section_slug_unique": {
+ "name": "guide_section_slug_unique",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "guide_section_order_idx": {
+ "name": "guide_section_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "guide_section_guide_id_guide_id_fk": {
+ "name": "guide_section_guide_id_guide_id_fk",
+ "tableFrom": "guide_section",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.guide": {
+ "name": "guide",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "character_key": {
+ "name": "character_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "overview": {
+ "name": "overview",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "youtube_url": {
+ "name": "youtube_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_media_id": {
+ "name": "cover_media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "trashed_at": {
+ "name": "trashed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "guide_character_key_unique": {
+ "name": "guide_character_key_unique",
+ "columns": [
+ {
+ "expression": "character_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "guide_slug_unique": {
+ "name": "guide_slug_unique",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "guide_directory_idx": {
+ "name": "guide_directory_idx",
+ "columns": [
+ {
+ "expression": "trashed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "guide_cover_media_id_media_id_fk": {
+ "name": "guide_cover_media_id_media_id_fk",
+ "tableFrom": "guide",
+ "tableTo": "media",
+ "schemaTo": "media",
+ "columnsFrom": [
+ "cover_media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "media.media": {
+ "name": "media",
+ "schema": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "object_key": {
+ "name": "object_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "byte_size": {
+ "name": "byte_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "current_reference_count": {
+ "name": "current_reference_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "revision_reference_count": {
+ "name": "revision_reference_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "media_object_key_unique": {
+ "name": "media_object_key_unique",
+ "columns": [
+ {
+ "expression": "object_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "media_visibility_idx": {
+ "name": "media_visibility_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "current_reference_count",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_created_by_id_user_id_fk": {
+ "name": "media_created_by_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "media.media_reference": {
+ "name": "media_reference",
+ "schema": "media",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_type": {
+ "name": "owner_type",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_kind": {
+ "name": "reference_kind",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "media_reference_owner_idx": {
+ "name": "media_reference_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_reference_media_id_media_id_fk": {
+ "name": "media_reference_media_id_media_id_fk",
+ "tableFrom": "media_reference",
+ "tableTo": "media",
+ "schemaTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_reference_media_id_owner_type_owner_id_reference_kind_pk": {
+ "name": "media_reference_media_id_owner_type_owner_id_reference_kind_pk",
+ "columns": [
+ "media_id",
+ "owner_type",
+ "owner_id",
+ "reference_kind"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbox_event": {
+ "name": "outbox_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigint",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "outbox_event_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "9223372036854775807",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "topic": {
+ "name": "topic",
+ "type": "varchar(160)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aggregate_id": {
+ "name": "aggregate_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "available_at": {
+ "name": "available_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "outbox_pending_idx": {
+ "name": "outbox_pending_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "available_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.page_data_source": {
+ "name": "page_data_source",
+ "schema": "editor",
+ "columns": {
+ "page_id": {
+ "name": "page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data_source_id": {
+ "name": "data_source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pinned_version": {
+ "name": "pinned_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "page_data_source_dependency_idx": {
+ "name": "page_data_source_dependency_idx",
+ "columns": [
+ {
+ "expression": "data_source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "page_data_source_page_id_page_id_fk": {
+ "name": "page_data_source_page_id_page_id_fk",
+ "tableFrom": "page_data_source",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "page_data_source_data_source_id_data_source_id_fk": {
+ "name": "page_data_source_data_source_id_data_source_id_fk",
+ "tableFrom": "page_data_source",
+ "tableTo": "data_source",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "data_source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "page_data_source_page_id_data_source_id_pk": {
+ "name": "page_data_source_page_id_data_source_id_pk",
+ "columns": [
+ "page_id",
+ "data_source_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.page": {
+ "name": "page",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nav_order": {
+ "name": "nav_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "visible": {
+ "name": "visible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "public_note": {
+ "name": "public_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "page_character_slug_unique": {
+ "name": "page_character_slug_unique",
+ "columns": [
+ {
+ "expression": "character_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "page_navigation_idx": {
+ "name": "page_navigation_idx",
+ "columns": [
+ {
+ "expression": "character_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visible",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "nav_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "page_character_id_character_id_fk": {
+ "name": "page_character_id_character_id_fk",
+ "tableFrom": "page",
+ "tableTo": "character",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.public_snapshot": {
+ "name": "public_snapshot",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_version": {
+ "name": "page_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version_vector": {
+ "name": "version_vector",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "vector_hash": {
+ "name": "vector_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dto": {
+ "name": "dto",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visible": {
+ "name": "visible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "public_snapshot_version_unique": {
+ "name": "public_snapshot_version_unique",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "page_version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "vector_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "public_snapshot_lookup_idx": {
+ "name": "public_snapshot_lookup_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visible",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "public_snapshot_page_id_page_id_fk": {
+ "name": "public_snapshot_page_id_page_id_fk",
+ "tableFrom": "public_snapshot",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.revision": {
+ "name": "revision",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_version": {
+ "name": "page_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_revision_id": {
+ "name": "source_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "checkpoint_hour": {
+ "name": "checkpoint_hour",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data_source_versions": {
+ "name": "data_source_versions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "media_ids": {
+ "name": "media_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "revision_page_created_idx": {
+ "name": "revision_page_created_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revision_checkpoint_hour_unique": {
+ "name": "revision_checkpoint_hour_unique",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "checkpoint_hour",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"editor\".\"revision\".\"kind\" = 'checkpoint'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "revision_page_id_page_id_fk": {
+ "name": "revision_page_id_page_id_fk",
+ "tableFrom": "revision",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revision_author_id_user_id_fk": {
+ "name": "revision_author_id_user_id_fk",
+ "tableFrom": "revision",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "session_user_id_idx": {
+ "name": "session_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.slug_alias": {
+ "name": "slug_alias",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "old_character_slug": {
+ "name": "old_character_slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "old_page_slug": {
+ "name": "old_page_slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_character_id": {
+ "name": "target_character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_page_id": {
+ "name": "target_page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "slug_alias_path_unique": {
+ "name": "slug_alias_path_unique",
+ "columns": [
+ {
+ "expression": "old_character_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "old_page_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "slug_alias_target_character_id_character_id_fk": {
+ "name": "slug_alias_target_character_id_character_id_fk",
+ "tableFrom": "slug_alias",
+ "tableTo": "character",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "target_character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "slug_alias_target_page_id_page_id_fk": {
+ "name": "slug_alias_target_page_id_page_id_fk",
+ "tableFrom": "slug_alias",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "target_page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.team_composition": {
+ "name": "team_composition",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rotation_seconds": {
+ "name": "rotation_seconds",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "combo": {
+ "name": "combo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "team_composition_order_idx": {
+ "name": "team_composition_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "team_composition_guide_id_guide_id_fk": {
+ "name": "team_composition_guide_id_guide_id_fk",
+ "tableFrom": "team_composition",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.team_member": {
+ "name": "team_member",
+ "schema": "guides",
+ "columns": {
+ "team_id": {
+ "name": "team_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "character_key": {
+ "name": "character_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weapon_key": {
+ "name": "weapon_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "artifact_key": {
+ "name": "artifact_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "second_artifact_key": {
+ "name": "second_artifact_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "constellation": {
+ "name": "constellation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "refinement": {
+ "name": "refinement",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "damage": {
+ "name": "damage",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_member_team_id_team_composition_id_fk": {
+ "name": "team_member_team_id_team_composition_id_fk",
+ "tableFrom": "team_member",
+ "tableTo": "team_composition",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "team_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "team_member_team_id_position_pk": {
+ "name": "team_member_team_id_position_pk",
+ "columns": [
+ "team_id",
+ "position"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.template": {
+ "name": "template",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "structure": {
+ "name": "structure",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "built_in": {
+ "name": "built_in",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "template_slug_unique": {
+ "name": "template_slug_unique",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'admin'"
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.weapon_condition": {
+ "name": "weapon_condition",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "weapon_condition_order_idx": {
+ "name": "weapon_condition_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "weapon_condition_guide_id_guide_id_fk": {
+ "name": "weapon_condition_guide_id_guide_id_fk",
+ "tableFrom": "weapon_condition",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.weapon_recommendation": {
+ "name": "weapon_recommendation",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "condition_id": {
+ "name": "condition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weapon_key": {
+ "name": "weapon_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refinement": {
+ "name": "refinement",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "overall_percent": {
+ "name": "overall_percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "personal_percent": {
+ "name": "personal_percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "weapon_recommendation_order_idx": {
+ "name": "weapon_recommendation_order_idx",
+ "columns": [
+ {
+ "expression": "condition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "weapon_recommendation_condition_id_weapon_condition_id_fk": {
+ "name": "weapon_recommendation_condition_id_weapon_condition_id_fk",
+ "tableFrom": "weapon_recommendation",
+ "tableTo": "weapon_condition",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "condition_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index db9dbc2..24e4375 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -204,6 +204,13 @@
"when": 1788586391986,
"tag": "0028_unknown_scarlet_witch",
"breakpoints": true
+ },
+ {
+ "idx": 29,
+ "version": "7",
+ "when": 1788594405237,
+ "tag": "0029_rare_multiple_man",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/lib/guides/extra-sheet.test.ts b/lib/guides/extra-sheet.test.ts
new file mode 100644
index 0000000..1bde083
--- /dev/null
+++ b/lib/guides/extra-sheet.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ addExtraSheetColumn,
+ addExtraSheetRow,
+ createExtraSheet,
+ deleteExtraSheetColumn,
+ deleteExtraSheetRow,
+ extraSheetSchema,
+ extraSheetToMatrix,
+ matrixToExtraSheet,
+ moveExtraSheetColumn,
+ moveExtraSheetRow,
+} from "@/lib/guides/extra-sheet";
+
+function idFactory() {
+ let index = 0;
+ return (prefix: string) => `${prefix}-${++index}`;
+}
+
+describe("Extra sheet", () => {
+ it("creates a valid two-by-two sheet", () => {
+ const sheet = createExtraSheet(idFactory());
+ expect(extraSheetSchema.safeParse(sheet).success).toBe(true);
+ expect(sheet.columns.map((column) => column.label)).toEqual(["คอลัมน์ 1", "คอลัมน์ 2"]);
+ expect(extraSheetToMatrix(sheet)).toEqual([
+ [{ value: "" }, { value: "" }],
+ [{ value: "" }, { value: "" }],
+ ]);
+ });
+
+ it("preserves cell identity while rows and columns move", () => {
+ const createId = idFactory();
+ const sheet = createExtraSheet(createId);
+ const firstColumnId = sheet.columns[0].id;
+ const secondColumnId = sheet.columns[1].id;
+ sheet.rows[0].cells[firstColumnId] = "A";
+ sheet.rows[0].cells[secondColumnId] = "B";
+ sheet.rows[1].cells[firstColumnId] = "C";
+
+ const moved = moveExtraSheetRow(moveExtraSheetColumn(sheet, 0, 1), 0, 1);
+ expect(extraSheetToMatrix(moved)).toEqual([
+ [{ value: "" }, { value: "C" }],
+ [{ value: "B" }, { value: "A" }],
+ ]);
+ });
+
+ it("adds and removes rows and columns without orphaned cells", () => {
+ const createId = idFactory();
+ const initial = createExtraSheet(createId);
+ const columnId = createId("column");
+ const expanded = addExtraSheetRow(
+ addExtraSheetColumn(initial, "Notes", columnId),
+ createId("row"),
+ );
+ expect(expanded.rows).toHaveLength(3);
+ expect(expanded.rows.every((row) => row.cells[columnId] === "")).toBe(true);
+
+ const reduced = deleteExtraSheetColumn(expanded, 2);
+ expect(reduced.rows.every((row) => !(columnId in row.cells))).toBe(true);
+ expect(deleteExtraSheetRow(reduced, 2).rows).toHaveLength(2);
+ });
+
+ it("expands rows and columns when a pasted matrix is larger", () => {
+ const createId = idFactory();
+ const initial = createExtraSheet(createId);
+ const expanded = matrixToExtraSheet(initial, [
+ [{ value: "1" }, { value: "2" }, { value: "3" }],
+ [{ value: "4" }, { value: "5" }, { value: "6" }],
+ [{ value: "7" }, { value: "8" }, { value: "=1+1" }],
+ ], createId);
+
+ expect(expanded.columns).toHaveLength(3);
+ expect(expanded.rows).toHaveLength(3);
+ expect(extraSheetToMatrix(expanded)[2][2]?.value).toBe("=1+1");
+ expect(extraSheetSchema.safeParse(expanded).success).toBe(true);
+ });
+
+ it("rejects duplicate labels and cells for missing columns", () => {
+ const sheet = createExtraSheet(idFactory());
+ sheet.columns[1].label = sheet.columns[0].label.toUpperCase();
+ sheet.rows[0].cells.missing = "orphan";
+ const result = extraSheetSchema.safeParse(sheet);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues.map((issue) => issue.message)).toContain("ชื่อคอลัมน์ต้องไม่ซ้ำกัน");
+ expect(result.error.issues.map((issue) => issue.message)).toContain("ไม่พบคอลัมน์ของเซลล์นี้");
+ }
+ });
+});
diff --git a/lib/guides/extra-sheet.ts b/lib/guides/extra-sheet.ts
new file mode 100644
index 0000000..f782a27
--- /dev/null
+++ b/lib/guides/extra-sheet.ts
@@ -0,0 +1,160 @@
+import * as z from "zod";
+
+const extraSheetId = z.string().min(1).max(128);
+
+export const extraSheetSchema = z.strictObject({
+ columns: z.array(z.strictObject({
+ id: extraSheetId,
+ label: z.string().trim().min(1).max(120),
+ })).min(1),
+ rows: z.array(z.strictObject({
+ id: extraSheetId,
+ cells: z.record(z.string(), z.string().max(10_000)),
+ })).min(1),
+}).superRefine((sheet, context) => {
+ const columnIds = new Set(sheet.columns.map((column) => column.id));
+ if (columnIds.size !== sheet.columns.length) {
+ context.addIssue({ code: "custom", path: ["columns"], message: "รหัสคอลัมน์ต้องไม่ซ้ำกัน" });
+ }
+ const columnLabels = new Set(sheet.columns.map((column) => column.label.toLocaleLowerCase()));
+ if (columnLabels.size !== sheet.columns.length) {
+ context.addIssue({ code: "custom", path: ["columns"], message: "ชื่อคอลัมน์ต้องไม่ซ้ำกัน" });
+ }
+ const rowIds = new Set(sheet.rows.map((row) => row.id));
+ if (rowIds.size !== sheet.rows.length) {
+ context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" });
+ }
+ sheet.rows.forEach((row, rowIndex) => {
+ Object.keys(row.cells).forEach((columnId) => {
+ if (!columnIds.has(columnId)) {
+ context.addIssue({
+ code: "custom",
+ path: ["rows", rowIndex, "cells", columnId],
+ message: "ไม่พบคอลัมน์ของเซลล์นี้",
+ });
+ }
+ });
+ });
+});
+
+export type ExtraSheet = z.output;
+
+export type ExtraSheetCell = { value: string };
+export type ExtraSheetMatrix = Array>;
+
+export function nextExtraSheetColumnLabel(columns: ExtraSheet["columns"]): string {
+ const labels = new Set(columns.map((column) => column.label.toLocaleLowerCase()));
+ let index = columns.length + 1;
+ while (labels.has(`คอลัมน์ ${index}`.toLocaleLowerCase())) index += 1;
+ return `คอลัมน์ ${index}`;
+}
+
+export function createExtraSheet(createId: (prefix: string) => string): ExtraSheet {
+ const columns = [1, 2].map((index) => ({
+ id: createId("extra-sheet-column"),
+ label: `คอลัมน์ ${index}`,
+ }));
+ return {
+ columns,
+ rows: [1, 2].map(() => ({
+ id: createId("extra-sheet-row"),
+ cells: Object.fromEntries(columns.map((column) => [column.id, ""])),
+ })),
+ };
+}
+
+export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix {
+ return sheet.rows.map((row) =>
+ sheet.columns.map((column) => ({ value: row.cells[column.id] ?? "" })),
+ );
+}
+
+export function matrixToExtraSheet(
+ sheet: ExtraSheet,
+ matrix: ExtraSheetMatrix,
+ createId: (prefix: string) => string,
+): ExtraSheet {
+ const columnCount = Math.max(
+ sheet.columns.length,
+ ...matrix.map((row) => row.length),
+ );
+ const columns = [...sheet.columns];
+ while (columns.length < columnCount) {
+ columns.push({
+ id: createId("extra-sheet-column"),
+ label: nextExtraSheetColumnLabel(columns),
+ });
+ }
+ const rowCount = Math.max(sheet.rows.length, matrix.length);
+ const rows = Array.from({ length: rowCount }, (_, rowIndex) => ({
+ id: sheet.rows[rowIndex]?.id ?? createId("extra-sheet-row"),
+ cells: Object.fromEntries(columns.map((column, columnIndex) => [
+ column.id,
+ String(matrix[rowIndex]?.[columnIndex]?.value ?? ""),
+ ])),
+ }));
+ return { columns, rows };
+}
+
+export function addExtraSheetColumn(
+ sheet: ExtraSheet,
+ label: string,
+ id: string,
+): ExtraSheet {
+ return {
+ columns: [...sheet.columns, { id, label }],
+ rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })),
+ };
+}
+
+export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet {
+ return {
+ ...sheet,
+ rows: [...sheet.rows, {
+ id,
+ cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
+ }],
+ };
+}
+
+export function moveExtraSheetColumn(
+ sheet: ExtraSheet,
+ from: number,
+ to: number,
+): ExtraSheet {
+ if (to < 0 || to >= sheet.columns.length || from === to) return sheet;
+ const columns = [...sheet.columns];
+ const [column] = columns.splice(from, 1);
+ columns.splice(to, 0, column);
+ return { ...sheet, columns };
+}
+
+export function moveExtraSheetRow(
+ sheet: ExtraSheet,
+ from: number,
+ to: number,
+): ExtraSheet {
+ if (to < 0 || to >= sheet.rows.length || from === to) return sheet;
+ const rows = [...sheet.rows];
+ const [row] = rows.splice(from, 1);
+ rows.splice(to, 0, row);
+ return { ...sheet, rows };
+}
+
+export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraSheet {
+ if (sheet.columns.length === 1) return sheet;
+ const columnId = sheet.columns[index]?.id;
+ if (!columnId) return sheet;
+ return {
+ columns: sheet.columns.filter((_, columnIndex) => columnIndex !== index),
+ rows: sheet.rows.map((row) => ({
+ ...row,
+ cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)),
+ })),
+ };
+}
+
+export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraSheet {
+ if (sheet.rows.length === 1) return sheet;
+ return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) };
+}
diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts
index 71bee25..8dbfb51 100644
--- a/lib/guides/schemas.test.ts
+++ b/lib/guides/schemas.test.ts
@@ -1,6 +1,7 @@
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 {
recommendedStatElement,
@@ -207,6 +208,37 @@ describe("structured guide validation", () => {
sections: [{ title: "Extra", rows: [{ ...row, catalogItems: [row.catalogItems[0], row.catalogItems[0]] }] }],
}).success).toBe(false);
});
+
+ it("validates an optional Extra card sheet", () => {
+ let id = 0;
+ const sheet = createExtraSheet((prefix) => `${prefix}-${++id}`);
+ sheet.rows[0].cells[sheet.columns[0].id] = "=1+1";
+ const row = {
+ mediaId: null,
+ heading: "Stats",
+ body: "",
+ percent: null,
+ backgroundColor: null,
+ highlights: [],
+ catalogItems: [],
+ sheet,
+ };
+ 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].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("=1+1");
+ }
+
+ const invalidSheet = structuredClone(sheet);
+ invalidSheet.rows[0].cells.missing = "orphan";
+ expect(extraSectionSchema.safeParse({
+ expectedVersion: 1,
+ sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }],
+ }).success).toBe(false);
+ });
});
describe("Team Calc", () => {
diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts
index 2006e02..c01e76b 100644
--- a/lib/guides/schemas.ts
+++ b/lib/guides/schemas.ts
@@ -2,6 +2,7 @@ import * as z from "zod";
import { targetStatOptions } from "@/lib/guides/stats";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
+import { extraSheetSchema } from "@/lib/guides/extra-sheet";
import { normalizeExtraRichText } from "@/lib/rich-text";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
@@ -154,6 +155,7 @@ const extraRowSchema = z.strictObject({
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
).default([]),
+ sheet: extraSheetSchema.nullable().default(null),
}).transform((row) => ({
...row,
body: normalizeExtraRichText(row.body, row.highlights),
diff --git a/package.json b/package.json
index dde8536..6e15b11 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,9 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"react-resizable-panels": "^4.12.3",
+ "react-spreadsheet": "0.10.1",
"recharts": "3.8.0",
+ "scheduler": "^0.27.0",
"server-only": "^0.0.1",
"shadcn": "^4.19.0",
"sharp": "^0.35.4",