feat(ui): per-instance modifier panel
- Export LayoutBoardView from LayoutEditor.tsx (reusable board display) - Create PerInstancePanel.tsx: 8x8 board + per-square modifier list/form - Wire PerInstancePanel into ModifierProfileEditor center panel - Add bound-layout-picker select in editor header (uses LAYOUT_REGISTRY) - 2 new e2e scenarios: no-layout prompt, attach modifier to b1 square
This commit is contained in:
parent
4c8e8467b7
commit
28e03d06fa
4 changed files with 766 additions and 36 deletions
|
|
@ -52,4 +52,92 @@ test.describe('Modifier Profiles', () => {
|
|||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||||
).not.toBeVisible({ timeout: 500 });
|
||||
});
|
||||
|
||||
test('add per-type HP modifier shows in row list', async ({ page }) => {
|
||||
// Open the modifier editor.
|
||||
await page.click('[data-testid="open-modifier-editor"]');
|
||||
await expect(
|
||||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Open the inline add form.
|
||||
await page.click('[data-testid="add-type-modifier"]');
|
||||
|
||||
// Fill in: piece type = knight, color = white, kind = hp-bonus, value = 2.
|
||||
await page.selectOption('[data-testid="piece-type-select"]', 'knight');
|
||||
await page.selectOption('[data-testid="color-select"]', 'white');
|
||||
await page.selectOption('[data-testid="kind-select"]', 'hp-bonus');
|
||||
await page.fill('[data-testid="value-input"]', '2');
|
||||
|
||||
// Save the modifier.
|
||||
await page.click('[data-testid="save-type-modifier"]');
|
||||
|
||||
// The row must appear and display the described value.
|
||||
await expect(
|
||||
page.locator('[data-testid="type-modifier-row"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="type-modifier-row"]'),
|
||||
).toContainText('HP +2');
|
||||
});
|
||||
|
||||
test('invalid value disables save button', async ({ page }) => {
|
||||
// Open the modifier editor.
|
||||
await page.click('[data-testid="open-modifier-editor"]');
|
||||
await expect(
|
||||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Open the inline add form.
|
||||
await page.click('[data-testid="add-type-modifier"]');
|
||||
|
||||
// Select range-bonus (max = 7) and enter 100 — outside valid range.
|
||||
await page.selectOption('[data-testid="kind-select"]', 'range-bonus');
|
||||
await page.fill('[data-testid="value-input"]', '100');
|
||||
|
||||
// Save button must be disabled since 100 > 7 fails Zod validation.
|
||||
await expect(
|
||||
page.locator('[data-testid="save-type-modifier"]'),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
// ── T22: per-instance modifier panel ───────────────────────────────────────
|
||||
|
||||
test('no-layout state shows select-a-layout prompt', async ({ page }) => {
|
||||
await page.click('[data-testid="open-modifier-editor"]');
|
||||
await expect(
|
||||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// With no layout bound, the center panel shows the empty-state prompt.
|
||||
await expect(page.getByText('Select a layout first')).toBeVisible();
|
||||
});
|
||||
|
||||
test('per-instance modifier attached to specific square', async ({ page }) => {
|
||||
await page.click('[data-testid="open-modifier-editor"]');
|
||||
await expect(
|
||||
page.locator('[data-testid="modifier-editor-modal"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Bind the Classic layout via the header picker.
|
||||
await page.selectOption('[data-testid="bound-layout-picker"]', 'classic');
|
||||
|
||||
// The per-instance board should now be visible.
|
||||
await expect(
|
||||
page.locator('[data-testid="per-instance-board"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Click the b1 square (white knight in the classic starting position).
|
||||
await page.click('[data-testid="piece-square-b1"]');
|
||||
|
||||
// Add a range-bonus modifier with value 1.
|
||||
await page.selectOption('[data-testid="instance-modifier-kind"]', 'range-bonus');
|
||||
await page.fill('[data-testid="instance-modifier-value"]', '1');
|
||||
await page.click('[data-testid="instance-modifier-add"]');
|
||||
|
||||
// The modifier row must appear in the selected-square list.
|
||||
await expect(
|
||||
page.locator('[data-testid="instance-modifier-b1-range-bonus"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -678,6 +678,95 @@ function ValidationPanel({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable read-only / interactive board view for piece layouts.
|
||||
*
|
||||
* Exported so that other panels (e.g. PerInstancePanel in the modifier
|
||||
* profile editor) can display the same 8×8 grid without duplicating
|
||||
* the rendering logic.
|
||||
*
|
||||
* - `selectedSquare` (algebraic, e.g. "b1") highlights that cell with a
|
||||
* blue ring so the caller can indicate which square is "active".
|
||||
* - `onSquareClick` is called with the algebraic notation of the clicked
|
||||
* cell; if omitted the board is purely decorative (all cells disabled).
|
||||
* - `testId` sets `data-testid` on the board wrapper div.
|
||||
*/
|
||||
export function LayoutBoardView({
|
||||
pieces,
|
||||
selectedSquare,
|
||||
testId = 'layout-board-view',
|
||||
onSquareClick,
|
||||
}: {
|
||||
pieces: readonly PiecePlacement[];
|
||||
selectedSquare?: string | null;
|
||||
testId?: string;
|
||||
onSquareClick?: (algebraic: string) => void;
|
||||
}) {
|
||||
const placementBySquare = useMemo(() => {
|
||||
const map = new Map<number, PiecePlacement>();
|
||||
for (const p of pieces) map.set(p.square, p);
|
||||
return map;
|
||||
}, [pieces]);
|
||||
|
||||
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 algebraic = squareToAlgebraic(sq);
|
||||
const piece = placementBySquare.get(sq);
|
||||
const isDark = (rank + file) % 2 === 0;
|
||||
const isSelected = selectedSquare === algebraic;
|
||||
const isOccupied = piece !== undefined;
|
||||
const hasClick = onSquareClick !== undefined;
|
||||
|
||||
cells.push(
|
||||
<button
|
||||
key={sq}
|
||||
data-testid={isOccupied ? `piece-square-${algebraic}` : undefined}
|
||||
data-square={algebraic}
|
||||
onClick={() => onSquareClick?.(algebraic)}
|
||||
disabled={!hasClick}
|
||||
className={`aspect-square flex items-center justify-center transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-200 ring-2 ring-inset ring-blue-500'
|
||||
: isDark
|
||||
? 'bg-neutral-400'
|
||||
: 'bg-neutral-100'
|
||||
} ${
|
||||
hasClick
|
||||
? '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 (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="w-full max-w-lg aspect-square border-2 border-neutral-800 shadow-xl"
|
||||
>
|
||||
{rows}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryDrawer({
|
||||
onLoad,
|
||||
onDelete,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,62 @@
|
|||
/**
|
||||
* Modifier Profile Editor — modal shell.
|
||||
*
|
||||
* Shell only: three placeholder panels filled by later tasks:
|
||||
* T21 — modifier catalog (left panel)
|
||||
* T22 — board preview (center panel)
|
||||
* T23 — profile list (right panel)
|
||||
* Three panels:
|
||||
* LEFT — modifier catalog (T21 placeholder)
|
||||
* CENTER — board preview (T22 placeholder)
|
||||
* RIGHT — profile library: save, share, list of saved profiles (T23)
|
||||
*
|
||||
* Follows the same overlay/close pattern as LayoutEditor.tsx.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { LAYOUT_REGISTRY, type StartingLayout } from '@paratype/chess';
|
||||
import {
|
||||
deleteFromLibrary,
|
||||
duplicateEntry,
|
||||
loadLibrary,
|
||||
makeId,
|
||||
saveToLibrary,
|
||||
setStarred,
|
||||
type SavedModifierProfile,
|
||||
} from '../modifiers/library';
|
||||
import type { InstanceModifier, ModifierProfile, TypeModifier } from '../modifiers/types';
|
||||
import { PerInstancePanel } from './PerInstancePanel';
|
||||
import { PerTypePanel } from './PerTypePanel.js';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function makeBlankProfile(): ModifierProfile {
|
||||
return {
|
||||
id: makeId(),
|
||||
name: 'My Profile',
|
||||
description: '',
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: 'custom',
|
||||
};
|
||||
}
|
||||
|
||||
export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||
// Register Esc listener only while the modal is visible.
|
||||
const [profile, setProfile] = useState<ModifierProfile>(makeBlankProfile);
|
||||
const [libraryVersion, setLibraryVersion] = useState(0);
|
||||
const [boundLayout, setBoundLayout] = useState<StartingLayout | null>(null);
|
||||
const [perInstance, setPerInstance] = useState<InstanceModifier[]>([]);
|
||||
const [perType, setPerType] = useState<TypeModifier[]>([]);
|
||||
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
|
||||
|
||||
// Fresh profile whenever the modal opens.
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setProfile(makeBlankProfile());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Esc closes the modal regardless of focus.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
|
|
@ -28,6 +68,70 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
|||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// ── Library actions ─────────────────────────────────────────────────
|
||||
|
||||
function handleSaveToLibrary() {
|
||||
const entry: SavedModifierProfile = {
|
||||
id: profile.id,
|
||||
name: profile.name.trim() || 'Untitled Profile',
|
||||
profile,
|
||||
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 handleShareProfile() {
|
||||
try {
|
||||
const json = JSON.stringify(profile);
|
||||
const b64 = btoa(json);
|
||||
if (b64.length > 8 * 1024) {
|
||||
toast.error('Profile too large for URL — save to library instead.');
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('modifierProfile', b64);
|
||||
void navigator.clipboard
|
||||
.writeText(url.toString())
|
||||
.then(() => toast.success('Copied to clipboard'))
|
||||
.catch(() =>
|
||||
toast.error(
|
||||
'Copy failed — share URL could not be written to clipboard',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error('Failed to generate share URL');
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadFromLibrary(entry: SavedModifierProfile) {
|
||||
setProfile({ ...entry.profile });
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="modifier-editor-modal"
|
||||
|
|
@ -36,45 +140,215 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
|||
<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">
|
||||
<h2 className="text-lg font-bold text-neutral-900">
|
||||
Modifier Profiles
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modifier editor"
|
||||
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
||||
<h2 className="text-lg font-bold text-neutral-900 shrink-0">
|
||||
Modifier Profiles
|
||||
</h2>
|
||||
<input
|
||||
data-testid="profile-name"
|
||||
type="text"
|
||||
value={profile.name}
|
||||
onChange={(e) =>
|
||||
setProfile((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
placeholder="Profile 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}
|
||||
/>
|
||||
{/* Bound layout for per-instance modifiers */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<label
|
||||
htmlFor="bound-layout-picker-input"
|
||||
className="text-xs font-bold text-neutral-500 uppercase tracking-widest whitespace-nowrap"
|
||||
>
|
||||
Layout:
|
||||
</label>
|
||||
<select
|
||||
id="bound-layout-picker-input"
|
||||
data-testid="bound-layout-picker"
|
||||
value={boundLayout?.id ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setBoundLayout(id ? (LAYOUT_REGISTRY.get(id) ?? null) : null);
|
||||
}}
|
||||
className="text-sm border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[160px]"
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{layouts.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
data-testid="save-profile"
|
||||
onClick={handleSaveToLibrary}
|
||||
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
data-testid="share-profile"
|
||||
onClick={handleShareProfile}
|
||||
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modifier editor"
|
||||
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body — three placeholder panels, filled by T21 / T22 / T23 */}
|
||||
{/* Body — three panels */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
<aside className="w-64 border-r border-neutral-200 bg-neutral-50 p-4">
|
||||
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest mb-2">
|
||||
Modifiers
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
Modifier catalog (T21)
|
||||
</p>
|
||||
{/* LEFT — per-type modifier panel (T21) */}
|
||||
<aside className="w-64 border-r border-neutral-200 overflow-hidden">
|
||||
<PerTypePanel
|
||||
modifiers={perType}
|
||||
onAdd={(m) => setPerType((prev) => [...prev, m])}
|
||||
onDelete={(i) =>
|
||||
setPerType((prev) => prev.filter((_, idx) => idx !== i))
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 flex items-center justify-center p-6 bg-white">
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
Board preview (T22)
|
||||
</p>
|
||||
{/* CENTER — per-instance board preview (T22) */}
|
||||
<main className="flex-1 flex overflow-hidden">
|
||||
<PerInstancePanel
|
||||
modifiers={perInstance}
|
||||
boundLayout={boundLayout}
|
||||
onLayoutSelect={() => {
|
||||
document
|
||||
.querySelector<HTMLSelectElement>(
|
||||
'[data-testid="bound-layout-picker"]',
|
||||
)
|
||||
?.focus();
|
||||
}}
|
||||
onAdd={(m) => setPerInstance((prev) => [...prev, m])}
|
||||
onDelete={(i) =>
|
||||
setPerInstance((prev) => prev.filter((_, idx) => idx !== i))
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 p-4">
|
||||
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest mb-2">
|
||||
Profiles
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
Profile list (T23)
|
||||
</p>
|
||||
</aside>
|
||||
{/* RIGHT — profile library (T23) */}
|
||||
<ProfileLibraryPanel
|
||||
key={libraryVersion}
|
||||
onLoad={handleLoadFromLibrary}
|
||||
onDelete={handleDeleteFromLibrary}
|
||||
onStarToggle={handleStarToggle}
|
||||
onDuplicate={handleDuplicate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ProfileLibraryPanel ──────────────────────────────────────────────────────
|
||||
|
||||
function ProfileLibraryPanel({
|
||||
onLoad,
|
||||
onDelete,
|
||||
onStarToggle,
|
||||
onDuplicate,
|
||||
}: {
|
||||
onLoad: (entry: SavedModifierProfile) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onStarToggle: (id: string, starred: boolean) => void;
|
||||
onDuplicate: (id: string) => void;
|
||||
}) {
|
||||
const entries = loadLibrary();
|
||||
// Starred entries first, then most-recent within each group.
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
if (a.starred !== b.starred) return a.starred ? -1 : 1;
|
||||
return b.updatedAt - a.updatedAt;
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 flex flex-col overflow-hidden">
|
||||
<div className="px-4 pt-4 pb-2 border-b border-neutral-200">
|
||||
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Profiles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
No saved profiles yet. Click{' '}
|
||||
<span className="font-semibold">Save</span> to add the current
|
||||
profile to your library.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{sorted.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
data-testid="profile-library-entry"
|
||||
className="flex items-start gap-2 p-3 bg-white border border-neutral-200 rounded hover:border-neutral-300 transition-colors"
|
||||
>
|
||||
<button
|
||||
onClick={() => onStarToggle(entry.id, !entry.starred)}
|
||||
aria-label={entry.starred ? 'Unstar' : 'Star'}
|
||||
className={
|
||||
entry.starred
|
||||
? 'text-amber-500 mt-0.5 shrink-0'
|
||||
: 'text-neutral-300 mt-0.5 shrink-0'
|
||||
}
|
||||
>
|
||||
★
|
||||
</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 mt-0.5">
|
||||
{entry.profile.perType.length +
|
||||
entry.profile.perInstance.length}{' '}
|
||||
modifier
|
||||
{entry.profile.perType.length +
|
||||
entry.profile.perInstance.length !==
|
||||
1
|
||||
? 's'
|
||||
: ''}{' '}
|
||||
· {new Date(entry.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1.5">
|
||||
<button
|
||||
onClick={() => onLoad(entry)}
|
||||
className="px-2 py-0.5 text-xs font-semibold text-blue-700 hover:bg-blue-50 rounded"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDuplicate(entry.id)}
|
||||
className="px-2 py-0.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
Dupe
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(entry.id)}
|
||||
aria-label="Delete"
|
||||
className="px-2 py-0.5 text-xs font-semibold text-red-700 hover:bg-red-50 rounded"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
279
packages/chess/src/ui/PerInstancePanel.tsx
Normal file
279
packages/chess/src/ui/PerInstancePanel.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* Per-Instance Modifier Panel (T22).
|
||||
*
|
||||
* Center panel of the Modifier Profile Editor. Displays an 8×8 board
|
||||
* preview for the bound layout and lets the user attach per-instance
|
||||
* modifiers to individual squares.
|
||||
*
|
||||
* Usage flow:
|
||||
* 1. If no layout is bound → prompt the user to choose one.
|
||||
* 2. With a bound layout → click a square with a piece to select it.
|
||||
* 3. For the selected square → view existing instance modifiers and
|
||||
* add/delete them via the right-hand sidebar form.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { InstanceModifier, ModifierKindId } from '../modifiers/types';
|
||||
import type { StartingLayout } from '../layouts/types';
|
||||
import { squareToAlgebraic } from '../coord';
|
||||
import { LayoutBoardView } from './LayoutEditor';
|
||||
|
||||
export interface PerInstancePanelProps {
|
||||
modifiers: readonly InstanceModifier[];
|
||||
boundLayout: StartingLayout | null;
|
||||
/** Called when the user requests to open the layout picker. */
|
||||
onLayoutSelect: () => void;
|
||||
onAdd: (modifier: InstanceModifier) => void;
|
||||
onDelete: (index: number) => void;
|
||||
}
|
||||
|
||||
const MODIFIER_KINDS: ReadonlyArray<{ id: ModifierKindId; label: string }> = [
|
||||
{ id: 'hp-bonus', label: 'HP Bonus' },
|
||||
{ id: 'range-bonus', label: 'Range Bonus' },
|
||||
{ id: 'direction-additions', label: 'Direction Additions' },
|
||||
{ id: 'capture-flags', label: 'Capture Flags' },
|
||||
{ id: 'promotion-override', label: 'Promotion Override' },
|
||||
{ id: 'damage-resistance', label: 'Damage Resistance' },
|
||||
];
|
||||
|
||||
export function PerInstancePanel({
|
||||
modifiers,
|
||||
boundLayout,
|
||||
onLayoutSelect,
|
||||
onAdd,
|
||||
onDelete,
|
||||
}: PerInstancePanelProps) {
|
||||
const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
|
||||
|
||||
// No layout bound — show the empty state prompt.
|
||||
if (boundLayout === null) {
|
||||
return (
|
||||
<div
|
||||
data-testid="per-instance-panel"
|
||||
className="flex flex-1 flex-col items-center justify-center gap-4 p-8"
|
||||
>
|
||||
<p className="text-sm text-neutral-500 italic">Select a layout first</p>
|
||||
<button
|
||||
data-testid="per-instance-choose-layout"
|
||||
onClick={onLayoutSelect}
|
||||
className="px-4 py-2 text-sm font-semibold text-white bg-neutral-900 rounded hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Choose Layout
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Collect modifiers for the currently selected square, preserving
|
||||
// their original indices so onDelete can target the right entry.
|
||||
const squareModifiers = modifiers
|
||||
.map((modifier, index) => ({ modifier, index }))
|
||||
.filter(({ modifier }) => modifier.square === selectedSquare);
|
||||
|
||||
const selectedPiece =
|
||||
selectedSquare !== null
|
||||
? boundLayout.pieces.find(
|
||||
(p) => squareToAlgebraic(p.square) === selectedSquare,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
function handleSquareClick(algebraic: string) {
|
||||
const hasPiece = boundLayout!.pieces.some(
|
||||
(p) => squareToAlgebraic(p.square) === algebraic,
|
||||
);
|
||||
if (hasPiece) setSelectedSquare(algebraic);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="per-instance-panel" className="flex flex-1 overflow-hidden">
|
||||
{/* Board preview */}
|
||||
<div className="flex-1 flex items-center justify-center p-4 bg-white">
|
||||
<LayoutBoardView
|
||||
pieces={boundLayout.pieces}
|
||||
selectedSquare={selectedSquare}
|
||||
testId="per-instance-board"
|
||||
onSquareClick={handleSquareClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Square detail sidebar */}
|
||||
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
|
||||
{selectedSquare === null ? (
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
Click a piece on the board to add modifiers.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Selected square header */}
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-1">
|
||||
Square {selectedSquare}
|
||||
</h3>
|
||||
{selectedPiece !== undefined && (
|
||||
<p className="text-sm text-neutral-700 capitalize">
|
||||
{selectedPiece.color} {selectedPiece.type}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing modifiers list */}
|
||||
{squareModifiers.length > 0 && (
|
||||
<ul data-testid="instance-modifiers-list" className="space-y-1">
|
||||
{squareModifiers.map(({ modifier, index }) => (
|
||||
<li
|
||||
key={index}
|
||||
data-testid={`instance-modifier-${selectedSquare}-${modifier.kind}`}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white border border-neutral-200 rounded text-sm"
|
||||
>
|
||||
<span className="flex-1 font-medium text-neutral-700 break-all">
|
||||
{modifier.kind}: {JSON.stringify(modifier.value)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onDelete(index)}
|
||||
aria-label={`Delete ${modifier.kind} modifier`}
|
||||
className="shrink-0 text-red-500 hover:text-red-700 text-xs font-bold leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Add modifier form */}
|
||||
<AddModifierForm square={selectedSquare} onAdd={onAdd} />
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Add-Modifier form ─────────────────────────────────────────────────────────
|
||||
|
||||
function defaultValue(kind: ModifierKindId): string {
|
||||
switch (kind) {
|
||||
case 'direction-additions':
|
||||
return '';
|
||||
case 'promotion-override':
|
||||
return 'queen';
|
||||
default:
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
function ValueInput({
|
||||
kind,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
kind: ModifierKindId;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
if (kind === 'promotion-override') {
|
||||
return (
|
||||
<select
|
||||
data-testid="instance-modifier-value"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{['queen', 'rook', 'bishop', 'knight', 'disabled'].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'direction-additions') {
|
||||
return (
|
||||
<input
|
||||
data-testid="instance-modifier-value"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="e.g. forward, backward"
|
||||
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Numeric kinds: hp-bonus, range-bonus, capture-flags, damage-resistance
|
||||
return (
|
||||
<input
|
||||
data-testid="instance-modifier-value"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AddModifierForm({
|
||||
square,
|
||||
onAdd,
|
||||
}: {
|
||||
square: string;
|
||||
onAdd: (modifier: InstanceModifier) => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<ModifierKindId>('hp-bonus');
|
||||
const [rawValue, setRawValue] = useState<string>('0');
|
||||
|
||||
function handleAdd() {
|
||||
let parsed: unknown;
|
||||
|
||||
if (kind === 'direction-additions') {
|
||||
parsed = rawValue
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else if (kind === 'promotion-override') {
|
||||
parsed = rawValue;
|
||||
} else {
|
||||
const n = Number(rawValue);
|
||||
if (isNaN(n)) return;
|
||||
parsed = n;
|
||||
}
|
||||
|
||||
onAdd({ kind, square, value: parsed });
|
||||
setRawValue(defaultValue(kind));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-neutral-200 pt-3">
|
||||
<h4 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
|
||||
Add Modifier
|
||||
</h4>
|
||||
|
||||
<select
|
||||
data-testid="instance-modifier-kind"
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
const newKind = e.target.value as ModifierKindId;
|
||||
setKind(newKind);
|
||||
setRawValue(defaultValue(newKind));
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{MODIFIER_KINDS.map(({ id, label }) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<ValueInput kind={kind} value={rawValue} onChange={setRawValue} />
|
||||
|
||||
<button
|
||||
data-testid="instance-modifier-add"
|
||||
onClick={handleAdd}
|
||||
className="w-full px-3 py-2 text-sm font-semibold text-white bg-blue-600 rounded hover:bg-blue-700 active:bg-blue-800 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue