Redesign UI: dark theme, collapsible object dropdowns, steppers, improved canvas rendering
This commit is contained in:
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>furniture-app</title>
|
||||
<script type="module" crossorigin src="/assets/index-DtrVKW5h.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B4HUQdL0.css">
|
||||
<script type="module" crossorigin src="/assets/index-DCx-KkvH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-YNouK8Wq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+11
-9
@@ -109,7 +109,7 @@ function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', fontFamily: 'sans-serif' }}>
|
||||
<div className="app">
|
||||
<ControlPanel
|
||||
state={state}
|
||||
onStateChange={setState}
|
||||
@@ -121,14 +121,16 @@ function App() {
|
||||
optimizing={optimizing}
|
||||
timeLeft={timeLeft}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
|
||||
<RoomCanvas
|
||||
state={state}
|
||||
onStateChange={setState}
|
||||
mode={mode}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
<div className="stage">
|
||||
<div className="canvas-frame">
|
||||
<RoomCanvas
|
||||
state={state}
|
||||
onStateChange={setState}
|
||||
mode={mode}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+332
-279
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import type { ConditionKind, Entrance, FurnitureConfig, LayoutState, RoomConfig } from '../types';
|
||||
|
||||
interface ControlPanelProps {
|
||||
@@ -12,6 +13,26 @@ interface ControlPanelProps {
|
||||
timeLeft: number;
|
||||
}
|
||||
|
||||
const CONDITION_KINDS: ConditionKind[] = [
|
||||
'walk_to_and_sit',
|
||||
'around_open_area',
|
||||
'near_entrance',
|
||||
'near_object',
|
||||
'accessible',
|
||||
'not_blocking_path',
|
||||
'open_circulation',
|
||||
];
|
||||
|
||||
const CONDITION_LABELS: Record<ConditionKind, string> = {
|
||||
walk_to_and_sit: 'Walk to and sit',
|
||||
around_open_area: 'Around open area',
|
||||
near_entrance: 'Near entrance',
|
||||
near_object: 'Near object',
|
||||
accessible: 'Accessible',
|
||||
not_blocking_path: 'Not blocking path',
|
||||
open_circulation: 'Open circulation',
|
||||
};
|
||||
|
||||
export function ControlPanel({
|
||||
state,
|
||||
onStateChange,
|
||||
@@ -23,6 +44,7 @@ export function ControlPanel({
|
||||
optimizing,
|
||||
timeLeft,
|
||||
}: ControlPanelProps) {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const setRoom = (room: Partial<RoomConfig>) => {
|
||||
onStateChange({ ...state, room: { ...state.room, ...room } });
|
||||
@@ -54,315 +76,346 @@ export function ControlPanel({
|
||||
};
|
||||
onStateChange({ ...state, furniture: [...state.furniture, newItem] });
|
||||
onSelect(id);
|
||||
setOpenId(id);
|
||||
};
|
||||
|
||||
const toggleLock = () => {
|
||||
if (!selectedId) return;
|
||||
const updateObject = (id: string, patch: Partial<FurnitureConfig>) => {
|
||||
const updated = state.furniture.map((f) => (f.id === id ? { ...f, ...patch } : f));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const updateSize = (id: string, width: number, height: number) => {
|
||||
const updated = state.furniture.map((f) =>
|
||||
f.id === selectedId ? { ...f, lockedForOptimization: !f.lockedForOptimization } : f,
|
||||
f.id === id ? { ...f, shape: { ...f.shape, width: Math.max(10, width), height: Math.max(10, height) } } : f,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const updateSelectedCondition = (kind: ConditionKind, enabled: boolean) => {
|
||||
if (!selectedId) return;
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== selectedId) return f;
|
||||
const has = f.conditions.some((c) => c.kind === kind);
|
||||
if (enabled && !has) {
|
||||
const params = kind === 'near_object' ? { targetId: '' } : {};
|
||||
return { ...f, conditions: [...f.conditions, { kind, weight: 1, params }] };
|
||||
}
|
||||
if (!enabled && has) {
|
||||
return { ...f, conditions: f.conditions.filter((c) => c.kind !== kind) };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
const toggleLock = (id: string) => {
|
||||
const updated = state.furniture.map((f) =>
|
||||
f.id === id ? { ...f, lockedForOptimization: !f.lockedForOptimization } : f,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const setNearObjectTarget = (targetId: string) => {
|
||||
if (!selectedId) return;
|
||||
const setNearObjectTarget = (id: string, targetId: string) => {
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== selectedId) return f;
|
||||
if (f.id !== id) return f;
|
||||
return {
|
||||
...f,
|
||||
conditions: f.conditions.map((c) => (c.kind === 'near_object' ? { ...c, params: { ...c.params, targetId } } : c)),
|
||||
conditions: f.conditions.map((c) =>
|
||||
c.kind === 'near_object' ? { ...c, params: { ...c.params, targetId } } : c,
|
||||
),
|
||||
};
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const updateSelectedSize = (width: number, height: number) => {
|
||||
if (!selectedId) return;
|
||||
const updated = state.furniture.map((f) =>
|
||||
f.id === selectedId
|
||||
? { ...f, shape: { ...f.shape, width: Math.max(10, width), height: Math.max(10, height) } }
|
||||
: f,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
const adjustAmount = (type: string, delta: number) => {
|
||||
const current = state.objectAmounts?.[type] ?? 0;
|
||||
onStateChange({
|
||||
...state,
|
||||
objectAmounts: { ...(state.objectAmounts || {}), [type]: Math.max(0, current + delta) },
|
||||
});
|
||||
};
|
||||
|
||||
const selected = state.furniture.find((f) => f.id === selectedId);
|
||||
const setAmount = (type: string, value: number) => {
|
||||
onStateChange({
|
||||
...state,
|
||||
objectAmounts: { ...(state.objectAmounts || {}), [type]: Math.max(0, value) },
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOpen = (id: string) => {
|
||||
onSelect(id);
|
||||
setOpenId((cur) => (cur === id ? null : id));
|
||||
};
|
||||
|
||||
const renderObjectCard = (f: FurnitureConfig) => {
|
||||
const open = openId === f.id;
|
||||
const selected = selectedId === f.id;
|
||||
return (
|
||||
<div key={f.id} className={`obj-card${selected ? ' selected' : ''}`}>
|
||||
<div className="obj-head" onClick={() => toggleOpen(f.id)}>
|
||||
<span className={`obj-caret ${open ? 'open' : ''}`}>{'\u25B6'}</span>
|
||||
<span className="obj-swatch" style={{ background: f.color || '#9aa0a6' }} />
|
||||
<span className="obj-name">{f.name}</span>
|
||||
{f.lockedForOptimization && <span className="obj-badge locked">locked</span>}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="obj-body">
|
||||
<div className="field" style={{ marginBottom: 8 }}>
|
||||
<span>Name</span>
|
||||
<input value={f.name} onChange={(e) => updateObject(f.id, { name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={f.shape.width}
|
||||
onChange={(e) => updateSize(f.id, Number(e.target.value), f.shape.height)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={f.shape.height}
|
||||
onChange={(e) => updateSize(f.id, f.shape.width, Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginTop: 10, justifyContent: 'space-between' }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.lockedForOptimization}
|
||||
onChange={() => toggleLock(f.id)}
|
||||
/>
|
||||
Lock position
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="section-title" style={{ marginTop: 12 }}>
|
||||
Conditions
|
||||
</div>
|
||||
<div className="cond-list">
|
||||
{CONDITION_KINDS.map((k) => {
|
||||
const active = f.conditions.some((c) => c.kind === k);
|
||||
const targetId = (f.conditions.find((c) => c.kind === k)?.params as { targetId?: string } | undefined)
|
||||
?.targetId;
|
||||
return (
|
||||
<div className="cond" key={k}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={(e) => {
|
||||
const updated = state.furniture.map((x) => {
|
||||
if (x.id !== f.id) return x;
|
||||
const has = x.conditions.some((c) => c.kind === k);
|
||||
if (e.target.checked && !has) {
|
||||
const params = k === 'near_object' ? { targetId: '' } : {};
|
||||
return { ...x, conditions: [...x.conditions, { kind: k, weight: 1, params }] };
|
||||
}
|
||||
if (!e.target.checked && has) {
|
||||
return { ...x, conditions: x.conditions.filter((c) => c.kind !== k) };
|
||||
}
|
||||
return x;
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
/>
|
||||
{CONDITION_LABELS[k]}
|
||||
</label>
|
||||
{k === 'near_object' && active && (
|
||||
<select value={targetId || ''} onChange={(e) => setNearObjectTarget(f.id, e.target.value)}>
|
||||
<option value="">Select object…</option>
|
||||
{state.furniture
|
||||
.filter((other) => other.id !== f.id)
|
||||
.map((other) => (
|
||||
<option key={other.id} value={other.id}>
|
||||
{other.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 12, borderRight: '1px solid #ccc', minWidth: 260 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => onModeChange('layout')} disabled={mode === 'layout'}>
|
||||
Layout
|
||||
</button>
|
||||
<button onClick={() => onModeChange('optimize')} disabled={mode === 'optimize'} style={{ marginLeft: 8 }}>
|
||||
Optimize
|
||||
</button>
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-title">
|
||||
<span className="dot" />
|
||||
Venue Planner
|
||||
</div>
|
||||
<div className="segmented" style={{ marginTop: 12 }}>
|
||||
<button
|
||||
className={mode === 'layout' ? 'active' : ''}
|
||||
onClick={() => onModeChange('layout')}
|
||||
>
|
||||
Layout
|
||||
</button>
|
||||
<button
|
||||
className={mode === 'optimize' ? 'active' : ''}
|
||||
onClick={() => onModeChange('optimize')}
|
||||
>
|
||||
Optimize
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'layout' && (
|
||||
<div>
|
||||
<h3>Room</h3>
|
||||
<label>
|
||||
Width:
|
||||
<input
|
||||
type="number"
|
||||
value={state.room.width}
|
||||
onChange={(e) => setRoom({ width: Number(e.target.value) })}
|
||||
style={{ width: 80, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 8 }}>
|
||||
Height:
|
||||
<input
|
||||
type="number"
|
||||
value={state.room.height}
|
||||
onChange={(e) => setRoom({ height: Number(e.target.value) })}
|
||||
style={{ width: 80, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<div className="sidebar-body">
|
||||
{mode === 'layout' && (
|
||||
<>
|
||||
<div className="section" style={{ marginTop: 0 }}>
|
||||
<div className="section-title">Room Size</div>
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<input
|
||||
type="number"
|
||||
value={state.room.width}
|
||||
onChange={(e) => setRoom({ width: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<input
|
||||
type="number"
|
||||
value={state.room.height}
|
||||
onChange={(e) => setRoom({ height: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={placeEntrance} style={{ marginLeft: 8 }}>
|
||||
Place Entrance
|
||||
</button>
|
||||
<button onClick={createObject} style={{ marginLeft: 8 }}>
|
||||
Create Object
|
||||
</button>
|
||||
</div>
|
||||
{state.entrance && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label>
|
||||
Ex:
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.x}
|
||||
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 4 }}>
|
||||
Ey:
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.y}
|
||||
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{ marginTop: 12 }}>Premade Objects</h3>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>Tables:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.objectAmounts?.table ?? 0}
|
||||
onChange={(e) =>
|
||||
onStateChange({
|
||||
...state,
|
||||
objectAmounts: { ...(state.objectAmounts || {}), table: Math.max(0, Number(e.target.value)) },
|
||||
})
|
||||
}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
|
||||
<span>Chairs:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.objectAmounts?.chair ?? 0}
|
||||
onChange={(e) =>
|
||||
onStateChange({
|
||||
...state,
|
||||
objectAmounts: { ...(state.objectAmounts || {}), chair: Math.max(0, Number(e.target.value)) },
|
||||
})
|
||||
}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h3 style={{ marginTop: 12 }}>Objects</h3>
|
||||
<div>
|
||||
{state.entrance && (
|
||||
<div
|
||||
onClick={() => onSelect('__entrance__')}
|
||||
style={{
|
||||
padding: 4,
|
||||
background: selectedId === '__entrance__' ? '#eee' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Entrance
|
||||
</div>
|
||||
)}
|
||||
{state.furniture.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
onClick={() => onSelect(f.id)}
|
||||
style={{
|
||||
padding: 4,
|
||||
background: selectedId === f.id ? '#eee' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{f.name} {f.lockedForOptimization ? '(locked)' : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedId === '__entrance__' && state.entrance && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>Entrance Settings</div>
|
||||
<label>
|
||||
X:
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.x}
|
||||
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
|
||||
style={{ width: 60, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 8 }}>
|
||||
Y:
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.y}
|
||||
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
|
||||
style={{ width: 60, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<label>
|
||||
W:
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={state.entrance.width ?? 20}
|
||||
onChange={(e) => setEntrance({ width: Math.max(10, Number(e.target.value)) })}
|
||||
style={{ width: 60, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 8 }}>
|
||||
H:
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={state.entrance.height ?? 20}
|
||||
onChange={(e) => setEntrance({ height: Math.max(10, Number(e.target.value)) })}
|
||||
style={{ width: 60, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<div className="section">
|
||||
<div className="section-title">Scene</div>
|
||||
<div className="row">
|
||||
<button onClick={placeEntrance} style={{ flex: 1 }}>
|
||||
Place Entrance
|
||||
</button>
|
||||
<button onClick={createObject} style={{ flex: 1 }}>
|
||||
Create Object
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>
|
||||
<label>
|
||||
Name:
|
||||
<input
|
||||
value={selected.name}
|
||||
onChange={(e) => {
|
||||
const updated = state.furniture.map((f) => (f.id === selectedId ? { ...f, name: e.target.value } : f));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
style={{ width: 120, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Furniture Presets</div>
|
||||
<div className="obj-list">
|
||||
<div className="counter">
|
||||
<span className="counter-label">
|
||||
<span className="swatch" style={{ background: '#a1887f' }} />
|
||||
Tables
|
||||
</span>
|
||||
<span className="stepper">
|
||||
<button onClick={() => adjustAmount('table', -1)}>−</button>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.objectAmounts?.table ?? 0}
|
||||
onChange={(e) => setAmount('table', Number(e.target.value))}
|
||||
/>
|
||||
<button onClick={() => adjustAmount('table', 1)}>+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="counter">
|
||||
<span className="counter-label">
|
||||
<span className="swatch" style={{ background: '#d7ccc5' }} />
|
||||
Chairs
|
||||
</span>
|
||||
<span className="stepper">
|
||||
<button onClick={() => adjustAmount('chair', -1)}>−</button>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.objectAmounts?.chair ?? 0}
|
||||
onChange={(e) => setAmount('chair', Number(e.target.value))}
|
||||
/>
|
||||
<button onClick={() => adjustAmount('chair', 1)}>+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button onClick={toggleLock}>{selected.lockedForOptimization ? 'Unlock' : 'Lock'} for opt</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>Size:</div>
|
||||
<label>
|
||||
W:
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={selected.shape.width}
|
||||
onChange={(e) => updateSelectedSize(Number(e.target.value), selected.shape.height)}
|
||||
style={{ width: 60, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 8 }}>
|
||||
H:
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={selected.shape.height}
|
||||
onChange={(e) => updateSelectedSize(selected.shape.width, Number(e.target.value))}
|
||||
style={{ width: 60, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>Conditions:</div>
|
||||
{(['walk_to_and_sit', 'around_open_area', 'near_entrance', 'near_object', 'accessible', 'not_blocking_path', 'open_circulation'] as ConditionKind[]).map(
|
||||
(k) => (
|
||||
<div key={k} style={{ display: 'block' }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.conditions.some((c) => c.kind === k)}
|
||||
onChange={(e) => updateSelectedCondition(k, e.target.checked)}
|
||||
/>
|
||||
{k}
|
||||
</label>
|
||||
{k === 'near_object' && selected.conditions.some((c) => c.kind === 'near_object') && (
|
||||
<select
|
||||
style={{ marginLeft: 8 }}
|
||||
value={(selected.conditions.find((c) => c.kind === 'near_object')?.params as any)?.targetId || ''}
|
||||
onChange={(e) => setNearObjectTarget(e.target.value)}
|
||||
>
|
||||
<option value="">Select object</option>
|
||||
{state.furniture
|
||||
.filter((f) => f.id !== selected.id)
|
||||
.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Objects</div>
|
||||
{state.entrance && (
|
||||
<div
|
||||
className={`obj-card ${selectedId === '__entrance__' ? 'selected' : ''}`}
|
||||
style={{ marginBottom: 6 }}
|
||||
>
|
||||
<div className="obj-head" onClick={() => onSelect('__entrance__')}>
|
||||
<span className="obj-swatch" style={{ background: '#3fbf7f' }} />
|
||||
<span className="obj-name">Entrance</span>
|
||||
<span className="obj-badge">fixed</span>
|
||||
</div>
|
||||
{selectedId === '__entrance__' && (
|
||||
<div className="obj-body">
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>X</span>
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.x}
|
||||
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Y</span>
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.y}
|
||||
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={state.entrance.width ?? 20}
|
||||
onChange={(e) => setEntrance({ width: Math.max(10, Number(e.target.value)) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={state.entrance.height ?? 20}
|
||||
onChange={(e) => setEntrance({ height: Math.max(10, Number(e.target.value)) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.furniture.length === 0 && !state.entrance ? (
|
||||
<div className="empty">No objects yet — add a preset or create one.</div>
|
||||
) : (
|
||||
<div className="obj-list">{state.furniture.map(renderObjectCard)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'optimize' && (
|
||||
<div>
|
||||
<h3>Optimize</h3>
|
||||
<p>Runs random search (15s) then NN-informed placement heuristic.</p>
|
||||
<button onClick={onOptimize} disabled={optimizing}>
|
||||
{optimizing ? `Optimizing... ${timeLeft}s` : 'Optimize'}
|
||||
</button>
|
||||
{optimizing && <div style={{ marginTop: 8 }}>Randomizing layouts, keeping best...</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mode === 'optimize' && (
|
||||
<div className="section" style={{ marginTop: 0 }}>
|
||||
<div className="section-title">Optimization</div>
|
||||
<p className="hint">
|
||||
Placed objects are locked in position. Unplaced objects are randomized across the room for 15
|
||||
seconds; the best-scoring layout is kept.
|
||||
</p>
|
||||
<button className="primary" onClick={onOptimize} disabled={optimizing} style={{ width: '100%' }}>
|
||||
{optimizing ? `Optimizing… ${timeLeft}s` : 'Optimize Layout'}
|
||||
</button>
|
||||
{optimizing && (
|
||||
<>
|
||||
<div className="progress">
|
||||
<div style={{ width: `${((15 - timeLeft) / 15) * 100}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,27 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
ctx.save();
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.fillStyle = '#12151b';
|
||||
ctx.fillRect(0, 0, state.room.width, state.room.height);
|
||||
|
||||
// subtle grid
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.035)';
|
||||
ctx.lineWidth = 1;
|
||||
const step = 25;
|
||||
for (let x = 0; x <= state.room.width; x += step) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, state.room.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= state.room.height; y += step) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(state.room.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = '#2c3342';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(0, 0, state.room.width, state.room.height);
|
||||
|
||||
@@ -42,30 +62,58 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
const e = state.entrance;
|
||||
const w = e.width ?? 20;
|
||||
const h = e.height ?? 20;
|
||||
ctx.fillStyle = '#4caf50';
|
||||
const selected = selectedId === '__entrance__';
|
||||
ctx.fillStyle = '#3fbf7f';
|
||||
ctx.fillRect(e.x, e.y, w, h);
|
||||
ctx.strokeStyle = selectedId === '__entrance__' ? '#ff5722' : '#333';
|
||||
ctx.lineWidth = selectedId === '__entrance__' ? 3 : 1;
|
||||
ctx.strokeStyle = selected ? '#4f8cff' : 'rgba(0, 0, 0, 0.4)';
|
||||
ctx.lineWidth = selected ? 2.5 : 1;
|
||||
ctx.strokeRect(e.x, e.y, w, h);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText('ENTRANCE', e.x + 2, e.y + 12);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.font = '600 10px Inter, sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
if (w > 50) ctx.fillText('ENTRANCE', e.x + 6, e.y + h / 2);
|
||||
// resize handle (SE)
|
||||
ctx.fillStyle = selectedId === '__entrance__' ? '#ff5722' : '#666';
|
||||
ctx.fillStyle = selected ? '#4f8cff' : 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillRect(e.x + w - 4, e.y + h - 4, 8, 8);
|
||||
}
|
||||
|
||||
for (const f of state.furniture) {
|
||||
ctx.fillStyle = f.color || '#9aa0a6';
|
||||
const b = { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height };
|
||||
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.5 : 0.9;
|
||||
ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||
ctx.strokeStyle = selectedId === f.id ? '#ff5722' : '#333';
|
||||
ctx.lineWidth = selectedId === f.id ? 3 : 1;
|
||||
ctx.strokeRect(b.x, b.y, b.w, b.h);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText(f.name.substring(0, 12), b.x + 2, b.y + 12);
|
||||
const r = Math.min(8, b.w / 4, b.h / 4);
|
||||
const selected = selectedId === f.id;
|
||||
|
||||
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.55 : 1;
|
||||
|
||||
ctx.beginPath();
|
||||
roundRect(ctx, b.x, b.y, b.w, b.h, r);
|
||||
ctx.fillStyle = f.color || '#9aa0a6';
|
||||
ctx.fill();
|
||||
|
||||
if (selected) {
|
||||
ctx.shadowColor = '#4f8cff';
|
||||
ctx.shadowBlur = 14;
|
||||
ctx.strokeStyle = '#4f8cff';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
} else {
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)';
|
||||
ctx.font = '600 11px Inter, sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
const label = b.w > 44 ? f.name.substring(0, 14) : '';
|
||||
if (label) ctx.fillText(label, b.x + 7, b.y + b.h / 2);
|
||||
|
||||
if (f.lockedForOptimization) {
|
||||
ctx.font = '10px Inter, sans-serif';
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
ctx.fillText('🔒', b.x + b.w - 15, b.y + 9);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
@@ -156,7 +204,7 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
ref={canvasRef}
|
||||
width={Math.max(state.room.width, 400)}
|
||||
height={Math.max(state.room.height, 300)}
|
||||
style={{ border: '1px solid #ccc', maxWidth: '100%', height: 'auto' }}
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
@@ -167,3 +215,19 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
function pointInRect(p: Point, r: { x: number; y: number; w: number; h: number }): boolean {
|
||||
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
|
||||
}
|
||||
|
||||
function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
): void {
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
+472
-1
@@ -1,5 +1,476 @@
|
||||
html, body, #root {
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--bg-elev: #171a21;
|
||||
--bg-panel: #1b1f27;
|
||||
--bg-input: #232833;
|
||||
--bg-hover: #262c38;
|
||||
--border: #2c3342;
|
||||
--border-strong: #3a4356;
|
||||
--text: #e6e9ef;
|
||||
--text-dim: #9aa3b2;
|
||||
--text-mute: #6b7486;
|
||||
--accent: #4f8cff;
|
||||
--accent-soft: rgba(79, 140, 255, 0.15);
|
||||
--accent-strong: #3b78e8;
|
||||
--green: #3fbf7f;
|
||||
--amber: #e0a83e;
|
||||
--red: #e05555;
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
width: 68px;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
accent-color: var(--accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ---------- App shell ---------- */
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
height: 100%;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-title .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px 24px;
|
||||
}
|
||||
|
||||
.sidebar-body::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
}
|
||||
|
||||
.sidebar-body::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--bg-panel);
|
||||
}
|
||||
|
||||
/* ---------- Segmented tabs ---------- */
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 7px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.segmented button:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.segmented button.active:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
.section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.9px;
|
||||
color: var(--text-mute);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.row.between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 6px 5px 12px;
|
||||
}
|
||||
|
||||
.counter .counter-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.counter .swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stepper button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stepper input {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.stepper input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ---------- Accordion object cards ---------- */
|
||||
.obj-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.obj-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.obj-card.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.obj-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.obj-head:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.obj-caret {
|
||||
width: 12px;
|
||||
color: var(--text-mute);
|
||||
transition: transform 0.15s ease;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.obj-caret.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.obj-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
flex: none;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.obj-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.obj-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.obj-badge.locked {
|
||||
color: var(--amber);
|
||||
background: rgba(224, 168, 62, 0.14);
|
||||
}
|
||||
|
||||
.obj-body {
|
||||
padding: 4px 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
animation: reveal 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.cond-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cond {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cond label {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cond select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
/* ---------- Canvas area ---------- */
|
||||
.stage {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.04) 1px, transparent 0) 0 0 / 22px 22px,
|
||||
var(--bg);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canvas-frame {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 14px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.canvas-frame canvas {
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 6px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.progress > div {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), #7db0ff);
|
||||
transition: width 0.9s linear;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 14px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user