Compare commits

...
10 Commits
Author SHA1 Message Date
selimaj-dev 171566da59 Merge pull request #3 from orus-dev/mvp
Mvp
2026-02-28 05:30:26 -08:00
selimaj-dev 3af4921fd2 Working rename 2026-02-28 14:24:18 +01:00
selimaj-dev e71b481dbb Context menu sidebar 2026-02-28 14:06:00 +01:00
selimaj-dev 1931f9208b Workspace editing 2026-02-28 13:55:30 +01:00
selimaj-dev 78daab4cf4 Working layout editor 2026-02-28 13:41:51 +01:00
selimaj-dev b543988f02 Sidebar elements editor 2026-02-28 02:41:17 +01:00
selimaj-dev 7d4a087c10 Sidbar more elegant 2026-02-28 02:33:50 +01:00
selimaj-dev 9200e1cfad Sidebar 2026-02-28 02:30:41 +01:00
selimaj-dev 3b35685d61 Flex on boardview 2026-02-28 02:24:56 +01:00
selimaj-dev f20fd1d65a Select on boardview 2026-02-28 02:20:11 +01:00
7 changed files with 1108 additions and 85 deletions
+23 -3
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>({});
@@ -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">
+317 -5
View File
@@ -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>
); );
} }
+203 -16
View File
@@ -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,29 +11,191 @@ 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"> <Collapsible defaultOpen className="group/collapsible">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel asChild> <SidebarGroupLabel asChild>
<SidebarMenuButton asChild> <ContextMenu>
<CollapsibleTrigger className="flex w-full items-center"> <ContextMenuTrigger asChild>
<CollapsibleTrigger asChild>
<SidebarMenuButton className="w-full">
{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>
</SidebarMenuButton> </SidebarMenuButton>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => openDialog("add-item")}>
Add list
</ContextMenuItem>
<ContextMenuItem onSelect={() => openDialog("add-group")}>
Add group
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() => openDialog("rename", node.name)}
>
Rename
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onSelect={() =>
confirmDelete("Delete this group and its children?", () =>
removeNode(node.id),
)
}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</SidebarGroupLabel> </SidebarGroupLabel>
<CollapsibleContent> <CollapsibleContent>
@@ -40,9 +204,8 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
{node.children.map((child) => ( {node.children.map((child) => (
<EntitySwitch <EntitySwitch
key={child.type === "group" ? child.id : child.entityId} key={child.type === "group" ? child.id : child.entityId}
{...props}
node={child} node={child}
workspace={workspace}
setRoute={setRoute}
/> />
))} ))}
</SidebarMenu> </SidebarMenu>
@@ -50,18 +213,21 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
</CollapsibleContent> </CollapsibleContent>
</SidebarGroup> </SidebarGroup>
</Collapsible> </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> <SidebarMenuItem>
<ContextMenu>
<ContextMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
onClick={() => onClick={() =>
setRoute({ setRoute({
@@ -70,8 +236,29 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
}) })
} }
> >
{workspaceEntity.name} {entity.name}
</SidebarMenuButton> </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> </SidebarMenuItem>
{dialog}
</>
); );
} }
+215 -7
View File
@@ -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" />
+10 -6
View File
@@ -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}
+125 -7
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";
@@ -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];
if (!current) return prev;
return {
...prev, ...prev,
entities: { entities: {
...prev.entities, ...prev.entities,
[entityId]: updater(prev.entities[entityId]), [entityId]: updater(current),
}, },
updatedAt: Date.now(), 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;
} }
: undefined, 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
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();
@@ -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,
};
}