Working layout editor

This commit is contained in:
2026-02-28 13:41:51 +01:00
parent b543988f02
commit 78daab4cf4
4 changed files with 153 additions and 63 deletions
+18 -2
View File
@@ -14,8 +14,18 @@ import {
} from "@/lib/workspace"; } from "@/lib/workspace";
export default function Workspace() { export default function Workspace() {
const { workspace, loadWorkspace, updateEntity, setWorkspace } = const {
useWorkspace(); workspace,
loadWorkspace,
updateEntity,
setWorkspace,
addEntity,
addGroup,
renameEntity,
deleteEntity,
removeNode,
renameGroup,
} = useWorkspace();
const [route, setRoute] = useState<Route>({}); const [route, setRoute] = useState<Route>({});
@@ -120,6 +130,12 @@ export default function Workspace() {
workspace={workspace} workspace={workspace}
setRoute={setRoute} setRoute={setRoute}
setWorkspace={setWorkspace} setWorkspace={setWorkspace}
addEntity={addEntity}
addGroup={addGroup}
renameEntity={renameEntity}
deleteEntity={deleteEntity}
removeNode={removeNode}
renameGroup={renameGroup}
> >
<SidebarTrigger className="w-full px-4"> <SidebarTrigger className="w-full px-4">
<div className="px-4"> <div className="px-4">
+87 -29
View File
@@ -36,7 +36,6 @@ 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") =>
@@ -47,7 +46,17 @@ type AppSidebarProps = {
workspace: Workspace; workspace: Workspace;
setRoute: SetState<Route>; setRoute: SetState<Route>;
/** optional setter for the current workspace, used for rename */ /** optional setter for the current workspace, used for rename */
setWorkspace?: React.Dispatch<React.SetStateAction<Workspace | undefined>>; 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({ export default function AppSidebar({
@@ -55,8 +64,14 @@ export default function AppSidebar({
workspace, workspace,
setRoute, setRoute,
setWorkspace, setWorkspace,
addEntity,
addGroup,
renameEntity,
deleteEntity,
removeNode,
renameGroup,
}: AppSidebarProps) { }: AppSidebarProps) {
const { addEntity, addGroup, renameEntity } = useWorkspace(); // actions are passed from parent `useWorkspace` instance
const [availableWorkspaces, setAvailableWorkspaces] = React.useState< const [availableWorkspaces, setAvailableWorkspaces] = React.useState<
Workspace[] Workspace[]
@@ -64,6 +79,11 @@ export default function AppSidebar({
const [isRenameOpen, setIsRenameOpen] = React.useState(false); const [isRenameOpen, setIsRenameOpen] = React.useState(false);
const [proposedName, setProposedName] = React.useState(workspace.name); const [proposedName, setProposedName] = React.useState(workspace.name);
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [createMode, setCreateMode] = React.useState<"list" | "doc" | "group">(
"list",
);
const [createValue, setCreateValue] = React.useState("");
React.useEffect(() => { React.useEffect(() => {
setProposedName(workspace.name); setProposedName(workspace.name);
@@ -163,44 +183,27 @@ export default function AppSidebar({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
const name = window.prompt( setCreateMode("list");
"Name for new list", setCreateValue("New list");
"New list", setIsCreateOpen(true);
);
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) New list (root)
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
const name = window.prompt( setCreateMode("doc");
"Name for new document", setCreateValue("New doc");
"New doc", setIsCreateOpen(true);
);
const id = addEntity("doc", null);
if (id && name) {
renameEntity(id, name);
}
if (id) setRoute({ workspace: workspace.id, entity: id });
}} }}
> >
New doc (root) New doc (root)
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
const name = window.prompt( setCreateMode("group");
"Name for new group", setCreateValue("New group");
"New group", setIsCreateOpen(true);
);
addGroup(name || "New group", null);
}} }}
> >
New group (root) New group (root)
@@ -231,6 +234,12 @@ export default function AppSidebar({
node={node} node={node}
workspace={workspace} workspace={workspace}
setRoute={setRoute} setRoute={setRoute}
addEntity={addEntity}
addGroup={addGroup}
renameEntity={renameEntity}
deleteEntity={deleteEntity}
removeNode={removeNode}
renameGroup={renameGroup}
/> />
))} ))}
</SidebarMenu> </SidebarMenu>
@@ -270,6 +279,55 @@ export default function AppSidebar({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </SidebarProvider>
); );
} }
+28 -16
View File
@@ -12,7 +12,6 @@ import {
import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { ChevronDown, MoreHorizontal } 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -36,18 +35,29 @@ type Props = {
node: LayoutNode; node: LayoutNode;
workspace: Workspace; workspace: Workspace;
setRoute: (route: Route) => void; setRoute: (route: Route) => void;
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 EntitySwitch({ node, workspace, setRoute }: Props) { export default function EntitySwitch({
const { node,
renameEntity, workspace,
deleteEntity, setRoute,
addEntity, addEntity,
addGroup, addGroup,
removeNode, renameEntity,
renameGroup, deleteEntity,
} = useWorkspace(); removeNode,
renameGroup,
}: Props) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState(""); const [dialogValue, setDialogValue] = useState("");
@@ -74,11 +84,7 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
renameEntity(node.entityId, dialogValue); renameEntity(node.entityId, dialogValue);
} else if (dialogMode === "add-item") { } else if (dialogMode === "add-item") {
const parentId = node.type === "group" ? node.id : null; const parentId = node.type === "group" ? node.id : null;
const id = addEntity("todo", parentId); addEntity("todo", parentId, dialogValue || "New list");
// optionally could rename newly created entity name
if (id && dialogValue) {
renameEntity(id, dialogValue);
}
} else if (dialogMode === "add-group") { } else if (dialogMode === "add-group") {
const parentId = node.type === "group" ? node.id : null; const parentId = node.type === "group" ? node.id : null;
addGroup(dialogValue || "New group", parentId); addGroup(dialogValue || "New group", parentId);
@@ -144,6 +150,12 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
node={child} node={child}
workspace={workspace} workspace={workspace}
setRoute={setRoute} setRoute={setRoute}
addEntity={addEntity}
addGroup={addGroup}
renameEntity={renameEntity}
deleteEntity={deleteEntity}
removeNode={removeNode}
renameGroup={renameGroup}
/> />
))} ))}
</SidebarMenu> </SidebarMenu>
+20 -16
View File
@@ -13,18 +13,19 @@ export function useWorkspace() {
const [workspace, setWorkspace] = useState<Workspace>(); const [workspace, setWorkspace] = useState<Workspace>();
const updateEntity = (entityId: EntityId, updater: (e: Entity) => Entity) => { const updateEntity = (entityId: EntityId, updater: (e: Entity) => Entity) => {
setWorkspace((prev) => setWorkspace((prev) => {
prev if (!prev) return prev;
? { const current = prev.entities[entityId];
...prev, if (!current) return prev;
entities: { return {
...prev.entities, ...prev,
[entityId]: updater(prev.entities[entityId]), entities: {
}, ...prev.entities,
updatedAt: Date.now(), [entityId]: updater(current),
} },
: undefined, updatedAt: Date.now(),
); };
});
}; };
const renameEntity = (entityId: EntityId, newName: string) => { const renameEntity = (entityId: EntityId, newName: string) => {
@@ -46,8 +47,11 @@ export function useWorkspace() {
}); });
}; };
const addEntity = (type: "todo" | "doc", parentGroupId: string | null) => { const addEntity = (
if (!workspace) return; type: "todo" | "doc",
parentGroupId: string | null,
name?: string,
) => {
const now = Date.now(); const now = Date.now();
const id = crypto.randomUUID(); const id = crypto.randomUUID();
let entity: Entity; let entity: Entity;
@@ -55,7 +59,7 @@ export function useWorkspace() {
entity = { entity = {
id, id,
type: "todo", type: "todo",
name: "New list", name: name || "New list",
config: { statuses: [] }, config: { statuses: [] },
tags: {}, tags: {},
tasks: {}, tasks: {},
@@ -66,7 +70,7 @@ export function useWorkspace() {
entity = { entity = {
id, id,
type: "doc", type: "doc",
name: "New doc", name: name || "New doc",
content: { version: "2.28.0", time: now, blocks: [] }, content: { version: "2.28.0", time: now, blocks: [] },
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,