Sidebar
This commit is contained in:
@@ -116,7 +116,11 @@ export default function Workspace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppSidebar workspace={workspace} setRoute={setRoute}>
|
<AppSidebar
|
||||||
|
workspace={workspace}
|
||||||
|
setRoute={setRoute}
|
||||||
|
setWorkspace={setWorkspace}
|
||||||
|
>
|
||||||
<SidebarTrigger className="w-full px-4">
|
<SidebarTrigger className="w-full px-4">
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<header className="w-full border-b pb-3 pt-2 hover:bg-accent/20 flex items-center text-foreground/90 gap-2">
|
<header className="w-full border-b pb-3 pt-2 hover:bg-accent/20 flex items-center text-foreground/90 gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -16,11 +18,13 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { Route, SetState, Workspace } from "@/lib/types";
|
import { Route, SetState, Workspace } from "@/lib/types";
|
||||||
|
import { listWorkspaces, saveWorkspaceToLocalStorage } from "@/lib/workspace";
|
||||||
|
|
||||||
// Simple ID generator
|
// Simple ID generator
|
||||||
const id = (prefix = "t") =>
|
const id = (prefix = "t") =>
|
||||||
@@ -30,13 +34,77 @@ type AppSidebarProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
setRoute: SetState<Route>;
|
setRoute: SetState<Route>;
|
||||||
|
/** optional setter for the current workspace, used for rename */
|
||||||
|
setWorkspace?: React.Dispatch<React.SetStateAction<Workspace | undefined>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppSidebar({
|
export default function AppSidebar({
|
||||||
children,
|
children,
|
||||||
workspace,
|
workspace,
|
||||||
setRoute,
|
setRoute,
|
||||||
|
setWorkspace,
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
|
const [availableWorkspaces, setAvailableWorkspaces] = React.useState<
|
||||||
|
Workspace[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// reload list when component mounts or whenever the current workspace changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
setAvailableWorkspaces(listWorkspaces());
|
||||||
|
}, [workspace]);
|
||||||
|
|
||||||
|
const switchWorkspace = (id: string) => {
|
||||||
|
setRoute({ workspace: id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const importWorkspace = () => {
|
||||||
|
const json = window.prompt(
|
||||||
|
"Paste a workspace JSON to import (generated by export)",
|
||||||
|
);
|
||||||
|
if (!json) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
if (parsed && typeof parsed.id === "string") {
|
||||||
|
saveWorkspaceToLocalStorage(parsed);
|
||||||
|
setAvailableWorkspaces(listWorkspaces());
|
||||||
|
switchWorkspace(parsed.id);
|
||||||
|
} else {
|
||||||
|
throw new Error("invalid shape");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to import workspace:", err);
|
||||||
|
window.alert("Import failed: invalid JSON");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportWorkspace = () => {
|
||||||
|
try {
|
||||||
|
const text = JSON.stringify(workspace, null, 2);
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
window.alert("Workspace JSON copied to clipboard");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Export failed", err);
|
||||||
|
window.alert("Failed to copy workspace JSON");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameWorkspace = () => {
|
||||||
|
const newName = window.prompt("New workspace name", workspace.name);
|
||||||
|
if (!newName || newName === workspace.name) return;
|
||||||
|
|
||||||
|
const updated: Workspace = {
|
||||||
|
...workspace,
|
||||||
|
name: newName,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
saveWorkspaceToLocalStorage(updated);
|
||||||
|
setAvailableWorkspaces(listWorkspaces());
|
||||||
|
if (setWorkspace) {
|
||||||
|
setWorkspace(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@@ -56,9 +124,30 @@ export default function AppSidebar({
|
|||||||
align="start"
|
align="start"
|
||||||
className="w-[--radix-popper-anchor-width]"
|
className="w-[--radix-popper-anchor-width]"
|
||||||
>
|
>
|
||||||
{/* Future: real workspace switching */}
|
{/* list all known workspaces */}
|
||||||
<DropdownMenuItem>Rename Workspace</DropdownMenuItem>
|
{availableWorkspaces.map((w) => (
|
||||||
<DropdownMenuItem>Export Workspace</DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
key={w.id}
|
||||||
|
className={
|
||||||
|
w.id === workspace.id ? "font-semibold" : undefined
|
||||||
|
}
|
||||||
|
onSelect={() => switchWorkspace(w.id)}
|
||||||
|
>
|
||||||
|
{w.name}
|
||||||
|
{w.id === workspace.id ? " (current)" : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onSelect={importWorkspace}>
|
||||||
|
Import Workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onSelect={renameWorkspace}>
|
||||||
|
Rename Workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={exportWorkspace}>
|
||||||
|
Export Workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -90,8 +90,42 @@ export function loadWorkspaceFromLocalStorage(id: string): Workspace {
|
|||||||
export function saveWorkspaceToLocalStorage(workspace: Workspace): void {
|
export function saveWorkspaceToLocalStorage(workspace: Workspace): void {
|
||||||
try {
|
try {
|
||||||
const serialized = JSON.stringify(workspace);
|
const serialized = JSON.stringify(workspace);
|
||||||
|
// keep using raw id for backwards compatibility
|
||||||
localStorage.setItem(workspace.id, serialized);
|
localStorage.setItem(workspace.id, serialized);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save workspace:", err);
|
console.error("Failed to save workspace:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan localStorage and return all entries that appear to be workspaces.
|
||||||
|
* We ignore keys starting with "route_" which are used for routing state.
|
||||||
|
*/
|
||||||
|
export function listWorkspaces(): Workspace[] {
|
||||||
|
const workspaces: Workspace[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (!key) continue;
|
||||||
|
if (key.startsWith("route_")) continue;
|
||||||
|
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
typeof parsed.id === "string" &&
|
||||||
|
typeof parsed.name === "string" &&
|
||||||
|
parsed.entities &&
|
||||||
|
parsed.layout
|
||||||
|
) {
|
||||||
|
workspaces.push(parsed as Workspace);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore non-json values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return workspaces;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user