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";
|
} 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>({});
|
||||||
|
|
||||||
@@ -116,7 +126,17 @@ export default function Workspace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<SidebarTrigger className="w-full px-4">
|
||||||
<div className="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">
|
<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";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -10,17 +12,34 @@ import {
|
|||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} 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 EntitySwitch from "./Entity";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { Route, SetState, Workspace } from "@/lib/types";
|
import { Route, SetState, Workspace } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
listWorkspaces,
|
||||||
|
saveWorkspaceToLocalStorage,
|
||||||
|
createDefaultWorkspace,
|
||||||
|
} from "@/lib/workspace";
|
||||||
|
import { openFileDialog } from "@/lib/import";
|
||||||
|
|
||||||
// Simple ID generator
|
// Simple ID generator
|
||||||
const id = (prefix = "t") =>
|
const id = (prefix = "t") =>
|
||||||
@@ -30,13 +49,112 @@ type AppSidebarProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
setRoute: SetState<Route>;
|
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({
|
export default function AppSidebar({
|
||||||
children,
|
children,
|
||||||
workspace,
|
workspace,
|
||||||
setRoute,
|
setRoute,
|
||||||
|
setWorkspace,
|
||||||
|
addEntity,
|
||||||
|
addGroup,
|
||||||
|
renameEntity,
|
||||||
|
deleteEntity,
|
||||||
|
removeNode,
|
||||||
|
renameGroup,
|
||||||
}: AppSidebarProps) {
|
}: 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 (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@@ -46,19 +164,86 @@ export default function AppSidebar({
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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}
|
{workspace.name}
|
||||||
<ChevronDown className="ml-auto h-4 w-4 opacity-60" />
|
<ChevronDown className="ml-auto h-4 w-4 opacity-60" />
|
||||||
</button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className="w-[--radix-popper-anchor-width]"
|
className="w-[--radix-popper-anchor-width]"
|
||||||
>
|
>
|
||||||
{/* Future: real workspace switching */}
|
{/* list all known workspaces */}
|
||||||
<DropdownMenuItem>Rename Workspace</DropdownMenuItem>
|
{[
|
||||||
<DropdownMenuItem>Export Workspace</DropdownMenuItem>
|
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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
@@ -74,6 +259,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>
|
||||||
@@ -83,6 +274,127 @@ export default function AppSidebar({
|
|||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
||||||
<main className="w-svw flex-1 flex flex-col min-h-0">{children}</main>
|
<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>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
@@ -9,69 +11,254 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "../ui/sidebar";
|
} 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";
|
import { Route, Workspace, LayoutNode } from "@/lib/types";
|
||||||
|
|
||||||
|
type DialogMode = "rename" | "add-item" | "add-group";
|
||||||
|
|
||||||
type Props = {
|
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, 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(props: Props) {
|
||||||
// ---- GROUP ----
|
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") {
|
if (node.type === "group") {
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen className="group/collapsible">
|
<>
|
||||||
<SidebarGroup>
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
<SidebarGroupLabel asChild>
|
<SidebarGroup>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarGroupLabel asChild>
|
||||||
<CollapsibleTrigger className="flex w-full items-center">
|
<ContextMenu>
|
||||||
{node.name}
|
<ContextMenuTrigger asChild>
|
||||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
<CollapsibleTrigger asChild>
|
||||||
</CollapsibleTrigger>
|
<SidebarMenuButton className="w-full">
|
||||||
</SidebarMenuButton>
|
{node.name}
|
||||||
</SidebarGroupLabel>
|
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<ContextMenuContent>
|
||||||
<SidebarGroupContent>
|
<ContextMenuItem onSelect={() => openDialog("add-item")}>
|
||||||
<SidebarMenu>
|
Add list
|
||||||
{node.children.map((child) => (
|
</ContextMenuItem>
|
||||||
<EntitySwitch
|
|
||||||
key={child.type === "group" ? child.id : child.entityId}
|
<ContextMenuItem onSelect={() => openDialog("add-group")}>
|
||||||
node={child}
|
Add group
|
||||||
workspace={workspace}
|
</ContextMenuItem>
|
||||||
setRoute={setRoute}
|
|
||||||
/>
|
<ContextMenuSeparator />
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
<ContextMenuItem
|
||||||
</SidebarGroupContent>
|
onSelect={() => openDialog("rename", node.name)}
|
||||||
</CollapsibleContent>
|
>
|
||||||
</SidebarGroup>
|
Rename
|
||||||
</Collapsible>
|
</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 ----
|
/* ================= ITEM ================= */
|
||||||
const workspaceEntity = workspace.entities[node.entityId];
|
|
||||||
|
|
||||||
if (!workspaceEntity) {
|
const entity = workspace.entities[node.entityId];
|
||||||
return null;
|
if (!entity) return null;
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem>
|
<>
|
||||||
<SidebarMenuButton
|
<SidebarMenuItem>
|
||||||
onClick={() =>
|
<ContextMenu>
|
||||||
setRoute({
|
<ContextMenuTrigger asChild>
|
||||||
workspace: workspace.id,
|
<SidebarMenuButton
|
||||||
entity: node.entityId,
|
onClick={() =>
|
||||||
})
|
setRoute({
|
||||||
}
|
workspace: workspace.id,
|
||||||
>
|
entity: node.entityId,
|
||||||
{workspaceEntity.name}
|
})
|
||||||
</SidebarMenuButton>
|
}
|
||||||
</SidebarMenuItem>
|
>
|
||||||
|
{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,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
export default function BoardView({
|
export default function BoardView({
|
||||||
tags,
|
tags,
|
||||||
@@ -20,6 +21,18 @@ export default function BoardView({
|
|||||||
createTask,
|
createTask,
|
||||||
statuses,
|
statuses,
|
||||||
}: TaskViewProps) {
|
}: 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) {
|
function onDragEnd(result: any) {
|
||||||
const { draggableId, source, destination } = result;
|
const { draggableId, source, destination } = result;
|
||||||
if (!destination) return;
|
if (!destination) return;
|
||||||
@@ -37,10 +50,130 @@ export default function BoardView({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group tasks by status, using statuses from first available todo entity
|
const deleteSelected = useCallback(() => {
|
||||||
const tasksArray = Object.values(tasks);
|
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) => ({
|
const tasksByStatus = statuses.map((status) => ({
|
||||||
status,
|
status,
|
||||||
tasks: tasksArray
|
tasks: tasksArray
|
||||||
@@ -48,17 +181,71 @@ export default function BoardView({
|
|||||||
.sort((a, b) => a.createdAt - b.createdAt),
|
.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 (
|
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}>
|
<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 }) => (
|
{tasksByStatus.map(({ status, tasks: statusTasks }) => (
|
||||||
<Droppable key={status.id} droppableId={status.id}>
|
<Droppable key={status.id} droppableId={status.id}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<Card
|
<Card
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.droppableProps}
|
{...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>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
@@ -85,9 +272,14 @@ export default function BoardView({
|
|||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
ref={provided.innerRef}
|
ref={(el) => {
|
||||||
|
provided.innerRef(el);
|
||||||
|
taskCardRefs.current[task.id] = el;
|
||||||
|
}}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
|
data-task-card={task.id}
|
||||||
|
onClick={(e) => handleTaskClick(e, task.id)}
|
||||||
>
|
>
|
||||||
<BoardTask
|
<BoardTask
|
||||||
setTask={(t) =>
|
setTask={(t) =>
|
||||||
@@ -99,6 +291,17 @@ export default function BoardView({
|
|||||||
provided={provided}
|
provided={provided}
|
||||||
task={task}
|
task={task}
|
||||||
tags={tags}
|
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>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -110,6 +313,11 @@ export default function BoardView({
|
|||||||
const updated = { ...tasks };
|
const updated = { ...tasks };
|
||||||
delete updated[task.id];
|
delete updated[task.id];
|
||||||
setTasks(updated);
|
setTasks(updated);
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(task.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="text-destructive" />
|
<Trash2 className="text-destructive" />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import TaskEdit from "../edit/TaskEdit";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { NotepadText, Trash2 } from "lucide-react";
|
import { NotepadText, Trash2 } from "lucide-react";
|
||||||
import DocDialog from "../edit/DocDialog";
|
import DocDialog from "../edit/DocDialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function BoardTask({
|
export default function BoardTask({
|
||||||
task,
|
task,
|
||||||
@@ -21,24 +22,27 @@ export default function BoardTask({
|
|||||||
setTask,
|
setTask,
|
||||||
tags,
|
tags,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
isSelected,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
setTask: (t: Task) => void;
|
setTask: (t: Task) => void;
|
||||||
provided: DraggableProvided;
|
provided: DraggableProvided;
|
||||||
tags: Record<TagId, Tag>;
|
tags: Record<TagId, Tag>;
|
||||||
onDelete?: (taskId: string) => void;
|
onDelete?: (taskId: string) => void;
|
||||||
|
isSelected?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Card
|
<Card
|
||||||
ref={provided.innerRef}
|
className={cn(
|
||||||
{...provided.draggableProps}
|
"bg-background cursor-grab active:cursor-grabbing transition-all duration-150",
|
||||||
{...provided.dragHandleProps}
|
isSelected &&
|
||||||
className="bg-background cursor-grab active:cursor-grabbing"
|
"ring-2 ring-primary ring-offset-1 bg-primary/5 mt-1"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<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>
|
<span className="text-sm font-semibold">{task.title}</span>
|
||||||
<div className="flex gap-1 ml-auto">
|
<div className="flex gap-1 ml-auto">
|
||||||
<DocDialog task={task} setTask={setTask} tags={tags}>
|
<DocDialog task={task} setTask={setTask} tags={tags}>
|
||||||
@@ -73,7 +77,7 @@ export default function BoardTask({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</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) => (
|
{task.tagIds?.map((tagId) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tagId}
|
key={tagId}
|
||||||
|
|||||||
+132
-14
@@ -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";
|
||||||
|
|
||||||
@@ -9,18 +13,121 @@ 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) => {
|
||||||
|
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) => {
|
const loadWorkspace = (workspaceId: string | undefined) => {
|
||||||
@@ -37,5 +144,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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+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 {
|
export function createDefaultWorkspace(): Workspace {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -90,8 +90,182 @@ export function loadWorkspaceFromLocalStorage(id: string): Workspace {
|
|||||||
export function saveWorkspaceToLocalStorage(workspace: Workspace): void {
|
export function saveWorkspaceToLocalStorage(workspace: Workspace): void {
|
||||||
try {
|
try {
|
||||||
const serialized = JSON.stringify(workspace);
|
const serialized = JSON.stringify(workspace);
|
||||||
|
// keep using raw id for backwards compatibility
|
||||||
localStorage.setItem(workspace.id, serialized);
|
localStorage.setItem(workspace.id, serialized);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save workspace:", 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