diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index f262885..c22bb41 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -36,6 +36,7 @@ import { ChevronDown } from "lucide-react"; import { Route, SetState, Workspace } from "@/lib/types"; import { listWorkspaces, saveWorkspaceToLocalStorage } from "@/lib/workspace"; import { openFileDialog } from "@/lib/import"; +import { useWorkspace } from "@/hooks/workspace"; // Simple ID generator const id = (prefix = "t") => @@ -55,6 +56,8 @@ export default function AppSidebar({ setRoute, setWorkspace, }: AppSidebarProps) { + const { addEntity, addGroup, renameEntity } = useWorkspace(); + const [availableWorkspaces, setAvailableWorkspaces] = React.useState< Workspace[] >([]); @@ -158,6 +161,51 @@ export default function AppSidebar({ ))} + { + 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) + + { + 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) + + { + const name = window.prompt( + "Name for new group", + "New group", + ); + addGroup(name || "New group", null); + }} + > + New group (root) + + Import Workspace diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index 13305da..2ea73e6 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -10,8 +10,27 @@ import { SidebarMenuItem, } from "../ui/sidebar"; 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 { 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 = { node: LayoutNode; @@ -20,36 +39,151 @@ type 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 ---- if (node.type === "group") { return ( - - - - - - {node.name} - - - - + <> + + + +
+ + + {node.name} + + + + + + + + + openDialog("add-item")}> + Add list + + openDialog("add-group")}> + Add group + + + openDialog("rename-group", node.name)} + > + Rename + + { + if ( + window.confirm("Delete this group and its children?") + ) { + removeNode(node.id); + } + }} + > + Delete + + + +
+
- - - - {node.children.map((child) => ( - - ))} - - - -
-
+ + + + {node.children.map((child) => ( + + ))} + + + +
+
+ + {/* dialog for add/rename */} + + + + + {dialogMode.startsWith("rename") + ? "Rename" + : dialogMode === "add-item" + ? "Add new list" + : "Add new group"} + + + {dialogMode === "add-item" && + "A default todo list will be created."} + {dialogMode === "add-group" && + "Enter the name for the new group."} + + + setDialogValue(e.target.value)} + className="w-full" + /> + + + + + + + ); } @@ -62,16 +196,41 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) { return ( - - setRoute({ - workspace: workspace.id, - entity: node.entityId, - }) - } - > - {workspaceEntity.name} - +
+ + setRoute({ + workspace: workspace.id, + entity: node.entityId, + }) + } + > + {workspaceEntity.name} + + + + + + + openDialog("rename-item", workspaceEntity.name)} + > + Rename + + { + if (window.confirm("Delete this list?")) { + deleteEntity(node.entityId); + } + }} + > + Delete + + + +
); } diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index c90f08c..72a182f 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -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"; @@ -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; + 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) => { if (!workspaceId) { return setWorkspace(undefined); @@ -37,5 +140,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, + }; } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index eee2c04..6206186 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -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(); @@ -129,3 +129,143 @@ export function listWorkspaces(): Workspace[] { } 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, + }; +}