feat(chess): custom-layout editor + saved library (Phase E)
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.
This commit is contained in:
parent
4b6306fb57
commit
832ebe9cd6
5 changed files with 1295 additions and 0 deletions
180
packages/chess/e2e/layouts.spec.ts
Normal file
180
packages/chess/e2e/layouts.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
205
packages/chess/src/persist/layout-library.test.ts
Normal file
205
packages/chess/src/persist/layout-library.test.ts
Normal file
|
|
@ -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<string, string>();
|
||||
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> = {}): 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<string>();
|
||||
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"));
|
||||
180
packages/chess/src/persist/layout-library.ts
Normal file
180
packages/chess/src/persist/layout-library.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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 };
|
||||
714
packages/chess/src/ui/LayoutEditor.tsx
Normal file
714
packages/chess/src/ui/LayoutEditor.tsx
Normal file
|
|
@ -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<PiecePlacement[]>(
|
||||
() => (initialPieces ? [...initialPieces] : []),
|
||||
);
|
||||
const [brush, setBrush] = useState<Brush>({ type: 'none' });
|
||||
const [fenDraft, setFenDraft] = useState<string>(() =>
|
||||
toFen(initialPieces ?? []),
|
||||
);
|
||||
const [name, setName] = useState<string>('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 (
|
||||
<div
|
||||
data-testid="layout-editor"
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl max-h-[95vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-6 py-4 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold text-neutral-900">
|
||||
Custom Layout Editor
|
||||
</h2>
|
||||
<input
|
||||
data-testid="layout-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
data-action="library-toggle"
|
||||
onClick={() => setLibraryOpen((v) => !v)}
|
||||
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
|
||||
>
|
||||
{libraryOpen ? 'Hide Library' : 'Library'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close editor"
|
||||
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{libraryOpen ? (
|
||||
<LibraryDrawer
|
||||
// Force reload by changing key when library mutates.
|
||||
key={libraryVersion}
|
||||
onLoad={handleLoadFromLibrary}
|
||||
onDelete={handleDeleteFromLibrary}
|
||||
onStarToggle={handleStarToggle}
|
||||
onDuplicate={handleDuplicate}
|
||||
onClose={() => setLibraryOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PalettePanel brush={brush} onSelect={setBrush} onClear={clearBoard} />
|
||||
<BoardPanel
|
||||
placements={placements}
|
||||
brush={brush}
|
||||
onSquareClick={placeAt}
|
||||
/>
|
||||
<ActionsPanel
|
||||
fenDraft={fenDraft}
|
||||
liveFen={liveFen}
|
||||
onFenDraftChange={setFenDraft}
|
||||
onLoadFen={loadFen}
|
||||
onSyncFen={syncFenDraft}
|
||||
validation={validation}
|
||||
canApply={canApply}
|
||||
onApply={handleApply}
|
||||
onSaveToLibrary={handleSaveToLibrary}
|
||||
onCopyShareLink={handleCopyShareLink}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Subcomponents ───────────────────────────────────────────────────
|
||||
|
||||
function PalettePanel({
|
||||
brush,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: {
|
||||
brush: Brush;
|
||||
onSelect: (b: Brush) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className="w-52 border-r border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
|
||||
White Pieces
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PIECE_PALETTE.map(({ type }) => (
|
||||
<PaletteButton
|
||||
key={`white-${type}`}
|
||||
type={type}
|
||||
color="white"
|
||||
selected={
|
||||
brush.type === 'place' &&
|
||||
brush.piece.type === type &&
|
||||
brush.piece.color === 'white'
|
||||
}
|
||||
onSelect={() =>
|
||||
onSelect({ type: 'place', piece: { type, color: 'white' } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
|
||||
Black Pieces
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PIECE_PALETTE.map(({ type }) => (
|
||||
<PaletteButton
|
||||
key={`black-${type}`}
|
||||
type={type}
|
||||
color="black"
|
||||
selected={
|
||||
brush.type === 'place' &&
|
||||
brush.piece.type === type &&
|
||||
brush.piece.color === 'black'
|
||||
}
|
||||
onSelect={() =>
|
||||
onSelect({ type: 'place', piece: { type, color: 'black' } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-neutral-200 pt-4 space-y-2">
|
||||
<button
|
||||
data-action="brush-erase"
|
||||
onClick={() => onSelect({ type: 'erase' })}
|
||||
className={`w-full px-3 py-2 text-sm font-semibold rounded transition-colors ${
|
||||
brush.type === 'erase'
|
||||
? 'bg-red-100 text-red-700 border border-red-300'
|
||||
: 'bg-white text-neutral-700 border border-neutral-200 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
Erase
|
||||
</button>
|
||||
<button
|
||||
data-action="brush-clear"
|
||||
onClick={onClear}
|
||||
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-200 rounded hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
Clear Board
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteButton({
|
||||
type,
|
||||
color,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
type: PieceType;
|
||||
color: PieceColor;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
data-testid={`palette-${color}-${type}`}
|
||||
data-selected={selected}
|
||||
onClick={onSelect}
|
||||
className={`aspect-square flex items-center justify-center rounded transition-all ${
|
||||
selected
|
||||
? 'bg-blue-100 border-2 border-blue-500 shadow-inner'
|
||||
: 'bg-white border border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={pieceAssets[color][type]}
|
||||
alt={`${color} ${type}`}
|
||||
className="w-10 h-10 drop-shadow-sm"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardPanel({
|
||||
placements,
|
||||
brush,
|
||||
onSquareClick,
|
||||
}: {
|
||||
placements: PiecePlacement[];
|
||||
brush: Brush;
|
||||
onSquareClick: (square: number) => void;
|
||||
}) {
|
||||
const placementBySquare = useMemo(() => {
|
||||
const map = new Map<number, PiecePlacement>();
|
||||
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(
|
||||
<button
|
||||
key={sq}
|
||||
data-testid={`editor-square-${String(sq)}`}
|
||||
data-square={squareToAlgebraic(sq)}
|
||||
onClick={() => onSquareClick(sq)}
|
||||
disabled={brush.type === 'none'}
|
||||
className={`aspect-square flex items-center justify-center transition-colors ${
|
||||
isDark ? 'bg-neutral-400' : 'bg-neutral-100'
|
||||
} ${
|
||||
brush.type !== 'none'
|
||||
? 'hover:brightness-110 cursor-pointer'
|
||||
: 'cursor-default'
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
{piece !== undefined && (
|
||||
<img
|
||||
src={pieceAssets[piece.color][piece.type]}
|
||||
alt={`${piece.color} ${piece.type}`}
|
||||
className="w-full h-full object-contain p-1"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
rows.push(
|
||||
<div key={rank} className="grid grid-cols-8">
|
||||
{cells}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 flex items-center justify-center p-6 bg-white">
|
||||
<div
|
||||
data-testid="editor-board"
|
||||
className="w-full max-w-lg aspect-square border-2 border-neutral-800 shadow-xl"
|
||||
>
|
||||
{rows}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<aside className="w-80 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
|
||||
<section>
|
||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
|
||||
FEN
|
||||
</h3>
|
||||
<textarea
|
||||
data-testid="editor-fen"
|
||||
value={fenDraft}
|
||||
onChange={(e) => onFenDraftChange(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-xs font-mono border border-neutral-300 rounded resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Paste FEN here…"
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
data-action="load-fen"
|
||||
onClick={onLoadFen}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
data-action="sync-fen"
|
||||
onClick={onSyncFen}
|
||||
title="Reset FEN field to current board"
|
||||
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100"
|
||||
>
|
||||
Reset to board
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-neutral-400 font-mono break-all">
|
||||
Current: {liveFen}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ValidationPanel validation={validation} />
|
||||
|
||||
<section className="space-y-2">
|
||||
<button
|
||||
data-action="save-to-library"
|
||||
onClick={onSaveToLibrary}
|
||||
disabled={!canApply}
|
||||
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save to Library
|
||||
</button>
|
||||
<button
|
||||
data-action="copy-share-link"
|
||||
onClick={onCopyShareLink}
|
||||
disabled={!canApply}
|
||||
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Copy Share Link
|
||||
</button>
|
||||
<button
|
||||
data-action="apply-layout"
|
||||
onClick={onApply}
|
||||
disabled={!canApply}
|
||||
className="w-full px-3 py-2.5 text-sm font-bold text-white bg-neutral-900 rounded hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed shadow"
|
||||
>
|
||||
Use This Layout
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationPanel({
|
||||
validation,
|
||||
}: {
|
||||
validation: { errors: readonly string[]; warnings: readonly string[] };
|
||||
}) {
|
||||
const { errors, warnings } = validation;
|
||||
if (errors.length === 0 && warnings.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-testid="validation-ok"
|
||||
className="px-3 py-2 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded"
|
||||
>
|
||||
✓ Layout valid
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{errors.length > 0 && (
|
||||
<ul
|
||||
data-testid="validation-errors"
|
||||
className="px-3 py-2 text-xs text-red-800 bg-red-50 border border-red-200 rounded space-y-1"
|
||||
>
|
||||
{errors.map((e, i) => (
|
||||
<li key={i}>⚠ {e}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{warnings.length > 0 && (
|
||||
<ul
|
||||
data-testid="validation-warnings"
|
||||
className="px-3 py-2 text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded space-y-1"
|
||||
>
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>ℹ {w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryDrawer({
|
||||
onLoad,
|
||||
onDelete,
|
||||
onStarToggle,
|
||||
onDuplicate,
|
||||
onClose,
|
||||
}: {
|
||||
onLoad: (entry: SavedLayout) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onStarToggle: (id: string, starred: boolean) => void;
|
||||
onDuplicate: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const entries = loadLibrary();
|
||||
// Sort: starred first (both groups by most-recent-first).
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
if (a.starred !== b.starred) return a.starred ? -1 : 1;
|
||||
return b.updatedAt - a.updatedAt;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-6 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-neutral-900">Saved Layouts</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-sm text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
← Back to editor
|
||||
</button>
|
||||
</div>
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
No saved layouts yet. Use the "Save to Library" button to stash
|
||||
your current board for later.
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="library-list" className="space-y-2">
|
||||
{sorted.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
data-testid={`library-entry-${entry.id}`}
|
||||
className="flex items-center gap-3 p-3 bg-white border border-neutral-200 rounded hover:border-neutral-300"
|
||||
>
|
||||
<button
|
||||
onClick={() => onStarToggle(entry.id, !entry.starred)}
|
||||
aria-label={entry.starred ? 'Unstar' : 'Star'}
|
||||
className={
|
||||
entry.starred ? 'text-amber-500' : 'text-neutral-300'
|
||||
}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-neutral-900 truncate">
|
||||
{entry.name}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{entry.pieces.length} pieces •{' '}
|
||||
{new Date(entry.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onLoad(entry)}
|
||||
className="px-2 py-1 text-xs font-semibold text-blue-700 hover:bg-blue-50 rounded"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDuplicate(entry.id)}
|
||||
className="px-2 py-1 text-xs font-semibold text-neutral-700 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(entry.id)}
|
||||
aria-label="Delete"
|
||||
className="px-2 py-1 text-xs font-semibold text-red-700 hover:bg-red-50 rounded"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@paratype/chess';
|
||||
import type { LayoutRequest } from '../net/types';
|
||||
import { LayoutPicker } from './LayoutPicker';
|
||||
import { LayoutEditor } from './LayoutEditor';
|
||||
|
||||
interface LobbyProps {
|
||||
/** Optional — when provided, create/join/solo flows reset the local
|
||||
|
|
@ -36,6 +37,7 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
// link pre-selects the right layout.
|
||||
const [selectedLayout, setSelectedLayout] =
|
||||
useState<StartingLayout>(CLASSIC_LAYOUT);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const layoutId = searchParams.get('layoutId');
|
||||
|
|
@ -205,6 +207,7 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
<LayoutPicker
|
||||
value={selectedLayout}
|
||||
onChange={setSelectedLayout}
|
||||
onCustomRequested={() => setEditorOpen(true)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
|
|
@ -268,6 +271,19 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{editorOpen && (
|
||||
<LayoutEditor
|
||||
initialPieces={
|
||||
selectedLayout.source === 'custom' ? selectedLayout.pieces : []
|
||||
}
|
||||
onApply={(layout) => {
|
||||
setSelectedLayout(layout);
|
||||
setEditorOpen(false);
|
||||
}}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue