From 629f7d7a6d718b9329eed01ca6a4af1beddab3a2 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 20:58:36 +0100 Subject: [PATCH 01/29] Small fixes --- package-lock.json | 14 ++++++++++++++ package.json | 1 + src/components/view/DocView.tsx | 4 ++++ src/components/view/edit/TaskEdit.tsx | 16 +--------------- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33ede45..8114a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@types/node": "^20.19.25", "@types/react": "^19", "@types/react-dom": "^19", + "baseline-browser-mapping": "^2.10.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5" @@ -2268,6 +2269,19 @@ "node": ">=10" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001754", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", diff --git a/package.json b/package.json index 4e09088..e4a9286 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/node": "^20.19.25", "@types/react": "^19", "@types/react-dom": "^19", + "baseline-browser-mapping": "^2.10.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5" diff --git a/src/components/view/DocView.tsx b/src/components/view/DocView.tsx index 6528f63..a99c029 100644 --- a/src/components/view/DocView.tsx +++ b/src/components/view/DocView.tsx @@ -118,6 +118,10 @@ export default function DocView({ }, data: doc, async onChange() { + if (!editor || !editor.save) { + console.warn("unable to save, editor.save is null"); + return; + } const savedData = await editor.save(); console.log("Document data:", toMarkdown(savedData)); setDoc(savedData); diff --git a/src/components/view/edit/TaskEdit.tsx b/src/components/view/edit/TaskEdit.tsx index 0bb65ff..81ca719 100644 --- a/src/components/view/edit/TaskEdit.tsx +++ b/src/components/view/edit/TaskEdit.tsx @@ -10,21 +10,7 @@ import { } from "@/components/ui/sheet"; import { Task } from "@/lib/types"; import { Color, getColor } from "@/lib/ui/color"; -import { DraggableProvided } from "@hello-pangea/dnd"; import { Button } from "../../ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTrigger, -} from "../../ui/dialog"; -import DocView from "../DocView"; -import { useState } from "react"; -import { OutputData } from "@editorjs/editorjs"; -import { DialogTitle } from "@radix-ui/react-dialog"; -import { ScrollArea } from "../../ui/scroll-area"; -import { Badge } from "../../ui/badge"; import DocDialog from "./DocDialog"; export default function TaskEdit({ @@ -66,7 +52,7 @@ export default function TaskEdit({ />
- + ({ label: tag.name, From 1a51dc8467ad53886302c454255375eecc33978b Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 21:16:02 +0100 Subject: [PATCH 02/29] Updating workspace structure --- src/hooks/workspace.ts | 113 ++++++++--------------------------------- src/lib/types.ts | 90 ++++++++++++++++++++++++++++---- src/lib/workspace.ts | 50 ++++++++++++++++++ 3 files changed, 153 insertions(+), 100 deletions(-) create mode 100644 src/lib/workspace.ts diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index 112a109..8dd9510 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -1,98 +1,29 @@ -import { SidebarEntity } from "@/components/sidebar/Entity"; -import { SetState, Task } from "@/lib/types"; -import { OutputData } from "@editorjs/editorjs"; +import { Entity, EntityId, Workspace } from "@/lib/types"; +import { + loadWorkspaceFromLocalStorage, + saveWorkspaceToLocalStorage, +} from "@/lib/workspace"; import { useEffect, useState } from "react"; -export type Entity = - | { - name: string; - kind: "todo"; - tasks: Task[]; - setTasks: SetState; - } - | { - name: string; - kind: "doc"; - doc: OutputData; - setDoc: SetState; - }; +export function useWorkspace(workspaceId: string) { + const [workspace, setWorkspace] = useState(() => + loadWorkspaceFromLocalStorage(workspaceId), + ); -export interface Workspace { - name: string; - entityLayout: SidebarEntity[]; - entities: Record; - - route: string; - setRoute: SetState; -} - -export default function useWorkspace(): Workspace { - const [route, setRoute] = useState("alpha_tasks"); - const [tasks, setTasks] = useState([]); - const [doc, setDoc] = useState({ - blocks: [], - }); - const [doc2, setDoc2] = useState({ - blocks: [], - }); + const updateEntity = (entityId: EntityId, updater: (e: Entity) => Entity) => { + setWorkspace((prev) => ({ + ...prev, + entities: { + ...prev.entities, + [entityId]: updater(prev.entities[entityId]), + }, + updatedAt: Date.now(), + })); + }; useEffect(() => { - setTasks([ - { id: "1", title: "Buy groceries", status: "todo" }, - { id: "2", title: "Plan sprint", status: "todo" }, - { - id: "3", - title: "Build App", - description: "A simple description", - tags: ["website", "api", "app"], - status: "inprogress", - }, - ]); - }, []); + saveWorkspaceToLocalStorage(workspace); + }, [workspace]); - return { - name: "Personal Workspace", - entities: { - alpha_tasks: { - name: "Alpha tasks", - kind: "todo", - tasks, - setTasks, - }, - beta_tasks: { - name: "Beta tasks", - kind: "todo", - tasks, - setTasks, - }, - - alpha_doc: { - name: "Alpha docs", - kind: "doc", - doc: doc, - setDoc: setDoc, - }, - beta_doc: { - name: "Beta docs", - kind: "doc", - doc: doc2, - setDoc: setDoc2, - }, - }, - entityLayout: [ - { - kind: "group", - name: "Project alpha", - children: [{ id: "alpha_tasks" }, { id: "alpha_doc" }], - }, - { - kind: "group", - name: "Project beta", - children: [{ id: "beta_tasks" }, { id: "beta_doc" }], - }, - ], - - route, - setRoute, - }; + return { workspace, updateEntity }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 6d7c086..0aa6331 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,11 +1,83 @@ -import { Color } from "./ui/color"; - -export type Task = { - id: string; - title: string; - status: string; - description?: string; - tags?: string[]; -}; +import { OutputData } from "@editorjs/editorjs"; export type SetState = React.Dispatch>; + +export type EntityId = string; +export type TagId = string; +export type TaskId = string; + +export interface Workspace { + id: string; + name: string; + + entities: Record; + layout: LayoutNode[]; + + createdAt: number; + updatedAt: number; +} + +export type LayoutNode = { + type: "group"; + id: string; + name: string; + children: LayoutItem[]; +}; + +export type LayoutItem = { + entityId: EntityId; +}; + +export type Entity = TodoEntity | DocEntity; + +export interface TodoEntity { + id: EntityId; + type: "todo"; + name: string; + + config: { + statuses: Status[]; + }; + + tags: Record; + tasks: Record; + + createdAt: number; + updatedAt: number; +} + +export interface Status { + id: string; + name: string; + color?: string; + order: number; +} + +export interface Tag { + id: TagId; + name: string; + color?: string; +} + +export interface Task { + id: TaskId; + title: string; + description?: string; + + statusId: string; + tagIds: TagId[]; + + createdAt: number; + updatedAt: number; +} + +export interface DocEntity { + id: EntityId; + type: "doc"; + name: string; + + content: OutputData; + + createdAt: number; + updatedAt: number; +} diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts new file mode 100644 index 0000000..44d6e4e --- /dev/null +++ b/src/lib/workspace.ts @@ -0,0 +1,50 @@ +import { Workspace } from "./types"; + +export function createDefaultWorkspace(): Workspace { + const now = Date.now(); + + return { + id: crypto.randomUUID(), + name: "New Workspace", + entities: {}, + layout: [], + createdAt: now, + updatedAt: now, + }; +} + +export function loadWorkspaceFromLocalStorage(id: string): Workspace { + try { + const raw = localStorage.getItem(id); + + if (!raw) { + return createDefaultWorkspace(); + } + + const parsed = JSON.parse(raw); + + // Basic shape validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.version !== "number" + ) { + console.warn("Invalid workspace shape. Resetting."); + return createDefaultWorkspace(); + } + + return parsed as Workspace; + } catch (err) { + console.error("Failed to load workspace:", err); + return createDefaultWorkspace(); + } +} + +export function saveWorkspaceToLocalStorage(workspace: Workspace): void { + try { + const serialized = JSON.stringify(workspace); + localStorage.setItem(workspace.id, serialized); + } catch (err) { + console.error("Failed to save workspace:", err); + } +} From 3c17c733905c17933c8180fd97d42e5043c770e9 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 21:37:42 +0100 Subject: [PATCH 03/29] Testing functionality of the workspace storing and receiving --- package-lock.json | 17 ++++++++++++ package.json | 2 ++ src/app/workspace/page.tsx | 27 ++++++++++++++---- src/components/sidebar/AppSidebar.tsx | 13 +++++++-- src/components/sidebar/Entity.tsx | 13 +++++++-- src/hooks/workspace.ts | 40 +++++++++++++++++---------- 6 files changed, 86 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8114a5f..dfe0640 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,11 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@types/js-cookie": "^3.0.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "js-cookie": "^3.0.5", "lucide-react": "^0.553.0", "next": "16.0.1", "react": "19.2.0", @@ -2221,6 +2223,12 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", @@ -2429,6 +2437,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", diff --git a/package.json b/package.json index e4a9286..544fbbc 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,11 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@types/js-cookie": "^3.0.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "js-cookie": "^3.0.5", "lucide-react": "^0.553.0", "next": "16.0.1", "react": "19.2.0", diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 5e945c9..2eb3af4 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -2,25 +2,40 @@ import Router from "@/components/Router"; import AppSidebar, { SidebarTrigger } from "@/components/sidebar/AppSidebar"; -import useWorkspace from "@/hooks/workspace"; +import { useWorkspace } from "@/hooks/workspace"; import { ChevronRight } from "lucide-react"; +import Cookies from "js-cookie"; +import { useEffect, useState } from "react"; +import { EntityId } from "@/lib/types"; export default function Workspace() { - const workspace = useWorkspace(); + const [currentWorkspace, setCurrentWorkspace] = useState(); - return ( - + const { workspace, loadWorkspace } = useWorkspace(); + + const [route, setRoute] = useState(); + + useEffect(() => { + setCurrentWorkspace(Cookies.get("workspace")); + }, []); + + useEffect(() => { + loadWorkspace(currentWorkspace); + }, [currentWorkspace]); + + return workspace ? ( +
{workspace.name} - {workspace.entities[workspace.route].name} + {workspace.entities[route || ""]?.name}
- ); + ) : null; } diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 0993702..3e31d16 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -16,14 +16,16 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { ChevronDown } from "lucide-react"; -import { Workspace } from "@/hooks/workspace"; +import { EntityId, SetState, Workspace } from "@/lib/types"; export default function AppSidebar({ children, workspace, + setRoute, }: Readonly<{ children: React.ReactNode; workspace: Workspace; + setRoute: SetState; }>) { return ( @@ -51,8 +53,13 @@ export default function AppSidebar({ - {workspace.entityLayout.map((entity, i) => ( - + {workspace.layout.map((entity, i) => ( + ))} diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index 0ac8a64..7260165 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -9,7 +9,7 @@ import { } from "../ui/sidebar"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { ChevronDown } from "lucide-react"; -import { Workspace } from "@/hooks/workspace"; +import { EntityId, SetState, Workspace } from "@/lib/types"; export type SidebarEntity = | { @@ -25,9 +25,11 @@ export type SidebarEntity = export default function EntitySwitch({ entity, workspace, + setRoute, }: { entity: SidebarEntity; workspace: Workspace; + setRoute: SetState; }) { if (entity.kind === "group") { return ( @@ -45,7 +47,12 @@ export default function EntitySwitch({ {entity.children?.map((entity, i) => ( - + ))} @@ -58,7 +65,7 @@ export default function EntitySwitch({ { - workspace.setRoute(entity.id); + setRoute(entity.id); }} > {workspace.entities[entity.id].name} diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index 8dd9510..5d1b435 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -5,25 +5,37 @@ import { } from "@/lib/workspace"; import { useEffect, useState } from "react"; -export function useWorkspace(workspaceId: string) { - const [workspace, setWorkspace] = useState(() => - loadWorkspaceFromLocalStorage(workspaceId), - ); +export function useWorkspace() { + const [workspace, setWorkspace] = useState(); const updateEntity = (entityId: EntityId, updater: (e: Entity) => Entity) => { - setWorkspace((prev) => ({ - ...prev, - entities: { - ...prev.entities, - [entityId]: updater(prev.entities[entityId]), - }, - updatedAt: Date.now(), - })); + setWorkspace((prev) => + prev + ? { + ...prev, + entities: { + ...prev.entities, + [entityId]: updater(prev.entities[entityId]), + }, + updatedAt: Date.now(), + } + : undefined, + ); + }; + + const loadWorkspace = (workspaceId: string | undefined) => { + if (!workspaceId) { + return setWorkspace(undefined); + } + + const w = loadWorkspaceFromLocalStorage(workspaceId); + setWorkspace(w); + return w; }; useEffect(() => { - saveWorkspaceToLocalStorage(workspace); + if (workspace) saveWorkspaceToLocalStorage(workspace); }, [workspace]); - return { workspace, updateEntity }; + return { workspace, loadWorkspace, updateEntity }; } From 3e61019b7f2bd0e94fb2ba227879345a5efb2b97 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 21:49:14 +0100 Subject: [PATCH 04/29] Theme and default functionality --- src/app/globals.css | 151 +++++++++++++++----------- src/app/workspace/page.tsx | 26 ++++- src/components/sidebar/AppSidebar.tsx | 4 +- src/components/sidebar/Entity.tsx | 6 +- src/lib/types.ts | 5 + 5 files changed, 121 insertions(+), 71 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 10dc118..ce37d1c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -46,72 +46,99 @@ } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --radius: 0.75rem; + + --background: oklch(0.97 0.01 240); + --foreground: oklch(0.22 0.02 240); + + --card: oklch(0.99 0.005 240); + --card-foreground: oklch(0.22 0.02 240); + --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --popover-foreground: oklch(0.22 0.02 240); + + /* Primary = architectural blue */ + --primary: oklch(0.55 0.16 250); + --primary-foreground: oklch(0.98 0.01 250); + + --secondary: oklch(0.93 0.02 240); + --secondary-foreground: oklch(0.28 0.02 240); + + --muted: oklch(0.94 0.015 240); + --muted-foreground: oklch(0.55 0.02 240); + + /* Accent = subtle violet for graph highlights */ + --accent: oklch(0.92 0.03 270); + --accent-foreground: oklch(0.30 0.04 270); + + --destructive: oklch(0.62 0.22 25); + + --border: oklch(0.90 0.015 240); + --input: oklch(0.90 0.015 240); + --ring: oklch(0.55 0.16 250); + + /* Charts (clean + modern) */ + --chart-1: oklch(0.55 0.16 250); + --chart-2: oklch(0.60 0.14 200); + --chart-3: oklch(0.62 0.18 280); + --chart-4: oklch(0.70 0.15 140); + --chart-5: oklch(0.65 0.20 30); + + --sidebar: oklch(0.95 0.01 240); + --sidebar-foreground: oklch(0.25 0.02 240); + --sidebar-primary: oklch(0.55 0.16 250); + --sidebar-primary-foreground: oklch(0.98 0.01 250); + --sidebar-accent: oklch(0.92 0.02 250); + --sidebar-accent-foreground: oklch(0.30 0.02 250); + --sidebar-border: oklch(0.88 0.015 240); + --sidebar-ring: oklch(0.55 0.16 250); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.17 0.02 250); + --foreground: oklch(0.94 0.01 240); + + --card: oklch(0.21 0.02 250); + --card-foreground: oklch(0.94 0.01 240); + + --popover: oklch(0.23 0.02 250); + --popover-foreground: oklch(0.94 0.01 240); + + /* Strong but not neon */ + --primary: oklch(0.65 0.17 250); + --primary-foreground: oklch(0.12 0.02 250); + + --secondary: oklch(0.27 0.02 250); + --secondary-foreground: oklch(0.94 0.01 240); + + --muted: oklch(0.25 0.02 250); + --muted-foreground: oklch(0.70 0.02 240); + + /* Accent for graph edges / highlights */ + --accent: oklch(0.60 0.18 280); + --accent-foreground: oklch(0.96 0.01 240); + + --destructive: oklch(0.68 0.20 25); + + --border: oklch(0.30 0.02 250); + --input: oklch(0.30 0.02 250); + --ring: oklch(0.65 0.17 250); + + /* Chart colors (balanced + readable on dark canvas) */ + --chart-1: oklch(0.65 0.17 250); + --chart-2: oklch(0.70 0.14 180); + --chart-3: oklch(0.72 0.18 300); + --chart-4: oklch(0.75 0.15 140); + --chart-5: oklch(0.72 0.20 40); + + --sidebar: oklch(0.19 0.02 250); + --sidebar-foreground: oklch(0.92 0.01 240); + --sidebar-primary: oklch(0.65 0.17 250); + --sidebar-primary-foreground: oklch(0.12 0.02 250); + --sidebar-accent: oklch(0.27 0.02 250); + --sidebar-accent-foreground: oklch(0.92 0.01 240); + --sidebar-border: oklch(0.30 0.02 250); + --sidebar-ring: oklch(0.65 0.17 250); } @layer base { diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 2eb3af4..3407d29 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -6,14 +6,19 @@ import { useWorkspace } from "@/hooks/workspace"; import { ChevronRight } from "lucide-react"; import Cookies from "js-cookie"; import { useEffect, useState } from "react"; -import { EntityId } from "@/lib/types"; +import { Route } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { + createDefaultWorkspace, + saveWorkspaceToLocalStorage, +} from "@/lib/workspace"; export default function Workspace() { const [currentWorkspace, setCurrentWorkspace] = useState(); const { workspace, loadWorkspace } = useWorkspace(); - const [route, setRoute] = useState(); + const [route, setRoute] = useState({}); useEffect(() => { setCurrentWorkspace(Cookies.get("workspace")); @@ -21,6 +26,8 @@ export default function Workspace() { useEffect(() => { loadWorkspace(currentWorkspace); + if (currentWorkspace) Cookies.set("workspace", currentWorkspace); + else Cookies.remove("workspace"); }, [currentWorkspace]); return workspace ? ( @@ -30,12 +37,23 @@ export default function Workspace() {
{workspace.name} - {workspace.entities[route || ""]?.name} + {workspace.entities[route.entity || ""]?.name}
- ) : null; + ) : ( +
{ + const w = createDefaultWorkspace(); + saveWorkspaceToLocalStorage(w); + setCurrentWorkspace(w.id); + }} + > + +
+ ); } diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 3e31d16..645a1d7 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -16,7 +16,7 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { ChevronDown } from "lucide-react"; -import { EntityId, SetState, Workspace } from "@/lib/types"; +import { EntityId, Route, SetState, Workspace } from "@/lib/types"; export default function AppSidebar({ children, @@ -25,7 +25,7 @@ export default function AppSidebar({ }: Readonly<{ children: React.ReactNode; workspace: Workspace; - setRoute: SetState; + setRoute: SetState; }>) { return ( diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index 7260165..6e0f8ea 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -9,7 +9,7 @@ import { } from "../ui/sidebar"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { ChevronDown } from "lucide-react"; -import { EntityId, SetState, Workspace } from "@/lib/types"; +import { EntityId, Route, SetState, Workspace } from "@/lib/types"; export type SidebarEntity = | { @@ -29,7 +29,7 @@ export default function EntitySwitch({ }: { entity: SidebarEntity; workspace: Workspace; - setRoute: SetState; + setRoute: SetState; }) { if (entity.kind === "group") { return ( @@ -65,7 +65,7 @@ export default function EntitySwitch({ { - setRoute(entity.id); + setRoute({ workspace: workspace.id, entity: entity.id }); }} > {workspace.entities[entity.id].name} diff --git a/src/lib/types.ts b/src/lib/types.ts index 0aa6331..70f2463 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -6,6 +6,11 @@ export type EntityId = string; export type TagId = string; export type TaskId = string; +export interface Route { + workspace?: string; + entity?: EntityId; +} + export interface Workspace { id: string; name: string; From 4823cca418d11a4efebec98ef69fd9ef86926857 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 21:59:55 +0100 Subject: [PATCH 05/29] Router --- src/app/workspace/page.tsx | 2 +- src/components/Router.tsx | 88 +++++++++++++++++++++++++------- src/components/view/TaskView.tsx | 25 +++++---- 3 files changed, 86 insertions(+), 29 deletions(-) diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 3407d29..195c873 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -42,7 +42,7 @@ export default function Workspace() { - + ) : (
`${prefix}_${Math.random().toString(36).slice(2, 9)}`; -export default function Router({ workspace }: { workspace: Workspace }) { - const entity = workspace.entities[workspace.route]; - switch (entity.kind) { - case "todo": - const createTask = (status: string) => { +export default function Router({ + route, + workspace, + setWorkspace, +}: { + route: Route; + workspace: Workspace; + setWorkspace: React.Dispatch>; +}) { + const entityId = route.entity; + const entity = entityId ? workspace.entities[entityId] : null; + + if (!entity || !entityId) return null; + + const updateEntity = (updater: (e: any) => any) => { + setWorkspace((prev) => { + const updated = updater(prev.entities[entityId]); + + return { + ...prev, + updatedAt: Date.now(), + entities: { + ...prev.entities, + [entityId]: updated, + }, + }; + }); + }; + + switch (entity.type) { + case "todo": { + const createTask = (statusId: string) => { const task: Task = { id: id(), title: "New task", - status: status, + statusId, + tagIds: [], + createdAt: new Date().getTime(), + updatedAt: new Date().getTime(), }; - entity.setTasks((c: Task[]) => [...c, task]); + + updateEntity((e) => ({ + ...e, + tasks: [...e.tasks, task], + })); + }; + + const setTasks = (tasks: Task[]) => { + updateEntity((e) => ({ + ...e, + tasks, + })); }; return ( ); - case "doc": + } + + case "doc": { + const setDoc = (doc: OutputData) => { + updateEntity((e) => ({ + ...e, + doc, + })); + }; + return ( -
- +
+
); + } + + default: + return null; } } diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index e3c334e..32f5320 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -1,15 +1,20 @@ "use client"; -import { SetState, Task } from "@/lib/types"; +import { Tag, TagId, Task, TaskId } from "@/lib/types"; import { Color } from "@/lib/ui/color"; import BoardView from "./board/BoardView"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { TableView } from "./table/TableView"; export type TaskViewProps = { - tags: Record; - tasks: Task[]; - setTasks: SetState; + tags: Record; + + tasks: Record; + + /** Replace entire task array */ + setTasks: (tasks: Task[]) => void; + + /** Create a new task with a status */ createTask: (status: string) => void; }; @@ -20,13 +25,14 @@ export default function TaskView({ createTask, }: TaskViewProps) { return ( -
- - +
+ + Board Table - + + - + + Date: Fri, 27 Feb 2026 22:07:33 +0100 Subject: [PATCH 06/29] Sidebar --- src/components/sidebar/AppSidebar.tsx | 45 +++++++++++-------- src/components/sidebar/Entity.tsx | 65 +++++++++++++++++---------- 2 files changed, 67 insertions(+), 43 deletions(-) diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 645a1d7..058ccbd 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Sidebar, SidebarContent, @@ -8,25 +10,30 @@ import { SidebarProvider, useSidebar, } from "@/components/ui/sidebar"; + import EntitySwitch from "./Entity"; + import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; + import { ChevronDown } from "lucide-react"; -import { EntityId, Route, SetState, Workspace } from "@/lib/types"; +import { Route, SetState, Workspace } from "@/lib/types"; + +type AppSidebarProps = { + children: React.ReactNode; + workspace: Workspace; + setRoute: SetState; +}; export default function AppSidebar({ children, workspace, setRoute, -}: Readonly<{ - children: React.ReactNode; - workspace: Workspace; - setRoute: SetState; -}>) { +}: AppSidebarProps) { return ( @@ -37,52 +44,52 @@ export default function AppSidebar({ + - Acme Inc - Acme Corp. + {/* Future: real workspace switching */} + Rename Workspace + Export Workspace + - {workspace.layout.map((entity, i) => ( + {workspace.layout.map((entity) => ( ))} + +
{children}
); } -export function SidebarTrigger({ - className, - ...props -}: React.ComponentProps<"div">) { +export function SidebarTrigger(props: React.ComponentProps<"div">) { const { toggleSidebar } = useSidebar(); return (
{ - toggleSidebar(); - }} + onClick={toggleSidebar} {...props} /> ); diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index 6e0f8ea..bdc06e4 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -1,3 +1,5 @@ +"use client"; + import { CollapsibleTrigger } from "@radix-ui/react-collapsible"; import { SidebarGroup, @@ -9,11 +11,12 @@ import { } from "../ui/sidebar"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { ChevronDown } from "lucide-react"; -import { EntityId, Route, SetState, Workspace } from "@/lib/types"; +import { Route, Workspace } from "@/lib/types"; export type SidebarEntity = | { kind: "group"; + id: string; // ✅ add stable id name: string; children: SidebarEntity[]; } @@ -22,35 +25,39 @@ export type SidebarEntity = id: string; }; +type Props = { + entity: SidebarEntity; + workspace: Workspace; + setRoute: (route: Route) => void; +}; + export default function EntitySwitch({ entity, workspace, setRoute, -}: { - entity: SidebarEntity; - workspace: Workspace; - setRoute: SetState; -}) { +}: Props) { + // ---- GROUP ---- if (entity.kind === "group") { return ( - + {entity.name} - + + - {entity.children?.map((entity, i) => ( + {entity.children.map((child) => ( ))} @@ -60,17 +67,27 @@ export default function EntitySwitch({ ); - } else { - return ( - - { - setRoute({ workspace: workspace.id, entity: entity.id }); - }} - > - {workspace.entities[entity.id].name} - - - ); } -} + + // ---- ITEM ---- + const workspaceEntity = workspace.entities[entity.id]; + + if (!workspaceEntity) { + return null; // defensive against stale layout + } + + return ( + + + setRoute({ + workspace: workspace.id, + entity: entity.id, + }) + } + > + {workspaceEntity.name} + + + ); +} \ No newline at end of file From 162e2b3d3f54a5a7a9b98b35c5889f0806e63337 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 22:35:31 +0100 Subject: [PATCH 07/29] Structuring and fixes --- package-lock.json | 916 ++++++++++++++++++++++++++ package.json | 1 + src/app/workspace/page.tsx | 12 +- src/components/Router.tsx | 45 +- src/components/sidebar/AppSidebar.tsx | 42 +- src/components/ui/context-menu.tsx | 252 +++++++ src/hooks/workspace.ts | 2 +- 7 files changed, 1231 insertions(+), 39 deletions(-) create mode 100644 src/components/ui/context-menu.tsx diff --git a/package-lock.json b/package-lock.json index dfe0640..191848c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.553.0", "next": "16.0.1", + "radix-ui": "^1.4.3", "react": "19.2.0", "react-dom": "19.2.0", "tailwind-merge": "^3.4.0" @@ -995,6 +996,106 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1018,6 +1119,86 @@ } } }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", @@ -1122,6 +1303,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -1287,6 +1496,88 @@ } } }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -1409,6 +1700,138 @@ } } }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", @@ -1585,6 +2008,62 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -1647,6 +2126,67 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", @@ -1693,6 +2233,39 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -1711,6 +2284,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -1741,6 +2343,146 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -1863,6 +2605,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -1878,6 +2638,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", @@ -2859,6 +3634,147 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", diff --git a/package.json b/package.json index 544fbbc..dacb107 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.553.0", "next": "16.0.1", + "radix-ui": "^1.4.3", "react": "19.2.0", "react-dom": "19.2.0", "tailwind-merge": "^3.4.0" diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 195c873..26b6639 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -16,7 +16,8 @@ import { export default function Workspace() { const [currentWorkspace, setCurrentWorkspace] = useState(); - const { workspace, loadWorkspace } = useWorkspace(); + const { workspace, loadWorkspace, updateEntity, setWorkspace } = + useWorkspace(); const [route, setRoute] = useState({}); @@ -31,7 +32,12 @@ export default function Workspace() { }, [currentWorkspace]); return workspace ? ( - +
@@ -42,7 +48,7 @@ export default function Workspace() {
- +
) : (
export default function Router({ route, workspace, - setWorkspace, + updateEntity, }: { route: Route; workspace: Workspace; - setWorkspace: React.Dispatch>; + updateEntity: (entityId: EntityId, updater: (e: Entity) => Entity) => void; }) { const entityId = route.entity; const entity = entityId ? workspace.entities[entityId] : null; if (!entity || !entityId) return null; - const updateEntity = (updater: (e: any) => any) => { - setWorkspace((prev) => { - const updated = updater(prev.entities[entityId]); - - return { - ...prev, - updatedAt: Date.now(), - entities: { - ...prev.entities, - [entityId]: updated, - }, - }; - }); - }; - switch (entity.type) { case "todo": { const createTask = (statusId: string) => { @@ -49,16 +42,20 @@ export default function Router({ updatedAt: new Date().getTime(), }; - updateEntity((e) => ({ + updateEntity(entityId, (e) => ({ ...e, - tasks: [...e.tasks, task], + tasks: e.type === "todo" ? { ...e.tasks, task } : {}, })); }; const setTasks = (tasks: Task[]) => { - updateEntity((e) => ({ + const tasksRecord = tasks.reduce( + (acc, task) => ({ ...acc, [task.id]: task }), + {} as Record, + ); + updateEntity(entityId, (e) => ({ ...e, - tasks, + tasks: e.type === "todo" ? tasksRecord : {}, })); }; @@ -75,9 +72,9 @@ export default function Router({ case "doc": { const setDoc = (doc: OutputData) => { - updateEntity((e) => ({ + updateEntity(entityId, (e) => ({ ...e, - doc, + content: doc, })); }; diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 058ccbd..0f84be9 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -12,6 +12,8 @@ import { } from "@/components/ui/sidebar"; import EntitySwitch from "./Entity"; +const id = (prefix = "t") => + `${prefix}_${Math.random().toString(36).slice(2, 9)}`; import { DropdownMenu, @@ -21,18 +23,28 @@ import { } from "../ui/dropdown-menu"; import { ChevronDown } from "lucide-react"; -import { Route, SetState, Workspace } from "@/lib/types"; +import { Entity, EntityId, Route, SetState, Workspace } from "@/lib/types"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "../ui/context-menu"; type AppSidebarProps = { children: React.ReactNode; workspace: Workspace; setRoute: SetState; + updateEntity?: (entityId: EntityId, updater: (e: Entity) => Entity) => void; + setWorkspace: SetState; }; export default function AppSidebar({ children, workspace, setRoute, + updateEntity, + setWorkspace, }: AppSidebarProps) { return ( @@ -62,16 +74,24 @@ export default function AppSidebar({ - - {workspace.layout.map((entity) => ( - - ))} - + + +
+ + {workspace.layout.map((entity) => ( + + ))} + +
+
+ + +
diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..e972fe5 --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,252 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { ContextMenu as ContextMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index 5d1b435..c90f08c 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -37,5 +37,5 @@ export function useWorkspace() { if (workspace) saveWorkspaceToLocalStorage(workspace); }, [workspace]); - return { workspace, loadWorkspace, updateEntity }; + return { workspace, loadWorkspace, updateEntity, setWorkspace }; } From 5895974af248805c8fab8d328927510efafd8588 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 22:51:02 +0100 Subject: [PATCH 08/29] Working system --- src/app/layout.tsx | 4 +- src/app/page.tsx | 64 +--------------- src/app/workspace/page.tsx | 53 +++++++------ src/components/Router.tsx | 12 +-- src/components/sidebar/AppSidebar.tsx | 49 +++++-------- src/components/sidebar/Entity.tsx | 40 +++------- src/components/ui/context-menu.tsx | 80 ++++++++++---------- src/components/view/TaskView.tsx | 6 +- src/components/view/board/BoardView.tsx | 98 ++++++++++++------------- src/components/view/board/Task.tsx | 68 +++++++++++------ src/components/view/edit/DocDialog.tsx | 24 ++++-- src/components/view/edit/TaskEdit.tsx | 36 ++++++--- src/components/view/table/TableView.tsx | 13 +++- src/components/view/table/Task.tsx | 58 +++++++++------ src/lib/types.ts | 21 +++--- src/lib/ui/color.ts | 5 +- src/lib/workspace.ts | 57 ++++++++++++-- 17 files changed, 356 insertions(+), 332 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 11234a9..1ea4507 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Drift - Productivity App", + description: "A powerful client-side productivity application", }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index e2dd8d3..598f5de 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect("/workspace"); } diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 26b6639..b848bc8 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -31,35 +31,44 @@ export default function Workspace() { else Cookies.remove("workspace"); }, [currentWorkspace]); - return workspace ? ( - + if (!workspace) { + return ( +
{ + const w = createDefaultWorkspace(); + saveWorkspaceToLocalStorage(w); + setCurrentWorkspace(w.id); + }} + > +
+

Welcome to Drift

+

+ Your personal productivity workspace +

+ +
+
+ ); + } + + return ( +
-
- {workspace.name} - - {workspace.entities[route.entity || ""]?.name} +
+ {workspace.name} + + + {workspace.entities[route.entity || ""]?.name || "Select an item"} +
- ) : ( -
{ - const w = createDefaultWorkspace(); - saveWorkspaceToLocalStorage(w); - setCurrentWorkspace(w.id); - }} - > - -
); } diff --git a/src/components/Router.tsx b/src/components/Router.tsx index 1ae7161..10f39cb 100644 --- a/src/components/Router.tsx +++ b/src/components/Router.tsx @@ -34,7 +34,7 @@ export default function Router({ case "todo": { const createTask = (statusId: string) => { const task: Task = { - id: id(), + id: id("task"), title: "New task", statusId, tagIds: [], @@ -44,18 +44,14 @@ export default function Router({ updateEntity(entityId, (e) => ({ ...e, - tasks: e.type === "todo" ? { ...e.tasks, task } : {}, + tasks: e.type === "todo" ? { ...e.tasks, [task.id]: task } : {}, })); }; - const setTasks = (tasks: Task[]) => { - const tasksRecord = tasks.reduce( - (acc, task) => ({ ...acc, [task.id]: task }), - {} as Record, - ); + const setTasks = (tasks: Record) => { updateEntity(entityId, (e) => ({ ...e, - tasks: e.type === "todo" ? tasksRecord : {}, + tasks: e.type === "todo" ? tasks : {}, })); }; diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 0f84be9..d81d4ae 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -12,9 +12,6 @@ import { } from "@/components/ui/sidebar"; import EntitySwitch from "./Entity"; -const id = (prefix = "t") => - `${prefix}_${Math.random().toString(36).slice(2, 9)}`; - import { DropdownMenu, DropdownMenuContent, @@ -23,32 +20,27 @@ import { } from "../ui/dropdown-menu"; import { ChevronDown } from "lucide-react"; -import { Entity, EntityId, Route, SetState, Workspace } from "@/lib/types"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "../ui/context-menu"; +import { Route, SetState, Workspace } from "@/lib/types"; + +// Simple ID generator +const id = (prefix = "t") => + `${prefix}_${Math.random().toString(36).slice(2, 9)}`; type AppSidebarProps = { children: React.ReactNode; workspace: Workspace; setRoute: SetState; - updateEntity?: (entityId: EntityId, updater: (e: Entity) => Entity) => void; - setWorkspace: SetState; }; export default function AppSidebar({ children, workspace, setRoute, - updateEntity, - setWorkspace, }: AppSidebarProps) { return ( + {/* Header */} @@ -73,25 +65,18 @@ export default function AppSidebar({ + {/* Sidebar Content */} - - -
- - {workspace.layout.map((entity) => ( - - ))} - -
-
- - -
+ + {workspace.layout.map((node) => ( + + ))} +
diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index bdc06e4..13305da 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -11,40 +11,24 @@ import { } from "../ui/sidebar"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { ChevronDown } from "lucide-react"; -import { Route, Workspace } from "@/lib/types"; - -export type SidebarEntity = - | { - kind: "group"; - id: string; // ✅ add stable id - name: string; - children: SidebarEntity[]; - } - | { - kind?: "item"; - id: string; - }; +import { Route, Workspace, LayoutNode } from "@/lib/types"; type Props = { - entity: SidebarEntity; + node: LayoutNode; workspace: Workspace; setRoute: (route: Route) => void; }; -export default function EntitySwitch({ - entity, - workspace, - setRoute, -}: Props) { +export default function EntitySwitch({ node, workspace, setRoute }: Props) { // ---- GROUP ---- - if (entity.kind === "group") { + if (node.type === "group") { return ( - {entity.name} + {node.name} @@ -53,10 +37,10 @@ export default function EntitySwitch({ - {entity.children.map((child) => ( + {node.children.map((child) => ( @@ -70,10 +54,10 @@ export default function EntitySwitch({ } // ---- ITEM ---- - const workspaceEntity = workspace.entities[entity.id]; + const workspaceEntity = workspace.entities[node.entityId]; if (!workspaceEntity) { - return null; // defensive against stale layout + return null; } return ( @@ -82,7 +66,7 @@ export default function EntitySwitch({ onClick={() => setRoute({ workspace: workspace.id, - entity: entity.id, + entity: node.entityId, }) } > @@ -90,4 +74,4 @@ export default function EntitySwitch({ ); -} \ No newline at end of file +} diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index e972fe5..183bdc3 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -1,15 +1,15 @@ -"use client" +"use client"; -import * as React from "react" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" -import { ContextMenu as ContextMenuPrimitive } from "radix-ui" +import * as React from "react"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function ContextMenu({ ...props }: React.ComponentProps) { - return + return ; } function ContextMenuTrigger({ @@ -17,7 +17,7 @@ function ContextMenuTrigger({ }: React.ComponentProps) { return ( - ) + ); } function ContextMenuGroup({ @@ -25,7 +25,7 @@ function ContextMenuGroup({ }: React.ComponentProps) { return ( - ) + ); } function ContextMenuPortal({ @@ -33,13 +33,13 @@ function ContextMenuPortal({ }: React.ComponentProps) { return ( - ) + ); } function ContextMenuSub({ ...props }: React.ComponentProps) { - return + return ; } function ContextMenuRadioGroup({ @@ -50,7 +50,7 @@ function ContextMenuRadioGroup({ data-slot="context-menu-radio-group" {...props} /> - ) + ); } function ContextMenuSubTrigger({ @@ -59,22 +59,22 @@ function ContextMenuSubTrigger({ children, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( {children} - ) + ); } function ContextMenuSubContent({ @@ -85,12 +85,12 @@ function ContextMenuSubContent({ - ) + ); } function ContextMenuContent({ @@ -102,13 +102,13 @@ function ContextMenuContent({ - ) + ); } function ContextMenuItem({ @@ -117,8 +117,8 @@ function ContextMenuItem({ variant = "default", ...props }: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" + inset?: boolean; + variant?: "default" | "destructive"; }) { return ( - ) + ); } function ContextMenuCheckboxItem({ @@ -144,8 +144,8 @@ function ContextMenuCheckboxItem({ {children} - ) + ); } function ContextMenuRadioItem({ @@ -169,8 +169,8 @@ function ContextMenuRadioItem({ @@ -181,7 +181,7 @@ function ContextMenuRadioItem({
{children} - ) + ); } function ContextMenuLabel({ @@ -189,19 +189,19 @@ function ContextMenuLabel({ inset, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( - ) + ); } function ContextMenuSeparator({ @@ -214,7 +214,7 @@ function ContextMenuSeparator({ className={cn("bg-border -mx-1 my-1 h-px", className)} {...props} /> - ) + ); } function ContextMenuShortcut({ @@ -226,11 +226,11 @@ function ContextMenuShortcut({ data-slot="context-menu-shortcut" className={cn( "text-muted-foreground ml-auto text-xs tracking-widest", - className + className, )} {...props} /> - ) + ); } export { @@ -249,4 +249,4 @@ export { ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, -} +}; diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index 32f5320..a632b76 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -11,11 +11,11 @@ export type TaskViewProps = { tasks: Record; - /** Replace entire task array */ - setTasks: (tasks: Task[]) => void; + /** Update entire tasks record */ + setTasks: (tasks: Record) => void; /** Create a new task with a status */ - createTask: (status: string) => void; + createTask: (statusId: string) => void; }; export default function TaskView({ diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index 3418f57..bb4e752 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -7,12 +7,6 @@ import { PlusIcon } from "lucide-react"; import BoardTask from "./Task"; import { TaskViewProps } from "../TaskView"; -const statusLabels: Record = { - todo: "To do", - inprogress: "In progress", - done: "Done", -}; - export default function BoardView({ tags, tasks, @@ -23,35 +17,37 @@ export default function BoardView({ const { draggableId, source, destination } = result; if (!destination) return; - setTasks((tasks) => { - const taskIndex = tasks.findIndex((task) => task.id === draggableId); - const task = tasks[taskIndex]; - tasks.splice(taskIndex, 1); + const task = Object.values(tasks).find((t) => t.id === draggableId); + if (!task) return; - const destinationGroupStartIndex = tasks.findIndex( - (t) => t.status === destination.droppableId - ); - - const insertIndex = - destinationGroupStartIndex === -1 - ? tasks.length - : destinationGroupStartIndex + destination.index; - - tasks.splice(insertIndex, 0, { + setTasks({ + ...tasks, + [task.id]: { ...task, - status: destination.droppableId, - }); - - return tasks; + statusId: destination.droppableId, + updatedAt: Date.now(), + }, }); } + // Group tasks by status, using statuses from first available todo entity + const tasksArray = Object.values(tasks); + const statusIds = new Set(tasksArray.map((t) => t.statusId)); + + // Sort tasks by creation date within each status + const tasksByStatus = Array.from(statusIds).map((statusId) => ({ + statusId, + tasks: tasksArray + .filter((t) => t.statusId === statusId) + .sort((a, b) => a.createdAt - b.createdAt), + })); + return (
- {Object.keys(statusLabels).map((status) => ( - + {tasksByStatus.map(({ statusId, tasks: statusTasks }) => ( + {(provided) => ( - {statusLabels[status]} + {statusId} @@ -73,31 +69,29 @@ export default function BoardView({
- {tasks - .filter((task) => task.status === status) - .map((task, index) => ( - - {(provided) => ( - - setTasks((cols) => - cols.map((c) => (c.id === t.id ? t : c)) - ) - } - provided={provided} - task={task} - tags={tags} - /> - )} - - ))} + {statusTasks.map((task, index) => ( + + {(provided) => ( + + setTasks({ + ...tasks, + [t.id]: t, + }) + } + provided={provided} + task={task} + tags={tags} + /> + )} + + ))} {provided.placeholder} - {tasks.filter((task) => task.status === status).length === - 0 && ( + {statusTasks.length === 0 && (

No tasks

diff --git a/src/components/view/board/Task.tsx b/src/components/view/board/Task.tsx index 6a5d96a..5f58544 100644 --- a/src/components/view/board/Task.tsx +++ b/src/components/view/board/Task.tsx @@ -7,12 +7,12 @@ import { CardTitle, } from "@/components/ui/card"; import { SheetTrigger } from "@/components/ui/sheet"; -import { Task } from "@/lib/types"; +import { Task, Tag, TagId } from "@/lib/types"; import { Color, getColor } from "@/lib/ui/color"; import { DraggableProvided } from "@hello-pangea/dnd"; import TaskEdit from "../edit/TaskEdit"; import { Button } from "@/components/ui/button"; -import { NotepadText } from "lucide-react"; +import { NotepadText, Trash2 } from "lucide-react"; import DocDialog from "../edit/DocDialog"; export default function BoardTask({ @@ -20,11 +20,13 @@ export default function BoardTask({ provided, setTask, tags, + onDelete, }: { task: Task; setTask: (t: Task) => void; provided: DraggableProvided; - tags: Record; + tags: Record; + onDelete?: (taskId: string) => void; }) { return ( @@ -33,34 +35,56 @@ export default function BoardTask({ ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} - className="bg-background" + className="bg-background cursor-grab active:cursor-grabbing" > - {task.title} - - - + {task.title} +
+ + + + {onDelete && ( + + )} +
- {task.description} + {task.description && ( + + {task.description} + + )}
- {task.tags?.map((tag, i) => ( + {task.tagIds?.map((tagId) => ( - {tag} + {tags[tagId]?.name || tagId} ))} diff --git a/src/components/view/edit/DocDialog.tsx b/src/components/view/edit/DocDialog.tsx index e4cb881..43058eb 100644 --- a/src/components/view/edit/DocDialog.tsx +++ b/src/components/view/edit/DocDialog.tsx @@ -10,8 +10,8 @@ import { import { ScrollArea } from "@/components/ui/scroll-area"; import DocView from "../DocView"; import { Badge } from "@/components/ui/badge"; -import { Task } from "@/lib/types"; -import { Color, getColor } from "@/lib/ui/color"; +import { Task, Tag, TagId } from "@/lib/types"; +import { getColor } from "@/lib/ui/color"; import { useState } from "react"; import { OutputData } from "@editorjs/editorjs"; @@ -23,10 +23,14 @@ export default function DocDialog({ }: { task: Task; setTask: (t: Task) => void; - tags: Record; + tags: Record; children: React.ReactNode; }) { - const [doc, setDoc] = useState({ blocks: [] }); + const [doc, setDoc] = useState({ + version: "2.28.0", + time: Date.now(), + blocks: [], + }); return ( @@ -41,13 +45,17 @@ export default function DocDialog({ {task.description}
- {task.tags?.map((tag, i) => ( + {task.tagIds?.map((tagId) => ( - {tag} + {tags[tagId]?.name || tagId} ))}
diff --git a/src/components/view/edit/TaskEdit.tsx b/src/components/view/edit/TaskEdit.tsx index 81ca719..45abb0b 100644 --- a/src/components/view/edit/TaskEdit.tsx +++ b/src/components/view/edit/TaskEdit.tsx @@ -8,7 +8,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; -import { Task } from "@/lib/types"; +import { Task, Tag, TagId } from "@/lib/types"; import { Color, getColor } from "@/lib/ui/color"; import { Button } from "../../ui/button"; import DocDialog from "./DocDialog"; @@ -21,7 +21,7 @@ export default function TaskEdit({ }: { task: Task; setTask: (t: Task) => void; - tags: Record; + tags: Record; children: React.ReactNode; }) { return ( @@ -38,16 +38,26 @@ export default function TaskEdit({
setTask({ ...task, title: e.target.value })} + value={task.title} + onChange={(e) => + setTask({ + ...task, + title: e.target.value, + updatedAt: Date.now(), + }) + } />
- setTask({ ...task, description: e.target.value }) + setTask({ + ...task, + description: e.target.value, + updatedAt: Date.now(), + }) } />
@@ -58,12 +68,18 @@ export default function TaskEdit({ label: tag.name, value: id, style: { - iconColor: getColor(tag.color).color, - badgeColor: getColor(tag.color).backgroundColor, + iconColor: getColor(tag.color || "#808080").color, + badgeColor: getColor(tag.color || "#808080").backgroundColor, }, }))} - onValueChange={(tags) => setTask({ ...task, tags })} - defaultValue={task.tags} + onValueChange={(tagIds) => + setTask({ + ...task, + tagIds: tagIds as TagId[], + updatedAt: Date.now(), + }) + } + defaultValue={task.tagIds} />
diff --git a/src/components/view/table/TableView.tsx b/src/components/view/table/TableView.tsx index 282e88a..248030d 100644 --- a/src/components/view/table/TableView.tsx +++ b/src/components/view/table/TableView.tsx @@ -15,6 +15,10 @@ import { getColor } from "@/lib/ui/color"; import TableTask from "./Task"; export function TableView({ tasks, setTasks, tags }: TaskViewProps) { + const tasksArray = Object.values(tasks).sort( + (a, b) => b.createdAt - a.createdAt, + ); + return ( @@ -26,7 +30,7 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) { - {tasks.length === 0 ? ( + {tasksArray.length === 0 ? ( ) : ( - tasks.map((task) => ( + tasksArray.map((task) => ( - setTasks((tasks) => tasks.map((c) => (c.id === t.id ? t : c))) + setTasks({ + ...tasks, + [t.id]: t, + }) } tags={tags} /> diff --git a/src/components/view/table/Task.tsx b/src/components/view/table/Task.tsx index 089fd18..3682d62 100644 --- a/src/components/view/table/Task.tsx +++ b/src/components/view/table/Task.tsx @@ -1,50 +1,62 @@ import { Badge } from "@/components/ui/badge"; import { SheetTrigger } from "@/components/ui/sheet"; import { TableCell, TableRow } from "@/components/ui/table"; -import { Task } from "@/lib/types"; +import { Task, Tag, TagId } from "@/lib/types"; import { Color, getColor } from "@/lib/ui/color"; import TaskEdit from "../edit/TaskEdit"; +import { Button } from "@/components/ui/button"; +import { Trash2 } from "lucide-react"; export default function TableTask({ task, setTask, tags, + onDelete, }: { task: Task; setTask: (t: Task) => void; - tags: Record; + tags: Record; + onDelete?: (taskId: string) => void; }) { return ( - + {task.title} {task.description || "-"} - - {task.status} - + {task.statusId} - - {task.tags?.map((tag, i) => ( - +
+ {task.tagIds?.map((tagId) => ( + + {tags[tagId]?.name || tagId} + + ))} +
+ {onDelete && ( + + )}
diff --git a/src/lib/types.ts b/src/lib/types.ts index 70f2463..e25eb75 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -22,16 +22,17 @@ export interface Workspace { updatedAt: number; } -export type LayoutNode = { - type: "group"; - id: string; - name: string; - children: LayoutItem[]; -}; - -export type LayoutItem = { - entityId: EntityId; -}; +export type LayoutNode = + | { + type: "group"; + id: string; + name: string; + children: LayoutNode[]; + } + | { + type: "item"; + entityId: EntityId; + }; export type Entity = TodoEntity | DocEntity; diff --git a/src/lib/ui/color.ts b/src/lib/ui/color.ts index d628397..6611575 100644 --- a/src/lib/ui/color.ts +++ b/src/lib/ui/color.ts @@ -8,7 +8,8 @@ export type Color = | "blue" | "purple" | "pink" - | "red"; + | "red" + | string; interface ColorStyle { backgroundColor: string; @@ -59,5 +60,5 @@ export function getColor(color: Color): ColorStyle { }, }; - return colors[color]; + return colors[color] || color; } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index 44d6e4e..01ab95f 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -1,13 +1,60 @@ -import { Workspace } from "./types"; +import { Workspace, TodoEntity, DocEntity, Status } from "./types"; export function createDefaultWorkspace(): Workspace { const now = Date.now(); + const todoId = crypto.randomUUID(); + const docId = crypto.randomUUID(); + + const defaultStatuses: Status[] = [ + { id: "status_todo", name: "To Do", order: 0, color: "#808080" }, + { + id: "status_inprogress", + name: "In Progress", + order: 1, + color: "#0ea5e9", + }, + { id: "status_done", name: "Done", order: 2, color: "#10b981" }, + ]; + + const todoEntity: TodoEntity = { + id: todoId, + type: "todo", + name: "Tasks", + config: { statuses: defaultStatuses }, + tags: {}, + tasks: {}, + createdAt: now, + updatedAt: now, + }; + + const docEntity: DocEntity = { + id: docId, + type: "doc", + name: "Notes", + content: { version: "2.28.0", time: now, blocks: [] }, + createdAt: now, + updatedAt: now, + }; + return { id: crypto.randomUUID(), - name: "New Workspace", - entities: {}, - layout: [], + name: "My Workspace", + entities: { + [todoId]: todoEntity, + [docId]: docEntity, + }, + layout: [ + { + type: "group", + id: crypto.randomUUID(), + name: "Productivity", + children: [ + { entityId: todoId, type: "item" }, + { entityId: docId, type: "item" }, + ], + }, + ], createdAt: now, updatedAt: now, }; @@ -27,7 +74,7 @@ export function loadWorkspaceFromLocalStorage(id: string): Workspace { if ( typeof parsed !== "object" || parsed === null || - typeof parsed.version !== "number" + typeof parsed.id !== "string" ) { console.warn("Invalid workspace shape. Resetting."); return createDefaultWorkspace(); From de77f0fe6470c0e4fbd6eac7fec255beb9da572c Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 22:59:00 +0100 Subject: [PATCH 09/29] Edit --- src/components/Router.tsx | 25 +++++++++++++++++++++++++ src/components/view/TaskView.tsx | 30 +++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/components/Router.tsx b/src/components/Router.tsx index 10f39cb..d2c1107 100644 --- a/src/components/Router.tsx +++ b/src/components/Router.tsx @@ -55,6 +55,28 @@ export default function Router({ })); }; + const setTags = (tags: Record) => { + updateEntity(entityId, (e) => ({ + ...e, + tags: e.type === "todo" ? tags : {}, + })); + }; + + const setStatuses = (statuses: any[]) => { + updateEntity(entityId, (e) => { + if (e.type === "todo") { + return { + ...e, + config: { + ...e.config, + statuses, + }, + }; + } + return e; + }); + }; + return ( ); } diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index a632b76..5eb6b0a 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -1,19 +1,28 @@ "use client"; -import { Tag, TagId, Task, TaskId } from "@/lib/types"; +import { Tag, TagId, Task, TaskId, Status } from "@/lib/types"; import { Color } from "@/lib/ui/color"; import BoardView from "./board/BoardView"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { TableView } from "./table/TableView"; +import EditView from "./edit/EditView"; export type TaskViewProps = { tags: Record; tasks: Record; + statuses: Status[]; + /** Update entire tasks record */ setTasks: (tasks: Record) => void; + /** Update tags */ + setTags: (tags: Record) => void; + + /** Update statuses */ + setStatuses: (statuses: Status[]) => void; + /** Create a new task with a status */ createTask: (statusId: string) => void; }; @@ -21,7 +30,10 @@ export type TaskViewProps = { export default function TaskView({ tags, tasks, + statuses, setTasks, + setTags, + setStatuses, createTask, }: TaskViewProps) { return ( @@ -30,6 +42,7 @@ export default function TaskView({ Board Table + Edit @@ -38,6 +51,9 @@ export default function TaskView({ setTasks={setTasks} createTask={createTask} tags={tags} + setTags={setTags} + setStatuses={setStatuses} + statuses={statuses} /> @@ -47,6 +63,18 @@ export default function TaskView({ setTasks={setTasks} createTask={createTask} tags={tags} + setTags={setTags} + setStatuses={setStatuses} + statuses={statuses} + /> + + + + From bee571ed19e77c1cb275c5c9ce3026555b78dcf7 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 22:59:04 +0100 Subject: [PATCH 10/29] Edit --- src/components/view/edit/EditView.tsx | 318 ++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 src/components/view/edit/EditView.tsx diff --git a/src/components/view/edit/EditView.tsx b/src/components/view/edit/EditView.tsx new file mode 100644 index 0000000..a9b8b4a --- /dev/null +++ b/src/components/view/edit/EditView.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { Tag, TagId, Status } from "@/lib/types"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { X, Plus, GripVertical } from "lucide-react"; + +export type EditViewProps = { + tags: Record; + statuses: Status[]; + setTags: (tags: Record) => void; + setStatuses: (statuses: Status[]) => void; +}; + +const COLOR_OPTIONS = [ + "red", + "orange", + "yellow", + "green", + "blue", + "indigo", + "purple", + "pink", + "gray", +]; + +export default function EditView({ + tags, + statuses, + setTags, + setStatuses, +}: EditViewProps) { + const [newTagName, setNewTagName] = useState(""); + const [newTagColor, setNewTagColor] = useState("blue"); + const [newStatusName, setNewStatusName] = useState(""); + const [newStatusColor, setNewStatusColor] = useState("blue"); + + const handleAddTag = () => { + if (newTagName.trim()) { + const newTagId = `tag-${Date.now()}`; + setTags({ + ...tags, + [newTagId]: { + id: newTagId, + name: newTagName, + color: newTagColor, + }, + }); + setNewTagName(""); + setNewTagColor("blue"); + } + }; + + const handleDeleteTag = (tagId: TagId) => { + const newTags = { ...tags }; + delete newTags[tagId]; + setTags(newTags); + }; + + const handleAddStatus = () => { + if (newStatusName.trim()) { + const newStatus: Status = { + id: `status-${Date.now()}`, + name: newStatusName, + color: newStatusColor, + order: statuses.length, + }; + setStatuses([...statuses, newStatus]); + setNewStatusName(""); + setNewStatusColor("blue"); + } + }; + + const handleDeleteStatus = (statusId: string) => { + setStatuses(statuses.filter((s) => s.id !== statusId)); + }; + + const handleUpdateStatusName = (statusId: string, newName: string) => { + setStatuses( + statuses.map((s) => (s.id === statusId ? { ...s, name: newName } : s)), + ); + }; + + const handleUpdateStatusColor = (statusId: string, newColor: string) => { + setStatuses( + statuses.map((s) => (s.id === statusId ? { ...s, color: newColor } : s)), + ); + }; + + const handleUpdateTagName = (tagId: TagId, newName: string) => { + setTags({ + ...tags, + [tagId]: { ...tags[tagId], name: newName }, + }); + }; + + const handleUpdateTagColor = (tagId: TagId, newColor: string) => { + setTags({ + ...tags, + [tagId]: { ...tags[tagId], color: newColor }, + }); + }; + + return ( +
+ {/* Tags Section */} +
+

Tags

+ + {Object.entries(tags).map(([tagId, tag]) => ( +
+ + +
+ + + + handleUpdateTagName(tagId, e.target.value)} + placeholder="Tag name" + className="flex-1" + /> + + +
+ ))} + + + +
+ + +
+ + + + setNewTagName(e.target.value)} + placeholder="New tag name" + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleAddTag(); + } + }} + /> + + +
+ + + + {/* Statuses Section */} +
+

Statuses

+ + {statuses.map((status) => ( +
+ + + + +
+ + + + + handleUpdateStatusName(status.id, e.target.value) + } + placeholder="Status name" + className="flex-1" + /> + + +
+ ))} + + + +
+ + +
+ + + + setNewStatusName(e.target.value)} + placeholder="New status name" + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleAddStatus(); + } + }} + /> + + + + + + + ); +} From bcb9fdb027debc054c365fa1d7ed78cd5e5962eb Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 23:09:07 +0100 Subject: [PATCH 11/29] Status stuff --- src/components/view/board/BoardView.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index bb4e752..6f3b5c4 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -12,6 +12,7 @@ export default function BoardView({ tasks, setTasks, createTask, + statuses, }: TaskViewProps) { function onDragEnd(result: any) { const { draggableId, source, destination } = result; @@ -32,13 +33,12 @@ export default function BoardView({ // Group tasks by status, using statuses from first available todo entity const tasksArray = Object.values(tasks); - const statusIds = new Set(tasksArray.map((t) => t.statusId)); // Sort tasks by creation date within each status - const tasksByStatus = Array.from(statusIds).map((statusId) => ({ - statusId, + const tasksByStatus = statuses.map((status) => ({ + status, tasks: tasksArray - .filter((t) => t.statusId === statusId) + .filter((t) => t.statusId === status.id) .sort((a, b) => a.createdAt - b.createdAt), })); @@ -46,8 +46,8 @@ export default function BoardView({
- {tasksByStatus.map(({ statusId, tasks: statusTasks }) => ( - + {tasksByStatus.map(({ status, tasks: statusTasks }) => ( + {(provided) => ( - {statusId} + {status.name} From e9c0bf5c5237f0b0da0c38e74f4715fe27d0d413 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 23:23:43 +0100 Subject: [PATCH 12/29] Entity export and stuff --- src/app/workspace/page.tsx | 75 +++++++- src/components/Router.tsx | 8 +- src/components/view/DocView.tsx | 82 ++++++++- src/components/view/TaskView.tsx | 78 +++++++- src/lib/export.ts | 298 +++++++++++++++++++++++++++++++ src/lib/import.ts | 194 ++++++++++++++++++++ 6 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 src/lib/export.ts create mode 100644 src/lib/import.ts diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index b848bc8..389f65a 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -15,26 +15,91 @@ import { export default function Workspace() { const [currentWorkspace, setCurrentWorkspace] = useState(); + const [isInitialized, setIsInitialized] = useState(false); const { workspace, loadWorkspace, updateEntity, setWorkspace } = useWorkspace(); const [route, setRoute] = useState({}); + /** + * Initialize workspace from cookie on mount + */ useEffect(() => { - setCurrentWorkspace(Cookies.get("workspace")); + const workspaceId = Cookies.get("workspace"); + setCurrentWorkspace(workspaceId); + setIsInitialized(true); }, []); + /** + * Load workspace when currentWorkspace changes. + * + * `loadWorkspace` is intentionally omitted from the dependency array + * because it is recreated on every render by the `useWorkspace` hook. + * Including it would cause this effect to run repeatedly and trigger + * the "maximum update depth exceeded" error. ESLint warning is disabled + * for this case. + */ useEffect(() => { + if (!isInitialized) return; + loadWorkspace(currentWorkspace); - if (currentWorkspace) Cookies.set("workspace", currentWorkspace); - else Cookies.remove("workspace"); - }, [currentWorkspace]); + if (currentWorkspace) { + Cookies.set("workspace", currentWorkspace); + } else { + Cookies.remove("workspace"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentWorkspace, isInitialized]); + + /** + * Load route (entity) from localStorage when workspace loads + */ + useEffect(() => { + if (!workspace) return; + + try { + const savedRoute = localStorage.getItem(`route_${workspace.id}`); + + if (savedRoute) { + const parsedRoute: Route = JSON.parse(savedRoute); + setRoute(parsedRoute); + } else { + // Default to first entity if no route saved + const firstEntityId = Object.keys(workspace.entities)[0]; + if (firstEntityId) { + setRoute({ workspace: workspace.id, entity: firstEntityId }); + } + } + } catch (err) { + console.error("Failed to load route:", err); + const firstEntityId = Object.keys(workspace.entities)[0]; + if (firstEntityId) { + setRoute({ workspace: workspace.id, entity: firstEntityId }); + } + } + }, [workspace?.id]); + + /** + * Save route whenever it changes + */ + useEffect(() => { + if (!workspace || !route.entity) return; + + try { + localStorage.setItem( + `route_${workspace.id}`, + JSON.stringify({ workspace: workspace.id, entity: route.entity }), + ); + } catch (err) { + console.error("Failed to save route:", err); + } + }, [route, workspace?.id]); if (!workspace) { return (
{ const w = createDefaultWorkspace(); saveWorkspaceToLocalStorage(w); diff --git a/src/components/Router.tsx b/src/components/Router.tsx index d2c1107..4fbd5da 100644 --- a/src/components/Router.tsx +++ b/src/components/Router.tsx @@ -87,6 +87,7 @@ export default function Router({ statuses={entity.config.statuses} setTags={setTags} setStatuses={setStatuses} + entityName={entity.name} /> ); } @@ -101,7 +102,12 @@ export default function Router({ return (
- +
); } diff --git a/src/components/view/DocView.tsx b/src/components/view/DocView.tsx index a99c029..eb4a107 100644 --- a/src/components/view/DocView.tsx +++ b/src/components/view/DocView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import Header from "@editorjs/header"; import List from "@editorjs/list"; @@ -11,6 +11,21 @@ import Delimiter from "@editorjs/delimiter"; import Warning from "@editorjs/warning"; import InlineCode from "@editorjs/inline-code"; import { OutputData } from "@editorjs/editorjs"; +import { + downloadFile, + exportDocAsJson, + exportDocAsMarkdown, + exportDocAsHtml, +} from "@/lib/export"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { FileDown } from "lucide-react"; function toMarkdown(data: OutputData): string { return data.blocks @@ -55,11 +70,48 @@ function toMarkdown(data: OutputData): string { export default function DocView({ doc, setDoc, + docName = "Document", }: { doc: OutputData; setDoc: (data: OutputData) => void; + docName?: string; }) { const holderRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + + const handleExport = (format: "json" | "markdown" | "html") => { + const docEntity = { + id: "", + type: "doc" as const, + name: docName, + content: doc, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + let content = ""; + let filename = `${docName.replace(/\s+/g, "_")}_${Date.now()}`; + + switch (format) { + case "json": + content = exportDocAsJson(docEntity); + filename += ".json"; + break; + case "markdown": + content = exportDocAsMarkdown(docEntity); + filename += ".md"; + break; + case "html": + content = exportDocAsHtml(docEntity); + filename += ".html"; + break; + } + + downloadFile( + content, + filename, + format === "html" ? "text/html" : "text/plain", + ); + }; useEffect(() => { let editor: any; @@ -135,5 +187,31 @@ export default function DocView({ }; }, []); - return
; + return ( +
+
+ + + + + + handleExport("markdown")}> + Export as Markdown + + handleExport("html")}> + Export as HTML + + + handleExport("json")}> + Export as JSON + + + +
+
+
+ ); } diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index 5eb6b0a..edca4d6 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -6,6 +6,22 @@ import BoardView from "./board/BoardView"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { TableView } from "./table/TableView"; import EditView from "./edit/EditView"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { FileDown } from "lucide-react"; +import { + downloadFile, + exportTodoAsJson, + exportTodoAsCsv, + exportTodoAsMarkdown, +} from "@/lib/export"; +import { useEffect, useState } from "react"; export type TaskViewProps = { tags: Record; @@ -25,6 +41,9 @@ export type TaskViewProps = { /** Create a new task with a status */ createTask: (statusId: string) => void; + + /** Entity name for export */ + entityName?: string; }; export default function TaskView({ @@ -35,9 +54,66 @@ export default function TaskView({ setTags, setStatuses, createTask, + entityName = "Tasks", }: TaskViewProps) { + const [isExporting, setIsExporting] = useState(false); + + const handleExport = (format: "json" | "csv" | "markdown") => { + const todoEntity = { + id: "", + type: "todo" as const, + name: entityName, + config: { statuses }, + tags, + tasks, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + let content = ""; + let filename = `${entityName.replace(/\s+/g, "_")}_${Date.now()}`; + + switch (format) { + case "json": + content = exportTodoAsJson(todoEntity); + filename += ".json"; + break; + case "csv": + content = exportTodoAsCsv(todoEntity); + filename += ".csv"; + break; + case "markdown": + content = exportTodoAsMarkdown(todoEntity); + filename += ".md"; + break; + } + + downloadFile(content, filename, "text/plain"); + }; return ( -
+
+
+ + + + + + handleExport("csv")}> + Export as CSV + + handleExport("markdown")}> + Export as Markdown + + + handleExport("json")}> + Export as JSON + + + +
Board diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..18a61ca --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,298 @@ +import { Workspace, DocEntity, TodoEntity, Entity } from "./types"; +import { OutputData } from "@editorjs/editorjs"; + +/** + * Convert EditorJS OutputData to Markdown + */ +export function docToMarkdown(content: OutputData): string { + return content.blocks + .map((block) => { + switch (block.type) { + case "header": + return `${"#".repeat((block.data as any).level)} ${(block.data as any).text}`; + + case "paragraph": + return `${(block.data as any).text}\n`; + + case "list": + if ((block.data as any).style === "ordered") { + return ((block.data as any).items as Array<{ content: string }>) + .map((item, i) => `${i + 1}. ${item.content}`) + .join("\n"); + } else { + return ((block.data as any).items as Array<{ content: string }>) + .map((item) => `- ${item.content}`) + .join("\n"); + } + + case "code": + return `\`\`\`\n${(block.data as any).code}\n\`\`\``; + + case "quote": + return `> ${(block.data as any).text}`; + + case "delimiter": + return `---`; + + case "image": + return `![${(block.data as any).caption || ""}](${(block.data as any).file.url})`; + + default: + return ""; + } + }) + .join("\n\n"); +} + +/** + * Convert EditorJS OutputData to HTML + */ +export function docToHtml(content: OutputData): string { + const htmlBlocks = content.blocks.map((block) => { + switch (block.type) { + case "header": + return `${(block.data as any).text}`; + + case "paragraph": + return `

${(block.data as any).text}

`; + + case "list": + const items = ((block.data as any).items as Array<{ content: string }>) + .map((item) => `
  • ${item.content}
  • `) + .join("\n"); + + if ((block.data as any).style === "ordered") { + return `
      \n${items}\n
    `; + } else { + return `
      \n${items}\n
    `; + } + + case "code": + return `
    ${(block.data as any).code}
    `; + + case "quote": + return `
    ${(block.data as any).text}
    `; + + case "delimiter": + return `
    `; + + case "image": + return `${(block.data as any).caption || `; + + default: + return ""; + } + }); + + return ` + + + + + + + +${htmlBlocks.join("\n")} + +`; +} + +/** + * Export a Doc entity as JSON + */ +export function exportDocAsJson(doc: DocEntity): string { + return JSON.stringify( + { + name: doc.name, + type: "doc", + content: doc.content, + createdAt: new Date(doc.createdAt).toISOString(), + updatedAt: new Date(doc.updatedAt).toISOString(), + }, + null, + 2, + ); +} + +/** + * Export a Doc entity as Markdown + */ +export function exportDocAsMarkdown(doc: DocEntity): string { + const header = `# ${doc.name}\n\nCreated: ${new Date(doc.createdAt).toISOString()}\nUpdated: ${new Date(doc.updatedAt).toISOString()}\n\n---\n\n`; + const content = docToMarkdown(doc.content); + return header + content; +} + +/** + * Export a Doc entity as HTML + */ +export function exportDocAsHtml(doc: DocEntity): string { + const html = docToHtml(doc.content); + const headerHtml = `

    ${doc.name}

    Created: ${new Date(doc.createdAt).toISOString()}\nUpdated: ${new Date(doc.updatedAt).toISOString()}


    `; + + // Insert header before the first body element + return html + .replace("", `${doc.name}\n`) + .replace("", `\n${headerHtml}\n`); +} + +/** + * Export a Todo entity as JSON + */ +export function exportTodoAsJson(todo: TodoEntity): string { + return JSON.stringify( + { + name: todo.name, + type: "todo", + config: todo.config, + tags: todo.tags, + tasks: todo.tasks, + createdAt: new Date(todo.createdAt).toISOString(), + updatedAt: new Date(todo.updatedAt).toISOString(), + }, + null, + 2, + ); +} + +/** + * Export a Todo entity as CSV + */ +export function exportTodoAsCsv(todo: TodoEntity): string { + const headers = [ + "Title", + "Description", + "Status", + "Tags", + "Created", + "Updated", + ]; + const rows = Object.values(todo.tasks).map((task) => [ + task.title, + task.description || "", + todo.config.statuses.find((s) => s.id === task.statusId)?.name || "", + task.tagIds.map((id) => todo.tags[id]?.name || "").join(";"), + new Date(task.createdAt).toISOString(), + new Date(task.updatedAt).toISOString(), + ]); + + const csvContent = [ + headers.join(","), + ...rows.map((row) => + row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","), + ), + ].join("\n"); + + return csvContent; +} + +/** + * Export a Todo entity as Markdown + */ +export function exportTodoAsMarkdown(todo: TodoEntity): string { + let markdown = `# ${todo.name}\n\nCreated: ${new Date(todo.createdAt).toISOString()}\nUpdated: ${new Date(todo.updatedAt).toISOString()}\n\n`; + + // Group tasks by status + const tasksByStatus: Record = {}; + for (const status of todo.config.statuses) { + tasksByStatus[status.id] = {}; + } + + for (const [taskId, task] of Object.entries(todo.tasks)) { + const statusId = task.statusId; + if (!tasksByStatus[statusId]) { + tasksByStatus[statusId] = {}; + } + tasksByStatus[statusId][taskId] = task; + } + + // Add tasks by status + for (const status of todo.config.statuses) { + markdown += `## ${status.name}\n\n`; + const tasks = tasksByStatus[status.id]; + if (Object.keys(tasks).length === 0) { + markdown += "- (No tasks)\n\n"; + } else { + for (const task of Object.values(tasks)) { + const tags = task.tagIds + .map((id) => todo.tags[id]?.name || "") + .filter((n) => n) + .join(", "); + markdown += `- **${task.title}**${tags ? ` (${tags})` : ""}`; + if (task.description) { + markdown += `\n - ${task.description}`; + } + markdown += "\n"; + } + } + markdown += "\n"; + } + + return markdown; +} + +/** + * Export entire workspace as JSON + */ +export function exportWorkspaceAsJson(workspace: Workspace): string { + return JSON.stringify(workspace, null, 2); +} + +/** + * Trigger download of a file + */ +export function downloadFile( + content: string, + filename: string, + mimeType: string = "text/plain", +): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Export any entity based on its type + */ +export function exportEntity( + entity: Entity, + format: "json" | "markdown" | "html" | "csv", +): string { + if (entity.type === "doc") { + switch (format) { + case "json": + return exportDocAsJson(entity); + case "markdown": + return exportDocAsMarkdown(entity); + case "html": + return exportDocAsHtml(entity); + default: + return exportDocAsJson(entity); + } + } else if (entity.type === "todo") { + switch (format) { + case "json": + return exportTodoAsJson(entity); + case "markdown": + return exportTodoAsMarkdown(entity); + case "csv": + return exportTodoAsCsv(entity); + default: + return exportTodoAsJson(entity); + } + } + + return ""; +} diff --git a/src/lib/import.ts b/src/lib/import.ts new file mode 100644 index 0000000..c000f1f --- /dev/null +++ b/src/lib/import.ts @@ -0,0 +1,194 @@ +import { Workspace, DocEntity, TodoEntity } from "./types"; +import { OutputData } from "@editorjs/editorjs"; + +/** + * Parse JSON file content back to Workspace + */ +export function importWorkspaceFromJson(jsonContent: string): Workspace { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.id !== "string" || + typeof parsed.name !== "string" + ) { + throw new Error("Invalid workspace JSON structure"); + } + + return parsed as Workspace; + } catch (err) { + throw new Error(`Failed to parse workspace JSON: ${err}`); + } +} + +/** + * Parse JSON file content back to DocEntity + */ +export function importDocFromJson(jsonContent: string): DocEntity { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.name !== "string" || + parsed.type !== "doc" || + typeof parsed.content !== "object" + ) { + throw new Error("Invalid doc JSON structure"); + } + + return parsed as DocEntity; + } catch (err) { + throw new Error(`Failed to parse doc JSON: ${err}`); + } +} + +/** + * Parse JSON file content back to TodoEntity + */ +export function importTodoFromJson(jsonContent: string): TodoEntity { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.name !== "string" || + parsed.type !== "todo" || + typeof parsed.config !== "object" + ) { + throw new Error("Invalid todo JSON structure"); + } + + return parsed as TodoEntity; + } catch (err) { + throw new Error(`Failed to parse todo JSON: ${err}`); + } +} + +/** + * Parse CSV file content to extract tasks + */ +export function importTodoFromCsv( + csvContent: string, + todoEntity: TodoEntity, +): { + tasks: Record; + errors: string[]; +} { + const lines = csvContent.trim().split("\n"); + const tasks: Record = {}; + const errors: string[] = []; + + if (lines.length < 2) { + errors.push("CSV file must have at least a header row"); + return { tasks, errors }; + } + + const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); + const titleIndex = headers.indexOf("title"); + const descriptionIndex = headers.indexOf("description"); + const statusIndex = headers.indexOf("status"); + const tagsIndex = headers.indexOf("tags"); + + if (titleIndex === -1) { + errors.push("CSV must have a 'Title' column"); + return { tasks, errors }; + } + + for (let i = 1; i < lines.length; i++) { + try { + const line = lines[i]; + if (!line.trim()) continue; + + const values = line.split(",").map((v) => v.trim().replace(/^"|"$/g, "")); + + const title = values[titleIndex]; + if (!title) { + errors.push(`Row ${i + 1}: Missing title`); + continue; + } + + // Find status by name + let statusId = todoEntity.config.statuses[0].id; + if (statusIndex !== -1 && values[statusIndex]) { + const status = todoEntity.config.statuses.find( + (s) => s.name.toLowerCase() === values[statusIndex].toLowerCase(), + ); + if (status) { + statusId = status.id; + } + } + + // Parse tags + let tagIds: string[] = []; + if (tagsIndex !== -1 && values[tagsIndex]) { + const tagNames = values[tagsIndex].split(";").map((t) => t.trim()); + tagIds = tagNames + .map((name) => { + const tag = Object.entries(todoEntity.tags).find( + ([, t]) => t.name.toLowerCase() === name.toLowerCase(), + ); + return tag ? tag[0] : null; + }) + .filter((id) => id !== null) as string[]; + } + + const taskId = `task_${Math.random().toString(36).slice(2, 9)}`; + tasks[taskId] = { + id: taskId, + title, + description: descriptionIndex !== -1 ? values[descriptionIndex] : "", + statusId, + tagIds, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } catch (err) { + errors.push(`Row ${i + 1}: ${err}`); + } + } + + return { tasks, errors }; +} + +/** + * Open file picker and read file content + */ +export function openFileDialog( + accept: string = ".json", +): Promise<{ content: string; filename: string }> { + return new Promise((resolve, reject) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = accept; + + input.onchange = (e: any) => { + const file = e.target.files?.[0]; + if (!file) { + reject(new Error("No file selected")); + return; + } + + const reader = new FileReader(); + reader.onload = (event: any) => { + try { + const content = event.target?.result as string; + resolve({ content, filename: file.name }); + } catch (err) { + reject(err); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsText(file); + }; + + input.click(); + }); +} From a9bf6d894bcc20f5c3543ad5665a162e9aed0ea1 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 23:31:58 +0100 Subject: [PATCH 13/29] Route saving --- src/app/workspace/page.tsx | 24 ++++++-------- src/components/view/TaskView.tsx | 56 +++++++++++++++++--------------- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 389f65a..4a512cb 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -14,9 +14,6 @@ import { } from "@/lib/workspace"; export default function Workspace() { - const [currentWorkspace, setCurrentWorkspace] = useState(); - const [isInitialized, setIsInitialized] = useState(false); - const { workspace, loadWorkspace, updateEntity, setWorkspace } = useWorkspace(); @@ -26,9 +23,10 @@ export default function Workspace() { * Initialize workspace from cookie on mount */ useEffect(() => { - const workspaceId = Cookies.get("workspace"); - setCurrentWorkspace(workspaceId); - setIsInitialized(true); + const routeString = Cookies.get("route"); + if (!routeString) return; + const route = JSON.parse(routeString) as Route; + setRoute(route); }, []); /** @@ -41,16 +39,14 @@ export default function Workspace() { * for this case. */ useEffect(() => { - if (!isInitialized) return; - - loadWorkspace(currentWorkspace); - if (currentWorkspace) { - Cookies.set("workspace", currentWorkspace); + loadWorkspace(route.workspace); + if (route.workspace) { + Cookies.set("route", JSON.stringify(route)); } else { - Cookies.remove("workspace"); + Cookies.remove("route"); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentWorkspace, isInitialized]); + }, [route]); /** * Load route (entity) from localStorage when workspace loads @@ -103,7 +99,7 @@ export default function Workspace() { onClick={() => { const w = createDefaultWorkspace(); saveWorkspaceToLocalStorage(w); - setCurrentWorkspace(w.id); + setRoute({ workspace: w.id }); }} >
    diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index edca4d6..bb28ec8 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -92,34 +92,36 @@ export default function TaskView({ }; return (
    -
    - - - - - - handleExport("csv")}> - Export as CSV - - handleExport("markdown")}> - Export as Markdown - - - handleExport("json")}> - Export as JSON - - - -
    +
    - - Board - Table - Edit - +
    + + Board + Table + Edit + + + + + + + + handleExport("csv")}> + Export as CSV + + handleExport("markdown")}> + Export as Markdown + + + handleExport("json")}> + Export as JSON + + + +
    Date: Fri, 27 Feb 2026 23:37:00 +0100 Subject: [PATCH 14/29] Improved export button on doc --- src/components/view/DocView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/view/DocView.tsx b/src/components/view/DocView.tsx index eb4a107..2e33bcc 100644 --- a/src/components/view/DocView.tsx +++ b/src/components/view/DocView.tsx @@ -188,8 +188,8 @@ export default function DocView({ }, []); return ( -
    -
    +
    +
    - +
    {statusTasks.map((task, index) => ( Date: Sat, 28 Feb 2026 01:44:20 +0100 Subject: [PATCH 18/29] Fixed overflow on board --- src/components/sidebar/AppSidebar.tsx | 2 +- src/components/view/TaskView.tsx | 13 ++++++++----- src/components/view/board/BoardView.tsx | 8 ++++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 7b6ef2c..200c40c 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -82,7 +82,7 @@ export default function AppSidebar({ -
    {children}
    +
    {children}
    ); } diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index 794bcf0..adffc7a 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -91,9 +91,12 @@ export default function TaskView({ downloadFile(content, filename, "text/plain"); }; return ( -
    +
    - +
    Board @@ -123,7 +126,7 @@ export default function TaskView({
    - + - + - + +
    -
    +
    {tasksByStatus.map(({ status, tasks: statusTasks }) => ( {(provided) => ( @@ -67,7 +67,7 @@ export default function BoardView({ - +
    {statusTasks.map((task, index) => ( Date: Sat, 28 Feb 2026 02:00:55 +0100 Subject: [PATCH 19/29] Fixed doc view --- src/components/Router.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Router.tsx b/src/components/Router.tsx index acf3d1e..9d3ee67 100644 --- a/src/components/Router.tsx +++ b/src/components/Router.tsx @@ -104,7 +104,7 @@ export default function Router({ }; return ( -
    +

    {entity.name}

    Date: Sat, 28 Feb 2026 02:13:44 +0100 Subject: [PATCH 20/29] Remove doc --- src/components/view/board/BoardView.tsx | 54 +++++++++++++++++++------ 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index 8e6d95c..f4ecabf 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -3,9 +3,15 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; -import { PlusIcon } from "lucide-react"; +import { PlusIcon, Trash2 } from "lucide-react"; import BoardTask from "./Task"; import { TaskViewProps } from "../TaskView"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; export default function BoardView({ tags, @@ -76,17 +82,41 @@ export default function BoardView({ index={index} > {(provided) => ( - - setTasks({ - ...tasks, - [t.id]: t, - }) - } - provided={provided} - task={task} - tags={tags} - /> + + +
    + + setTasks({ + ...tasks, + [t.id]: t, + }) + } + provided={provided} + task={task} + tags={tags} + /> +
    +
    + + + { + const updated = { ...tasks }; + delete updated[task.id]; + setTasks(updated); + }} + > + + Delete + + +
    )} ))} From f20fd1d65ab8b35a518e1e88534750d42512352e Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 02:20:11 +0100 Subject: [PATCH 21/29] Select on boardview --- src/components/view/board/BoardView.tsx | 220 +++++++++++++++++++++++- src/components/view/board/Task.tsx | 18 +- 2 files changed, 225 insertions(+), 13 deletions(-) diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index f4ecabf..1afe933 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -12,6 +12,7 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import { useState, useEffect, useRef, useCallback } from "react"; export default function BoardView({ tags, @@ -20,6 +21,18 @@ export default function BoardView({ createTask, statuses, }: TaskViewProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isDraggingSelection, setIsDraggingSelection] = useState(false); + const [selectionBox, setSelectionBox] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + } | null>(null); + const boardRef = useRef(null); + const taskCardRefs = useRef>({}); + const mouseDownPos = useRef<{ x: number; y: number } | null>(null); + function onDragEnd(result: any) { const { draggableId, source, destination } = result; if (!destination) return; @@ -37,10 +50,130 @@ export default function BoardView({ }); } - // Group tasks by status, using statuses from first available todo entity - const tasksArray = Object.values(tasks); + const deleteSelected = useCallback(() => { + 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(); + 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) => ({ status, tasks: tasksArray @@ -48,8 +181,62 @@ export default function BoardView({ .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 ( -
    +
    + {/* Selection box overlay */} + {isDraggingSelection && selRect && ( +
    + )} + + {/* Delete bar when items selected */} + {selectedIds.size > 0 && ( +
    + + {selectedIds.size} task{selectedIds.size !== 1 ? "s" : ""} selected + + + — press Backspace to delete + + + +
    + )} +
    {tasksByStatus.map(({ status, tasks: statusTasks }) => ( @@ -85,9 +272,14 @@ export default function BoardView({
    { + provided.innerRef(el); + taskCardRefs.current[task.id] = el; + }} {...provided.draggableProps} {...provided.dragHandleProps} + data-task-card={task.id} + onClick={(e) => handleTaskClick(e, task.id)} > @@ -99,6 +291,17 @@ export default function BoardView({ provided={provided} task={task} 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; + }); + }} />
    @@ -110,6 +313,11 @@ export default function BoardView({ const updated = { ...tasks }; delete updated[task.id]; setTasks(updated); + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(task.id); + return next; + }); }} > @@ -136,4 +344,4 @@ export default function BoardView({
    ); -} +} \ No newline at end of file diff --git a/src/components/view/board/Task.tsx b/src/components/view/board/Task.tsx index 5f58544..d662510 100644 --- a/src/components/view/board/Task.tsx +++ b/src/components/view/board/Task.tsx @@ -14,6 +14,7 @@ import TaskEdit from "../edit/TaskEdit"; import { Button } from "@/components/ui/button"; import { NotepadText, Trash2 } from "lucide-react"; import DocDialog from "../edit/DocDialog"; +import { cn } from "@/lib/utils"; export default function BoardTask({ task, @@ -21,24 +22,27 @@ export default function BoardTask({ setTask, tags, onDelete, + isSelected, }: { task: Task; setTask: (t: Task) => void; provided: DraggableProvided; tags: Record; onDelete?: (taskId: string) => void; + isSelected?: boolean; }) { return ( - + {task.title}
    @@ -73,7 +77,7 @@ export default function BoardTask({ )} - + {task.tagIds?.map((tagId) => ( ); -} +} \ No newline at end of file From 3b35685d61d88fa0546eec1e55e280cf37efe98d Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 02:24:56 +0100 Subject: [PATCH 22/29] Flex on boardview --- src/components/view/board/BoardView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index 1afe933..8cef5de 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -238,14 +238,14 @@ export default function BoardView({ )} -
    +
    {tasksByStatus.map(({ status, tasks: statusTasks }) => ( {(provided) => ( @@ -344,4 +344,4 @@ export default function BoardView({
    ); -} \ No newline at end of file +} From 9200e1cfadc736bef968ec3cba16c26c16370d98 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 02:30:41 +0100 Subject: [PATCH 23/29] Sidebar --- src/app/workspace/page.tsx | 6 +- src/components/sidebar/AppSidebar.tsx | 95 ++++++++++++++++++++++++++- src/lib/workspace.ts | 34 ++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 4a512cb..d9a4302 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -116,7 +116,11 @@ export default function Workspace() { } return ( - +
    diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 200c40c..d0c07d8 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -1,5 +1,7 @@ "use client"; +import * as React from "react"; + import { Sidebar, SidebarContent, @@ -16,11 +18,13 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { ChevronDown } from "lucide-react"; import { Route, SetState, Workspace } from "@/lib/types"; +import { listWorkspaces, saveWorkspaceToLocalStorage } from "@/lib/workspace"; // Simple ID generator const id = (prefix = "t") => @@ -30,13 +34,77 @@ type AppSidebarProps = { children: React.ReactNode; workspace: Workspace; setRoute: SetState; + /** optional setter for the current workspace, used for rename */ + setWorkspace?: React.Dispatch>; }; export default function AppSidebar({ children, workspace, setRoute, + setWorkspace, }: AppSidebarProps) { + const [availableWorkspaces, setAvailableWorkspaces] = React.useState< + Workspace[] + >([]); + + // 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 = () => { + const json = window.prompt( + "Paste a workspace JSON to import (generated by export)", + ); + if (!json) return; + + try { + const parsed = JSON.parse(json); + 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 JSON"); + } + }; + + const exportWorkspace = () => { + try { + const text = JSON.stringify(workspace, null, 2); + navigator.clipboard.writeText(text); + window.alert("Workspace JSON copied to clipboard"); + } catch (err) { + console.error("Export failed", err); + window.alert("Failed to copy workspace JSON"); + } + }; + + const renameWorkspace = () => { + const newName = window.prompt("New workspace name", workspace.name); + if (!newName || newName === workspace.name) return; + + const updated: Workspace = { + ...workspace, + name: newName, + updatedAt: Date.now(), + }; + saveWorkspaceToLocalStorage(updated); + setAvailableWorkspaces(listWorkspaces()); + if (setWorkspace) { + setWorkspace(updated); + } + }; + return ( @@ -56,9 +124,30 @@ export default function AppSidebar({ align="start" className="w-[--radix-popper-anchor-width]" > - {/* Future: real workspace switching */} - Rename Workspace - Export Workspace + {/* list all known workspaces */} + {availableWorkspaces.map((w) => ( + switchWorkspace(w.id)} + > + {w.name} + {w.id === workspace.id ? " (current)" : ""} + + ))} + + + Import Workspace + + + + Rename Workspace + + + Export Workspace + diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index 01ab95f..eee2c04 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -90,8 +90,42 @@ export function loadWorkspaceFromLocalStorage(id: string): Workspace { export function saveWorkspaceToLocalStorage(workspace: Workspace): void { try { const serialized = JSON.stringify(workspace); + // keep using raw id for backwards compatibility localStorage.setItem(workspace.id, serialized); } catch (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; +} From 7d4a087c10d2cdd13273e92497226f068932d370 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 02:33:50 +0100 Subject: [PATCH 24/29] Sidbar more elegant --- src/components/sidebar/AppSidebar.tsx | 82 +++++++++++++++++++++------ 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index d0c07d8..f262885 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -12,6 +12,16 @@ import { SidebarProvider, useSidebar, } 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 { @@ -25,6 +35,7 @@ import { import { ChevronDown } from "lucide-react"; import { Route, SetState, Workspace } from "@/lib/types"; import { listWorkspaces, saveWorkspaceToLocalStorage } from "@/lib/workspace"; +import { openFileDialog } from "@/lib/import"; // Simple ID generator const id = (prefix = "t") => @@ -48,6 +59,13 @@ export default function AppSidebar({ Workspace[] >([]); + const [isRenameOpen, setIsRenameOpen] = React.useState(false); + const [proposedName, setProposedName] = React.useState(workspace.name); + + React.useEffect(() => { + setProposedName(workspace.name); + }, [workspace.name]); + // reload list when component mounts or whenever the current workspace changes React.useEffect(() => { setAvailableWorkspaces(listWorkspaces()); @@ -57,14 +75,10 @@ export default function AppSidebar({ setRoute({ workspace: id }); }; - const importWorkspace = () => { - const json = window.prompt( - "Paste a workspace JSON to import (generated by export)", - ); - if (!json) return; - + const importWorkspace = async () => { try { - const parsed = JSON.parse(json); + const { content } = await openFileDialog(".json"); + const parsed = JSON.parse(content); if (parsed && typeof parsed.id === "string") { saveWorkspaceToLocalStorage(parsed); setAvailableWorkspaces(listWorkspaces()); @@ -74,28 +88,34 @@ export default function AppSidebar({ } } catch (err) { console.error("Failed to import workspace:", err); - window.alert("Import failed: invalid JSON"); + window.alert("Import failed: invalid file"); } }; const exportWorkspace = () => { try { const text = JSON.stringify(workspace, null, 2); - navigator.clipboard.writeText(text); - window.alert("Workspace JSON copied to clipboard"); + 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 copy workspace JSON"); + window.alert("Failed to download workspace"); } }; - const renameWorkspace = () => { - const newName = window.prompt("New workspace name", workspace.name); - if (!newName || newName === workspace.name) return; + const renameWorkspace = (name: string) => { + if (!name || name === workspace.name) return; const updated: Workspace = { ...workspace, - name: newName, + name, updatedAt: Date.now(), }; saveWorkspaceToLocalStorage(updated); @@ -142,7 +162,7 @@ export default function AppSidebar({ Import Workspace - + setIsRenameOpen(true)}> Rename Workspace @@ -172,6 +192,36 @@ export default function AppSidebar({
    {children}
    + + {/* rename dialog */} + + + + Rename workspace + + Provide a new name for the workspace. + + + setProposedName(e.target.value)} + className="w-full" + /> + + + + + +
    ); } From b543988f02ed4a99dae3e7f7a101b81763cff1fe Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 02:41:17 +0100 Subject: [PATCH 25/29] Sidebar elements editor --- src/components/sidebar/AppSidebar.tsx | 48 ++++++ src/components/sidebar/Entity.tsx | 233 ++++++++++++++++++++++---- src/hooks/workspace.ts | 118 ++++++++++++- src/lib/workspace.ts | 142 +++++++++++++++- 4 files changed, 501 insertions(+), 40 deletions(-) diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index f262885..c22bb41 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -36,6 +36,7 @@ import { ChevronDown } from "lucide-react"; import { Route, SetState, Workspace } from "@/lib/types"; import { listWorkspaces, saveWorkspaceToLocalStorage } from "@/lib/workspace"; import { openFileDialog } from "@/lib/import"; +import { useWorkspace } from "@/hooks/workspace"; // Simple ID generator const id = (prefix = "t") => @@ -55,6 +56,8 @@ export default function AppSidebar({ setRoute, setWorkspace, }: AppSidebarProps) { + const { addEntity, addGroup, renameEntity } = useWorkspace(); + const [availableWorkspaces, setAvailableWorkspaces] = React.useState< Workspace[] >([]); @@ -158,6 +161,51 @@ export default function AppSidebar({ ))} + { + const name = window.prompt( + "Name for new list", + "New list", + ); + const id = addEntity("todo", null); + if (id && name) { + // rename newly created entity + // obtain hook function + // import renameEntity above + renameEntity(id, name); + } + if (id) setRoute({ workspace: workspace.id, entity: id }); + }} + > + New list (root) + + { + const name = window.prompt( + "Name for new document", + "New doc", + ); + const id = addEntity("doc", null); + if (id && name) { + renameEntity(id, name); + } + if (id) setRoute({ workspace: workspace.id, entity: id }); + }} + > + New doc (root) + + { + const name = window.prompt( + "Name for new group", + "New group", + ); + addGroup(name || "New group", null); + }} + > + New group (root) + + Import Workspace diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index 13305da..2ea73e6 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -10,8 +10,27 @@ import { SidebarMenuItem, } from "../ui/sidebar"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, MoreHorizontal } from "lucide-react"; import { Route, Workspace, LayoutNode } from "@/lib/types"; +import { useWorkspace } from "@/hooks/workspace"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useState } from "react"; type Props = { node: LayoutNode; @@ -20,36 +39,151 @@ type Props = { }; export default function EntitySwitch({ node, workspace, setRoute }: Props) { + const { + renameEntity, + deleteEntity, + addEntity, + addGroup, + removeNode, + renameGroup, + } = useWorkspace(); + + const [menuOpen, setMenuOpen] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogValue, setDialogValue] = useState(""); + const [dialogMode, setDialogMode] = useState< + "rename-group" | "rename-item" | "add-item" | "add-group" + >("rename-item"); + + const openDialog = (mode: typeof dialogMode, initial = "") => { + setDialogMode(mode); + // supply default for add-item + if (mode === "add-item") { + setDialogValue("New list"); + } else { + setDialogValue(initial); + } + setDialogOpen(true); + setMenuOpen(false); + }; + + const handleDialogSubmit = () => { + if (dialogMode === "rename-group" && node.type === "group") { + renameGroup(node.id, dialogValue); + } else if (dialogMode === "rename-item" && node.type === "item") { + renameEntity(node.entityId, dialogValue); + } else if (dialogMode === "add-item") { + const parentId = node.type === "group" ? node.id : null; + const id = addEntity("todo", parentId); + // optionally could rename newly created entity name + if (id && dialogValue) { + renameEntity(id, dialogValue); + } + } else if (dialogMode === "add-group") { + const parentId = node.type === "group" ? node.id : null; + addGroup(dialogValue || "New group", parentId); + } + setDialogOpen(false); + }; + // ---- GROUP ---- if (node.type === "group") { return ( - - - - - - {node.name} - - - - + <> + + + +
    + + + {node.name} + + + + + + + + + openDialog("add-item")}> + Add list + + openDialog("add-group")}> + Add group + + + openDialog("rename-group", node.name)} + > + Rename + + { + if ( + window.confirm("Delete this group and its children?") + ) { + removeNode(node.id); + } + }} + > + Delete + + + +
    +
    - - - - {node.children.map((child) => ( - - ))} - - - -
    -
    + + + + {node.children.map((child) => ( + + ))} + + + +
    +
    + + {/* dialog for add/rename */} + + + + + {dialogMode.startsWith("rename") + ? "Rename" + : dialogMode === "add-item" + ? "Add new list" + : "Add new group"} + + + {dialogMode === "add-item" && + "A default todo list will be created."} + {dialogMode === "add-group" && + "Enter the name for the new group."} + + + setDialogValue(e.target.value)} + className="w-full" + /> + + + + + + + ); } @@ -62,16 +196,41 @@ export default function EntitySwitch({ node, workspace, setRoute }: Props) { return ( - - setRoute({ - workspace: workspace.id, - entity: node.entityId, - }) - } - > - {workspaceEntity.name} - +
    + + setRoute({ + workspace: workspace.id, + entity: node.entityId, + }) + } + > + {workspaceEntity.name} + + + + + + + openDialog("rename-item", workspaceEntity.name)} + > + Rename + + { + if (window.confirm("Delete this list?")) { + deleteEntity(node.entityId); + } + }} + > + Delete + + + +
    ); } diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index c90f08c..72a182f 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -1,7 +1,11 @@ -import { Entity, EntityId, Workspace } from "@/lib/types"; +import { Entity, EntityId, LayoutNode, Workspace } from "@/lib/types"; import { loadWorkspaceFromLocalStorage, saveWorkspaceToLocalStorage, + addLayoutItem, + addLayoutGroup, + removeLayoutNode, + renameLayoutGroup, } from "@/lib/workspace"; import { useEffect, useState } from "react"; @@ -23,6 +27,105 @@ export function useWorkspace() { ); }; + const renameEntity = (entityId: EntityId, newName: string) => { + updateEntity(entityId, (e) => ({ ...e, name: newName })); + }; + + const deleteEntity = (entityId: EntityId) => { + setWorkspace((prev) => { + if (!prev) return prev; + const { [entityId]: _, ...rest } = prev.entities; + // also remove from layout + const { layout: newLayout } = removeLayoutNode(prev.layout, entityId); + return { + ...prev, + entities: rest, + layout: newLayout, + updatedAt: Date.now(), + }; + }); + }; + + const addEntity = (type: "todo" | "doc", parentGroupId: string | null) => { + if (!workspace) return; + const now = Date.now(); + const id = crypto.randomUUID(); + let entity: Entity; + if (type === "todo") { + entity = { + id, + type: "todo", + name: "New list", + config: { statuses: [] }, + tags: {}, + tasks: {}, + createdAt: now, + updatedAt: now, + } as any; + } else { + entity = { + id, + type: "doc", + name: "New doc", + content: { version: "2.28.0", time: now, blocks: [] }, + createdAt: now, + updatedAt: now, + } as any; + } + setWorkspace((prev) => { + if (!prev) return prev; + const newEntities = { ...prev.entities, [id]: entity }; + const newLayout = addLayoutItem(prev.layout, parentGroupId, { + type: "item", + entityId: id, + }); + return { + ...prev, + entities: newEntities, + layout: newLayout, + updatedAt: Date.now(), + }; + }); + return id; + }; + + const addGroup = (name = "New group", parentGroupId: string | null) => { + const id = crypto.randomUUID(); + const group: LayoutNode = { type: "group", id, name, children: [] }; + setWorkspace((prev) => { + if (!prev) return prev; + const newLayout = addLayoutGroup(prev.layout, parentGroupId, group); + return { ...prev, layout: newLayout, updatedAt: Date.now() }; + }); + return id; + }; + + const removeNode = (id: string) => { + setWorkspace((prev) => { + if (!prev) return prev; + const { layout: newLayout, removedEntityIds } = removeLayoutNode( + prev.layout, + id, + ); + const newEntities = { ...prev.entities } as Record; + removedEntityIds.forEach((eid) => delete newEntities[eid]); + return { + ...prev, + entities: newEntities, + layout: newLayout, + updatedAt: Date.now(), + }; + }); + }; + + const renameGroup = (id: string, newName: string) => { + setWorkspace((prev) => { + if (!prev) return prev; + const newLayout = renameLayoutGroup(prev.layout, id, newName); + return { ...prev, layout: newLayout, updatedAt: Date.now() }; + }); + }; + const loadWorkspace = (workspaceId: string | undefined) => { if (!workspaceId) { return setWorkspace(undefined); @@ -37,5 +140,16 @@ export function useWorkspace() { if (workspace) saveWorkspaceToLocalStorage(workspace); }, [workspace]); - return { workspace, loadWorkspace, updateEntity, setWorkspace }; + return { + workspace, + loadWorkspace, + updateEntity, + renameEntity, + deleteEntity, + addEntity, + addGroup, + removeNode, + renameGroup, + setWorkspace, + }; } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index eee2c04..6206186 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -1,4 +1,4 @@ -import { Workspace, TodoEntity, DocEntity, Status } from "./types"; +import { Workspace, TodoEntity, DocEntity, Status, LayoutNode } from "./types"; export function createDefaultWorkspace(): Workspace { const now = Date.now(); @@ -129,3 +129,143 @@ export function listWorkspaces(): Workspace[] { } return workspaces; } + +// layout helpers ------------------------------------------------------------------- + +type RemoveResult = { layout: LayoutNode[]; removedEntityIds: string[] }; + +function collectEntityIds(node: LayoutNode): string[] { + if (node.type === "item") return [node.entityId]; + const ids: string[] = []; + for (const child of node.children) { + ids.push(...collectEntityIds(child)); + } + return ids; +} + +export function removeLayoutNode( + layout: LayoutNode[], + id: string, +): RemoveResult { + let removed: string[] = []; + + function recurse(nodes: LayoutNode[]): LayoutNode[] { + return nodes + .map((node) => { + if (node.type === "group") { + if (node.id === id) { + removed.push(...collectEntityIds(node)); + return null; + } + const children = recurse(node.children); + if (children.length !== node.children.length) { + // some children removed + } + return { ...node, children } as LayoutNode; + } else { + if (node.entityId === id) { + removed.push(node.entityId); + return null; + } + return node; + } + }) + .filter(Boolean) as LayoutNode[]; + } + + const newLayout = recurse(layout); + return { layout: newLayout, removedEntityIds: removed }; +} + +export function renameLayoutGroup( + layout: LayoutNode[], + id: string, + newName: string, +): LayoutNode[] { + return layout.map((node) => { + if (node.type === "group") { + if (node.id === id) { + return { ...node, name: newName }; + } + return { + ...node, + children: renameLayoutGroup(node.children, id, newName), + }; + } + return node; + }); +} + +export function addLayoutGroup( + layout: LayoutNode[], + parentId: string | null, + group: LayoutNode, +): LayoutNode[] { + if (!parentId) { + return [...layout, group]; + } + return layout.map((node) => { + if (node.type === "group") { + if (node.id === parentId) { + return { ...node, children: [...node.children, group] }; + } + return { + ...node, + children: addLayoutGroup(node.children, parentId, group), + }; + } + return node; + }); +} + +export function addLayoutItem( + layout: LayoutNode[], + parentId: string | null, + item: LayoutNode, +): LayoutNode[] { + if (!parentId) { + return [...layout, item]; + } + return layout.map((node) => { + if (node.type === "group") { + if (node.id === parentId) { + return { ...node, children: [...node.children, item] }; + } + return { + ...node, + children: addLayoutItem(node.children, parentId, item), + }; + } + return node; + }); +} + +// entity creation helpers ---------------------------------------------------------- + +export function createTodoEntity(name = "New list"): TodoEntity { + const id = crypto.randomUUID(); + const now = Date.now(); + return { + id, + type: "todo", + name, + config: { statuses: [] }, + tags: {}, + tasks: {}, + createdAt: now, + updatedAt: now, + }; +} + +export function createDocEntity(name = "New doc"): DocEntity { + const id = crypto.randomUUID(); + const now = Date.now(); + return { + id, + type: "doc", + name, + content: { version: "2.28.0", time: now, blocks: [] }, + createdAt: now, + updatedAt: now, + }; +} From 78daab4cf42702565b2412ca66662c0c25305deb Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 13:41:51 +0100 Subject: [PATCH 26/29] Working layout editor --- src/app/workspace/page.tsx | 20 ++++- src/components/sidebar/AppSidebar.tsx | 116 +++++++++++++++++++------- src/components/sidebar/Entity.tsx | 44 ++++++---- src/hooks/workspace.ts | 36 ++++---- 4 files changed, 153 insertions(+), 63 deletions(-) diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index d9a4302..7615d84 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -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({}); @@ -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} >
    diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index c22bb41..aecb03a 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -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; /** optional setter for the current workspace, used for rename */ - setWorkspace?: React.Dispatch>; + setWorkspace: React.Dispatch>; + 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({ { - 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) { - 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) { - 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} /> ))} @@ -270,6 +279,55 @@ export default function AppSidebar({ + {/* create dialog */} + + + + + {createMode === "group" + ? "Create group" + : createMode === "doc" + ? "Create document" + : "Create list"} + + + {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."} + + + setCreateValue(e.target.value)} + className="w-full" + /> + + + + + + ); } diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index 2ea73e6..cfc1993 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -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} /> ))} diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index 72a182f..2551d45 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -13,18 +13,19 @@ export function useWorkspace() { const [workspace, setWorkspace] = useState(); 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, From 1931f9208b16c221b0ff5abf1500c01b244df445 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 13:55:30 +0100 Subject: [PATCH 27/29] Workspace editing --- src/components/sidebar/AppSidebar.tsx | 81 +++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index aecb03a..2caa1b1 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -34,7 +34,11 @@ import { import { ChevronDown } from "lucide-react"; import { Route, SetState, Workspace } from "@/lib/types"; -import { listWorkspaces, saveWorkspaceToLocalStorage } from "@/lib/workspace"; +import { + listWorkspaces, + saveWorkspaceToLocalStorage, + createDefaultWorkspace, +} from "@/lib/workspace"; import { openFileDialog } from "@/lib/import"; // Simple ID generator @@ -79,6 +83,9 @@ export default function AppSidebar({ 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", @@ -168,16 +175,19 @@ export default function AppSidebar({ className="w-[--radix-popper-anchor-width]" > {/* list all known workspaces */} - {availableWorkspaces.map((w) => ( + {[ + availableWorkspaces.find((w) => w.id === workspace.id), + ...availableWorkspaces.filter((w) => w.id !== workspace.id), + ].map((w) => ( switchWorkspace(w.id)} + onSelect={() => w && switchWorkspace(w?.id)} > - {w.name} - {w.id === workspace.id ? " (current)" : ""} + {w?.name} + {w?.id === workspace.id ? " (current)" : ""} ))} @@ -209,16 +219,23 @@ export default function AppSidebar({ New group (root) - - Import Workspace - - setIsRenameOpen(true)}> Rename Workspace + + Import Workspace + Export Workspace + { + setNewWorkspaceName("New workspace"); + setIsWorkspaceCreateOpen(true); + }} + > + Create workspace + @@ -279,6 +296,48 @@ export default function AppSidebar({ + {/* create workspace dialog */} + + + + Create workspace + + Provide a name for the workspace. + + + setNewWorkspaceName(e.target.value)} + className="w-full" + /> + + + + + + {/* create dialog */} From e71b481dbb7b0a1fd79fa294a5ac73449bb1966d Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 14:06:00 +0100 Subject: [PATCH 28/29] Context menu sidebar --- src/components/sidebar/AppSidebar.tsx | 12 +++- src/components/sidebar/Entity.tsx | 99 ++++++++++++++------------- 2 files changed, 60 insertions(+), 51 deletions(-) diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 2caa1b1..0b77f91 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -164,10 +164,10 @@ export default function AppSidebar({ - + Create workspace + { + localStorage.removeItem(workspace.id); + }} + variant="destructive" + > + Delete workspace + diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index cfc1993..0cc5ba5 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -30,6 +30,13 @@ import { import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { useState } from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; type Props = { node: LayoutNode; @@ -58,7 +65,6 @@ export default function EntitySwitch({ removeNode, renameGroup, }: Props) { - const [menuOpen, setMenuOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [dialogValue, setDialogValue] = useState(""); const [dialogMode, setDialogMode] = useState< @@ -74,7 +80,6 @@ export default function EntitySwitch({ setDialogValue(initial); } setDialogOpen(true); - setMenuOpen(false); }; const handleDialogSubmit = () => { @@ -100,32 +105,30 @@ export default function EntitySwitch({
    - - - {node.name} - - - - - - - - - openDialog("add-item")}> + + + + + {node.name} + + + + + + openDialog("add-item")}> Add list - - openDialog("add-group")}> + + openDialog("add-group")}> Add group - - - + + openDialog("rename-group", node.name)} > Rename - - + { if ( window.confirm("Delete this group and its children?") @@ -135,9 +138,9 @@ export default function EntitySwitch({ }} > Delete - - - + + +
    @@ -209,29 +212,27 @@ export default function EntitySwitch({ return (
    - - setRoute({ - workspace: workspace.id, - entity: node.entityId, - }) - } - > - {workspaceEntity.name} - - - - - - - + + + setRoute({ + workspace: workspace.id, + entity: node.entityId, + }) + } + > + {workspaceEntity.name} + + + + openDialog("rename-item", workspaceEntity.name)} > Rename - - + { if (window.confirm("Delete this list?")) { deleteEntity(node.entityId); @@ -239,9 +240,9 @@ export default function EntitySwitch({ }} > Delete - - - + + +
    ); From 3af4921fd29c6a542cd87cea73631fe9a93130db Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sat, 28 Feb 2026 14:24:18 +0100 Subject: [PATCH 29/29] Working rename --- src/components/sidebar/Entity.tsx | 321 ++++++++++++++++-------------- 1 file changed, 168 insertions(+), 153 deletions(-) diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index 0cc5ba5..bf9310a 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -1,6 +1,8 @@ "use client"; -import { CollapsibleTrigger } from "@radix-ui/react-collapsible"; +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; + import { SidebarGroup, SidebarGroupContent, @@ -9,27 +11,13 @@ import { SidebarMenuButton, SidebarMenuItem, } from "../ui/sidebar"; -import { Collapsible, CollapsibleContent } from "../ui/collapsible"; -import { ChevronDown, MoreHorizontal } from "lucide-react"; -import { Route, Workspace, LayoutNode } from "@/lib/types"; + import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { useState } from "react"; + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; + import { ContextMenu, ContextMenuContent, @@ -38,6 +26,22 @@ import { 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"; + +type DialogMode = "rename" | "add-item" | "add-group"; + type Props = { node: LayoutNode; workspace: Workspace; @@ -47,101 +51,151 @@ type Props = { parentGroupId: string | null, name?: string, ) => string | undefined; - addGroup: (name: string | undefined, parentGroupId: string | null) => string; + 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, - addEntity, - addGroup, - renameEntity, - deleteEntity, - removeNode, - renameGroup, -}: Props) { - const [dialogOpen, setDialogOpen] = useState(false); +export default function EntitySwitch(props: Props) { + const { + node, + workspace, + setRoute, + addEntity, + addGroup, + renameEntity, + deleteEntity, + removeNode, + renameGroup, + } = props; + const [dialogValue, setDialogValue] = useState(""); - const [dialogMode, setDialogMode] = useState< - "rename-group" | "rename-item" | "add-item" | "add-group" - >("rename-item"); + const [dialogMode, setDialogMode] = useState(); - const openDialog = (mode: typeof dialogMode, initial = "") => { + const parentId = node.type === "group" ? node.id : null; + + const openDialog = (mode: DialogMode, initial = "") => { setDialogMode(mode); - // supply default for add-item - if (mode === "add-item") { - setDialogValue("New list"); - } else { - setDialogValue(initial); - } - setDialogOpen(true); + setDialogValue( + initial || + (mode === "add-item" + ? "New list" + : mode === "add-group" + ? "New group" + : ""), + ); }; - const handleDialogSubmit = () => { - if (dialogMode === "rename-group" && node.type === "group") { - renameGroup(node.id, dialogValue); - } else if (dialogMode === "rename-item" && node.type === "item") { - renameEntity(node.entityId, dialogValue); - } else if (dialogMode === "add-item") { - const parentId = node.type === "group" ? node.id : null; - addEntity("todo", parentId, dialogValue || "New list"); - } else if (dialogMode === "add-group") { - const parentId = node.type === "group" ? node.id : null; - addGroup(dialogValue || "New group", parentId); + 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; } - setDialogOpen(false); + + closeDialog(); }; - // ---- GROUP ---- + 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 = ( + !o && closeDialog()}> + + + {dialogTitle} + + {(dialogMode === "add-item" || dialogMode === "add-group") && ( + Enter a name below. + )} + + + setDialogValue(e.target.value)} + /> + + + + + + + + ); + + /* ================= GROUP ================= */ + if (node.type === "group") { return ( <> -
    - - - - - {node.name} - - + + + + + {node.name} + - - - openDialog("add-item")}> - Add list - - openDialog("add-group")}> - Add group - - - openDialog("rename-group", node.name)} - > - Rename - - { - if ( - window.confirm("Delete this group and its children?") - ) { - removeNode(node.id); - } - }} - > - Delete - - - -
    + + + + + openDialog("add-item")}> + Add list + + + openDialog("add-group")}> + Add group + + + + + openDialog("rename", node.name)} + > + Rename + + + + confirmDelete("Delete this group and its children?", () => + removeNode(node.id), + ) + } + > + Delete + + +
    @@ -150,15 +204,8 @@ export default function EntitySwitch({ {node.children.map((child) => ( ))} @@ -166,52 +213,19 @@ export default function EntitySwitch({
    - - {/* dialog for add/rename */} - - - - - {dialogMode.startsWith("rename") - ? "Rename" - : dialogMode === "add-item" - ? "Add new list" - : "Add new group"} - - - {dialogMode === "add-item" && - "A default todo list will be created."} - {dialogMode === "add-group" && - "Enter the name for the new group."} - - - setDialogValue(e.target.value)} - className="w-full" - /> - - - - - - + {dialog} ); } - // ---- ITEM ---- - const workspaceEntity = workspace.entities[node.entityId]; + /* ================= ITEM ================= */ - if (!workspaceEntity) { - return null; - } + const entity = workspace.entities[node.entityId]; + if (!entity) return null; return ( - -
    + <> + - {workspaceEntity.name} + {entity.name} + - openDialog("rename-item", workspaceEntity.name)} - > + openDialog("rename", entity.name)}> Rename + { - if (window.confirm("Delete this list?")) { - deleteEntity(node.entityId); - } - }} + onSelect={() => + confirmDelete("Delete this list?", () => + deleteEntity(node.entityId), + ) + } > Delete -
    -
    + + {dialog} + ); }