Initial project import
This commit is contained in:
555
src/components/Toolbar.tsx
Normal file
555
src/components/Toolbar.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user