587 lines
22 KiB
TypeScript
587 lines
22 KiB
TypeScript
import { useRef, useState, useEffect } from 'react';
|
||
import { useBuilder } from '../store/builderStore';
|
||
import type { RenderMode, ViewMode, PlacedPart } from '../types';
|
||
import {
|
||
downloadProject,
|
||
importProjectFile,
|
||
} from '../utils/serializer';
|
||
import { useAuth } from '../auth/AuthContext';
|
||
import { PaywallModal } from '../payments/PaywallModal';
|
||
import { addToast } from './Toast';
|
||
import { useSaveStatus } from '../store/saveStatusStore';
|
||
|
||
function SaveIndicator() {
|
||
const status = useSaveStatus((s) => s.status);
|
||
if (status === 'saved') {
|
||
return (
|
||
<span
|
||
title="All changes saved to browser"
|
||
className="h-2 w-2 rounded-full bg-emerald-500 transition-colors duration-300 shrink-0"
|
||
/>
|
||
);
|
||
}
|
||
if (status === 'saving') {
|
||
return (
|
||
<span
|
||
title="Saving changes..."
|
||
className="h-2 w-2 rounded-full bg-amber-500 animate-pulse shrink-0"
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<span
|
||
title="Unsaved changes"
|
||
className="h-2 w-2 rounded-full bg-amber-500 shrink-0"
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Toolbar — project actions (new/save/load/export/import), undo/redo,
|
||
* tools (measure, snap), view presets, and the flow-animation toggle.
|
||
*/
|
||
export function Toolbar({
|
||
onOpenBom,
|
||
onCheckBuild,
|
||
}: {
|
||
onOpenBom: () => void;
|
||
onCheckBuild: () => void;
|
||
}) {
|
||
const { token, user, logout, refreshProfile } = useAuth();
|
||
const [showPaywall, setShowPaywall] = useState(false);
|
||
const [projectsList, setProjectsList] = useState<{ name: string; savedAt: string; parts: PlacedPart[] }[]>([]);
|
||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||
|
||
const projectName = useBuilder((s) => s.projectName);
|
||
const setProjectName = useBuilder((s) => s.setProjectName);
|
||
const growthStage = useBuilder((s) => s.growthStage);
|
||
const setGrowthStage = useBuilder((s) => s.setGrowthStage);
|
||
const viewMode = useBuilder((s) => s.viewMode);
|
||
const setViewMode = useBuilder((s) => s.setViewMode);
|
||
const renderMode = useBuilder((s) => s.renderMode);
|
||
const setRenderMode = useBuilder((s) => s.setRenderMode);
|
||
const tool = useBuilder((s) => s.tool);
|
||
const setTool = useBuilder((s) => s.setTool);
|
||
const showFlow = useBuilder((s) => s.showFlow);
|
||
const toggleFlow = useBuilder((s) => s.toggleFlow);
|
||
const simRunning = useBuilder((s) => s.simRunning);
|
||
const toggleSimRunning = useBuilder((s) => s.toggleSimRunning);
|
||
const snapToGrid = useBuilder((s) => s.snapToGrid);
|
||
const toggleSnap = useBuilder((s) => s.toggleSnap);
|
||
const undo = useBuilder((s) => s.undo);
|
||
const redo = useBuilder((s) => s.redo);
|
||
const canUndo = useBuilder((s) => s.past.length > 0);
|
||
const canRedo = useBuilder((s) => s.future.length > 0);
|
||
const autoCapOpenEnds = useBuilder((s) => s.autoCapOpenEnds);
|
||
const rooms = useBuilder((s) => s.rooms);
|
||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||
const addRoom = useBuilder((s) => s.addRoom);
|
||
const removeRoom = useBuilder((s) => s.removeRoom);
|
||
const setActiveRoomId = useBuilder((s) => s.setActiveRoomId);
|
||
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const [loadOpen, setLoadOpen] = useState(false);
|
||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||
|
||
const fetchProjects = async () => {
|
||
if (!token) return;
|
||
setLoadingProjects(true);
|
||
try {
|
||
const response = await fetch('/api/projects', {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setProjectsList(data);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to fetch projects', err);
|
||
} finally {
|
||
setLoadingProjects(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (loadOpen) {
|
||
void fetchProjects();
|
||
}
|
||
}, [loadOpen]);
|
||
|
||
const handleSave = async () => {
|
||
if (!token) return;
|
||
const s = useBuilder.getState();
|
||
const partsArray = Object.values(s.parts);
|
||
|
||
try {
|
||
const response = await fetch('/api/projects', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify({ name: s.projectName, parts: partsArray })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 402) {
|
||
setShowPaywall(true);
|
||
} else {
|
||
addToast(data.error || 'Failed to save project', 'error');
|
||
}
|
||
return;
|
||
}
|
||
|
||
await refreshProfile();
|
||
addToast('Project saved successfully!', 'success');
|
||
} catch (err) {
|
||
addToast('Save failed: ' + err, 'error');
|
||
}
|
||
};
|
||
|
||
const handleLoad = (name: string) => {
|
||
const proj = projectsList.find((p) => p.name === name);
|
||
if (proj) {
|
||
useBuilder.getState().loadParts(proj.parts, proj.name);
|
||
}
|
||
setLoadOpen(false);
|
||
};
|
||
|
||
const handleDelete = async (e: React.MouseEvent, name: string) => {
|
||
e.stopPropagation();
|
||
if (!token || !confirm(`Delete project "${name}" permanently?`)) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/projects/${encodeURIComponent(name)}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
if (response.ok) {
|
||
await fetchProjects();
|
||
await refreshProfile();
|
||
addToast('Project deleted successfully', 'success');
|
||
} else {
|
||
const data = await response.json();
|
||
addToast(data.error || 'Failed to delete project', 'error');
|
||
}
|
||
} catch (err) {
|
||
addToast('Delete failed: ' + err, 'error');
|
||
}
|
||
};
|
||
|
||
const handleExport = () => {
|
||
const s = useBuilder.getState();
|
||
downloadProject(s.projectName, Object.values(s.parts));
|
||
};
|
||
|
||
const handleImport = async (file: File) => {
|
||
try {
|
||
const proj = await importProjectFile(file);
|
||
useBuilder.getState().loadParts(proj.parts, proj.name);
|
||
addToast('Project imported successfully', 'success');
|
||
} catch (err) {
|
||
addToast(err instanceof Error ? err.message : 'Import failed', 'error');
|
||
}
|
||
};
|
||
|
||
const handleNew = () => {
|
||
if (confirm('Start a new empty project? Unsaved changes are kept in undo history.')) {
|
||
useBuilder.getState().clearAll();
|
||
setProjectName('Untitled system');
|
||
}
|
||
};
|
||
|
||
const views: { id: ViewMode; label: string; title: string }[] = [
|
||
{ id: 'orbit', label: '3D', title: 'Free 3D orbit view' },
|
||
{ id: 'top', label: 'Top', title: 'Top-down plan view' },
|
||
{ id: 'front', label: 'Front', title: 'Front elevation view' },
|
||
{ id: 'side', label: 'Side', title: 'Side elevation view' },
|
||
];
|
||
const renderModes: { id: RenderMode; label: string; title: string }[] = [
|
||
{ id: 'builder', label: 'Builder', title: 'Standard building mode' },
|
||
{ id: 'xray', label: 'X-Ray', title: 'Transparent shell with internal water visibility' },
|
||
{ id: 'water', label: 'Water', title: 'Water-first view with solids faded back' },
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-zinc-800 bg-zinc-950 px-3">
|
||
<div className="flex items-center gap-2 pr-2">
|
||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-sky-500 to-blue-700 text-sm font-black text-white">
|
||
H
|
||
</span>
|
||
<input
|
||
value={projectName}
|
||
onChange={(e) => setProjectName(e.target.value)}
|
||
className="w-44 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-sm font-semibold text-zinc-100 outline-none hover:border-zinc-800 focus:border-sky-700 focus:bg-zinc-900"
|
||
/>
|
||
<SaveIndicator />
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
<TBtn onClick={handleNew} title="New empty project">New</TBtn>
|
||
<TBtn onClick={handleSave} title="Save to cloud storage">Save</TBtn>
|
||
<div className="relative">
|
||
<TBtn onClick={() => setLoadOpen((o) => !o)} title="Load a saved project">
|
||
Load ▾
|
||
</TBtn>
|
||
{loadOpen && (
|
||
<div className="absolute top-9 left-0 z-50 max-h-72 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 p-1 shadow-2xl">
|
||
{loadingProjects && (
|
||
<div className="px-3 py-2 text-xs text-zinc-500">Loading...</div>
|
||
)}
|
||
{!loadingProjects && projectsList.length === 0 && (
|
||
<div className="px-3 py-2 text-xs text-zinc-500">No saved projects yet.</div>
|
||
)}
|
||
{!loadingProjects && projectsList.map((p) => (
|
||
<div
|
||
key={p.name}
|
||
className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-zinc-800 group"
|
||
>
|
||
<button
|
||
onClick={() => handleLoad(p.name)}
|
||
className="flex-1 text-left text-xs text-zinc-200 outline-none"
|
||
>
|
||
{p.name}
|
||
<span className="block text-[10px] text-zinc-500">
|
||
{new Date(p.savedAt).toLocaleString()}
|
||
</span>
|
||
</button>
|
||
<button
|
||
onClick={(e) => handleDelete(e, p.name)}
|
||
className="hidden group-hover:block text-[10px] text-red-400 hover:text-red-300 px-2 py-0.5 rounded bg-zinc-950/40"
|
||
title="Delete project"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
|
||
<div className="mt-2 border-t border-zinc-700/50 pt-2">
|
||
<div className="px-3 pb-1 text-[10px] font-bold tracking-wider text-amber-500 uppercase">
|
||
Premium Templates
|
||
</div>
|
||
{['Commercial DWC Layout', 'Basement Aeroponics Tower', 'NFT Lettuce Wall'].map((tName) => (
|
||
<div key={tName} className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-amber-950/30 group">
|
||
<button
|
||
onClick={() => {
|
||
if (!user || user.purchased_slots === 0) {
|
||
setShowPaywall(true);
|
||
setLoadOpen(false);
|
||
} else {
|
||
// Normally we would load the specific template JSON here.
|
||
// For the demo, we just reuse demoParts or clear the board.
|
||
addToast(`Loaded template: ${tName}`, 'success');
|
||
useBuilder.getState().clearAll();
|
||
setProjectName(tName);
|
||
setLoadOpen(false);
|
||
}
|
||
}}
|
||
className="flex-1 text-left text-xs text-amber-200/80 outline-none"
|
||
>
|
||
{tName} 🔒
|
||
<span className="block text-[10px] text-zinc-500">
|
||
Pro Template
|
||
</span>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<TBtn onClick={handleExport} title="Download design as JSON">Export</TBtn>
|
||
<TBtn onClick={() => fileRef.current?.click()} title="Import a design JSON">Import</TBtn>
|
||
<TBtn onClick={() => setShowShortcuts(true)} title="Show Keyboard Shortcuts">⌨️ Shortcuts</TBtn>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="application/json,.json"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) void handleImport(f);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
|
||
<Divider />
|
||
|
||
<TBtn onClick={onOpenBom} title="Open bill of materials">BOM</TBtn>
|
||
<TBtn onClick={onCheckBuild} title="Run design validation">Check Build</TBtn>
|
||
<TBtn onClick={() => autoCapOpenEnds()} title="Add caps to every loose plumbing end">
|
||
Auto Cap
|
||
</TBtn>
|
||
|
||
<Divider />
|
||
|
||
<TBtn onClick={undo} disabled={!canUndo} title="Undo (⌘Z)">↶</TBtn>
|
||
<TBtn onClick={redo} disabled={!canRedo} title="Redo (⇧⌘Z)">↷</TBtn>
|
||
|
||
<Divider />
|
||
|
||
<TBtn
|
||
active={tool === 'measure'}
|
||
onClick={() => setTool(tool === 'measure' ? 'select' : 'measure')}
|
||
title="Measure: click two points in the scene"
|
||
>
|
||
📏 Measure
|
||
</TBtn>
|
||
<TBtn
|
||
active={tool === 'pipeRun'}
|
||
onClick={() => setTool(tool === 'pipeRun' ? 'select' : 'pipeRun')}
|
||
title="Pipe Run: click two ground points"
|
||
>
|
||
Pipe Run
|
||
</TBtn>
|
||
<TBtn active={snapToGrid} onClick={toggleSnap} title="Snap to grid (0.25 ft)">
|
||
⌗ Snap
|
||
</TBtn>
|
||
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{/* Master play / pause for the water simulation */}
|
||
<button
|
||
onClick={toggleSimRunning}
|
||
title={simRunning ? 'Pause water simulation (Space)' : 'Run water simulation (Space)'}
|
||
className={`grid h-8 w-8 shrink-0 place-items-center rounded-full text-white shadow-lg transition-all hover:scale-110 active:scale-95 ${
|
||
simRunning
|
||
? 'bg-emerald-500 shadow-emerald-500/40 hover:bg-emerald-400'
|
||
: 'bg-sky-600 shadow-sky-600/40 hover:bg-sky-500'
|
||
}`}
|
||
>
|
||
{simRunning ? (
|
||
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor">
|
||
<rect x="3" y="2.5" width="3.4" height="11" rx="1" />
|
||
<rect x="9.6" y="2.5" width="3.4" height="11" rx="1" />
|
||
</svg>
|
||
) : (
|
||
<svg viewBox="0 0 16 16" className="ml-0.5 h-3.5 w-3.5" fill="currentColor">
|
||
<path d="M4 2.8a1 1 0 0 1 1.52-.86l8.2 5.2a1 1 0 0 1 0 1.7l-8.2 5.2A1 1 0 0 1 4 13.2V2.8Z" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
<div className="flex items-center gap-1.5 bg-zinc-900 border border-zinc-800 rounded-lg px-2.5 py-1">
|
||
<span className="text-[10px] uppercase font-bold text-zinc-400">Stage</span>
|
||
<select
|
||
value={growthStage}
|
||
onChange={(e) => setGrowthStage(e.target.value as any)}
|
||
className="bg-transparent text-xs text-zinc-200 outline-none border-none cursor-pointer pr-1"
|
||
>
|
||
<option value="seedling" className="bg-zinc-900 text-zinc-200">Seedling</option>
|
||
<option value="vegetative" className="bg-zinc-900 text-zinc-200">Vegetative</option>
|
||
<option value="flowering" className="bg-zinc-900 text-zinc-200">Flowering</option>
|
||
</select>
|
||
</div>
|
||
<TBtn active={showFlow} onClick={toggleFlow} title="Toggle water flow animation">
|
||
💧 Flow
|
||
</TBtn>
|
||
<div className="flex overflow-hidden rounded-lg border border-zinc-800">
|
||
{renderModes.map((mode) => (
|
||
<button
|
||
key={mode.id}
|
||
onClick={() => setRenderMode(mode.id)}
|
||
title={mode.title}
|
||
className={`px-3 py-1.5 text-xs font-medium transition ${
|
||
renderMode === mode.id
|
||
? 'bg-cyan-600 text-white'
|
||
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||
}`}
|
||
>
|
||
{mode.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex overflow-hidden rounded-lg border border-zinc-800">
|
||
{views.map((v) => (
|
||
<button
|
||
key={v.id}
|
||
onClick={() => setViewMode(v.id)}
|
||
title={v.title}
|
||
className={`px-3 py-1.5 text-xs font-medium transition ${
|
||
viewMode === v.id
|
||
? 'bg-sky-600 text-white'
|
||
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||
}`}
|
||
>
|
||
{v.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{user && (
|
||
<>
|
||
<Divider />
|
||
<div className="flex items-center gap-2 px-1">
|
||
<div className="flex flex-col items-end">
|
||
<span className="text-[10px] font-semibold text-zinc-300 max-w-24 truncate">
|
||
{user.email}
|
||
</span>
|
||
<span className={`text-[9px] font-bold uppercase ${user.total_slots === 0 ? 'text-amber-400' : user.project_count >= user.total_slots ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||
{user.project_count} / {user.total_slots} Slots
|
||
</span>
|
||
</div>
|
||
|
||
{user.total_slots === 0 ? (
|
||
<button
|
||
onClick={() => setShowPaywall(true)}
|
||
className="rounded bg-gradient-to-r from-sky-500/20 to-blue-500/20 hover:from-sky-500/30 hover:to-blue-500/30 text-sky-300 border border-sky-700 px-2 py-1 text-[10px] font-bold transition cursor-pointer flex items-center gap-1 shadow-lg"
|
||
>
|
||
💎 Upgrade
|
||
</button>
|
||
) : (
|
||
user.project_count >= user.total_slots && (
|
||
<button
|
||
onClick={() => setShowPaywall(true)}
|
||
className="rounded bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-600/30 px-2 py-1 text-[10px] font-semibold transition cursor-pointer"
|
||
>
|
||
+ Add Slot
|
||
</button>
|
||
)
|
||
)}
|
||
|
||
<button
|
||
onClick={logout}
|
||
className="rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-[10px] font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition cursor-pointer"
|
||
>
|
||
Logout
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{showPaywall && <PaywallModal onClose={() => setShowPaywall(false)} />}
|
||
|
||
{showShortcuts && (
|
||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
||
<div className="w-full max-w-sm rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-2xl text-left">
|
||
<h3 className="text-sm font-bold text-zinc-100 mb-4 flex items-center gap-1.5 border-b border-zinc-800 pb-2">
|
||
⌨️ Keyboard Shortcuts
|
||
</h3>
|
||
<div className="space-y-2.5 text-xs text-zinc-400">
|
||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||
<span>Undo / Redo</span>
|
||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">⌘Z / ⌘Y</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||
<span>Rotate Selected</span>
|
||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">R</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||
<span>Duplicate Selected</span>
|
||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">D</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||
<span>Delete Selected</span>
|
||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">⌫ / Del</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||
<span>Run / Pause Sim</span>
|
||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Spacebar</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||
<span>Pan Camera View</span>
|
||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Arrow Keys</span>
|
||
</div>
|
||
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
|
||
<span>Clear Selection & Tool</span>
|
||
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Esc</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowShortcuts(false)}
|
||
className="mt-6 w-full rounded bg-zinc-800 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-700 transition cursor-pointer"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</header>
|
||
|
||
{/* Tabbed room bar */}
|
||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-zinc-850 bg-zinc-900/60 px-3">
|
||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||
{rooms.map((room) => {
|
||
const isActive = room.id === activeRoomId;
|
||
return (
|
||
<div
|
||
key={room.id}
|
||
className={`group flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold transition cursor-pointer select-none ${
|
||
isActive
|
||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||
: 'text-zinc-400 hover:text-zinc-200 border border-transparent'
|
||
}`}
|
||
onClick={() => setActiveRoomId(room.id)}
|
||
>
|
||
<span>{room.name}</span>
|
||
{rooms.length > 1 && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (confirm(`Delete room "${room.name}" and all its parts permanently?`)) {
|
||
removeRoom(room.id);
|
||
}
|
||
}}
|
||
className="opacity-0 group-hover:opacity-100 hover:text-red-400 text-[10px] ml-1 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const name = prompt('Enter room name:', `Room ${rooms.length + 1}`);
|
||
if (name) {
|
||
addRoom(name.trim());
|
||
}
|
||
}}
|
||
className="rounded border border-zinc-700 bg-zinc-850 hover:bg-zinc-800 text-zinc-300 px-2.5 py-1 text-[11px] font-semibold transition flex items-center gap-1 cursor-pointer"
|
||
>
|
||
+ Add Room
|
||
</button>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Divider() {
|
||
return <div className="mx-1 h-6 w-px bg-zinc-800" />;
|
||
}
|
||
|
||
function TBtn({
|
||
children,
|
||
onClick,
|
||
title,
|
||
active,
|
||
disabled,
|
||
}: {
|
||
children: React.ReactNode;
|
||
onClick: () => void;
|
||
title?: string;
|
||
active?: boolean;
|
||
disabled?: boolean;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
title={title}
|
||
disabled={disabled}
|
||
className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition disabled:cursor-default disabled:opacity-30
|
||
${
|
||
active
|
||
? 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-700'
|
||
: 'text-zinc-300 hover:bg-zinc-800'
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|