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";
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>({});
@@ -120,6 +130,12 @@ export default function Workspace() {
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">
+87 -29
View File
@@ -36,7 +36,6 @@ 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") =>
@@ -47,7 +46,17 @@ type AppSidebarProps = {
workspace: Workspace;
setRoute: SetState<Route>;
/** 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({
@@ -55,8 +64,14 @@ export default function AppSidebar({
workspace,
setRoute,
setWorkspace,
addEntity,
addGroup,
renameEntity,
deleteEntity,
removeNode,
renameGroup,
}: AppSidebarProps) {
const { addEntity, addGroup, renameEntity } = useWorkspace();
// actions are passed from parent `useWorkspace` instance
const [availableWorkspaces, setAvailableWorkspaces] = React.useState<
Workspace[]
@@ -64,6 +79,11 @@ export default function AppSidebar({
const [isRenameOpen, setIsRenameOpen] = React.useState(false);
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(() => {
setProposedName(workspace.name);
@@ -163,44 +183,27 @@ export default function AppSidebar({
<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 });
setCreateMode("list");
setCreateValue("New list");
setIsCreateOpen(true);
}}
>
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 });
setCreateMode("doc");
setCreateValue("New doc");
setIsCreateOpen(true);
}}
>
New doc (root)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
const name = window.prompt(
"Name for new group",
"New group",
);
addGroup(name || "New group", null);
setCreateMode("group");
setCreateValue("New group");
setIsCreateOpen(true);
}}
>
New group (root)
@@ -231,6 +234,12 @@ export default function AppSidebar({
node={node}
workspace={workspace}
setRoute={setRoute}
addEntity={addEntity}
addGroup={addGroup}
renameEntity={renameEntity}
deleteEntity={deleteEntity}
removeNode={removeNode}
renameGroup={renameGroup}
/>
))}
</SidebarMenu>
@@ -270,6 +279,55 @@ export default function AppSidebar({
</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>
);
}
+28 -16
View File
@@ -12,7 +12,6 @@ import {
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { ChevronDown, MoreHorizontal } from "lucide-react";
import { Route, Workspace, LayoutNode } from "@/lib/types";
import { useWorkspace } from "@/hooks/workspace";
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,18 +35,29 @@ type Props = {
node: LayoutNode;
workspace: Workspace;
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) {
const {
renameEntity,
deleteEntity,
addEntity,
addGroup,
removeNode,
renameGroup,
} = useWorkspace();
export default function EntitySwitch({
node,
workspace,
setRoute,
addEntity,
addGroup,
renameEntity,
deleteEntity,
removeNode,
renameGroup,
}: Props) {
const [menuOpen, setMenuOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState("");
@@ -74,11 +84,7 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
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);
}
addEntity("todo", parentId, dialogValue || "New list");
} else if (dialogMode === "add-group") {
const parentId = node.type === "group" ? node.id : null;
addGroup(dialogValue || "New group", parentId);
@@ -144,6 +150,12 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
node={child}
workspace={workspace}
setRoute={setRoute}
addEntity={addEntity}
addGroup={addGroup}
renameEntity={renameEntity}
deleteEntity={deleteEntity}
removeNode={removeNode}
renameGroup={renameGroup}
/>
))}
</SidebarMenu>
+20 -16
View File
@@ -13,18 +13,19 @@ 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) => {
@@ -46,8 +47,11 @@ export function useWorkspace() {
});
};
const addEntity = (type: "todo" | "doc", parentGroupId: string | null) => {
if (!workspace) return;
const addEntity = (
type: "todo" | "doc",
parentGroupId: string | null,
name?: string,
) => {
const now = Date.now();
const id = crypto.randomUUID();
let entity: Entity;
@@ -55,7 +59,7 @@ export function useWorkspace() {
entity = {
id,
type: "todo",
name: "New list",
name: name || "New list",
config: { statuses: [] },
tags: {},
tasks: {},
@@ -66,7 +70,7 @@ export function useWorkspace() {
entity = {
id,
type: "doc",
name: "New doc",
name: name || "New doc",
content: { version: "2.28.0", time: now, blocks: [] },
createdAt: now,
updatedAt: now,