Compare commits
10
Commits
b3d4558671
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
171566da59 | ||
|
|
3af4921fd2 | ||
|
|
e71b481dbb | ||
|
|
1931f9208b | ||
|
|
78daab4cf4 | ||
|
|
b543988f02 | ||
|
|
7d4a087c10 | ||
|
|
9200e1cfad | ||
|
|
3b35685d61 | ||
|
|
f20fd1d65a |
@@ -14,8 +14,18 @@ import {
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export default function Workspace() {
|
||||
const { workspace, loadWorkspace, updateEntity, setWorkspace } =
|
||||
useWorkspace();
|
||||
const {
|
||||
workspace,
|
||||
loadWorkspace,
|
||||
updateEntity,
|
||||
setWorkspace,
|
||||
addEntity,
|
||||
addGroup,
|
||||
renameEntity,
|
||||
deleteEntity,
|
||||
removeNode,
|
||||
renameGroup,
|
||||
} = useWorkspace();
|
||||
|
||||
const [route, setRoute] = useState<Route>({});
|
||||
|
||||
@@ -116,7 +126,17 @@ export default function Workspace() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppSidebar workspace={workspace} setRoute={setRoute}>
|
||||
<AppSidebar
|
||||
workspace={workspace}
|
||||
setRoute={setRoute}
|
||||
setWorkspace={setWorkspace}
|
||||
addEntity={addEntity}
|
||||
addGroup={addGroup}
|
||||
renameEntity={renameEntity}
|
||||
deleteEntity={deleteEntity}
|
||||
removeNode={removeNode}
|
||||
renameGroup={renameGroup}
|
||||
>
|
||||
<SidebarTrigger className="w-full 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">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -10,17 +12,34 @@ import {
|
||||
SidebarProvider,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import EntitySwitch from "./Entity";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Route, SetState, Workspace } from "@/lib/types";
|
||||
import {
|
||||
listWorkspaces,
|
||||
saveWorkspaceToLocalStorage,
|
||||
createDefaultWorkspace,
|
||||
} from "@/lib/workspace";
|
||||
import { openFileDialog } from "@/lib/import";
|
||||
|
||||
// Simple ID generator
|
||||
const id = (prefix = "t") =>
|
||||
@@ -30,13 +49,112 @@ type AppSidebarProps = {
|
||||
children: React.ReactNode;
|
||||
workspace: Workspace;
|
||||
setRoute: SetState<Route>;
|
||||
/** optional setter for the current workspace, used for rename */
|
||||
setWorkspace: React.Dispatch<React.SetStateAction<Workspace | undefined>>;
|
||||
addEntity: (
|
||||
type: "todo" | "doc",
|
||||
parentGroupId: string | null,
|
||||
name?: string,
|
||||
) => string | undefined;
|
||||
addGroup: (name: string | undefined, parentGroupId: string | null) => string;
|
||||
renameEntity: (entityId: string, newName: string) => void;
|
||||
deleteEntity: (entityId: string) => void;
|
||||
removeNode: (id: string) => void;
|
||||
renameGroup: (id: string, name: string) => void;
|
||||
};
|
||||
|
||||
export default function AppSidebar({
|
||||
children,
|
||||
workspace,
|
||||
setRoute,
|
||||
setWorkspace,
|
||||
addEntity,
|
||||
addGroup,
|
||||
renameEntity,
|
||||
deleteEntity,
|
||||
removeNode,
|
||||
renameGroup,
|
||||
}: AppSidebarProps) {
|
||||
// actions are passed from parent `useWorkspace` instance
|
||||
|
||||
const [availableWorkspaces, setAvailableWorkspaces] = React.useState<
|
||||
Workspace[]
|
||||
>([]);
|
||||
|
||||
const [isRenameOpen, setIsRenameOpen] = React.useState(false);
|
||||
const [proposedName, setProposedName] = React.useState(workspace.name);
|
||||
const [isWorkspaceCreateOpen, setIsWorkspaceCreateOpen] =
|
||||
React.useState(false);
|
||||
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||
const [createMode, setCreateMode] = React.useState<"list" | "doc" | "group">(
|
||||
"list",
|
||||
);
|
||||
const [createValue, setCreateValue] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setProposedName(workspace.name);
|
||||
}, [workspace.name]);
|
||||
|
||||
// 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 = async () => {
|
||||
try {
|
||||
const { content } = await openFileDialog(".json");
|
||||
const parsed = JSON.parse(content);
|
||||
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 file");
|
||||
}
|
||||
};
|
||||
|
||||
const exportWorkspace = () => {
|
||||
try {
|
||||
const text = JSON.stringify(workspace, null, 2);
|
||||
const blob = new Blob([text], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `workspace-${workspace.name}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Export failed", err);
|
||||
window.alert("Failed to download workspace");
|
||||
}
|
||||
};
|
||||
|
||||
const renameWorkspace = (name: string) => {
|
||||
if (!name || name === workspace.name) return;
|
||||
|
||||
const updated: Workspace = {
|
||||
...workspace,
|
||||
name,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
saveWorkspaceToLocalStorage(updated);
|
||||
setAvailableWorkspaces(listWorkspaces());
|
||||
if (setWorkspace) {
|
||||
setWorkspace(updated);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
@@ -46,19 +164,86 @@ export default function AppSidebar({
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex w-full items-center justify-between px-2 py-1 text-sm">
|
||||
<Button className="w-full" variant='ghost'>
|
||||
{workspace.name}
|
||||
<ChevronDown className="ml-auto h-4 w-4 opacity-60" />
|
||||
</button>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
>
|
||||
{/* Future: real workspace switching */}
|
||||
<DropdownMenuItem>Rename Workspace</DropdownMenuItem>
|
||||
<DropdownMenuItem>Export Workspace</DropdownMenuItem>
|
||||
{/* list all known workspaces */}
|
||||
{[
|
||||
availableWorkspaces.find((w) => w.id === workspace.id),
|
||||
...availableWorkspaces.filter((w) => w.id !== workspace.id),
|
||||
].map((w) => (
|
||||
<DropdownMenuItem
|
||||
key={w?.id}
|
||||
className={
|
||||
w?.id === workspace.id ? "font-semibold" : undefined
|
||||
}
|
||||
onSelect={() => w && switchWorkspace(w?.id)}
|
||||
>
|
||||
{w?.name}
|
||||
{w?.id === workspace.id ? " (current)" : ""}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setCreateMode("list");
|
||||
setCreateValue("New list");
|
||||
setIsCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
New list (root)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setCreateMode("doc");
|
||||
setCreateValue("New doc");
|
||||
setIsCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
New doc (root)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setCreateMode("group");
|
||||
setCreateValue("New group");
|
||||
setIsCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
New group (root)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => setIsRenameOpen(true)}>
|
||||
Rename Workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={importWorkspace}>
|
||||
Import Workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={exportWorkspace}>
|
||||
Export Workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setNewWorkspaceName("New workspace");
|
||||
setIsWorkspaceCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
Create workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
localStorage.removeItem(workspace.id);
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
Delete workspace
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
@@ -74,6 +259,12 @@ export default function AppSidebar({
|
||||
node={node}
|
||||
workspace={workspace}
|
||||
setRoute={setRoute}
|
||||
addEntity={addEntity}
|
||||
addGroup={addGroup}
|
||||
renameEntity={renameEntity}
|
||||
deleteEntity={deleteEntity}
|
||||
removeNode={removeNode}
|
||||
renameGroup={renameGroup}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
@@ -83,6 +274,127 @@ export default function AppSidebar({
|
||||
</Sidebar>
|
||||
|
||||
<main className="w-svw flex-1 flex flex-col min-h-0">{children}</main>
|
||||
|
||||
{/* rename dialog */}
|
||||
<Dialog open={isRenameOpen} onOpenChange={setIsRenameOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Provide a new name for the workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={proposedName}
|
||||
onChange={(e) => setProposedName(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsRenameOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
renameWorkspace(proposedName);
|
||||
setIsRenameOpen(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* create workspace dialog */}
|
||||
<Dialog
|
||||
open={isWorkspaceCreateOpen}
|
||||
onOpenChange={setIsWorkspaceCreateOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Provide a name for the workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newWorkspaceName}
|
||||
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsWorkspaceCreateOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const ws = createDefaultWorkspace();
|
||||
ws.name = newWorkspaceName || ws.name;
|
||||
ws.updatedAt = Date.now();
|
||||
ws.createdAt = ws.createdAt || Date.now();
|
||||
saveWorkspaceToLocalStorage(ws);
|
||||
setAvailableWorkspaces(listWorkspaces());
|
||||
switchWorkspace(ws.id);
|
||||
if (setWorkspace) setWorkspace(ws);
|
||||
setIsWorkspaceCreateOpen(false);
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* create dialog */}
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{createMode === "group"
|
||||
? "Create group"
|
||||
: createMode === "doc"
|
||||
? "Create document"
|
||||
: "Create list"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{createMode === "group" && "Provide a name for the new group."}
|
||||
{createMode === "doc" && "Provide a name for the new document."}
|
||||
{createMode === "list" && "Provide a name for the new list."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={createValue}
|
||||
onChange={(e) => setCreateValue(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (createMode === "group") {
|
||||
addGroup?.(createValue || "New group", null);
|
||||
} else if (createMode === "doc") {
|
||||
const id = addEntity?.("doc", null, createValue || "New doc");
|
||||
if (id) setRoute({ workspace: workspace.id, entity: id });
|
||||
} else {
|
||||
const id = addEntity?.(
|
||||
"todo",
|
||||
null,
|
||||
createValue || "New list",
|
||||
);
|
||||
if (id) setRoute({ workspace: workspace.id, entity: id });
|
||||
}
|
||||
setIsCreateOpen(false);
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@@ -9,69 +11,254 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "../ui/sidebar";
|
||||
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "../ui/dialog";
|
||||
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
import { Route, Workspace, LayoutNode } from "@/lib/types";
|
||||
|
||||
type DialogMode = "rename" | "add-item" | "add-group";
|
||||
|
||||
type Props = {
|
||||
node: LayoutNode;
|
||||
workspace: Workspace;
|
||||
setRoute: (route: Route) => void;
|
||||
addEntity: (
|
||||
type: "todo" | "doc",
|
||||
parentGroupId: string | null,
|
||||
name?: string,
|
||||
) => string | undefined;
|
||||
addGroup: (name: string, parentGroupId: string | null) => string;
|
||||
renameEntity: (entityId: string, newName: string) => void;
|
||||
deleteEntity: (entityId: string) => void;
|
||||
removeNode: (id: string) => void;
|
||||
renameGroup: (id: string, name: string) => void;
|
||||
};
|
||||
|
||||
export default function EntitySwitch({ node, workspace, setRoute }: Props) {
|
||||
// ---- GROUP ----
|
||||
export default function EntitySwitch(props: Props) {
|
||||
const {
|
||||
node,
|
||||
workspace,
|
||||
setRoute,
|
||||
addEntity,
|
||||
addGroup,
|
||||
renameEntity,
|
||||
deleteEntity,
|
||||
removeNode,
|
||||
renameGroup,
|
||||
} = props;
|
||||
|
||||
const [dialogValue, setDialogValue] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | undefined>();
|
||||
|
||||
const parentId = node.type === "group" ? node.id : null;
|
||||
|
||||
const openDialog = (mode: DialogMode, initial = "") => {
|
||||
setDialogMode(mode);
|
||||
setDialogValue(
|
||||
initial ||
|
||||
(mode === "add-item"
|
||||
? "New list"
|
||||
: mode === "add-group"
|
||||
? "New group"
|
||||
: ""),
|
||||
);
|
||||
};
|
||||
|
||||
const closeDialog = () => setDialogMode(undefined);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const value = dialogValue.trim();
|
||||
if (!value) return;
|
||||
|
||||
switch (dialogMode) {
|
||||
case "rename":
|
||||
if (node.type === "group") renameGroup(node.id, value);
|
||||
else if (node.type === "item") renameEntity(node.entityId, value);
|
||||
break;
|
||||
|
||||
case "add-item":
|
||||
addEntity("todo", parentId, value);
|
||||
break;
|
||||
|
||||
case "add-group":
|
||||
addGroup(value, parentId);
|
||||
break;
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const confirmDelete = (message: string, action: () => void) => {
|
||||
if (window.confirm(message)) action();
|
||||
};
|
||||
|
||||
const dialogTitle = dialogMode?.startsWith("rename")
|
||||
? "Rename"
|
||||
: dialogMode === "add-item"
|
||||
? "Add new list"
|
||||
: "Add new group";
|
||||
|
||||
const dialog = (
|
||||
<Dialog open={!!dialogMode} onOpenChange={(o) => !o && closeDialog()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
|
||||
{(dialogMode === "add-item" || dialogMode === "add-group") && (
|
||||
<DialogDescription>Enter a name below.</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<Input
|
||||
autoFocus
|
||||
value={dialogValue}
|
||||
onChange={(e) => setDialogValue(e.target.value)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
/* ================= GROUP ================= */
|
||||
|
||||
if (node.type === "group") {
|
||||
return (
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<SidebarMenuButton asChild>
|
||||
<CollapsibleTrigger className="flex w-full items-center">
|
||||
{node.name}
|
||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarMenuButton>
|
||||
</SidebarGroupLabel>
|
||||
<>
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
{node.name}
|
||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{node.children.map((child) => (
|
||||
<EntitySwitch
|
||||
key={child.type === "group" ? child.id : child.entityId}
|
||||
node={child}
|
||||
workspace={workspace}
|
||||
setRoute={setRoute}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => openDialog("add-item")}>
|
||||
Add list
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onSelect={() => openDialog("add-group")}>
|
||||
Add group
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
onSelect={() => openDialog("rename", node.name)}
|
||||
>
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() =>
|
||||
confirmDelete("Delete this group and its children?", () =>
|
||||
removeNode(node.id),
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</SidebarGroupLabel>
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{node.children.map((child) => (
|
||||
<EntitySwitch
|
||||
key={child.type === "group" ? child.id : child.entityId}
|
||||
{...props}
|
||||
node={child}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ITEM ----
|
||||
const workspaceEntity = workspace.entities[node.entityId];
|
||||
/* ================= ITEM ================= */
|
||||
|
||||
if (!workspaceEntity) {
|
||||
return null;
|
||||
}
|
||||
const entity = workspace.entities[node.entityId];
|
||||
if (!entity) return null;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() =>
|
||||
setRoute({
|
||||
workspace: workspace.id,
|
||||
entity: node.entityId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{workspaceEntity.name}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<>
|
||||
<SidebarMenuItem>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
onClick={() =>
|
||||
setRoute({
|
||||
workspace: workspace.id,
|
||||
entity: node.entityId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{entity.name}
|
||||
</SidebarMenuButton>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => openDialog("rename", entity.name)}>
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() =>
|
||||
confirmDelete("Delete this list?", () =>
|
||||
deleteEntity(node.entityId),
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</SidebarMenuItem>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
export default function BoardView({
|
||||
tags,
|
||||
@@ -20,6 +21,18 @@ export default function BoardView({
|
||||
createTask,
|
||||
statuses,
|
||||
}: TaskViewProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [isDraggingSelection, setIsDraggingSelection] = useState(false);
|
||||
const [selectionBox, setSelectionBox] = useState<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
} | null>(null);
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
const taskCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
function onDragEnd(result: any) {
|
||||
const { draggableId, source, destination } = result;
|
||||
if (!destination) return;
|
||||
@@ -37,10 +50,130 @@ export default function BoardView({
|
||||
});
|
||||
}
|
||||
|
||||
// Group tasks by status, using statuses from first available todo entity
|
||||
const tasksArray = Object.values(tasks);
|
||||
const deleteSelected = useCallback(() => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const updated = { ...tasks };
|
||||
selectedIds.forEach((id) => delete updated[id]);
|
||||
setTasks(updated);
|
||||
setSelectedIds(new Set());
|
||||
}, [selectedIds, tasks, setTasks]);
|
||||
|
||||
// Sort tasks by creation date within each status
|
||||
// Ctrl+A to select all, Backspace/Delete to delete selected
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "a" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
setSelectedIds(new Set(Object.keys(tasks)));
|
||||
}
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
// Don't fire if user is typing in an input/textarea
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
)
|
||||
return;
|
||||
deleteSelected();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [tasks, deleteSelected]);
|
||||
|
||||
// Mouse drag selection
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
// Only start selection if clicking on the board background (not a card)
|
||||
const target = e.target as HTMLElement;
|
||||
const isCard = target.closest("[data-task-card]");
|
||||
if (isCard) return;
|
||||
if (e.button !== 0) return;
|
||||
|
||||
mouseDownPos.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!mouseDownPos.current) return;
|
||||
const dx = Math.abs(e.clientX - mouseDownPos.current.x);
|
||||
const dy = Math.abs(e.clientY - mouseDownPos.current.y);
|
||||
if (dx > 4 || dy > 4) {
|
||||
setIsDraggingSelection(true);
|
||||
setSelectionBox({
|
||||
startX: mouseDownPos.current.x,
|
||||
startY: mouseDownPos.current.y,
|
||||
endX: e.clientX,
|
||||
endY: e.clientY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp(e: MouseEvent) {
|
||||
if (isDraggingSelection && selectionBox) {
|
||||
// Find all task cards that intersect with the selection box
|
||||
const minX = Math.min(selectionBox.startX, selectionBox.endX);
|
||||
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
|
||||
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
||||
const maxY = Math.max(selectionBox.startY, selectionBox.endY);
|
||||
|
||||
const newSelected = new Set<string>();
|
||||
Object.entries(taskCardRefs.current).forEach(([id, el]) => {
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (
|
||||
rect.left < maxX &&
|
||||
rect.right > minX &&
|
||||
rect.top < maxY &&
|
||||
rect.bottom > minY
|
||||
) {
|
||||
newSelected.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (e.shiftKey) {
|
||||
setSelectedIds((prev) => new Set([...prev, ...newSelected]));
|
||||
} else {
|
||||
setSelectedIds(newSelected);
|
||||
}
|
||||
} else if (!isDraggingSelection) {
|
||||
// Click on empty area deselects
|
||||
const target = e.target as HTMLElement;
|
||||
const isCard = target.closest("[data-task-card]");
|
||||
if (!isCard) {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
mouseDownPos.current = null;
|
||||
setIsDraggingSelection(false);
|
||||
setSelectionBox(null);
|
||||
}
|
||||
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDraggingSelection, selectionBox]);
|
||||
|
||||
function handleTaskClick(e: React.MouseEvent, taskId: string) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(taskId)) next.delete(taskId);
|
||||
else next.add(taskId);
|
||||
return next;
|
||||
});
|
||||
} else if (e.shiftKey) {
|
||||
setSelectedIds((prev) => new Set([...prev, taskId]));
|
||||
}
|
||||
// Normal click handled by SheetTrigger (task edit) if not multi-select
|
||||
}
|
||||
|
||||
const tasksArray = Object.values(tasks);
|
||||
const tasksByStatus = statuses.map((status) => ({
|
||||
status,
|
||||
tasks: tasksArray
|
||||
@@ -48,17 +181,71 @@ export default function BoardView({
|
||||
.sort((a, b) => a.createdAt - b.createdAt),
|
||||
}));
|
||||
|
||||
const selRect = selectionBox
|
||||
? {
|
||||
left: Math.min(selectionBox.startX, selectionBox.endX),
|
||||
top: Math.min(selectionBox.startY, selectionBox.endY),
|
||||
width: Math.abs(selectionBox.endX - selectionBox.startX),
|
||||
height: Math.abs(selectionBox.endY - selectionBox.startY),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col gap-6 overflow-hidden flex-1 min-h-0">
|
||||
<div
|
||||
ref={boardRef}
|
||||
className="w-full h-full flex flex-col gap-6 overflow-hidden flex-1 min-h-0 relative"
|
||||
>
|
||||
{/* Selection box overlay */}
|
||||
{isDraggingSelection && selRect && (
|
||||
<div
|
||||
className="fixed pointer-events-none z-50 border border-primary bg-primary/10 rounded"
|
||||
style={{
|
||||
left: selRect.left,
|
||||
top: selRect.top,
|
||||
width: selRect.width,
|
||||
height: selRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete bar when items selected */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-destructive/10 border border-destructive/30 rounded-md text-sm text-destructive">
|
||||
<span className="font-medium">
|
||||
{selectedIds.size} task{selectedIds.size !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
— press Backspace to delete
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="ml-auto h-7"
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-muted-foreground"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start flex-1 h-full min-h-0">
|
||||
<div className="flex flex-col md:flex-row w-full gap-6 items-start flex-1 h-full min-h-0">
|
||||
{tasksByStatus.map(({ status, tasks: statusTasks }) => (
|
||||
<Droppable key={status.id} droppableId={status.id}>
|
||||
{(provided) => (
|
||||
<Card
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className="bg-background h-full flex flex-col overflow-hidden min-h-0"
|
||||
className="bg-background w-full h-full flex flex-col overflow-hidden min-h-0"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
@@ -85,9 +272,14 @@ export default function BoardView({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
ref={(el) => {
|
||||
provided.innerRef(el);
|
||||
taskCardRefs.current[task.id] = el;
|
||||
}}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
data-task-card={task.id}
|
||||
onClick={(e) => handleTaskClick(e, task.id)}
|
||||
>
|
||||
<BoardTask
|
||||
setTask={(t) =>
|
||||
@@ -99,6 +291,17 @@ export default function BoardView({
|
||||
provided={provided}
|
||||
task={task}
|
||||
tags={tags}
|
||||
isSelected={selectedIds.has(task.id)}
|
||||
onDelete={(id) => {
|
||||
const updated = { ...tasks };
|
||||
delete updated[id];
|
||||
setTasks(updated);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -110,6 +313,11 @@ export default function BoardView({
|
||||
const updated = { ...tasks };
|
||||
delete updated[task.id];
|
||||
setTasks(updated);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(task.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="text-destructive" />
|
||||
|
||||
@@ -14,6 +14,7 @@ import TaskEdit from "../edit/TaskEdit";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotepadText, Trash2 } from "lucide-react";
|
||||
import DocDialog from "../edit/DocDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function BoardTask({
|
||||
task,
|
||||
@@ -21,24 +22,27 @@ export default function BoardTask({
|
||||
setTask,
|
||||
tags,
|
||||
onDelete,
|
||||
isSelected,
|
||||
}: {
|
||||
task: Task;
|
||||
setTask: (t: Task) => void;
|
||||
provided: DraggableProvided;
|
||||
tags: Record<TagId, Tag>;
|
||||
onDelete?: (taskId: string) => void;
|
||||
isSelected?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
||||
<SheetTrigger asChild>
|
||||
<Card
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="bg-background cursor-grab active:cursor-grabbing"
|
||||
className={cn(
|
||||
"bg-background cursor-grab active:cursor-grabbing transition-all duration-150",
|
||||
isSelected &&
|
||||
"ring-2 ring-primary ring-offset-1 bg-primary/5 mt-1"
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center justify-between select-none">
|
||||
<span className="text-sm font-semibold">{task.title}</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<DocDialog task={task} setTask={setTask} tags={tags}>
|
||||
@@ -73,7 +77,7 @@ export default function BoardTask({
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden">
|
||||
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden select-none">
|
||||
{task.tagIds?.map((tagId) => (
|
||||
<Badge
|
||||
key={tagId}
|
||||
|
||||
+132
-14
@@ -1,7 +1,11 @@
|
||||
import { Entity, EntityId, Workspace } from "@/lib/types";
|
||||
import { Entity, EntityId, LayoutNode, Workspace } from "@/lib/types";
|
||||
import {
|
||||
loadWorkspaceFromLocalStorage,
|
||||
saveWorkspaceToLocalStorage,
|
||||
addLayoutItem,
|
||||
addLayoutGroup,
|
||||
removeLayoutNode,
|
||||
renameLayoutGroup,
|
||||
} from "@/lib/workspace";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -9,18 +13,121 @@ export function useWorkspace() {
|
||||
const [workspace, setWorkspace] = useState<Workspace>();
|
||||
|
||||
const updateEntity = (entityId: EntityId, updater: (e: Entity) => Entity) => {
|
||||
setWorkspace((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
entities: {
|
||||
...prev.entities,
|
||||
[entityId]: updater(prev.entities[entityId]),
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setWorkspace((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.entities[entityId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
entities: {
|
||||
...prev.entities,
|
||||
[entityId]: updater(current),
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const renameEntity = (entityId: EntityId, newName: string) => {
|
||||
updateEntity(entityId, (e) => ({ ...e, name: newName }));
|
||||
};
|
||||
|
||||
const deleteEntity = (entityId: EntityId) => {
|
||||
setWorkspace((prev) => {
|
||||
if (!prev) return prev;
|
||||
const { [entityId]: _, ...rest } = prev.entities;
|
||||
// also remove from layout
|
||||
const { layout: newLayout } = removeLayoutNode(prev.layout, entityId);
|
||||
return {
|
||||
...prev,
|
||||
entities: rest,
|
||||
layout: newLayout,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const addEntity = (
|
||||
type: "todo" | "doc",
|
||||
parentGroupId: string | null,
|
||||
name?: string,
|
||||
) => {
|
||||
const now = Date.now();
|
||||
const id = crypto.randomUUID();
|
||||
let entity: Entity;
|
||||
if (type === "todo") {
|
||||
entity = {
|
||||
id,
|
||||
type: "todo",
|
||||
name: name || "New list",
|
||||
config: { statuses: [] },
|
||||
tags: {},
|
||||
tasks: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as any;
|
||||
} else {
|
||||
entity = {
|
||||
id,
|
||||
type: "doc",
|
||||
name: name || "New doc",
|
||||
content: { version: "2.28.0", time: now, blocks: [] },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as any;
|
||||
}
|
||||
setWorkspace((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newEntities = { ...prev.entities, [id]: entity };
|
||||
const newLayout = addLayoutItem(prev.layout, parentGroupId, {
|
||||
type: "item",
|
||||
entityId: id,
|
||||
});
|
||||
return {
|
||||
...prev,
|
||||
entities: newEntities,
|
||||
layout: newLayout,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
return id;
|
||||
};
|
||||
|
||||
const addGroup = (name = "New group", parentGroupId: string | null) => {
|
||||
const id = crypto.randomUUID();
|
||||
const group: LayoutNode = { type: "group", id, name, children: [] };
|
||||
setWorkspace((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newLayout = addLayoutGroup(prev.layout, parentGroupId, group);
|
||||
return { ...prev, layout: newLayout, updatedAt: Date.now() };
|
||||
});
|
||||
return id;
|
||||
};
|
||||
|
||||
const removeNode = (id: string) => {
|
||||
setWorkspace((prev) => {
|
||||
if (!prev) return prev;
|
||||
const { layout: newLayout, removedEntityIds } = removeLayoutNode(
|
||||
prev.layout,
|
||||
id,
|
||||
);
|
||||
const newEntities = { ...prev.entities } as Record<string, Entity>;
|
||||
removedEntityIds.forEach((eid) => delete newEntities[eid]);
|
||||
return {
|
||||
...prev,
|
||||
entities: newEntities,
|
||||
layout: newLayout,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const renameGroup = (id: string, newName: string) => {
|
||||
setWorkspace((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newLayout = renameLayoutGroup(prev.layout, id, newName);
|
||||
return { ...prev, layout: newLayout, updatedAt: Date.now() };
|
||||
});
|
||||
};
|
||||
|
||||
const loadWorkspace = (workspaceId: string | undefined) => {
|
||||
@@ -37,5 +144,16 @@ export function useWorkspace() {
|
||||
if (workspace) saveWorkspaceToLocalStorage(workspace);
|
||||
}, [workspace]);
|
||||
|
||||
return { workspace, loadWorkspace, updateEntity, setWorkspace };
|
||||
return {
|
||||
workspace,
|
||||
loadWorkspace,
|
||||
updateEntity,
|
||||
renameEntity,
|
||||
deleteEntity,
|
||||
addEntity,
|
||||
addGroup,
|
||||
removeNode,
|
||||
renameGroup,
|
||||
setWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
+175
-1
@@ -1,4 +1,4 @@
|
||||
import { Workspace, TodoEntity, DocEntity, Status } from "./types";
|
||||
import { Workspace, TodoEntity, DocEntity, Status, LayoutNode } from "./types";
|
||||
|
||||
export function createDefaultWorkspace(): Workspace {
|
||||
const now = Date.now();
|
||||
@@ -90,8 +90,182 @@ export function loadWorkspaceFromLocalStorage(id: string): Workspace {
|
||||
export function saveWorkspaceToLocalStorage(workspace: Workspace): void {
|
||||
try {
|
||||
const serialized = JSON.stringify(workspace);
|
||||
// keep using raw id for backwards compatibility
|
||||
localStorage.setItem(workspace.id, serialized);
|
||||
} catch (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;
|
||||
}
|
||||
|
||||
// layout helpers -------------------------------------------------------------------
|
||||
|
||||
type RemoveResult = { layout: LayoutNode[]; removedEntityIds: string[] };
|
||||
|
||||
function collectEntityIds(node: LayoutNode): string[] {
|
||||
if (node.type === "item") return [node.entityId];
|
||||
const ids: string[] = [];
|
||||
for (const child of node.children) {
|
||||
ids.push(...collectEntityIds(child));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function removeLayoutNode(
|
||||
layout: LayoutNode[],
|
||||
id: string,
|
||||
): RemoveResult {
|
||||
let removed: string[] = [];
|
||||
|
||||
function recurse(nodes: LayoutNode[]): LayoutNode[] {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
if (node.type === "group") {
|
||||
if (node.id === id) {
|
||||
removed.push(...collectEntityIds(node));
|
||||
return null;
|
||||
}
|
||||
const children = recurse(node.children);
|
||||
if (children.length !== node.children.length) {
|
||||
// some children removed
|
||||
}
|
||||
return { ...node, children } as LayoutNode;
|
||||
} else {
|
||||
if (node.entityId === id) {
|
||||
removed.push(node.entityId);
|
||||
return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as LayoutNode[];
|
||||
}
|
||||
|
||||
const newLayout = recurse(layout);
|
||||
return { layout: newLayout, removedEntityIds: removed };
|
||||
}
|
||||
|
||||
export function renameLayoutGroup(
|
||||
layout: LayoutNode[],
|
||||
id: string,
|
||||
newName: string,
|
||||
): LayoutNode[] {
|
||||
return layout.map((node) => {
|
||||
if (node.type === "group") {
|
||||
if (node.id === id) {
|
||||
return { ...node, name: newName };
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children: renameLayoutGroup(node.children, id, newName),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
export function addLayoutGroup(
|
||||
layout: LayoutNode[],
|
||||
parentId: string | null,
|
||||
group: LayoutNode,
|
||||
): LayoutNode[] {
|
||||
if (!parentId) {
|
||||
return [...layout, group];
|
||||
}
|
||||
return layout.map((node) => {
|
||||
if (node.type === "group") {
|
||||
if (node.id === parentId) {
|
||||
return { ...node, children: [...node.children, group] };
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children: addLayoutGroup(node.children, parentId, group),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
export function addLayoutItem(
|
||||
layout: LayoutNode[],
|
||||
parentId: string | null,
|
||||
item: LayoutNode,
|
||||
): LayoutNode[] {
|
||||
if (!parentId) {
|
||||
return [...layout, item];
|
||||
}
|
||||
return layout.map((node) => {
|
||||
if (node.type === "group") {
|
||||
if (node.id === parentId) {
|
||||
return { ...node, children: [...node.children, item] };
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children: addLayoutItem(node.children, parentId, item),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
// entity creation helpers ----------------------------------------------------------
|
||||
|
||||
export function createTodoEntity(name = "New list"): TodoEntity {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
type: "todo",
|
||||
name,
|
||||
config: { statuses: [] },
|
||||
tags: {},
|
||||
tasks: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDocEntity(name = "New doc"): DocEntity {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
type: "doc",
|
||||
name,
|
||||
content: { version: "2.28.0", time: now, blocks: [] },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user