Threads the multiplayer publisher all the way from useMultiplayerGame down through GameView → RulesDrawer → ModifierProfileEditor → CustomModifierEditor, surfacing a Share with Room button in the custom modifier editor when (and only when) the editor was opened from a multiplayer game. Wiring summary (top-down): - useMultiplayerGame.ts: returns sendRegisterCustomModifier(descriptor), a thin wrapper around the GameClient.sendRegisterCustomModifier helper added in the previous commit. - useMultiplayerGame.ts: onError handler surfaces CUSTOM_MODIFIER_INVALID and CUSTOM_MODIFIER_LIMIT as toasts on top of the existing in-game error banner so the user notices the rejection immediately. - GameView.tsx: GameEngineState gains an optional sendRegisterCustomModifier field; the multiplayer destructure pulls it out and passes it to RulesDrawer as onShareCustomModifierWithRoom (omitted in solo, where the prop is undefined and Share UI doesn't render). - RulesDrawer.tsx: optional onShareCustomModifierWithRoom prop; conditionally forwards to ModifierProfileEditor. - ModifierProfileEditor.tsx: optional onShareCustomModifierWithRoom prop; conditionally forwards to CustomModifierEditor as onShareWithRoom. - CustomModifierEditor.tsx: when onShareWithRoom is provided, renders a green Share with Room button in the header alongside Save. Click invokes the publisher with the current descriptor; toast confirms the share landed (server broadcast is the actual proof, observed by the local PredictionManager subscriber registering the descriptor on the engine's customModifiers registry). E2E coverage (both formerly-fixme tests now PASS): - multiplayer custom modifier sharing — both clients see the registered descriptor: opens two browser contexts via raw WS (matches modifier-profiles.spec.ts MP pattern), host registers a descriptor after both reconnect-by-token complete, both sides observe custom-modifier.registered. - server rejects custom modifier with > 50 primitives — error event observed: host registers a 51-primitive descriptor, asserts an INVALID_MESSAGE / CUSTOM_MODIFIER_INVALID error is observed and no broadcast fires. Final state: 79/79 e2e + 1386 unit tests, zero fixmes, zero skipped.
536 lines
19 KiB
TypeScript
536 lines
19 KiB
TypeScript
/**
|
||
* Modifier Profile Editor — modal shell.
|
||
*
|
||
* 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 { useCallback, 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 { CustomModifierEditor } from './CustomModifierEditor.js';
|
||
import { PerInstancePanel } from './PerInstancePanel';
|
||
import { PerTypePanel } from './PerTypePanel.js';
|
||
import { ConflictResolutionPanel } from './ConflictResolutionPanel.js';
|
||
|
||
export type ModifierClipboard =
|
||
| { kind: 'empty' }
|
||
| { kind: 'type-modifiers'; entries: readonly TypeModifier[] }
|
||
| { kind: 'instance-modifiers'; entries: readonly Omit<InstanceModifier, 'square'>[] };
|
||
|
||
interface Props {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
/**
|
||
* Optional multiplayer publisher passed through to the nested
|
||
* Custom Modifier editor. Present only when this editor was opened
|
||
* inside an in-game RulesDrawer with a multiplayer hook in scope.
|
||
*/
|
||
onShareCustomModifierWithRoom?: (
|
||
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
||
) => void;
|
||
}
|
||
|
||
function makeBlankProfile(): ModifierProfile {
|
||
return {
|
||
id: makeId(),
|
||
name: 'My Profile',
|
||
description: '',
|
||
perType: [],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
};
|
||
}
|
||
|
||
export function ModifierProfileEditor({
|
||
isOpen,
|
||
onClose,
|
||
onShareCustomModifierWithRoom,
|
||
}: Props) {
|
||
const [showCustomModifierEditor, setShowCustomModifierEditor] = useState(false);
|
||
|
||
const [history, setHistory] = useState<ModifierProfile[]>(() => [
|
||
makeBlankProfile(),
|
||
]);
|
||
const [historyIndex, setHistoryIndex] = useState(0);
|
||
const profile = history[historyIndex]!;
|
||
const canUndo = historyIndex > 0;
|
||
const canRedo = historyIndex < history.length - 1;
|
||
|
||
const pushSnapshot = useCallback(
|
||
(next: ModifierProfile | ((prev: ModifierProfile) => ModifierProfile)) => {
|
||
setHistory((h) => {
|
||
const current = h[historyIndex]!;
|
||
const nextProfile = typeof next === 'function' ? next(current) : next;
|
||
const truncated = h.slice(0, historyIndex + 1);
|
||
truncated.push(nextProfile);
|
||
while (truncated.length > 50) truncated.shift();
|
||
return truncated;
|
||
});
|
||
setHistoryIndex((i) => Math.min(i + 1, 49));
|
||
},
|
||
[historyIndex],
|
||
);
|
||
|
||
const undo = useCallback(() => {
|
||
setHistoryIndex((i) => Math.max(0, i - 1));
|
||
}, []);
|
||
|
||
const redo = useCallback(() => {
|
||
setHistoryIndex((i) => Math.min(history.length - 1, i + 1));
|
||
}, [history.length]);
|
||
|
||
const [libraryVersion, setLibraryVersion] = useState(0);
|
||
const [boundLayout, setBoundLayout] = useState<StartingLayout | null>(null);
|
||
const [clipboard, setClipboard] = useState<ModifierClipboard>({ kind: 'empty' });
|
||
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
|
||
|
||
// Fresh profile whenever the modal opens.
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
setHistory([makeBlankProfile()]);
|
||
setHistoryIndex(0);
|
||
}
|
||
}, [isOpen]);
|
||
|
||
// Esc closes the modal regardless of focus.
|
||
//
|
||
// We use capture phase + stopImmediatePropagation so that other
|
||
// window-level Esc handlers (e.g. the RulesDrawer behind us) do not
|
||
// ALSO fire on the same keystroke. Without this, pressing Esc with
|
||
// both editor+drawer open would close both, leaving neither visible
|
||
// but the drawer's pointer-events-blocking backdrop briefly lingers.
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
function handleKeyDown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
e.stopImmediatePropagation();
|
||
onClose();
|
||
return;
|
||
}
|
||
const mod = e.metaKey || e.ctrlKey;
|
||
if (!mod) return;
|
||
if (e.key === 'z' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
undo();
|
||
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'Z') {
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
redo();
|
||
}
|
||
}
|
||
window.addEventListener('keydown', handleKeyDown, true);
|
||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||
}, [isOpen, onClose, undo, redo]);
|
||
|
||
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);
|
||
setHistory([profile]);
|
||
setHistoryIndex(0);
|
||
}
|
||
|
||
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) {
|
||
setHistory([{ ...entry.profile }]);
|
||
setHistoryIndex(0);
|
||
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"
|
||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||
>
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl min-h-[85vh] 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 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) =>
|
||
pushSnapshot((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;
|
||
const newLayout = id ? (LAYOUT_REGISTRY.get(id) ?? null) : null;
|
||
setBoundLayout(newLayout);
|
||
// Any layout change is tracked as an action because per-instance
|
||
// rules are inherently bound to the layout context.
|
||
pushSnapshot((p) => ({ ...p }));
|
||
}}
|
||
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="undo-button"
|
||
onClick={undo}
|
||
disabled={!canUndo}
|
||
aria-label="Undo"
|
||
title="Undo (Cmd/Ctrl+Z)"
|
||
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
|
||
>
|
||
↶
|
||
</button>
|
||
<button
|
||
data-testid="redo-button"
|
||
onClick={redo}
|
||
disabled={!canRedo}
|
||
aria-label="Redo"
|
||
title="Redo (Cmd/Ctrl+Shift+Z)"
|
||
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
|
||
>
|
||
↷
|
||
</button>
|
||
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
||
<div data-testid="clipboard-status" className="px-2 text-xs font-medium text-neutral-500">
|
||
{clipboard.kind === 'empty'
|
||
? 'Clipboard: empty'
|
||
: `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`}
|
||
</div>
|
||
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
||
<button
|
||
data-testid="open-custom-modifier-editor"
|
||
onClick={() => setShowCustomModifierEditor(true)}
|
||
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
|
||
title="Author a custom modifier from primitives"
|
||
>
|
||
+ Custom Modifier
|
||
</button>
|
||
<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>
|
||
|
||
{/* Conflict Resolution Panel */}
|
||
<ConflictResolutionPanel
|
||
profile={profile}
|
||
layout={boundLayout}
|
||
onResolve={(next) => pushSnapshot(next)}
|
||
/>
|
||
|
||
{/* Body — three panels */}
|
||
<div className="flex-1 overflow-hidden flex border-t border-neutral-200">
|
||
{/* LEFT — per-type modifier panel (T21) */}
|
||
<aside className="w-64 border-r border-neutral-200 overflow-hidden">
|
||
<PerTypePanel
|
||
modifiers={profile.perType}
|
||
clipboard={clipboard}
|
||
onClipboardChange={setClipboard}
|
||
onAdd={(m) =>
|
||
pushSnapshot((p) => ({ ...p, perType: [...p.perType, m] }))
|
||
}
|
||
onDelete={(i) =>
|
||
pushSnapshot((p) => ({
|
||
...p,
|
||
perType: p.perType.filter((_, idx) => idx !== i),
|
||
}))
|
||
}
|
||
onPaste={(entries: readonly TypeModifier[]) => {
|
||
pushSnapshot((p) => {
|
||
const newPerType = [...p.perType];
|
||
for (const entry of entries) {
|
||
const existingIdx = newPerType.findIndex(
|
||
(m) =>
|
||
m.kind === entry.kind &&
|
||
m.pieceType === entry.pieceType &&
|
||
m.color === entry.color
|
||
);
|
||
if (existingIdx !== -1) {
|
||
newPerType[existingIdx] = entry;
|
||
} else {
|
||
newPerType.push(entry);
|
||
}
|
||
}
|
||
return { ...p, perType: newPerType };
|
||
});
|
||
}}
|
||
/>
|
||
</aside>
|
||
|
||
{/* CENTER — per-instance board preview (T22) */}
|
||
<main className="flex-1 flex overflow-hidden">
|
||
<PerInstancePanel
|
||
modifiers={profile.perInstance}
|
||
boundLayout={boundLayout}
|
||
clipboard={clipboard}
|
||
onClipboardChange={setClipboard}
|
||
onLayoutSelect={() => {
|
||
document
|
||
.querySelector<HTMLSelectElement>(
|
||
'[data-testid="bound-layout-picker"]',
|
||
)
|
||
?.focus();
|
||
}}
|
||
onAdd={(m) =>
|
||
pushSnapshot((p) => ({
|
||
...p,
|
||
perInstance: [...p.perInstance, m],
|
||
}))
|
||
}
|
||
onDelete={(i) =>
|
||
pushSnapshot((p) => ({
|
||
...p,
|
||
perInstance: p.perInstance.filter((_, idx) => idx !== i),
|
||
}))
|
||
}
|
||
onPaste={(entries: readonly InstanceModifier[]) => {
|
||
pushSnapshot((p) => {
|
||
const newPerInstance = [...p.perInstance];
|
||
for (const entry of entries) {
|
||
const existingIdx = newPerInstance.findIndex(
|
||
(m) =>
|
||
m.kind === entry.kind &&
|
||
m.square === entry.square
|
||
);
|
||
if (existingIdx !== -1) {
|
||
newPerInstance[existingIdx] = entry;
|
||
} else {
|
||
newPerInstance.push(entry);
|
||
}
|
||
}
|
||
return { ...p, perInstance: newPerInstance };
|
||
});
|
||
}}
|
||
/>
|
||
</main>
|
||
|
||
{/* RIGHT — profile library (T23) */}
|
||
<ProfileLibraryPanel
|
||
key={libraryVersion}
|
||
onLoad={handleLoadFromLibrary}
|
||
onDelete={handleDeleteFromLibrary}
|
||
onStarToggle={handleStarToggle}
|
||
onDuplicate={handleDuplicate}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<CustomModifierEditor
|
||
isOpen={showCustomModifierEditor}
|
||
onClose={() => setShowCustomModifierEditor(false)}
|
||
{...(onShareCustomModifierWithRoom !== undefined
|
||
? { onShareWithRoom: onShareCustomModifierWithRoom }
|
||
: {})}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|