1
0
Fork 0

style(admin): format admin narrative-flow and award components

This commit is contained in:
Jason Fraley 2026-08-16 23:27:51 -04:00
parent a21564a78d
commit 5058d9ce6c
23 changed files with 264 additions and 164 deletions

View file

@ -116,13 +116,20 @@ function MedalBody({
</defs> </defs>
{shape === "circle" && ( {shape === "circle" && (
<circle cx={cx} cy={cy} r={r} fill={`url(#${gradId})`} stroke={finishColors[2]} strokeWidth={1.5} /> <circle
cx={cx}
cy={cy}
r={r}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)} )}
{shape === "star" && ( {shape === "star" && (
<polygon <polygon
points={Array.from({ length: 10 }, (_, i) => { points={Array.from({ length: 10 }, (_, i) => {
const angle = (Math.PI / 2) + (i * Math.PI) / 5; const angle = Math.PI / 2 + (i * Math.PI) / 5;
const radius = i % 2 === 0 ? r : r * 0.45; const radius = i % 2 === 0 ? r : r * 0.45;
return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`; return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`;
}).join(" ")} }).join(" ")}
@ -228,14 +235,7 @@ function MedalPreview({ design }: { design: MedalDesign }) {
})()} })()}
{/* Small connector tab between ribbon and medal */} {/* Small connector tab between ribbon and medal */}
<rect <rect x={medalCx - 4} y={ribbonBottom} width={8} height={8} rx={2} fill="#9ca3af" />
x={medalCx - 4}
y={ribbonBottom}
width={8}
height={8}
rx={2}
fill="#9ca3af"
/>
{/* Medal body */} {/* Medal body */}
<MedalBody shape={shape} finish={finish} engraving={engraving} cx={medalCx} cy={medalCy} /> <MedalBody shape={shape} finish={finish} engraving={engraving} cx={medalCx} cy={medalCy} />
@ -424,14 +424,8 @@ export default function MedalDesigner() {
(stripes: Stripe[]) => setDesign((d) => ({ ...d, ribbonStripes: stripes })), (stripes: Stripe[]) => setDesign((d) => ({ ...d, ribbonStripes: stripes })),
[], [],
); );
const setShape = useCallback( const setShape = useCallback((shape: MedalShape) => setDesign((d) => ({ ...d, shape })), []);
(shape: MedalShape) => setDesign((d) => ({ ...d, shape })), const setFinish = useCallback((finish: MetalFinish) => setDesign((d) => ({ ...d, finish })), []);
[],
);
const setFinish = useCallback(
(finish: MetalFinish) => setDesign((d) => ({ ...d, finish })),
[],
);
const setEngraving = useCallback( const setEngraving = useCallback(
(engraving: string) => setDesign((d) => ({ ...d, engraving })), (engraving: string) => setDesign((d) => ({ ...d, engraving })),
[], [],

View file

@ -79,9 +79,7 @@ function RibbonPreview({ design }: { design: RibbonDesign }) {
const { stripes, devices, mirrored } = design; const { stripes, devices, mirrored } = design;
if (stripes.length === 0) return null; if (stripes.length === 0) return null;
const displayStripes = mirrored const displayStripes = mirrored ? [...stripes, ...stripes.slice(0, -1).reverse()] : stripes;
? [...stripes, ...stripes.slice(0, -1).reverse()]
: stripes;
const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0); const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0);
const viewWidth = RIBBON_WIDTH; const viewWidth = RIBBON_WIDTH;
@ -98,16 +96,7 @@ function RibbonPreview({ design }: { design: RibbonDesign }) {
let x = 0; let x = 0;
return displayStripes.map((stripe, i) => { return displayStripes.map((stripe, i) => {
const w = (stripe.width / totalWeight) * viewWidth; const w = (stripe.width / totalWeight) * viewWidth;
const el = ( const el = <rect key={i} x={x} y={0} width={w} height={viewHeight} fill={stripe.color} />;
<rect
key={i}
x={x}
y={0}
width={w}
height={viewHeight}
fill={stripe.color}
/>
);
x += w; x += w;
return el; return el;
}); });
@ -156,9 +145,7 @@ function StripeEditor({
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider"> <span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Stripes</span>
Stripes
</span>
<button <button
type="button" type="button"
onClick={addStripe} onClick={addStripe}
@ -222,19 +209,14 @@ function DeviceEditor({
onChange: (d: Device[]) => void; onChange: (d: Device[]) => void;
}) { }) {
const addDevice = (type: DeviceType) => const addDevice = (type: DeviceType) =>
onChange([ onChange([...devices, { id: crypto.randomUUID(), type, position: 50, positionY: 50 }]);
...devices,
{ id: crypto.randomUUID(), type, position: 50, positionY: 50 },
]);
const removeDevice = (id: string) => onChange(devices.filter((d) => d.id !== id)); const removeDevice = (id: string) => onChange(devices.filter((d) => d.id !== id));
const updateDevice = (id: string, patch: Partial<Device>) => const updateDevice = (id: string, patch: Partial<Device>) =>
onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d))); onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d)));
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider"> <span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Devices</span>
Devices
</span>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => { {(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => {
const info = DEVICE_INFO[type]; const info = DEVICE_INFO[type];
@ -268,9 +250,7 @@ function DeviceEditor({
min={0} min={0}
max={100} max={100}
value={device.position} value={device.position}
onChange={(e) => onChange={(e) => updateDevice(device.id, { position: Number(e.target.value) })}
updateDevice(device.id, { position: Number(e.target.value) })
}
className="flex-1 h-1 accent-zinc-400" className="flex-1 h-1 accent-zinc-400"
/> />
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right"> <span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
@ -284,9 +264,7 @@ function DeviceEditor({
min={0} min={0}
max={100} max={100}
value={device.positionY} value={device.positionY}
onChange={(e) => onChange={(e) => updateDevice(device.id, { positionY: Number(e.target.value) })}
updateDevice(device.id, { positionY: Number(e.target.value) })
}
className="flex-1 h-1 accent-zinc-400" className="flex-1 h-1 accent-zinc-400"
/> />
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right"> <span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
@ -330,18 +308,9 @@ export default function RibbonDesigner() {
} }
}, [design, setValue]); }, [design, setValue]);
const setStripes = useCallback( const setStripes = useCallback((stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })), []);
(stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })), const setDevices = useCallback((devices: Device[]) => setDesign((d) => ({ ...d, devices })), []);
[], const toggleMirror = useCallback(() => setDesign((d) => ({ ...d, mirrored: !d.mirrored })), []);
);
const setDevices = useCallback(
(devices: Device[]) => setDesign((d) => ({ ...d, devices })),
[],
);
const toggleMirror = useCallback(
() => setDesign((d) => ({ ...d, mirrored: !d.mirrored })),
[],
);
return ( return (
<div className="field-type json flex flex-col gap-3"> <div className="field-type json flex flex-col gap-3">

View file

@ -8,7 +8,10 @@ export function BooleanNode({ data }: NodeProps) {
const d = data as { value?: boolean; description?: string }; const d = data as { value?: boolean; description?: string };
return ( return (
<NodeShell nodeType="boolean" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28"> <NodeShell
nodeType="boolean"
className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
<CheckCircleIcon className="size-4 text-emerald-400 shrink-0" /> <CheckCircleIcon className="size-4 text-emerald-400 shrink-0" />
<span className="text-sm font-medium text-emerald-200 flex-1 truncate"> <span className="text-sm font-medium text-emerald-200 flex-1 truncate">

View file

@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
export function BranchNode(_props: NodeProps) { export function BranchNode(_props: NodeProps) {
return ( return (
<NodeShell nodeType="branch" className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36"> <NodeShell
nodeType="branch"
className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-rose-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-rose-500/30">
<GitBranchIcon className="size-4 text-rose-400 shrink-0" /> <GitBranchIcon className="size-4 text-rose-400 shrink-0" />
<span className="text-sm font-medium text-rose-200 flex-1">Branch</span> <span className="text-sm font-medium text-rose-200 flex-1">Branch</span>

View file

@ -9,7 +9,10 @@ export function ChoiceNode({ data }: NodeProps) {
const options = d.options ?? []; const options = d.options ?? [];
return ( return (
<NodeShell nodeType="choice" className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56"> <NodeShell
nodeType="choice"
className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-amber-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-amber-500/30">
<WaypointsIcon className="size-4 text-amber-400 shrink-0" /> <WaypointsIcon className="size-4 text-amber-400 shrink-0" />
<span className="text-sm font-medium text-amber-200 flex-1 truncate"> <span className="text-sm font-medium text-amber-200 flex-1 truncate">

View file

@ -9,7 +9,10 @@ export function CompareNode({ data }: NodeProps) {
const op = d.operator ?? ">"; const op = d.operator ?? ">";
return ( return (
<NodeShell nodeType="compare" className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="compare"
className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-slate-500/30">
<EqualIcon className="size-4 text-slate-400 shrink-0" /> <EqualIcon className="size-4 text-slate-400 shrink-0" />
<span className="text-sm font-medium text-slate-200 flex-1">Compare</span> <span className="text-sm font-medium text-slate-200 flex-1">Compare</span>

View file

@ -8,7 +8,10 @@ export function DateNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string }; const d = data as { value?: string; description?: string };
return ( return (
<NodeShell nodeType="date" className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="date"
className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-cyan-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-cyan-500/30">
<CalendarIcon className="size-4 text-cyan-400 shrink-0" /> <CalendarIcon className="size-4 text-cyan-400 shrink-0" />
<span className="text-sm font-medium text-cyan-200 flex-1 truncate"> <span className="text-sm font-medium text-cyan-200 flex-1 truncate">

View file

@ -8,12 +8,13 @@ export function FlagNode({ data }: NodeProps) {
const d = data as { flagName?: string }; const d = data as { flagName?: string };
return ( return (
<NodeShell nodeType="flag" className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40"> <NodeShell
nodeType="flag"
className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-orange-500/30">
<FlagIcon className="size-4 text-orange-400 shrink-0" /> <FlagIcon className="size-4 text-orange-400 shrink-0" />
<span className="text-sm font-medium text-orange-200 flex-1 truncate"> <span className="text-sm font-medium text-orange-200 flex-1 truncate">Flag</span>
Flag
</span>
</div> </div>
<div className="px-3 py-2 text-xs text-orange-300/80 truncate"> <div className="px-3 py-2 text-xs text-orange-300/80 truncate">
{d.flagName || "Unnamed flag"} {d.flagName || "Unnamed flag"}

View file

@ -25,7 +25,11 @@ export function FormatNode({ data }: NodeProps) {
]; ];
return ( return (
<NodeShell nodeType="format" dynamicInputs={dynamicInputs} className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40"> <NodeShell
nodeType="format"
dynamicInputs={dynamicInputs}
className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30">
<AlignLeftIcon className="size-4 text-sky-400 shrink-0" /> <AlignLeftIcon className="size-4 text-sky-400 shrink-0" />
<span className="text-sm font-medium text-sky-200 flex-1">Format</span> <span className="text-sm font-medium text-sky-200 flex-1">Format</span>

View file

@ -20,16 +20,15 @@ export function GameDataNode({ data }: NodeProps) {
const fieldLabel = GAME_DATA_FIELDS.find((f) => f.value === d.field)?.label ?? "Select field"; const fieldLabel = GAME_DATA_FIELDS.find((f) => f.value === d.field)?.label ?? "Select field";
return ( return (
<NodeShell nodeType="gameData" className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44"> <NodeShell
nodeType="gameData"
className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-indigo-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-indigo-500/30">
<DatabaseIcon className="size-4 text-indigo-400 shrink-0" /> <DatabaseIcon className="size-4 text-indigo-400 shrink-0" />
<span className="text-sm font-medium text-indigo-200 flex-1 truncate"> <span className="text-sm font-medium text-indigo-200 flex-1 truncate">Game Data</span>
Game Data
</span>
</div>
<div className="px-3 py-2 text-xs text-indigo-300/80">
{fieldLabel}
</div> </div>
<div className="px-3 py-2 text-xs text-indigo-300/80">{fieldLabel}</div>
</NodeShell> </NodeShell>
); );
} }

View file

@ -9,7 +9,10 @@ export function LogicNode({ data }: NodeProps) {
const op = d.operator ?? "AND"; const op = d.operator ?? "AND";
return ( return (
<NodeShell nodeType="logic" className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="logic"
className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-lime-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-lime-500/30">
<BinaryIcon className="size-4 text-lime-400 shrink-0" /> <BinaryIcon className="size-4 text-lime-400 shrink-0" />
<span className="text-sm font-medium text-lime-200 flex-1">Logic</span> <span className="text-sm font-medium text-lime-200 flex-1">Logic</span>

View file

@ -9,7 +9,10 @@ export function MathNode({ data }: NodeProps) {
const op = d.operator ?? "+"; const op = d.operator ?? "+";
return ( return (
<NodeShell nodeType="math" className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="math"
className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-violet-500/30">
<CalculatorIcon className="size-4 text-violet-400 shrink-0" /> <CalculatorIcon className="size-4 text-violet-400 shrink-0" />
<span className="text-sm font-medium text-violet-200 flex-1">Math</span> <span className="text-sm font-medium text-violet-200 flex-1">Math</span>

View file

@ -8,7 +8,10 @@ export function NarrativeBeatNode({ data }: NodeProps) {
const d = data as { title?: string; text?: string }; const d = data as { title?: string; text?: string };
return ( return (
<NodeShell nodeType="narrativeBeat" className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56"> <NodeShell
nodeType="narrativeBeat"
className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-blue-500/30">
<BookOpenTextIcon className="size-4 text-blue-400 shrink-0" /> <BookOpenTextIcon className="size-4 text-blue-400 shrink-0" />
<span className="text-sm font-medium text-blue-200 flex-1 truncate"> <span className="text-sm font-medium text-blue-200 flex-1 truncate">

View file

@ -2,16 +2,16 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
addEdge, addEdge,
type Node, Background,
type Edge,
type Connection, type Connection,
Controls,
type Edge,
MiniMap,
type Node,
ReactFlow,
useEdgesState,
useNodesState,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
@ -38,7 +38,7 @@ import { NotNode } from "./NotNode";
import { BranchNode } from "./BranchNode"; import { BranchNode } from "./BranchNode";
import { SequenceNode } from "./SequenceNode"; import { SequenceNode } from "./SequenceNode";
import { NodeEditDialog } from "./NodeEditDialog"; import { NodeEditDialog } from "./NodeEditDialog";
import { NODE_SOCKETS, type SocketType } from "./types"; import { NODE_SOCKETS } from "./types";
const nodeTypes = { const nodeTypes = {
narrativeBeat: NarrativeBeatNode, narrativeBeat: NarrativeBeatNode,
@ -106,7 +106,12 @@ export function NarrativeFlowEditor() {
const isValidConnection = useCallback( const isValidConnection = useCallback(
(connection: Edge | Connection) => { (connection: Edge | Connection) => {
if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) { if (
!connection.source ||
!connection.target ||
!connection.sourceHandle ||
!connection.targetHandle
) {
return false; return false;
} }
const sourceNode = nodes.find((n) => n.id === connection.source); const sourceNode = nodes.find((n) => n.id === connection.source);
@ -121,8 +126,16 @@ export function NarrativeFlowEditor() {
const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle); const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle);
// Dynamic handles (arg-*, option-*) are treated as "any" // Dynamic handles (arg-*, option-*) are treated as "any"
const sourceType = sourceSocket?.type ?? (connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-") ? "any" : null); const sourceType =
const targetType = targetSocket?.type ?? (connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-") ? "any" : null); sourceSocket?.type ??
(connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-")
? "any"
: null);
const targetType =
targetSocket?.type ??
(connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-")
? "any"
: null);
if (sourceType === null || targetType === null) return false; if (sourceType === null || targetType === null) return false;
if (sourceType === "any" || targetType === "any") return true; if (sourceType === "any" || targetType === "any") return true;
@ -135,21 +148,15 @@ export function NarrativeFlowEditor() {
setEditingNodeId(node.id); setEditingNodeId(node.id);
}, []); }, []);
const onNodeContextMenu = useCallback( const onNodeContextMenu = useCallback((e: React.MouseEvent, node: Node) => {
(e: React.MouseEvent, node: Node) => { e.preventDefault();
e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id });
setContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id }); }, []);
},
[],
);
const onEdgeContextMenu = useCallback( const onEdgeContextMenu = useCallback((e: React.MouseEvent, edge: Edge) => {
(e: React.MouseEvent, edge: Edge) => { e.preventDefault();
e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
setContextMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id }); }, []);
},
[],
);
const onPaneContextMenu = useCallback((e: MouseEvent | React.MouseEvent) => { const onPaneContextMenu = useCallback((e: MouseEvent | React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -178,7 +185,7 @@ export function NarrativeFlowEditor() {
[setNodes], [setNodes],
); );
const selectedNode = editingNodeId ? nodes.find((n) => n.id === editingNodeId) ?? null : null; const selectedNode = editingNodeId ? (nodes.find((n) => n.id === editingNodeId) ?? null) : null;
const addNode = useCallback( const addNode = useCallback(
( (
@ -240,32 +247,129 @@ export function NarrativeFlowEditor() {
const toolbarButtons = ( const toolbarButtons = (
<div className="flex flex-wrap items-center gap-1 px-2 py-1.5 bg-zinc-900 rounded-t-md border border-zinc-700"> <div className="flex flex-wrap items-center gap-1 px-2 py-1.5 bg-zinc-900 rounded-t-md border border-zinc-700">
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold mr-1">Story</span> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold mr-1">
<button className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`} onClick={() => addNode("narrativeBeat")}>Beat</button> Story
<button className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`} onClick={() => addNode("choice")}>Choice</button> </span>
<button className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`} onClick={() => addNode("outcome")}>Outcome</button> <button
<button className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`} onClick={() => addNode("wait")}>Wait</button> className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`}
onClick={() => addNode("narrativeBeat")}
>
Beat
</button>
<button
className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`}
onClick={() => addNode("choice")}
>
Choice
</button>
<button
className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`}
onClick={() => addNode("outcome")}
>
Outcome
</button>
<button
className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`}
onClick={() => addNode("wait")}
>
Wait
</button>
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Data</span> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">
<button className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`} onClick={() => addNode("number")}>Number</button> Data
<button className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`} onClick={() => addNode("string")}>String</button> </span>
<button className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`} onClick={() => addNode("boolean")}>Bool</button> <button
<button className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`} onClick={() => addNode("date")}>Date</button> className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`}
<button className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`} onClick={() => addNode("random")}>Random</button> onClick={() => addNode("number")}
<button className={`${buttonClass} bg-indigo-950 border-indigo-700 text-indigo-200 hover:bg-indigo-900`} onClick={() => addNode("gameData")}>GameData</button> >
<button className={`${buttonClass} bg-orange-950 border-orange-700 text-orange-200 hover:bg-orange-900`} onClick={() => addNode("flag")}>Flag</button> Number
</button>
<button
className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`}
onClick={() => addNode("string")}
>
String
</button>
<button
className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`}
onClick={() => addNode("boolean")}
>
Bool
</button>
<button
className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`}
onClick={() => addNode("date")}
>
Date
</button>
<button
className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`}
onClick={() => addNode("random")}
>
Random
</button>
<button
className={`${buttonClass} bg-indigo-950 border-indigo-700 text-indigo-200 hover:bg-indigo-900`}
onClick={() => addNode("gameData")}
>
GameData
</button>
<button
className={`${buttonClass} bg-orange-950 border-orange-700 text-orange-200 hover:bg-orange-900`}
onClick={() => addNode("flag")}
>
Flag
</button>
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Logic</span> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">
<button className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`} onClick={() => addNode("compare")}>Compare</button> Logic
<button className={`${buttonClass} bg-violet-950 border-violet-700 text-violet-200 hover:bg-violet-900`} onClick={() => addNode("math")}>Math</button> </span>
<button className={`${buttonClass} bg-sky-950 border-sky-700 text-sky-200 hover:bg-sky-900`} onClick={() => addNode("format")}>Format</button> <button
<button className={`${buttonClass} bg-lime-950 border-lime-700 text-lime-200 hover:bg-lime-900`} onClick={() => addNode("logic")}>Logic</button> className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`}
<button className={`${buttonClass} bg-red-950 border-red-700 text-red-200 hover:bg-red-900`} onClick={() => addNode("not")}>NOT</button> onClick={() => addNode("compare")}
<button className={`${buttonClass} bg-rose-950 border-rose-700 text-rose-200 hover:bg-rose-900`} onClick={() => addNode("branch")}>Branch</button> >
<button className={`${buttonClass} bg-fuchsia-950 border-fuchsia-700 text-fuchsia-200 hover:bg-fuchsia-900`} onClick={() => addNode("sequence")}>Seq</button> Compare
</button>
<button
className={`${buttonClass} bg-violet-950 border-violet-700 text-violet-200 hover:bg-violet-900`}
onClick={() => addNode("math")}
>
Math
</button>
<button
className={`${buttonClass} bg-sky-950 border-sky-700 text-sky-200 hover:bg-sky-900`}
onClick={() => addNode("format")}
>
Format
</button>
<button
className={`${buttonClass} bg-lime-950 border-lime-700 text-lime-200 hover:bg-lime-900`}
onClick={() => addNode("logic")}
>
Logic
</button>
<button
className={`${buttonClass} bg-red-950 border-red-700 text-red-200 hover:bg-red-900`}
onClick={() => addNode("not")}
>
NOT
</button>
<button
className={`${buttonClass} bg-rose-950 border-rose-700 text-rose-200 hover:bg-rose-900`}
onClick={() => addNode("branch")}
>
Branch
</button>
<button
className={`${buttonClass} bg-fuchsia-950 border-fuchsia-700 text-fuchsia-200 hover:bg-fuchsia-900`}
onClick={() => addNode("sequence")}
>
Seq
</button>
<span className="ml-auto text-[10px] text-zinc-500"> <span className="ml-auto text-[10px] text-zinc-500">
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge{edges.length !== 1 ? "s" : ""} {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge
{edges.length !== 1 ? "s" : ""}
</span> </span>
</div> </div>
); );

View file

@ -1,12 +1,12 @@
"use client"; "use client";
import { useEffect, useState, type FC } from "react"; import { type FC, useEffect, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -22,12 +22,7 @@ interface NodeEditDialogProps {
onSave: (nodeId: string, data: Record<string, unknown>) => void; onSave: (nodeId: string, data: Record<string, unknown>) => void;
} }
export const NodeEditDialog: FC<NodeEditDialogProps> = ({ export const NodeEditDialog: FC<NodeEditDialogProps> = ({ node, open, onOpenChange, onSave }) => {
node,
open,
onOpenChange,
onSave,
}) => {
const [formData, setFormData] = useState<Record<string, unknown>>({}); const [formData, setFormData] = useState<Record<string, unknown>>({});
useEffect(() => { useEffect(() => {
@ -326,10 +321,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const updated = [ const updated = [...options, { id: crypto.randomUUID(), label: "" }];
...options,
{ id: crypto.randomUUID(), label: "" },
];
updateField("options", updated); updateField("options", updated);
}} }}
className="dark:border-zinc-700 dark:text-zinc-300" className="dark:border-zinc-700 dark:text-zinc-300"
@ -584,11 +576,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
case "not": case "not":
case "branch": case "branch":
case "sequence": { case "sequence": {
return ( return <p className="text-sm text-muted-foreground">No configurable properties.</p>;
<p className="text-sm text-muted-foreground">
No configurable properties.
</p>
);
} }
default: default:
@ -620,7 +608,10 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900" showCloseButton={false}> <DialogContent
className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900"
showCloseButton={false}
>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center justify-between"> <DialogTitle className="flex items-center justify-between">
<span>Edit {label}</span> <span>Edit {label}</span>
@ -642,10 +633,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
> >
Cancel Cancel
</Button> </Button>
<Button <Button onClick={handleSave} className="bg-blue-600 hover:bg-blue-700 text-white">
onClick={handleSave}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
Save Save
</Button> </Button>
</DialogFooter> </DialogFooter>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { Handle, Position } from "@xyflow/react"; import { Handle, Position } from "@xyflow/react";
import { SOCKET_COLORS, NODE_SOCKETS, type SocketDef, type SocketType } from "./types"; import { NODE_SOCKETS, SOCKET_COLORS, type SocketDef } from "./types";
function SocketHandle({ function SocketHandle({
socket, socket,
@ -81,16 +81,12 @@ export function NodeShell({
{/* Input handles - positioned on left edge */} {/* Input handles - positioned on left edge */}
{inputs.map((socket, i) => { {inputs.map((socket, i) => {
const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10; const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
return ( return <SocketHandle key={socket.id} socket={socket} isInput={true} topPercent={top} />;
<SocketHandle key={socket.id} socket={socket} isInput={true} topPercent={top} />
);
})} })}
{/* Output handles - positioned on right edge */} {/* Output handles - positioned on right edge */}
{outputs.map((socket, i) => { {outputs.map((socket, i) => {
const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10; const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
return ( return <SocketHandle key={socket.id} socket={socket} isInput={false} topPercent={top} />;
<SocketHandle key={socket.id} socket={socket} isInput={false} topPercent={top} />
);
})} })}
{/* Content area with padding to avoid handle overlap */} {/* Content area with padding to avoid handle overlap */}
<div className="px-8">{children}</div> <div className="px-8">{children}</div>

View file

@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
export function NotNode(_props: NodeProps) { export function NotNode(_props: NodeProps) {
return ( return (
<NodeShell nodeType="not" className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28"> <NodeShell
nodeType="not"
className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-red-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-red-500/30">
<ToggleLeftIcon className="size-4 text-red-400 shrink-0" /> <ToggleLeftIcon className="size-4 text-red-400 shrink-0" />
<span className="text-sm font-medium text-red-200 flex-1">NOT</span> <span className="text-sm font-medium text-red-200 flex-1">NOT</span>

View file

@ -8,7 +8,10 @@ export function NumberNode({ data }: NodeProps) {
const d = data as { value?: number; description?: string }; const d = data as { value?: number; description?: string };
return ( return (
<NodeShell nodeType="number" className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="number"
className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-purple-500/30">
<HashIcon className="size-4 text-purple-400 shrink-0" /> <HashIcon className="size-4 text-purple-400 shrink-0" />
<span className="text-sm font-medium text-purple-200 flex-1 truncate"> <span className="text-sm font-medium text-purple-200 flex-1 truncate">

View file

@ -13,7 +13,10 @@ export function OutcomeNode({ data }: NodeProps) {
const effects = d.effects ?? []; const effects = d.effects ?? [];
return ( return (
<NodeShell nodeType="outcome" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56"> <NodeShell
nodeType="outcome"
className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
<FlagIcon className="size-4 text-emerald-400 shrink-0" /> <FlagIcon className="size-4 text-emerald-400 shrink-0" />
<span className="text-sm font-medium text-emerald-200 flex-1 truncate"> <span className="text-sm font-medium text-emerald-200 flex-1 truncate">

View file

@ -8,7 +8,10 @@ export function RandomNode({ data }: NodeProps) {
const d = data as { min?: number; max?: number; description?: string }; const d = data as { min?: number; max?: number; description?: string };
return ( return (
<NodeShell nodeType="random" className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="random"
className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-pink-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-pink-500/30">
<ShuffleIcon className="size-4 text-pink-400 shrink-0" /> <ShuffleIcon className="size-4 text-pink-400 shrink-0" />
<span className="text-sm font-medium text-pink-200 flex-1 truncate"> <span className="text-sm font-medium text-pink-200 flex-1 truncate">

View file

@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
export function SequenceNode(_props: NodeProps) { export function SequenceNode(_props: NodeProps) {
return ( return (
<NodeShell nodeType="sequence" className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="sequence"
className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-fuchsia-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-fuchsia-500/30">
<ListOrderedIcon className="size-4 text-fuchsia-400 shrink-0" /> <ListOrderedIcon className="size-4 text-fuchsia-400 shrink-0" />
<span className="text-sm font-medium text-fuchsia-200 flex-1">Sequence</span> <span className="text-sm font-medium text-fuchsia-200 flex-1">Sequence</span>

View file

@ -8,7 +8,10 @@ export function StringNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string }; const d = data as { value?: string; description?: string };
return ( return (
<NodeShell nodeType="string" className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="string"
className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-teal-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-teal-500/30">
<TypeIcon className="size-4 text-teal-400 shrink-0" /> <TypeIcon className="size-4 text-teal-400 shrink-0" />
<span className="text-sm font-medium text-teal-200 flex-1 truncate"> <span className="text-sm font-medium text-teal-200 flex-1 truncate">

View file

@ -8,7 +8,10 @@ export function WaitNode({ data }: NodeProps) {
const d = data as { ticks?: number; input?: unknown }; const d = data as { ticks?: number; input?: unknown };
return ( return (
<NodeShell nodeType="wait" className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48"> <NodeShell
nodeType="wait"
className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-yellow-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-yellow-500/30">
<TimerIcon className="size-4 text-yellow-400 shrink-0" /> <TimerIcon className="size-4 text-yellow-400 shrink-0" />
<span className="text-sm font-medium text-yellow-200 flex-1 truncate"> <span className="text-sm font-medium text-yellow-200 flex-1 truncate">