Sidebar elements editor

This commit is contained in:
2026-02-28 02:41:17 +01:00
parent 7d4a087c10
commit b543988f02
4 changed files with 501 additions and 40 deletions
+48
View File
@@ -36,6 +36,7 @@ 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"; import { listWorkspaces, saveWorkspaceToLocalStorage } from "@/lib/workspace";
import { openFileDialog } from "@/lib/import"; import { openFileDialog } from "@/lib/import";
import { useWorkspace } from "@/hooks/workspace";
// Simple ID generator // Simple ID generator
const id = (prefix = "t") => const id = (prefix = "t") =>
@@ -55,6 +56,8 @@ export default function AppSidebar({
setRoute, setRoute,
setWorkspace, setWorkspace,
}: AppSidebarProps) { }: AppSidebarProps) {
const { addEntity, addGroup, renameEntity } = useWorkspace();
const [availableWorkspaces, setAvailableWorkspaces] = React.useState< const [availableWorkspaces, setAvailableWorkspaces] = React.useState<
Workspace[] Workspace[]
>([]); >([]);
@@ -158,6 +161,51 @@ export default function AppSidebar({
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
const name = window.prompt(
"Name for new list",
"New list",
);
const id = addEntity("todo", null);
if (id && name) {
// rename newly created entity
// obtain hook function
// import renameEntity above
renameEntity(id, name);
}
if (id) setRoute({ workspace: workspace.id, entity: id });
}}
>
New list (root)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
const name = window.prompt(
"Name for new document",
"New doc",
);
const id = addEntity("doc", null);
if (id && name) {
renameEntity(id, name);
}
if (id) setRoute({ workspace: workspace.id, entity: id });
}}
>
New doc (root)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
const name = window.prompt(
"Name for new group",
"New group",
);
addGroup(name || "New group", null);
}}
>
New group (root)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={importWorkspace}> <DropdownMenuItem onSelect={importWorkspace}>
Import Workspace Import Workspace
</DropdownMenuItem> </DropdownMenuItem>
+160 -1
View File
@@ -10,8 +10,27 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "../ui/sidebar"; } from "../ui/sidebar";
import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { ChevronDown } from "lucide-react"; import { ChevronDown, MoreHorizontal } from "lucide-react";
import { Route, Workspace, LayoutNode } from "@/lib/types"; import { Route, Workspace, LayoutNode } from "@/lib/types";
import { useWorkspace } from "@/hooks/workspace";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
type Props = { type Props = {
node: LayoutNode; node: LayoutNode;
@@ -20,18 +39,100 @@ type Props = {
}; };
export default function EntitySwitch({ node, workspace, setRoute }: Props) { export default function EntitySwitch({ node, workspace, setRoute }: Props) {
const {
renameEntity,
deleteEntity,
addEntity,
addGroup,
removeNode,
renameGroup,
} = useWorkspace();
const [menuOpen, setMenuOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState("");
const [dialogMode, setDialogMode] = useState<
"rename-group" | "rename-item" | "add-item" | "add-group"
>("rename-item");
const openDialog = (mode: typeof dialogMode, initial = "") => {
setDialogMode(mode);
// supply default for add-item
if (mode === "add-item") {
setDialogValue("New list");
} else {
setDialogValue(initial);
}
setDialogOpen(true);
setMenuOpen(false);
};
const handleDialogSubmit = () => {
if (dialogMode === "rename-group" && node.type === "group") {
renameGroup(node.id, dialogValue);
} else if (dialogMode === "rename-item" && node.type === "item") {
renameEntity(node.entityId, dialogValue);
} else if (dialogMode === "add-item") {
const parentId = node.type === "group" ? node.id : null;
const id = addEntity("todo", parentId);
// optionally could rename newly created entity name
if (id && dialogValue) {
renameEntity(id, dialogValue);
}
} else if (dialogMode === "add-group") {
const parentId = node.type === "group" ? node.id : null;
addGroup(dialogValue || "New group", parentId);
}
setDialogOpen(false);
};
// ---- GROUP ---- // ---- GROUP ----
if (node.type === "group") { if (node.type === "group") {
return ( return (
<>
<Collapsible defaultOpen className="group/collapsible"> <Collapsible defaultOpen className="group/collapsible">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel asChild> <SidebarGroupLabel asChild>
<div className="flex items-center justify-between w-full">
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<CollapsibleTrigger className="flex w-full items-center"> <CollapsibleTrigger className="flex w-full items-center">
{node.name} {node.name}
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" /> <ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger> </CollapsibleTrigger>
</SidebarMenuButton> </SidebarMenuButton>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button className="p-1 hover:bg-accent rounded">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => openDialog("add-item")}>
Add list
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openDialog("add-group")}>
Add group
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialog("rename-group", node.name)}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (
window.confirm("Delete this group and its children?")
) {
removeNode(node.id);
}
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</SidebarGroupLabel> </SidebarGroupLabel>
<CollapsibleContent> <CollapsibleContent>
@@ -50,6 +151,39 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
</CollapsibleContent> </CollapsibleContent>
</SidebarGroup> </SidebarGroup>
</Collapsible> </Collapsible>
{/* dialog for add/rename */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialogMode.startsWith("rename")
? "Rename"
: dialogMode === "add-item"
? "Add new list"
: "Add new group"}
</DialogTitle>
<DialogDescription>
{dialogMode === "add-item" &&
"A default todo list will be created."}
{dialogMode === "add-group" &&
"Enter the name for the new group."}
</DialogDescription>
</DialogHeader>
<Input
value={dialogValue}
onChange={(e) => setDialogValue(e.target.value)}
className="w-full"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleDialogSubmit}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }
@@ -62,6 +196,7 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
return ( return (
<SidebarMenuItem> <SidebarMenuItem>
<div className="flex items-center justify-between w-full">
<SidebarMenuButton <SidebarMenuButton
onClick={() => onClick={() =>
setRoute({ setRoute({
@@ -72,6 +207,30 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
> >
{workspaceEntity.name} {workspaceEntity.name}
</SidebarMenuButton> </SidebarMenuButton>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button className="p-1 hover:bg-accent rounded">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onSelect={() => openDialog("rename-item", workspaceEntity.name)}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (window.confirm("Delete this list?")) {
deleteEntity(node.entityId);
}
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</SidebarMenuItem> </SidebarMenuItem>
); );
} }
+116 -2
View File
@@ -1,7 +1,11 @@
import { Entity, EntityId, Workspace } from "@/lib/types"; import { Entity, EntityId, LayoutNode, Workspace } from "@/lib/types";
import { import {
loadWorkspaceFromLocalStorage, loadWorkspaceFromLocalStorage,
saveWorkspaceToLocalStorage, saveWorkspaceToLocalStorage,
addLayoutItem,
addLayoutGroup,
removeLayoutNode,
renameLayoutGroup,
} from "@/lib/workspace"; } from "@/lib/workspace";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -23,6 +27,105 @@ export function useWorkspace() {
); );
}; };
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) => {
if (!workspace) return;
const now = Date.now();
const id = crypto.randomUUID();
let entity: Entity;
if (type === "todo") {
entity = {
id,
type: "todo",
name: "New list",
config: { statuses: [] },
tags: {},
tasks: {},
createdAt: now,
updatedAt: now,
} as any;
} else {
entity = {
id,
type: "doc",
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) => { const loadWorkspace = (workspaceId: string | undefined) => {
if (!workspaceId) { if (!workspaceId) {
return setWorkspace(undefined); return setWorkspace(undefined);
@@ -37,5 +140,16 @@ export function useWorkspace() {
if (workspace) saveWorkspaceToLocalStorage(workspace); if (workspace) saveWorkspaceToLocalStorage(workspace);
}, [workspace]); }, [workspace]);
return { workspace, loadWorkspace, updateEntity, setWorkspace }; return {
workspace,
loadWorkspace,
updateEntity,
renameEntity,
deleteEntity,
addEntity,
addGroup,
removeNode,
renameGroup,
setWorkspace,
};
} }
+141 -1
View File
@@ -1,4 +1,4 @@
import { Workspace, TodoEntity, DocEntity, Status } from "./types"; import { Workspace, TodoEntity, DocEntity, Status, LayoutNode } from "./types";
export function createDefaultWorkspace(): Workspace { export function createDefaultWorkspace(): Workspace {
const now = Date.now(); const now = Date.now();
@@ -129,3 +129,143 @@ export function listWorkspaces(): Workspace[] {
} }
return workspaces; 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,
};
}