diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx
index 5b58451..49bdeac 100644
--- a/app/admin/[character]/[page]/page.tsx
+++ b/app/admin/[character]/[page]/page.tsx
@@ -24,6 +24,8 @@ const sections = [
"extra",
] as const;
+export const instant = false;
+
export default async function StructuredEditorPage({
params,
}: {
diff --git a/components/admin/editor-presence.tsx b/components/admin/editor-presence.tsx
index 4e47898..dad118b 100644
--- a/components/admin/editor-presence.tsx
+++ b/components/admin/editor-presence.tsx
@@ -12,7 +12,7 @@ export function EditorPresence({ guideId }: { guideId: string }) {
- Editing {editors.length || (presence ? 0 : "—")}
+ Editing {editors.length || (presence ? 0 : "")}
{editors.map((editor) => (
{editor.name}
diff --git a/components/admin/extra-sheet-editor.tsx b/components/admin/extra-sheet-editor.tsx
index b5d9d4a..8e77684 100644
--- a/components/admin/extra-sheet-editor.tsx
+++ b/components/admin/extra-sheet-editor.tsx
@@ -8,10 +8,11 @@ import {
Columns3Icon,
PlusIcon,
PencilIcon,
+ PaletteIcon,
Rows3Icon,
Trash2Icon,
} from "lucide-react";
-import Spreadsheet, { type ColumnIndicatorProps, type DataEditorProps, type Point } from "react-spreadsheet";
+import Spreadsheet, { type ColumnIndicatorProps, type DataEditorProps, type Point, type Selection } from "react-spreadsheet";
import { useCallback, useEffect, useMemo, useState } from "react";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
@@ -48,16 +49,21 @@ import {
moveExtraSheetRow,
nextExtraSheetColumnLabel,
updateExtraSheetCell,
+ updateExtraSheetCells,
type ExtraSheet,
type ExtraSheetMatrix,
} from "@/lib/guides/extra-sheet";
import { normalizeExtraRichText } from "@/lib/rich-text";
type DeleteTarget = "column" | "row" | null;
+const DEFAULT_CELL_BACKGROUND = "#16A34A";
-function LiteralDataViewer({ cell }: { cell?: { value?: unknown } }) {
+function LiteralDataViewer({ cell }: { cell?: { value?: unknown; backgroundColor?: string } }) {
return (
-
+
);
@@ -119,6 +125,7 @@ export function ExtraSheetEditor({
const [removeOpen, setRemoveOpen] = useState(false);
const [cellDialogOpen, setCellDialogOpen] = useState(false);
const [cellDraft, setCellDraft] = useState("");
+ const [selection, setSelection] = useState
(null);
const matrix = useMemo(() => extraSheetToMatrix(value), [value]);
const activeColumn = value.columns[active.column];
const activeRow = value.rows[active.row];
@@ -127,6 +134,11 @@ export function ExtraSheetEditor({
index !== (renaming ? active.column : -1) &&
column.label.toLocaleLowerCase() === normalizedLabel,
);
+ const selectedPoints = useCallback(() => {
+ const range = selection?.toRange(matrix);
+ return range ? Array.from(range) : [active];
+ }, [active, matrix, selection]);
+ const selectedCount = selectedPoints().length;
const openAddColumn = () => {
setRenaming(false);
@@ -200,7 +212,30 @@ export function ExtraSheetEditor({
+
+
onChange(matrixToExtraSheet(
value,
next as ExtraSheetMatrix,
@@ -313,13 +349,17 @@ export function ExtraSheetEditor({
แก้ไขเซลล์
- แถว {active.row + 1} · {activeColumn?.label ?? "คอลัมน์"}
+ {selectedCount > 1
+ ? `นำข้อความเดียวกันไปใช้กับ ${selectedCount} เซลล์ที่เลือก`
+ : `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`}
diff --git a/components/admin/glossary-manager.tsx b/components/admin/glossary-manager.tsx
index 8f1f4e9..f24f9fd 100644
--- a/components/admin/glossary-manager.tsx
+++ b/components/admin/glossary-manager.tsx
@@ -16,8 +16,10 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import type { GlossaryKind } from "@/lib/glossary";
+import { rarityGradientClass } from "@/lib/catalog/rarity";
+import { cn } from "@/lib/utils";
-type CatalogItem = { key: string; name: string; imageUrl: string };
+type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null };
type Alias = { id: string; kind: string; catalogKey: string; alias: string };
const kindLabels: Record = {
@@ -101,7 +103,10 @@ export function GlossaryManager({
{(catalogItem) => (
-
+
{catalogItem.name}
diff --git a/components/admin/guide-visibility-control.tsx b/components/admin/guide-visibility-control.tsx
index 85dbe96..4cca262 100644
--- a/components/admin/guide-visibility-control.tsx
+++ b/components/admin/guide-visibility-control.tsx
@@ -89,7 +89,7 @@ export function GuideVisibilityControl({
onClick={() => updateVisibility(true)}
>
{pending ? : }
- Publish
+ Make Publish
)}
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx
index 6b757c5..a166afb 100644
--- a/components/public/structured-guide.tsx
+++ b/components/public/structured-guide.tsx
@@ -1285,7 +1285,13 @@ function ExtraSectionContent({
{row.sheet.rows.map((sheetRow) => (
{row.sheet!.columns.map((column) => (
-
+
))}
diff --git a/components/public/visitor-presence.tsx b/components/public/visitor-presence.tsx
index 689588e..66feba5 100644
--- a/components/public/visitor-presence.tsx
+++ b/components/public/visitor-presence.tsx
@@ -11,11 +11,11 @@ export function VisitorPresence({ guideId }: { guideId?: string }) {
- ทั้งหมด {counts?.global ?? "—"}
+ ทั้งหมด {counts?.global ?? ""}
{guideId ? (
- Guide นี้ {counts?.guide ?? "—"}
+ Guide นี้ {counts?.guide ?? ""}
) : null}
diff --git a/lib/guides/extra-sheet.test.ts b/lib/guides/extra-sheet.test.ts
index 48c97fa..dcabf7a 100644
--- a/lib/guides/extra-sheet.test.ts
+++ b/lib/guides/extra-sheet.test.ts
@@ -13,6 +13,7 @@ import {
moveExtraSheetRow,
normalizeExtraSheetRichText,
updateExtraSheetCell,
+ updateExtraSheetCells,
} from "@/lib/guides/extra-sheet";
function idFactory() {
@@ -26,8 +27,8 @@ describe("Extra sheet", () => {
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: "" }],
+ [{ value: "", backgroundColor: undefined }, { value: "", backgroundColor: undefined }],
+ [{ value: "", backgroundColor: undefined }, { value: "", backgroundColor: undefined }],
]);
});
@@ -42,8 +43,8 @@ describe("Extra sheet", () => {
const moved = moveExtraSheetRow(moveExtraSheetColumn(sheet, 0, 1), 0, 1);
expect(extraSheetToMatrix(moved)).toEqual([
- [{ value: "" }, { value: "C" }],
- [{ value: "B" }, { value: "A" }],
+ [{ value: "", backgroundColor: undefined }, { value: "C", backgroundColor: undefined }],
+ [{ value: "B", backgroundColor: undefined }, { value: "A", backgroundColor: undefined }],
]);
});
@@ -60,7 +61,10 @@ describe("Extra sheet", () => {
const reduced = deleteExtraSheetColumn(expanded, 2);
expect(reduced.rows.every((row) => !(columnId in row.cells))).toBe(true);
- expect(deleteExtraSheetRow(reduced, 2).rows).toHaveLength(2);
+ const secondRowId = reduced.rows[1].id;
+ const withoutFirstRow = deleteExtraSheetRow(reduced, 0);
+ expect(withoutFirstRow.rows).toHaveLength(2);
+ expect(withoutFirstRow.rows[0].id).toBe(secondRowId);
});
it("expands rows and columns when a pasted matrix is larger", () => {
@@ -102,4 +106,20 @@ describe("Extra sheet", () => {
"safe
",
);
});
+
+ it("updates rich text and backgrounds across a cell selection", () => {
+ const sheet = createExtraSheet(idFactory());
+ const updated = updateExtraSheetCells(sheet, [
+ { row: 0, column: 0 },
+ { row: 1, column: 1 },
+ ], { value: "Shared
", backgroundColor: "#16A34A" });
+
+ expect(extraSheetToMatrix(updated)).toEqual([
+ [{ value: "Shared
", backgroundColor: "#16A34A" }, { value: "", backgroundColor: undefined }],
+ [{ value: "", backgroundColor: undefined }, { value: "Shared
", backgroundColor: "#16A34A" }],
+ ]);
+ expect(extraSheetSchema.safeParse(updated).success).toBe(true);
+ expect(updateExtraSheetCells(updated, [{ row: 0, column: 0 }], { backgroundColor: null })
+ .rows[0].backgrounds).toEqual({});
+ });
});
diff --git a/lib/guides/extra-sheet.ts b/lib/guides/extra-sheet.ts
index 2f06559..c456d7f 100644
--- a/lib/guides/extra-sheet.ts
+++ b/lib/guides/extra-sheet.ts
@@ -12,6 +12,7 @@ export const extraSheetSchema = z.strictObject({
rows: z.array(z.strictObject({
id: extraSheetId,
cells: z.record(z.string(), z.string().max(10_000)),
+ backgrounds: z.record(z.string(), z.string().regex(/^#[0-9A-Fa-f]{6}$/)).default({}),
})).min(1),
}).superRefine((sheet, context) => {
const columnIds = new Set(sheet.columns.map((column) => column.id));
@@ -27,7 +28,7 @@ export const extraSheetSchema = z.strictObject({
context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" });
}
sheet.rows.forEach((row, rowIndex) => {
- Object.keys(row.cells).forEach((columnId) => {
+ [...Object.keys(row.cells), ...Object.keys(row.backgrounds)].forEach((columnId) => {
if (!columnIds.has(columnId)) {
context.addIssue({
code: "custom",
@@ -41,8 +42,9 @@ export const extraSheetSchema = z.strictObject({
export type ExtraSheet = z.output;
-export type ExtraSheetCell = { value: string };
+export type ExtraSheetCell = { value: string; backgroundColor?: string };
export type ExtraSheetMatrix = Array>;
+type SheetPoint = { row: number; column: number };
export function nextExtraSheetColumnLabel(columns: ExtraSheet["columns"]): string {
const labels = new Set(columns.map((column) => column.label.toLocaleLowerCase()));
@@ -61,13 +63,17 @@ export function createExtraSheet(createId: (prefix: string) => string): ExtraShe
rows: [1, 2].map(() => ({
id: createId("extra-sheet-row"),
cells: Object.fromEntries(columns.map((column) => [column.id, ""])),
+ backgrounds: {},
})),
};
}
export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix {
return sheet.rows.map((row) =>
- sheet.columns.map((column) => ({ value: row.cells[column.id] ?? "" })),
+ sheet.columns.map((column) => ({
+ value: row.cells[column.id] ?? "",
+ backgroundColor: row.backgrounds[column.id],
+ })),
);
}
@@ -94,6 +100,11 @@ export function matrixToExtraSheet(
column.id,
String(matrix[rowIndex]?.[columnIndex]?.value ?? ""),
])),
+ backgrounds: Object.fromEntries(columns.flatMap((column, columnIndex) => {
+ const backgroundColor = matrix[rowIndex]?.[columnIndex]?.backgroundColor
+ ?? sheet.rows[rowIndex]?.backgrounds[column.id];
+ return backgroundColor ? [[column.id, backgroundColor]] : [];
+ })),
}));
return { columns, rows };
}
@@ -115,6 +126,7 @@ export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet {
rows: [...sheet.rows, {
id,
cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
+ backgrounds: {},
}],
};
}
@@ -152,6 +164,7 @@ export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraS
rows: sheet.rows.map((row) => ({
...row,
cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)),
+ backgrounds: Object.fromEntries(Object.entries(row.backgrounds).filter(([key]) => key !== columnId)),
})),
};
}
@@ -178,6 +191,28 @@ export function updateExtraSheetCell(
};
}
+export function updateExtraSheetCells(
+ sheet: ExtraSheet,
+ points: Iterable,
+ update: { value?: string; backgroundColor?: string | null },
+): ExtraSheet {
+ const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
+ return {
+ ...sheet,
+ rows: sheet.rows.map((row, rowIndex) => {
+ const cells = { ...row.cells };
+ const backgrounds = { ...row.backgrounds };
+ sheet.columns.forEach((column, columnIndex) => {
+ if (!selected.has(`${rowIndex}:${columnIndex}`)) return;
+ if (update.value !== undefined) cells[column.id] = update.value;
+ if (update.backgroundColor === null) delete backgrounds[column.id];
+ else if (update.backgroundColor !== undefined) backgrounds[column.id] = update.backgroundColor;
+ });
+ return { ...row, cells, backgrounds };
+ }),
+ };
+}
+
export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet {
return {
...sheet,