Merge pull request #139 from pascalorg/feat/project-switch-and-spotlight
Feat/project switch and spotlight
This commit is contained in:
@@ -0,0 +1,772 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import {
|
||||||
|
AppWindow,
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
Camera,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
DoorOpen,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FileJson,
|
||||||
|
Hexagon,
|
||||||
|
Layers,
|
||||||
|
Map,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Moon,
|
||||||
|
MousePointer2,
|
||||||
|
Package,
|
||||||
|
PencilLine,
|
||||||
|
Plus,
|
||||||
|
Redo2,
|
||||||
|
Search,
|
||||||
|
Square,
|
||||||
|
SquareStack,
|
||||||
|
Sun,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
Video,
|
||||||
|
Box,
|
||||||
|
Grid3X3,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/primitives/dialog";
|
||||||
|
import useEditor from "@/store/use-editor";
|
||||||
|
import type { StructureTool } from "@/store/use-editor";
|
||||||
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
|
import { emitter, LevelNode, useScene } from "@pascal-app/core";
|
||||||
|
import type { AnyNodeId } from "@pascal-app/core";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Open-state store — imported by icon-rail to trigger the palette
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface CommandPaletteStore {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommandPalette = create<CommandPaletteStore>((set) => ({
|
||||||
|
open: false,
|
||||||
|
setOpen: (open) => set({ open }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function Shortcut({ keys }: { keys: string[] }) {
|
||||||
|
return (
|
||||||
|
<span className="ml-auto flex items-center gap-0.5 shrink-0">
|
||||||
|
{keys.map((k) => (
|
||||||
|
<kbd
|
||||||
|
key={k}
|
||||||
|
className="flex items-center justify-center rounded border border-border/60 bg-muted/60 px-1 py-0.5 text-[10px] leading-none text-muted-foreground min-w-4.5"
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onSelect,
|
||||||
|
shortcut,
|
||||||
|
disabled = false,
|
||||||
|
keywords = [],
|
||||||
|
badge,
|
||||||
|
navigate = false,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
shortcut?: string[];
|
||||||
|
disabled?: boolean;
|
||||||
|
keywords?: string[];
|
||||||
|
badge?: string;
|
||||||
|
navigate?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
value={label}
|
||||||
|
keywords={keywords}
|
||||||
|
onSelect={onSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{label}</span>
|
||||||
|
{badge && (
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{shortcut && <Shortcut keys={shortcut} />}
|
||||||
|
{(badge || navigate) && <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionItem({
|
||||||
|
label,
|
||||||
|
isActive = false,
|
||||||
|
onSelect,
|
||||||
|
icon,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
value={label}
|
||||||
|
onSelect={onSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
|
{isActive
|
||||||
|
? <div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
: icon
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{label}</span>
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-page label map
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PAGE_LABEL: Record<string, string> = {
|
||||||
|
"wall-mode": "Wall Mode",
|
||||||
|
"level-mode": "Level Mode",
|
||||||
|
"rename-level": "Rename Level",
|
||||||
|
"goto-level": "Go to Level",
|
||||||
|
"camera-view": "Camera Snapshot",
|
||||||
|
"camera-scope": "", // dynamic — overridden in breadcrumb
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function CommandPalette() {
|
||||||
|
const { open, setOpen } = useCommandPalette();
|
||||||
|
const [meta, setMeta] = useState("⌘");
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [cameraScope, setCameraScope] = useState<{ nodeId: string; label: string } | null>(null);
|
||||||
|
|
||||||
|
const page = pages[pages.length - 1];
|
||||||
|
|
||||||
|
const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
|
||||||
|
useEditor();
|
||||||
|
|
||||||
|
const cameraMode = useViewer((s) => s.cameraMode);
|
||||||
|
const setCameraMode = useViewer((s) => s.setCameraMode);
|
||||||
|
const levelMode = useViewer((s) => s.levelMode);
|
||||||
|
const setLevelMode = useViewer((s) => s.setLevelMode);
|
||||||
|
const wallMode = useViewer((s) => s.wallMode);
|
||||||
|
const setWallMode = useViewer((s) => s.setWallMode);
|
||||||
|
const theme = useViewer((s) => s.theme);
|
||||||
|
const setTheme = useViewer((s) => s.setTheme);
|
||||||
|
const selection = useViewer((s) => s.selection);
|
||||||
|
const exportScene = useViewer((s) => s.exportScene);
|
||||||
|
|
||||||
|
const activeLevelId = selection.levelId;
|
||||||
|
const activeLevelNode = useScene((s) => activeLevelId ? s.nodes[activeLevelId] : null);
|
||||||
|
const isLevelZero =
|
||||||
|
activeLevelNode?.type === "level" && (activeLevelNode as LevelNode).level === 0;
|
||||||
|
|
||||||
|
// Reactive snapshot status for the selected camera scope
|
||||||
|
const cameraScopeNode = useScene((s) => cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null);
|
||||||
|
const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera;
|
||||||
|
|
||||||
|
const allLevels = useScene(
|
||||||
|
useShallow((s) =>
|
||||||
|
(Object.values(s.nodes).filter((n) => n.type === "level") as LevelNode[]).sort(
|
||||||
|
(a, b) => a.level - b.level
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSelection = selection.selectedIds.length > 0;
|
||||||
|
|
||||||
|
// Platform detection
|
||||||
|
useEffect(() => {
|
||||||
|
setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘" : "Ctrl");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fullscreen tracking
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener("fullscreenchange", handler);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cmd/Ctrl+K global shortcut
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [setOpen]);
|
||||||
|
|
||||||
|
// Reset sub-pages when palette closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setPages([]);
|
||||||
|
setInputValue("");
|
||||||
|
setCameraScope(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Navigation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const goBack = () => {
|
||||||
|
const leavingPage = pages[pages.length - 1];
|
||||||
|
if (leavingPage === "camera-scope") setCameraScope(null);
|
||||||
|
setPages((p) => p.slice(0, -1));
|
||||||
|
setInputValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateTo = (p: string) => {
|
||||||
|
// Pre-fill the rename input with the current level name
|
||||||
|
if (p === "rename-level" && activeLevelId) {
|
||||||
|
const level = useScene.getState().nodes[activeLevelId] as LevelNode;
|
||||||
|
setInputValue(level?.name ?? "");
|
||||||
|
} else {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
setPages((prev) => [...prev, p]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToCameraScope = (nodeId: string, label: string) => {
|
||||||
|
setCameraScope({ nodeId, label });
|
||||||
|
setInputValue("");
|
||||||
|
setPages((prev) => [...prev, "camera-scope"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const run = (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activateTool = (tool: StructureTool) => {
|
||||||
|
run(() => {
|
||||||
|
setPhase("structure");
|
||||||
|
setMode("build");
|
||||||
|
if (tool === "zone") setStructureLayer("zones");
|
||||||
|
setTool(tool);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const wallModeLabel: Record<"cutaway" | "up" | "down", string> = { cutaway: "Cutaway", up: "Up", down: "Down" };
|
||||||
|
const levelModeLabel: Record<"manual" | "stacked" | "exploded" | "solo", string> = {
|
||||||
|
manual: "Manual",
|
||||||
|
stacked: "Stacked",
|
||||||
|
exploded: "Exploded",
|
||||||
|
solo: "Solo",
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelection = () => {
|
||||||
|
if (!hasSelection) return;
|
||||||
|
run(() => {
|
||||||
|
useScene.getState().deleteNodes(selection.selectedIds as any[]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Level management
|
||||||
|
const addLevel = () =>
|
||||||
|
run(() => {
|
||||||
|
const { nodes } = useScene.getState();
|
||||||
|
const building = Object.values(nodes).find((n) => n.type === "building");
|
||||||
|
if (!building) return;
|
||||||
|
const newLevel = LevelNode.parse({
|
||||||
|
level: building.children.length,
|
||||||
|
children: [],
|
||||||
|
parentId: building.id,
|
||||||
|
});
|
||||||
|
useScene.getState().createNode(newLevel, building.id);
|
||||||
|
useViewer.getState().setSelection({ levelId: newLevel.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteActiveLevel = () => {
|
||||||
|
if (!activeLevelId || isLevelZero) return;
|
||||||
|
run(() => {
|
||||||
|
useScene.getState().deleteNode(activeLevelId as AnyNodeId);
|
||||||
|
const { nodes } = useScene.getState();
|
||||||
|
const level0 = Object.values(nodes).find(
|
||||||
|
(n) => n.type === "level" && (n as LevelNode).level === 0
|
||||||
|
);
|
||||||
|
if (level0) useViewer.getState().setSelection({ levelId: level0.id as `level_${string}` });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRename = () => {
|
||||||
|
if (!activeLevelId || !inputValue.trim()) return;
|
||||||
|
run(() => {
|
||||||
|
useScene.getState().updateNode(activeLevelId as AnyNodeId, { name: inputValue.trim() } as any);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Camera snapshot (scoped to the currently selected camera scope)
|
||||||
|
const takeSnapshot = () => {
|
||||||
|
if (!cameraScope) return;
|
||||||
|
run(() => emitter.emit("camera-controls:capture", { nodeId: cameraScope.nodeId as AnyNodeId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewSnapshot = () => {
|
||||||
|
if (!cameraScope || !hasScopeSnapshot) return;
|
||||||
|
run(() => emitter.emit("camera-controls:view", { nodeId: cameraScope.nodeId as AnyNodeId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSnapshot = () => {
|
||||||
|
if (!cameraScope || !hasScopeSnapshot) return;
|
||||||
|
run(() => {
|
||||||
|
useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export helpers
|
||||||
|
const exportJson = () =>
|
||||||
|
run(() => {
|
||||||
|
const { nodes, rootNodeIds } = useScene.getState();
|
||||||
|
const blob = new Blob([JSON.stringify({ nodes, rootNodeIds }, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = Object.assign(document.createElement("a"), {
|
||||||
|
href: url,
|
||||||
|
download: `scene_${new Date().toISOString().split("T")[0]}.json`,
|
||||||
|
});
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyShareLink = () =>
|
||||||
|
run(() => {
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
const takeScreenshot = () =>
|
||||||
|
run(() => {
|
||||||
|
const canvas = document.querySelector("canvas");
|
||||||
|
if (!canvas) return;
|
||||||
|
const a = Object.assign(document.createElement("a"), {
|
||||||
|
href: canvas.toDataURL("image/png"),
|
||||||
|
download: `screenshot_${new Date().toISOString().split("T")[0]}.png`,
|
||||||
|
});
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFullscreen = () =>
|
||||||
|
run(() => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="p-0 gap-0 max-w-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
shouldFilter={page !== "rename-level"}
|
||||||
|
className="**:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-3 **:[[cmdk-group-heading]]:text-[10px] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-muted-foreground"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Backspace" && !inputValue && pages.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
goBack();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="flex items-center border-b border-border/50 px-3">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
{page && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goBack}
|
||||||
|
className="mr-2 shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/70 transition-colors"
|
||||||
|
>
|
||||||
|
{page === "camera-scope"
|
||||||
|
? (cameraScope?.label ?? "Snapshot")
|
||||||
|
: (PAGE_LABEL[page] ?? page)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Command.Input
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
className="flex h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder={
|
||||||
|
page === "rename-level"
|
||||||
|
? "Type a new name…"
|
||||||
|
: page
|
||||||
|
? "Filter options…"
|
||||||
|
: "Search actions…"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Command.List className="max-h-100 overflow-y-auto p-1.5">
|
||||||
|
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No commands found.
|
||||||
|
</Command.Empty>
|
||||||
|
|
||||||
|
{/* ── Root view ─────────────────────────────────────────────── */}
|
||||||
|
{!page && (
|
||||||
|
<>
|
||||||
|
{/* Scene / Tools */}
|
||||||
|
<Command.Group heading="Scene">
|
||||||
|
<Item icon={<Square className="h-4 w-4" />} label="Wall Tool" onSelect={() => activateTool("wall")} keywords={["draw", "build", "structure"]} />
|
||||||
|
<Item icon={<Layers className="h-4 w-4" />} label="Slab Tool" onSelect={() => activateTool("slab")} keywords={["floor", "build"]} />
|
||||||
|
<Item icon={<Grid3X3 className="h-4 w-4" />} label="Ceiling Tool" onSelect={() => activateTool("ceiling")} keywords={["top", "build"]} />
|
||||||
|
<Item icon={<DoorOpen className="h-4 w-4" />} label="Door Tool" onSelect={() => activateTool("door")} keywords={["opening", "entrance"]} />
|
||||||
|
<Item icon={<AppWindow className="h-4 w-4" />} label="Window Tool" onSelect={() => activateTool("window")} keywords={["opening", "glass"]} />
|
||||||
|
<Item icon={<Package className="h-4 w-4" />} label="Item Tool" onSelect={() => activateTool("item")} keywords={["furniture", "object", "asset", "furnish"]} />
|
||||||
|
<Item icon={<Hexagon className="h-4 w-4" />} label="Zone Tool" onSelect={() => activateTool("zone")} keywords={["area", "room", "space"]} />
|
||||||
|
<Item
|
||||||
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
|
label="Delete Selection"
|
||||||
|
onSelect={deleteSelection}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
shortcut={["⌫"]}
|
||||||
|
keywords={["remove", "erase"]}
|
||||||
|
/>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{/* Levels */}
|
||||||
|
<Command.Group heading="Levels">
|
||||||
|
<Item
|
||||||
|
icon={<ArrowRight className="h-4 w-4" />}
|
||||||
|
label="Go to Level"
|
||||||
|
navigate
|
||||||
|
onSelect={() => navigateTo("goto-level")}
|
||||||
|
disabled={allLevels.length === 0}
|
||||||
|
keywords={["level", "floor", "go", "navigate", "switch", "select"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={<Plus className="h-4 w-4" />}
|
||||||
|
label="Add Level"
|
||||||
|
onSelect={addLevel}
|
||||||
|
keywords={["level", "floor", "add", "create", "new"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={<PencilLine className="h-4 w-4" />}
|
||||||
|
label="Rename Level"
|
||||||
|
navigate
|
||||||
|
onSelect={() => navigateTo("rename-level")}
|
||||||
|
disabled={!activeLevelId}
|
||||||
|
keywords={["level", "floor", "rename", "name"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
|
label="Delete Level"
|
||||||
|
onSelect={deleteActiveLevel}
|
||||||
|
disabled={!activeLevelId || isLevelZero}
|
||||||
|
keywords={["level", "floor", "delete", "remove"]}
|
||||||
|
/>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{/* Viewer Controls */}
|
||||||
|
<Command.Group heading="Viewer Controls">
|
||||||
|
<Item
|
||||||
|
icon={<Layers className="h-4 w-4" />}
|
||||||
|
label="Wall Mode"
|
||||||
|
badge={wallModeLabel[wallMode]}
|
||||||
|
onSelect={() => navigateTo("wall-mode")}
|
||||||
|
keywords={["wall", "cutaway", "up", "down", "view"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={<SquareStack className="h-4 w-4" />}
|
||||||
|
label="Level Mode"
|
||||||
|
badge={levelModeLabel[levelMode]}
|
||||||
|
onSelect={() => navigateTo("level-mode")}
|
||||||
|
keywords={["level", "floor", "exploded", "stacked", "solo"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={<Video className="h-4 w-4" />}
|
||||||
|
label={`Camera: Switch to ${cameraMode === "perspective" ? "Orthographic" : "Perspective"}`}
|
||||||
|
onSelect={() =>
|
||||||
|
run(() =>
|
||||||
|
setCameraMode(
|
||||||
|
cameraMode === "perspective" ? "orthographic" : "perspective"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
keywords={["camera", "ortho", "perspective", "2d", "3d", "view"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
label={theme === "dark" ? "Switch to Light Theme" : "Switch to Dark Theme"}
|
||||||
|
onSelect={() => run(() => setTheme(theme === "dark" ? "light" : "dark"))}
|
||||||
|
keywords={["theme", "dark", "light", "appearance", "color"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={<Camera className="h-4 w-4" />}
|
||||||
|
label="Camera Snapshot"
|
||||||
|
navigate
|
||||||
|
onSelect={() => navigateTo("camera-view")}
|
||||||
|
keywords={["camera", "snapshot", "capture", "save", "view", "bookmark"]}
|
||||||
|
/>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{/* View / Mode */}
|
||||||
|
<Command.Group heading="View">
|
||||||
|
<Item
|
||||||
|
icon={isPreviewMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
label={isPreviewMode ? "Exit Preview" : "Enter Preview"}
|
||||||
|
onSelect={() => run(() => setPreviewMode(!isPreviewMode))}
|
||||||
|
keywords={["preview", "view", "read-only", "present"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={
|
||||||
|
isFullscreen ? (
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||||
|
onSelect={toggleFullscreen}
|
||||||
|
keywords={["fullscreen", "maximize", "expand", "window"]}
|
||||||
|
/>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
<Command.Group heading="History">
|
||||||
|
<Item
|
||||||
|
icon={<Undo2 className="h-4 w-4" />}
|
||||||
|
label="Undo"
|
||||||
|
onSelect={() => run(() => useScene.temporal.getState().undo())}
|
||||||
|
shortcut={[meta, "Z"]}
|
||||||
|
keywords={["undo", "revert", "back"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={<Redo2 className="h-4 w-4" />}
|
||||||
|
label="Redo"
|
||||||
|
onSelect={() => run(() => useScene.temporal.getState().redo())}
|
||||||
|
shortcut={[meta, "⇧", "Z"]}
|
||||||
|
keywords={["redo", "forward", "repeat"]}
|
||||||
|
/>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{/* Export / Share */}
|
||||||
|
<Command.Group heading="Export & Share">
|
||||||
|
<Item
|
||||||
|
icon={<FileJson className="h-4 w-4" />}
|
||||||
|
label="Export Scene (JSON)"
|
||||||
|
onSelect={exportJson}
|
||||||
|
keywords={["export", "download", "json", "save", "data"]}
|
||||||
|
/>
|
||||||
|
{exportScene && (
|
||||||
|
<Item
|
||||||
|
icon={<Box className="h-4 w-4" />}
|
||||||
|
label="Export 3D Model (GLB)"
|
||||||
|
onSelect={() => run(() => exportScene())}
|
||||||
|
keywords={["export", "glb", "gltf", "3d", "model", "download"]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Item
|
||||||
|
icon={<Copy className="h-4 w-4" />}
|
||||||
|
label="Copy Share Link"
|
||||||
|
onSelect={copyShareLink}
|
||||||
|
keywords={["share", "copy", "url", "link"]}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon={<Camera className="h-4 w-4" />}
|
||||||
|
label="Take Screenshot"
|
||||||
|
onSelect={takeScreenshot}
|
||||||
|
keywords={["screenshot", "capture", "image", "photo", "png"]}
|
||||||
|
/>
|
||||||
|
</Command.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Wall Mode sub-page ────────────────────────────────────── */}
|
||||||
|
{page === "wall-mode" && (
|
||||||
|
<Command.Group heading="Wall Mode">
|
||||||
|
{(["cutaway", "up", "down"] as const).map((mode) => (
|
||||||
|
<OptionItem
|
||||||
|
key={mode}
|
||||||
|
label={wallModeLabel[mode]}
|
||||||
|
isActive={wallMode === mode}
|
||||||
|
onSelect={() => run(() => setWallMode(mode))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Level Mode sub-page ───────────────────────────────────── */}
|
||||||
|
{page === "level-mode" && (
|
||||||
|
<Command.Group heading="Level Mode">
|
||||||
|
{(["stacked", "exploded", "solo"] as const).map((mode) => (
|
||||||
|
<OptionItem
|
||||||
|
key={mode}
|
||||||
|
label={levelModeLabel[mode]}
|
||||||
|
isActive={levelMode === mode}
|
||||||
|
onSelect={() => run(() => setLevelMode(mode))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Go to Level sub-page ──────────────────────────────────── */}
|
||||||
|
{page === "goto-level" && (
|
||||||
|
<Command.Group heading="Go to Level">
|
||||||
|
{allLevels.map((level) => (
|
||||||
|
<OptionItem
|
||||||
|
key={level.id}
|
||||||
|
label={level.name ?? `Level ${level.level}`}
|
||||||
|
isActive={level.id === activeLevelId}
|
||||||
|
onSelect={() =>
|
||||||
|
run(() => useViewer.getState().setSelection({ levelId: level.id }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Rename Level sub-page ─────────────────────────────────── */}
|
||||||
|
{page === "rename-level" && (
|
||||||
|
<Command.Group heading="Rename Level">
|
||||||
|
<Command.Item
|
||||||
|
value="confirm-rename"
|
||||||
|
onSelect={confirmRename}
|
||||||
|
disabled={!inputValue.trim()}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
|
<PencilLine className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{inputValue.trim() ? (
|
||||||
|
<>Rename to <span className="font-medium">"{inputValue.trim()}"</span></>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Type a new name above…</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Command.Item>
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Camera Snapshot: scope picker ─────────────────────────── */}
|
||||||
|
{page === "camera-view" && (
|
||||||
|
<Command.Group heading="Camera Snapshot — Select Scope">
|
||||||
|
<OptionItem
|
||||||
|
label="Site"
|
||||||
|
icon={<Map className="h-4 w-4" />}
|
||||||
|
onSelect={() => {
|
||||||
|
const { rootNodeIds } = useScene.getState();
|
||||||
|
const siteId = rootNodeIds[0];
|
||||||
|
if (siteId) navigateToCameraScope(siteId, "Site");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<OptionItem
|
||||||
|
label="Building"
|
||||||
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
|
onSelect={() => {
|
||||||
|
const building = Object.values(useScene.getState().nodes).find(
|
||||||
|
(n) => n.type === "building"
|
||||||
|
);
|
||||||
|
if (building) navigateToCameraScope(building.id, "Building");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<OptionItem
|
||||||
|
label="Level"
|
||||||
|
icon={<Layers className="h-4 w-4" />}
|
||||||
|
disabled={!activeLevelId}
|
||||||
|
onSelect={() => {
|
||||||
|
if (activeLevelId) navigateToCameraScope(activeLevelId, "Level");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<OptionItem
|
||||||
|
label="Selection"
|
||||||
|
icon={<MousePointer2 className="h-4 w-4" />}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
const firstId = selection.selectedIds[0];
|
||||||
|
if (firstId) navigateToCameraScope(firstId, "Selection");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Camera Snapshot: actions for selected scope ───────────── */}
|
||||||
|
{page === "camera-scope" && cameraScope && (
|
||||||
|
<Command.Group heading={`${cameraScope.label} Snapshot`}>
|
||||||
|
<OptionItem
|
||||||
|
label={hasScopeSnapshot ? "Update Snapshot" : "Take Snapshot"}
|
||||||
|
icon={<Camera className="h-4 w-4" />}
|
||||||
|
onSelect={takeSnapshot}
|
||||||
|
/>
|
||||||
|
{hasScopeSnapshot && (
|
||||||
|
<OptionItem
|
||||||
|
label="View Snapshot"
|
||||||
|
icon={<Eye className="h-4 w-4" />}
|
||||||
|
onSelect={viewSnapshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasScopeSnapshot && (
|
||||||
|
<OptionItem
|
||||||
|
label="Clear Snapshot"
|
||||||
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
|
onSelect={clearSnapshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
</Command.List>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<div className="flex items-center justify-between border-t border-border/50 px-3 py-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
<Shortcut keys={["↑", "↓"]} /> navigate
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
<Shortcut keys={["↵"]} /> select
|
||||||
|
</span>
|
||||||
|
{page ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
<Shortcut keys={["⌫"]} /> back
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
<Shortcut keys={["Esc"]} /> close
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,17 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
import { IconRail, type PanelId } from "./icon-rail";
|
import { IconRail, type PanelId } from "./icon-rail";
|
||||||
|
import { CommandPalette } from "@/components/ui/command-palette";
|
||||||
import {
|
import {
|
||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clock3,
|
Clock3,
|
||||||
Moon,
|
|
||||||
Pencil,
|
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
Sun,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useScene } from "@pascal-app/core";
|
import { useScene } from "@pascal-app/core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +45,6 @@ import {
|
|||||||
} from "@/features/community/lib/models/actions";
|
} from "@/features/community/lib/models/actions";
|
||||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||||
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import { applySceneGraphToEditor } from "@/features/community/lib/models/hooks";
|
import { applySceneGraphToEditor } from "@/features/community/lib/models/hooks";
|
||||||
|
|
||||||
function formatRelativeTime(value: string): string {
|
function formatRelativeTime(value: string): string {
|
||||||
@@ -84,9 +80,6 @@ export function AppSidebar() {
|
|||||||
const setIsVersionPreviewMode = useProjectStore((s) => s.setIsVersionPreviewMode);
|
const setIsVersionPreviewMode = useProjectStore((s) => s.setIsVersionPreviewMode);
|
||||||
const setIsSceneLoading = useProjectStore((s) => s.setIsSceneLoading);
|
const setIsSceneLoading = useProjectStore((s) => s.setIsSceneLoading);
|
||||||
const setAutosaveStatus = useProjectStore((s) => s.setAutosaveStatus);
|
const setAutosaveStatus = useProjectStore((s) => s.setAutosaveStatus);
|
||||||
const theme = useViewer((state) => state.theme);
|
|
||||||
const setTheme = useViewer((state) => state.setTheme);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [titleValue, setTitleValue] = useState("");
|
const [titleValue, setTitleValue] = useState("");
|
||||||
@@ -108,7 +101,6 @@ export function AppSidebar() {
|
|||||||
const activeProjectId = activeProject?.id ?? null;
|
const activeProjectId = activeProject?.id ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
|
||||||
// Widen default sidebar (288px → 432px) for better project title visibility
|
// Widen default sidebar (288px → 432px) for better project title visibility
|
||||||
const store = useSidebarStore.getState();
|
const store = useSidebarStore.getState();
|
||||||
if (store.width <= 288) {
|
if (store.width <= 288) {
|
||||||
@@ -513,18 +505,8 @@ export function AppSidebar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPanelTitle = () => {
|
|
||||||
switch (activePanel) {
|
|
||||||
case "site":
|
|
||||||
return "Site";
|
|
||||||
case "settings":
|
|
||||||
return "Settings";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Sidebar className={cn("dark text-white ")} variant="floating">
|
<Sidebar className={cn("dark text-white ")} variant="floating">
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* Icon Rail */}
|
{/* Icon Rail */}
|
||||||
@@ -547,15 +529,12 @@ export function AppSidebar() {
|
|||||||
className="w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-7 font-semibold text-lg"
|
className="w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-7 font-semibold text-lg"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<h1
|
||||||
className="flex items-center gap-2 group/title cursor-pointer w-full h-7 border-b border-transparent"
|
className="font-semibold text-lg truncate cursor-text w-full h-7 border-b border-transparent hover:border-border/50 transition-colors leading-7"
|
||||||
onClick={() => setIsEditingTitle(true)}
|
onClick={() => setIsEditingTitle(true)}
|
||||||
>
|
>
|
||||||
<h1 className="font-semibold text-lg truncate">
|
|
||||||
{activeProject?.name || "Untitled Project"}
|
{activeProject?.name || "Untitled Project"}
|
||||||
</h1>
|
</h1>
|
||||||
<Pencil className="w-3.5 h-3.5 opacity-0 group-hover/title:opacity-100 transition-opacity text-muted-foreground shrink-0" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("shrink-0 flex items-center gap-1 transition-all duration-200", isEditingTitle && "hidden")}>
|
<div className={cn("shrink-0 flex items-center gap-1 transition-all duration-200", isEditingTitle && "hidden")}>
|
||||||
@@ -569,14 +548,11 @@ export function AppSidebar() {
|
|||||||
onClick={() => runVersionAction("save")}
|
onClick={() => runVersionAction("save")}
|
||||||
disabled={isQuickSaveDisabled}
|
disabled={isQuickSaveDisabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/save-trigger relative inline-flex h-full min-w-0 items-center border-r border-border/50 px-1.5 text-[10px] transition-colors",
|
"group/save-trigger relative inline-flex h-full w-16 items-center border-r border-border/50 px-1.5 text-[10px] transition-colors",
|
||||||
isQuickSaveDisabled
|
isQuickSaveDisabled
|
||||||
? "cursor-not-allowed opacity-50"
|
? "cursor-not-allowed opacity-50"
|
||||||
: "hover:bg-black/30",
|
: "hover:bg-black/30",
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
width: "clamp(48px, calc(var(--sidebar-width) - 16.5rem), 80px)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="pointer-events-none inline-flex min-w-0 items-center gap-1 transition-opacity group-hover/save-trigger:opacity-0">
|
<span className="pointer-events-none inline-flex min-w-0 items-center gap-1 transition-opacity group-hover/save-trigger:opacity-0">
|
||||||
<Clock3 className="h-3 w-3 shrink-0 text-muted-foreground" />
|
<Clock3 className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
@@ -718,61 +694,8 @@ export function AppSidebar() {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mounted && (
|
|
||||||
<button
|
|
||||||
className="shrink-0 flex items-center bg-black/20 rounded-full p-1 border border-border/50 cursor-pointer"
|
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
||||||
type="button"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
>
|
|
||||||
<div className="relative flex">
|
|
||||||
{/* Sliding Background */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0 bg-[#3A3A3C] shadow-sm rounded-full"
|
|
||||||
initial={false}
|
|
||||||
animate={{
|
|
||||||
x: theme === "light" ? "100%" : "0%",
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 500,
|
|
||||||
damping: 35,
|
|
||||||
}}
|
|
||||||
style={{ width: "50%" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dark Mode Icon */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
|
||||||
theme === "dark"
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Moon className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Light Mode Icon */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
|
||||||
theme === "light"
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Sun className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
||||||
{getPanelTitle()}
|
|
||||||
</span>
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
@@ -783,5 +706,7 @@ export function AppSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
<CommandPalette />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,103 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Moon, Sun } from "lucide-react";
|
import { ArrowLeft, Command, FolderOpen, Moon, Search, Sun } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/primitives/tooltip";
|
} from "@/components/ui/primitives/tooltip";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/primitives/popover";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/primitives/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
|
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||||
|
import { useCommandPalette } from "@/components/ui/command-palette";
|
||||||
|
|
||||||
|
function OpenProjectModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const projects = useProjectStore((s) => s.projects);
|
||||||
|
const activeProject = useProjectStore((s) => s.activeProject);
|
||||||
|
const fetchProjects = useProjectStore((s) => s.fetchProjects);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && projects.length === 0) {
|
||||||
|
fetchProjects();
|
||||||
|
}
|
||||||
|
}, [open, projects.length, fetchProjects]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-sm p-0 gap-0 overflow-hidden">
|
||||||
|
<DialogHeader className="px-4 pt-4 pb-3 border-b border-border/50">
|
||||||
|
<DialogTitle className="text-sm font-medium">Open project</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-80 overflow-y-auto p-1.5">
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-sm text-muted-foreground text-center">
|
||||||
|
No projects found
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
projects.map((project) => {
|
||||||
|
const isActive = project.id === activeProject?.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent",
|
||||||
|
isActive && "bg-accent/50"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
router.push(`/editor/${project.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted overflow-hidden">
|
||||||
|
{project.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={project.thumbnail_url}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="flex-1 min-w-0 truncate text-sm font-medium">
|
||||||
|
{project.name}
|
||||||
|
</p>
|
||||||
|
{isActive && (
|
||||||
|
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type PanelId = "site" | "settings";
|
export type PanelId = "site" | "settings";
|
||||||
|
|
||||||
@@ -33,23 +120,31 @@ export function IconRail({
|
|||||||
const theme = useViewer((state) => state.theme);
|
const theme = useViewer((state) => state.theme);
|
||||||
const setTheme = useViewer((state) => state.setTheme);
|
const setTheme = useViewer((state) => state.setTheme);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [isOpenProjectOpen, setIsSwitchProjectOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenProject = () => {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
setIsSwitchProjectOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
"flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Pascal logo - link to the home page */}
|
{/* Pascal logo — app menu */}
|
||||||
<Tooltip>
|
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||||
<TooltipTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Link
|
<button
|
||||||
href="/"
|
type="button"
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all hover:bg-accent"
|
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -59,10 +154,40 @@ export function IconRail({
|
|||||||
height={24}
|
height={24}
|
||||||
className="h-6 w-6 dark:invert"
|
className="h-6 w-6 dark:invert"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="right" align="start" className="w-52 p-1" sideOffset={8}>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
Back to community
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
<button
|
||||||
<TooltipContent side="right">Back to Pascal Editor</TooltipContent>
|
type="button"
|
||||||
</Tooltip>
|
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
onClick={handleOpenProject}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
Open project
|
||||||
|
</button>
|
||||||
|
<div className="my-1 h-px bg-border/50" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
onClick={() => { setIsMenuOpen(false); useCommandPalette.getState().setOpen(true); }}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 text-left">Actions...</span>
|
||||||
|
<span className="flex items-center gap-0.5 rounded border border-border/60 bg-muted/60 px-1 py-0.5 text-[10px] leading-none text-muted-foreground">
|
||||||
|
<Command className="h-2.5 w-2.5" />
|
||||||
|
K
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="w-8 h-px bg-border/50 mb-1" />
|
<div className="w-8 h-px bg-border/50 mb-1" />
|
||||||
@@ -75,9 +200,7 @@ export function IconRail({
|
|||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
|
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
|
||||||
isActive
|
isActive ? "bg-accent" : "hover:bg-accent",
|
||||||
? "bg-accent"
|
|
||||||
: "hover:bg-accent",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onPanelChange(panel.id)}
|
onClick={() => onPanelChange(panel.id)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -105,17 +228,27 @@ export function IconRail({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all text-muted-foreground hover:bg-accent hover:text-accent-foreground mb-2"
|
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 transition-all text-foreground hover:bg-accent mb-2"
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
<motion.div
|
||||||
|
key={theme}
|
||||||
|
initial={{ rotate: -90, opacity: 0 }}
|
||||||
|
animate={{ rotate: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</motion.div>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">Toggle theme</TooltipContent>
|
<TooltipContent side="right">Toggle theme</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OpenProjectModal open={isOpenProjectOpen} onOpenChange={setIsSwitchProjectOpen} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"better-auth": "^1.5.2",
|
"better-auth": "^1.5.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.34.3",
|
"motion": "^12.34.3",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"better-auth": "^1.5.2",
|
"better-auth": "^1.5.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.34.3",
|
"motion": "^12.34.3",
|
||||||
@@ -911,6 +912,8 @@
|
|||||||
|
|
||||||
"cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="],
|
"cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="],
|
||||||
|
|
||||||
|
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
@@ -1959,6 +1962,8 @@
|
|||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user