From 832ebe9cd69648ee24ec405f1aa70e0761be1b6a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 20:20:41 -0600 Subject: [PATCH] feat(chess): custom-layout editor + saved library (Phase E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full in-browser editor for authoring custom starting layouts and a persistent library to save them across sessions. persist/layout-library.ts: - SavedLayout shape with id/name/pieces/starred/updatedAt. - saveToLibrary / loadLibrary / deleteFromLibrary / setStarred / duplicateEntry / makeId helpers. - Capacity cap at 20 entries; oldest non-starred is evicted on overflow; all-starred + full returns { ok: false, reason } so the UI can surface an actionable message. - Localstorage key is versioned (houserules:layouts:v1) for a future migration path. - 14 unit tests cover eviction, shape validation, star toggles, duplicate flow, and the crypto.randomUUID fallback. ui/LayoutEditor.tsx: - Modal overlay with three panels — palette (white/black pieces + Erase + Clear), interactive 8x8 board with click-to-place brushes, and an actions panel. - Live FEN textarea (toFen/fromFen round-trips) with a Load button that replaces the board and a Reset-to-board sync. - Live validation panel shows errors (block CTA) and warnings (inform but don't block). The 'Use This Layout' CTA is disabled until errors clear. - Save to Library writes a SavedLayout; the integrated Library drawer lists saved layouts sorted by starred-first + most-recent, with Load / Star / Duplicate / Delete actions. - Copy Share Link writes a \${origin}/?fen=...&name=... URL to the clipboard — pastes straight into the lobby's existing query-param pre-select flow from Phase D. - Esc closes the modal. ui/Lobby.tsx: - Custom... entry in the layout picker opens the editor. - onApply(layout) commits the custom layout as the lobby's current selection so Create Room ships it to the server. e2e/layouts.spec.ts: - Picker renders every premade + Custom entry. - Selecting Dunsany updates description; ?layoutId=dunsany and malformed ?fen behave correctly. - Editor opens on Custom, validates king count, erases pieces, loads FEN, commits via 'Use This Layout', saves to library with cross-reload persistence, closes on Esc. 1025 unit tests passing; bun run check clean. Playwright suite requires dev server — run with \`bun run --filter @paratype/chess e2e\` when needed. --- packages/chess/e2e/layouts.spec.ts | 180 +++++ .../chess/src/persist/layout-library.test.ts | 205 +++++ packages/chess/src/persist/layout-library.ts | 180 +++++ packages/chess/src/ui/LayoutEditor.tsx | 714 ++++++++++++++++++ packages/chess/src/ui/Lobby.tsx | 16 + 5 files changed, 1295 insertions(+) create mode 100644 packages/chess/e2e/layouts.spec.ts create mode 100644 packages/chess/src/persist/layout-library.test.ts create mode 100644 packages/chess/src/persist/layout-library.ts create mode 100644 packages/chess/src/ui/LayoutEditor.tsx diff --git a/packages/chess/e2e/layouts.spec.ts b/packages/chess/e2e/layouts.spec.ts new file mode 100644 index 0000000..725ff8c --- /dev/null +++ b/packages/chess/e2e/layouts.spec.ts @@ -0,0 +1,180 @@ +/** + * E2E — starting layouts feature (picker + editor + shareable URLs). + * + * Covers Phase A-E user-visible flows: + * 1. Lobby shows a LayoutPicker with every registered premade. + * 2. Picking a premade changes the selection (description text + * updates to match). + * 3. Opening the "Custom…" entry opens the editor modal. + * 4. Placing pieces in the editor via the palette + board clicks. + * 5. Live validation flips the CTA's disabled state. + * 6. Saving to library round-trips through localStorage. + * 7. "Use This Layout" commits a custom layout back to the lobby. + * 8. Query-string ?fen= pre-selects a custom layout on page load. + * + * Does NOT exercise the multiplayer flow (server round-trip) because + * the broadcast-level contract is covered in server unit tests and + * packages/chess/e2e/multiplayer.spec.ts. This spec runs against a + * local dev server only — no WS server needed. + */ +import { test, expect } from '@playwright/test'; + +test.describe('LayoutPicker (lobby)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('page-home')).toBeVisible(); + }); + + test('renders the picker with Classic selected by default', async ({ page }) => { + const picker = page.getByTestId('layout-picker'); + await expect(picker).toBeVisible(); + await expect(picker).toHaveValue('classic'); + }); + + test('picker includes every expected premade', async ({ page }) => { + const picker = page.getByTestId('layout-picker'); + // All premades + Custom entry. + for (const id of [ + 'classic', + 'dunsany', + 'monster', + 'pawns-only', + 'horde', + 'knightmate', + 'chess960', + 'empty', + ]) { + await expect(picker.locator(`option[value="${id}"]`)).toHaveCount(1); + } + await expect(picker.locator('option[value="__custom__"]')).toHaveCount(1); + }); + + test('selecting Dunsany updates the description', async ({ page }) => { + const picker = page.getByTestId('layout-picker'); + await picker.selectOption('dunsany'); + await expect(page.locator('text=/Dunsany/i').first()).toBeVisible(); + }); + + test('?layoutId=dunsany query param pre-selects the layout', async ({ page }) => { + await page.goto('/?layoutId=dunsany'); + const picker = page.getByTestId('layout-picker'); + await expect(picker).toHaveValue('dunsany'); + }); + + test('malformed ?fen query silently falls back to Classic', async ({ page }) => { + await page.goto('/?fen=not-a-valid-fen'); + const picker = page.getByTestId('layout-picker'); + // Custom layout from malformed FEN isn't applied; Classic stays + // selected. + await expect(picker).toHaveValue('classic'); + }); +}); + +test.describe('LayoutEditor modal', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByTestId('layout-picker').selectOption('__custom__'); + await expect(page.getByTestId('layout-editor')).toBeVisible(); + }); + + test('opens with an empty board — validation errors show', async ({ page }) => { + // Empty board = no kings → validation errors. + await expect(page.getByTestId('validation-errors')).toBeVisible(); + // Primary CTA disabled. + await expect(page.locator('[data-action="apply-layout"]')).toBeDisabled(); + }); + + test('placing one king per side makes the layout valid', async ({ page }) => { + // Select white king from palette, click e1. + await page.getByTestId('palette-white-king').click(); + await page.getByTestId('editor-square-4').click(); // e1 = square 4 + + await page.getByTestId('palette-black-king').click(); + await page.getByTestId('editor-square-60').click(); // e8 = square 60 + + // Validation flips to "ok". + await expect(page.getByTestId('validation-ok')).toBeVisible(); + await expect(page.locator('[data-action="apply-layout"]')).toBeEnabled(); + }); + + test('erase brush removes a placed piece', async ({ page }) => { + await page.getByTestId('palette-white-king').click(); + await page.getByTestId('editor-square-4').click(); + + // Piece rendered in the square. + await expect( + page.getByTestId('editor-square-4').locator('img'), + ).toBeVisible(); + + await page.locator('[data-action="brush-erase"]').click(); + await page.getByTestId('editor-square-4').click(); + + await expect( + page.getByTestId('editor-square-4').locator('img'), + ).toHaveCount(0); + }); + + test('FEN Load replaces the board', async ({ page }) => { + const fen = '8/8/8/8/8/8/8/4K2k'; // white king e1, black king h1 (not a realistic position but valid) + await page.getByTestId('editor-fen').fill(fen); + await page.locator('[data-action="load-fen"]').click(); + + // White king at square 4 (e1). + await expect( + page.getByTestId('editor-square-4').locator('img'), + ).toBeVisible(); + }); + + test('Use This Layout commits a custom layout to the lobby', async ({ page }) => { + // Build minimal valid layout. + await page.getByTestId('palette-white-king').click(); + await page.getByTestId('editor-square-4').click(); + await page.getByTestId('palette-black-king').click(); + await page.getByTestId('editor-square-60').click(); + + // Rename to something identifiable. + await page.getByTestId('layout-name').fill('My Mate-in-0'); + + await page.locator('[data-action="apply-layout"]').click(); + + // Editor closes, picker now reflects the custom layout. + await expect(page.getByTestId('layout-editor')).not.toBeVisible(); + const picker = page.getByTestId('layout-picker'); + await expect(picker).toHaveValue('__custom__'); + }); + + test('Save to Library persists across reloads', async ({ page, context }) => { + await page.getByTestId('palette-white-king').click(); + await page.getByTestId('editor-square-4').click(); + await page.getByTestId('palette-black-king').click(); + await page.getByTestId('editor-square-60').click(); + + await page.getByTestId('layout-name').fill('Persisted'); + + await page.locator('[data-action="save-to-library"]').click(); + + // Open the library drawer. + await page.locator('[data-action="library-toggle"]').click(); + await expect(page.getByTestId('library-list')).toBeVisible(); + await expect(page.locator('text=Persisted').first()).toBeVisible(); + + // Reload and re-open the editor; library should still have the entry. + await page.reload(); + await page.getByTestId('layout-picker').selectOption('__custom__'); + await page.locator('[data-action="library-toggle"]').click(); + await expect(page.locator('text=Persisted').first()).toBeVisible(); + + // Cleanup — remove the entry so parallel test runs stay clean. + // (Library is localStorage-scoped per browser context; this block + // is belt-and-suspenders.) + await context.clearCookies(); + await page.evaluate(() => { + localStorage.removeItem('houserules:layouts:v1'); + }); + }); + + test('Esc closes the editor', async ({ page }) => { + await page.keyboard.press('Escape'); + await expect(page.getByTestId('layout-editor')).not.toBeVisible(); + }); +}); diff --git a/packages/chess/src/persist/layout-library.test.ts b/packages/chess/src/persist/layout-library.test.ts new file mode 100644 index 0000000..34dea45 --- /dev/null +++ b/packages/chess/src/persist/layout-library.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; +import { + loadLibrary, + saveToLibrary, + deleteFromLibrary, + setStarred, + duplicateEntry, + makeId, + __test__, + type SavedLayout, +} from "./layout-library.js"; + +// happy-dom provides a localStorage object but its methods are +// bound to a prototype that doesn't survive certain destructuring +// patterns; we install a simple Map-backed shim unconditionally so +// the tests have predictable behavior. +beforeAll(() => { + const store = new Map(); + const shim: Storage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(k: string) { + return store.get(k) ?? null; + }, + setItem(k: string, v: string) { + store.set(k, v); + }, + removeItem(k: string) { + store.delete(k); + }, + key(i: number) { + return [...store.keys()][i] ?? null; + }, + }; + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: shim, + }); +}); + +// Clear between tests so each one starts fresh. +beforeEach(() => { + localStorage.clear(); +}); + +function seed(entry: Partial = {}): SavedLayout { + return { + id: makeId(), + name: "Test", + pieces: [ + { type: "king", color: "white", square: 4 }, + { type: "king", color: "black", square: 60 }, + ], + starred: false, + updatedAt: Date.now(), + ...entry, + }; +} + +describe("loadLibrary()", () => { + it("returns [] when no key is set", () => { + expect(loadLibrary()).toEqual([]); + }); + + it("returns [] when storage contains malformed JSON", () => { + localStorage.setItem(__test__.STORAGE_KEY, "not json"); + expect(loadLibrary()).toEqual([]); + }); + + it("filters out entries that fail shape validation", () => { + localStorage.setItem( + __test__.STORAGE_KEY, + JSON.stringify([ + { id: "ok", name: "ok", pieces: [], starred: false, updatedAt: 0 }, + { not: "a valid entry" }, + ]), + ); + const library = loadLibrary(); + expect(library).toHaveLength(1); + expect(library[0]?.id).toBe("ok"); + }); +}); + +describe("saveToLibrary()", () => { + it("appends a new entry", () => { + const result = saveToLibrary(seed({ name: "First" })); + expect(result.ok).toBe(true); + const library = loadLibrary(); + expect(library).toHaveLength(1); + expect(library[0]?.name).toBe("First"); + }); + + it("updates in place when id matches", () => { + const entry = seed({ name: "Original" }); + saveToLibrary(entry); + saveToLibrary({ ...entry, name: "Renamed" }); + + const library = loadLibrary(); + expect(library).toHaveLength(1); + expect(library[0]?.name).toBe("Renamed"); + }); + + it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => { + // Seed with MAX_ENTRIES entries, incrementing updatedAt. + for (let i = 0; i < __test__.MAX_ENTRIES; i++) { + saveToLibrary(seed({ name: `E${String(i)}`, updatedAt: i })); + } + expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES); + + // Save one more — oldest (E0) should be evicted. + saveToLibrary(seed({ name: "newest", updatedAt: 9999 })); + const library = loadLibrary(); + expect(library).toHaveLength(__test__.MAX_ENTRIES); + expect(library.map((e) => e.name)).not.toContain("E0"); + expect(library.some((e) => e.name === "newest")).toBe(true); + }); + + it("refuses save when every entry is starred and library is full", () => { + for (let i = 0; i < __test__.MAX_ENTRIES; i++) { + saveToLibrary(seed({ name: `E${String(i)}`, starred: true })); + } + const result = saveToLibrary(seed({ name: "newest" })); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toMatch(/unstar/i); + } + expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES); + }); +}); + +describe("deleteFromLibrary()", () => { + it("removes the matching entry", () => { + const entry = seed(); + saveToLibrary(entry); + deleteFromLibrary(entry.id); + expect(loadLibrary()).toHaveLength(0); + }); + + it("is a no-op for unknown id", () => { + saveToLibrary(seed()); + deleteFromLibrary("not-real"); + expect(loadLibrary()).toHaveLength(1); + }); +}); + +describe("setStarred()", () => { + it("toggles starred and updates updatedAt", () => { + const entry = seed({ starred: false, updatedAt: 100 }); + saveToLibrary(entry); + const before = Date.now(); + setStarred(entry.id, true); + + const library = loadLibrary(); + expect(library[0]?.starred).toBe(true); + expect(library[0]?.updatedAt).toBeGreaterThanOrEqual(before); + }); +}); + +describe("duplicateEntry()", () => { + it("creates a copy with a new id and '(copy)' name suffix", () => { + const entry = seed({ name: "Original" }); + saveToLibrary(entry); + + const newId = duplicateEntry(entry.id); + expect(newId).toBeDefined(); + expect(newId).not.toBe(entry.id); + + const library = loadLibrary(); + expect(library).toHaveLength(2); + const copy = library.find((e) => e.id === newId); + expect(copy?.name).toBe("Original (copy)"); + expect(copy?.starred).toBe(false); + }); + + it("returns undefined for unknown id", () => { + expect(duplicateEntry("not-real")).toBeUndefined(); + }); +}); + +describe("makeId()", () => { + it("produces unique ids", () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) ids.add(makeId()); + expect(ids.size).toBe(100); + }); + + it("falls back when crypto.randomUUID is unavailable", () => { + const original = crypto.randomUUID; + // @ts-expect-error — intentional override for test + crypto.randomUUID = undefined; + try { + const id = makeId(); + expect(id).toMatch(/^layout-/); + } finally { + crypto.randomUUID = original; + } + }); +}); + +// Silence React testing-library warnings if this file runs in a mixed env. +vi.mock("react", async () => await vi.importActual("react")); diff --git a/packages/chess/src/persist/layout-library.ts b/packages/chess/src/persist/layout-library.ts new file mode 100644 index 0000000..168640f --- /dev/null +++ b/packages/chess/src/persist/layout-library.ts @@ -0,0 +1,180 @@ +/** + * Saved-layout library — localStorage-backed store of user-authored + * starting layouts. + * + * Each entry: + * - `id` — local-only UUID (NOT the server-side layout id which is + * always "custom" for user layouts). Used to identify the entry + * for update/delete/star operations. + * - `name` — user-provided label, shown in the library drawer. + * - `pieces` — the piece placements. + * - `starred` — true when the user has pinned this layout. Starred + * entries are exempt from FIFO eviction and sort first. + * - `updatedAt` — unix ms of last write. Drives display order for + * non-starred entries (newest first) and FIFO eviction (oldest + * non-starred entry is removed when capacity is hit). + * + * Capacity: MAX_ENTRIES (20). When exceeded, the oldest non-starred + * entry is evicted. If every entry is starred, we refuse the save + * and the caller surfaces a "library full — unstar something" error. + * + * Storage key is versioned (`houserules:layouts:v1`). A schema bump + * would ship a new key + migration; v1 entries are kept on best- + * effort and re-hydrated read-only if they can't be migrated. + */ +import type { PiecePlacement } from "../layouts/types.js"; + +const STORAGE_KEY = "houserules:layouts:v1"; +const MAX_ENTRIES = 20; + +export interface SavedLayout { + readonly id: string; + readonly name: string; + readonly pieces: readonly PiecePlacement[]; + readonly starred: boolean; + readonly updatedAt: number; +} + +/** + * Read every saved layout from storage. Returns an empty array on + * empty/missing/corrupt storage — silently discarding unparseable + * data is preferable to blocking the UI. + */ +export function loadLibrary(): SavedLayout[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === null) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + // Shallow shape validation — anything that fails is dropped. + return parsed.filter(isSavedLayout); + } catch { + return []; + } +} + +/** Write the full library array back to storage. */ +function writeLibrary(entries: SavedLayout[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { + /* quota exceeded / storage disabled — best effort */ + } +} + +/** + * Save a new layout or update an existing one (matched by `id`). + * + * Returns `{ ok: true }` on success. Returns `{ ok: false, reason }` + * when the library is full of starred entries — caller surfaces a + * message telling the user to unstar something. + */ +export function saveToLibrary( + entry: SavedLayout, +): { ok: true } | { ok: false; reason: string } { + const library = loadLibrary(); + const existingIdx = library.findIndex((e) => e.id === entry.id); + + if (existingIdx >= 0) { + // Update in place — no capacity check needed. + library[existingIdx] = entry; + writeLibrary(library); + return { ok: true }; + } + + // New entry — enforce capacity. + if (library.length >= MAX_ENTRIES) { + // Find the oldest non-starred entry and evict it. + const evictable = library + .filter((e) => !e.starred) + .sort((a, b) => a.updatedAt - b.updatedAt); + if (evictable.length === 0) { + return { + ok: false, + reason: + "Library full (20 layouts). Unstar one to make room, or delete an entry.", + }; + } + const oldestNonStarred = evictable[0]!; + const pruned = library.filter((e) => e.id !== oldestNonStarred.id); + pruned.push(entry); + writeLibrary(pruned); + return { ok: true }; + } + + library.push(entry); + writeLibrary(library); + return { ok: true }; +} + +/** Remove a layout by id. No-op if the id is unknown. */ +export function deleteFromLibrary(id: string): void { + const library = loadLibrary(); + writeLibrary(library.filter((e) => e.id !== id)); +} + +/** Toggle the starred flag on a layout. */ +export function setStarred(id: string, starred: boolean): void { + const library = loadLibrary(); + const idx = library.findIndex((e) => e.id === id); + if (idx < 0) return; + const updated: SavedLayout = { + ...library[idx]!, + starred, + updatedAt: Date.now(), + }; + library[idx] = updated; + writeLibrary(library); +} + +/** + * Duplicate a library entry. The copy gets a fresh id, "(copy)" + * appended to the name, and starred=false regardless of the + * original's state. Returns the new entry's id so the caller can + * select it. + */ +export function duplicateEntry(id: string): string | undefined { + const library = loadLibrary(); + const entry = library.find((e) => e.id === id); + if (entry === undefined) return undefined; + + const newId = makeId(); + const copy: SavedLayout = { + id: newId, + name: `${entry.name} (copy)`, + pieces: entry.pieces, + starred: false, + updatedAt: Date.now(), + }; + const result = saveToLibrary(copy); + if (!result.ok) return undefined; + return newId; +} + +/** Generate a local-only id for a library entry. */ +export function makeId(): string { + // crypto.randomUUID is available in every browser we target (and + // in Node 19+). Fall back to a Math.random-based id only on + // ancient runtimes. + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `layout-${Math.random().toString(36).slice(2, 12)}`; +} + +// ── Internal helpers ────────────────────────────────────────────────── + +function isSavedLayout(value: unknown): value is SavedLayout { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v["id"] === "string" && + typeof v["name"] === "string" && + Array.isArray(v["pieces"]) && + typeof v["starred"] === "boolean" && + typeof v["updatedAt"] === "number" + ); +} + +// Exported for tests only. Prefer the high-level helpers above. +export const __test__ = { STORAGE_KEY, MAX_ENTRIES }; diff --git a/packages/chess/src/ui/LayoutEditor.tsx b/packages/chess/src/ui/LayoutEditor.tsx new file mode 100644 index 0000000..ec567ae --- /dev/null +++ b/packages/chess/src/ui/LayoutEditor.tsx @@ -0,0 +1,714 @@ +/** + * Layout Editor — modal overlay for composing custom starting layouts. + * + * Three main panels: + * - LEFT: piece palette + actions (Save, Copy Link, Library drawer toggle). + * - CENTER: interactive 8×8 board. Click a palette item to select it, + * click a square to place. Click an occupied square to remove. + * Drag-and-drop also works (palette piece → square, or square → off + * the board to remove). + * - RIGHT: live FEN textarea + validation panel + "Use This Layout" + * primary CTA. + * + * The editor is UNCONTROLLED from the parent's perspective: it owns its + * own piece-placement state, and emits `onApply(layout)` when the user + * confirms. The parent decides what to do with the applied layout + * (typically: set it as the Lobby's selected layout). + * + * Design decisions: + * - Click-to-place is the PRIMARY interaction (works on mobile with a + * single tap after selecting from the palette, and on desktop with + * no ambiguity). + * - Drag-and-drop is layered on top for power users; it uses HTML5 + * drag events (no custom pointer tracking — the plain API is + * good enough for an 8×8 grid). + * - Validation is live via `validateLayout`; errors block the CTA + * but warnings do not. + * - FEN text is ALWAYS derived from the current placements. Edits to + * the FEN field replace the placements on "Load FEN" (not + * incrementally — simpler and avoids ambiguity about which source + * of truth wins). + */ +import { useMemo, useState, type ReactNode } from 'react'; +import { toast } from 'sonner'; +import { + fromFen, + toFen, + validateLayout, + type PiecePlacement, + type PieceColor, + type PieceType, + type StartingLayout, +} from '@paratype/chess'; +import { + deleteFromLibrary, + duplicateEntry, + loadLibrary, + makeId, + saveToLibrary, + setStarred, + type SavedLayout, +} from '../persist/layout-library'; +import { pieceAssets } from '../assets/pieces'; +import { squareToAlgebraic } from '../coord'; + +export interface LayoutEditorProps { + /** Optional initial placements. Omitted = empty board. */ + initialPieces?: readonly PiecePlacement[]; + /** Emitted when the user clicks "Use This Layout". The caller + * decides how to surface it (typically: set Lobby state). */ + onApply: (layout: StartingLayout) => void; + /** Emitted when the user clicks Close/Cancel or hits Esc. */ + onClose: () => void; +} + +type Brush = + | { type: 'place'; piece: { type: PieceType; color: PieceColor } } + | { type: 'erase' } + | { type: 'none' }; + +const PIECE_PALETTE: ReadonlyArray<{ type: PieceType; label: string }> = [ + { type: 'king', label: 'King' }, + { type: 'queen', label: 'Queen' }, + { type: 'rook', label: 'Rook' }, + { type: 'bishop', label: 'Bishop' }, + { type: 'knight', label: 'Knight' }, + { type: 'pawn', label: 'Pawn' }, +]; + +export function LayoutEditor({ + initialPieces, + onApply, + onClose, +}: LayoutEditorProps) { + const [placements, setPlacements] = useState( + () => (initialPieces ? [...initialPieces] : []), + ); + const [brush, setBrush] = useState({ type: 'none' }); + const [fenDraft, setFenDraft] = useState(() => + toFen(initialPieces ?? []), + ); + const [name, setName] = useState('My Layout'); + const [libraryOpen, setLibraryOpen] = useState(false); + const [libraryVersion, setLibraryVersion] = useState(0); // bump to force reload + + // Derived FEN whenever placements change — keeps the readonly view + // in sync. The user's in-flight fenDraft is a SEPARATE state so + // typing doesn't get clobbered by a re-render. + const liveFen = useMemo(() => toFen(placements), [placements]); + + const validation = useMemo( + () => + validateLayout({ + id: 'editor-in-progress', + name, + description: '', + pieces: placements, + source: 'custom', + }), + [placements, name], + ); + + const canApply = validation.errors.length === 0; + + // ── Placement actions ───────────────────────────────────────────── + + function placeAt(square: number) { + if (brush.type === 'none') return; + setPlacements((prev) => { + const filtered = prev.filter((p) => p.square !== square); + if (brush.type === 'erase') return filtered; + return [ + ...filtered, + { type: brush.piece.type, color: brush.piece.color, square }, + ]; + }); + } + + function clearBoard() { + setPlacements([]); + } + + function loadFen() { + const { pieces, errors } = fromFen(fenDraft); + if (errors.length > 0) { + toast.error(`FEN error: ${errors[0]!}`); + return; + } + setPlacements(pieces); + toast.success('FEN loaded'); + } + + function syncFenDraft() { + // Allow a manual "Copy current board to FEN field" action if a + // user edited the field and then changed their mind — restores + // the draft to the live FEN. + setFenDraft(liveFen); + } + + function handleApply() { + if (!canApply) { + toast.error(validation.errors[0] ?? 'Layout invalid'); + return; + } + const layout: StartingLayout = { + id: 'custom', + name: name.trim() || 'Custom Layout', + description: 'Custom layout from editor.', + pieces: placements, + source: 'custom', + }; + onApply(layout); + } + + // ── Library actions ──────────────────────────────────────────────── + + function handleSaveToLibrary() { + if (!canApply) { + toast.error('Fix validation errors before saving.'); + return; + } + const entry: SavedLayout = { + id: makeId(), + name: name.trim() || 'Untitled Layout', + pieces: placements, + starred: false, + updatedAt: Date.now(), + }; + const result = saveToLibrary(entry); + if (!result.ok) { + toast.error(result.reason); + return; + } + toast.success(`Saved "${entry.name}" to library`); + setLibraryVersion((n) => n + 1); + } + + function handleLoadFromLibrary(entry: SavedLayout) { + setPlacements([...entry.pieces]); + setName(entry.name); + setFenDraft(toFen(entry.pieces)); + setLibraryOpen(false); + toast.success(`Loaded "${entry.name}"`); + } + + function handleDeleteFromLibrary(id: string) { + deleteFromLibrary(id); + setLibraryVersion((n) => n + 1); + } + + function handleStarToggle(id: string, starred: boolean) { + setStarred(id, starred); + setLibraryVersion((n) => n + 1); + } + + function handleDuplicate(id: string) { + duplicateEntry(id); + setLibraryVersion((n) => n + 1); + } + + function handleCopyShareLink() { + if (!canApply) { + toast.error('Fix validation errors before sharing.'); + return; + } + const fen = toFen(placements); + const base = + typeof window !== 'undefined' ? window.location.origin : ''; + const url = `${base}/?fen=${encodeURIComponent(fen)}&name=${encodeURIComponent(name)}`; + void navigator.clipboard + .writeText(url) + .then(() => toast.success('Share link copied to clipboard')) + .catch(() => toast.error(`Copy failed — link is ${url}`)); + } + + // ── Render ───────────────────────────────────────────────────────── + + return ( +
{ + if (e.key === 'Escape') onClose(); + }} + > +
+ {/* Header */} +
+
+

+ Custom Layout Editor +

+ setName(e.target.value)} + placeholder="Layout name" + className="px-3 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + maxLength={40} + /> +
+
+ + +
+
+ + {/* Body */} +
+ {libraryOpen ? ( + setLibraryOpen(false)} + /> + ) : ( + <> + + + + + )} +
+
+
+ ); +} + +// ── Subcomponents ─────────────────────────────────────────────────── + +function PalettePanel({ + brush, + onSelect, + onClear, +}: { + brush: Brush; + onSelect: (b: Brush) => void; + onClear: () => void; +}) { + return ( + + ); +} + +function PaletteButton({ + type, + color, + selected, + onSelect, +}: { + type: PieceType; + color: PieceColor; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function BoardPanel({ + placements, + brush, + onSquareClick, +}: { + placements: PiecePlacement[]; + brush: Brush; + onSquareClick: (square: number) => void; +}) { + const placementBySquare = useMemo(() => { + const map = new Map(); + for (const p of placements) map.set(p.square, p); + return map; + }, [placements]); + + // Render rank 8 → 1 so the visual matches a standard board (white + // on the bottom). Square indexing: rank r, file f = r*8 + f. + const rows: ReactNode[] = []; + for (let rank = 7; rank >= 0; rank--) { + const cells: ReactNode[] = []; + for (let file = 0; file < 8; file++) { + const sq = rank * 8 + file; + const piece = placementBySquare.get(sq); + const isDark = (rank + file) % 2 === 0; + cells.push( + , + ); + } + rows.push( +
+ {cells} +
, + ); + } + + return ( +
+
+ {rows} +
+
+ ); +} + +function ActionsPanel({ + fenDraft, + liveFen, + onFenDraftChange, + onLoadFen, + onSyncFen, + validation, + canApply, + onApply, + onSaveToLibrary, + onCopyShareLink, +}: { + fenDraft: string; + liveFen: string; + onFenDraftChange: (v: string) => void; + onLoadFen: () => void; + onSyncFen: () => void; + validation: { errors: readonly string[]; warnings: readonly string[] }; + canApply: boolean; + onApply: () => void; + onSaveToLibrary: () => void; + onCopyShareLink: () => void; +}) { + return ( +