Generated
+947
File diff suppressed because it is too large
Load Diff
@@ -32,11 +32,14 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "16.0.1",
|
"next": "16.0.1",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
"@types/node": "^20.19.25",
|
"@types/node": "^20.19.25",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"baseline-browser-mapping": "^2.10.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
+94
-62
@@ -46,72 +46,99 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.75rem;
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
--background: oklch(0.97 0.01 240);
|
||||||
--card: oklch(1 0 0);
|
--foreground: oklch(0.22 0.02 240);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
|
--card: oklch(0.99 0.005 240);
|
||||||
|
--card-foreground: oklch(0.22 0.02 240);
|
||||||
|
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.22 0.02 240);
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
/* Primary = architectural blue */
|
||||||
--secondary: oklch(0.97 0 0);
|
--primary: oklch(0.55 0.16 250);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.98 0.01 250);
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--secondary: oklch(0.93 0.02 240);
|
||||||
--accent: oklch(0.97 0 0);
|
--secondary-foreground: oklch(0.28 0.02 240);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--muted: oklch(0.94 0.015 240);
|
||||||
--border: oklch(0.922 0 0);
|
--muted-foreground: oklch(0.55 0.02 240);
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
/* Accent = subtle violet for graph highlights */
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--accent: oklch(0.92 0.03 270);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--accent-foreground: oklch(0.30 0.04 270);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--destructive: oklch(0.62 0.22 25);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
--border: oklch(0.90 0.015 240);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--input: oklch(0.90 0.015 240);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--ring: oklch(0.55 0.16 250);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
/* Charts (clean + modern) */
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--chart-1: oklch(0.55 0.16 250);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--chart-2: oklch(0.60 0.14 200);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--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 {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.17 0.02 250);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.94 0.01 240);
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card: oklch(0.21 0.02 250);
|
||||||
--popover: oklch(0.205 0 0);
|
--card-foreground: oklch(0.94 0.01 240);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
--popover: oklch(0.23 0.02 250);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--popover-foreground: oklch(0.94 0.01 240);
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
/* Strong but not neon */
|
||||||
--muted: oklch(0.269 0 0);
|
--primary: oklch(0.65 0.17 250);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--primary-foreground: oklch(0.12 0.02 250);
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--secondary: oklch(0.27 0.02 250);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--secondary-foreground: oklch(0.94 0.01 240);
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
--muted: oklch(0.25 0.02 250);
|
||||||
--ring: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.70 0.02 240);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
/* Accent for graph edges / highlights */
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--accent: oklch(0.60 0.18 280);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--accent-foreground: oklch(0.96 0.01 240);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
--destructive: oklch(0.68 0.20 25);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--border: oklch(0.30 0.02 250);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--input: oklch(0.30 0.02 250);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--ring: oklch(0.65 0.17 250);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
/* Chart colors (balanced + readable on dark canvas) */
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--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 {
|
@layer base {
|
||||||
@@ -205,3 +232,8 @@ h3.ce-header {
|
|||||||
color: var(--popover-foreground) !important;
|
color: var(--popover-foreground) !important;
|
||||||
background-color: var(--accent) !important;
|
background-color: var(--accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-block, .ce-block__content {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|||||||
+4
-4
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Drift - Productivity App",
|
||||||
description: "Generated by create next app",
|
description: "A powerful client-side productivity application",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,9 +23,9 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="h-full">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased dark`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased dark h-screen flex flex-col`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+2
-62
@@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
redirect("/workspace");
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/8 px-5 transition-colors hover:border-transparent hover:bg-black/4 dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-8
@@ -2,25 +2,154 @@
|
|||||||
|
|
||||||
import Router from "@/components/Router";
|
import Router from "@/components/Router";
|
||||||
import AppSidebar, { SidebarTrigger } from "@/components/sidebar/AppSidebar";
|
import AppSidebar, { SidebarTrigger } from "@/components/sidebar/AppSidebar";
|
||||||
import useWorkspace from "@/hooks/workspace";
|
import { useWorkspace } from "@/hooks/workspace";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Route } from "@/lib/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
createDefaultWorkspace,
|
||||||
|
saveWorkspaceToLocalStorage,
|
||||||
|
} from "@/lib/workspace";
|
||||||
|
|
||||||
export default function Workspace() {
|
export default function Workspace() {
|
||||||
const workspace = useWorkspace();
|
const {
|
||||||
|
workspace,
|
||||||
|
loadWorkspace,
|
||||||
|
updateEntity,
|
||||||
|
setWorkspace,
|
||||||
|
addEntity,
|
||||||
|
addGroup,
|
||||||
|
renameEntity,
|
||||||
|
deleteEntity,
|
||||||
|
removeNode,
|
||||||
|
renameGroup,
|
||||||
|
} = useWorkspace();
|
||||||
|
|
||||||
|
const [route, setRoute] = useState<Route>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize workspace from cookie on mount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const routeString = Cookies.get("route");
|
||||||
|
if (!routeString) return;
|
||||||
|
const route = JSON.parse(routeString) as Route;
|
||||||
|
setRoute(route);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(() => {
|
||||||
|
loadWorkspace(route.workspace);
|
||||||
|
if (route.workspace) {
|
||||||
|
Cookies.set("route", JSON.stringify(route));
|
||||||
|
} else {
|
||||||
|
Cookies.remove("route");
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [route]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<main
|
||||||
|
className="flex h-svh items-center justify-center bg-linear-to-br from-blue-50 to-indigo-100 dark:from-slate-900 dark:to-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
const w = createDefaultWorkspace();
|
||||||
|
saveWorkspaceToLocalStorage(w);
|
||||||
|
setRoute({ workspace: w.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<h1 className="text-4xl font-bold">Welcome to Drift</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
Your personal productivity workspace
|
||||||
|
</p>
|
||||||
|
<Button size="lg" className="text-base">
|
||||||
|
Create a new workspace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppSidebar workspace={workspace}>
|
<AppSidebar
|
||||||
|
workspace={workspace}
|
||||||
|
setRoute={setRoute}
|
||||||
|
setWorkspace={setWorkspace}
|
||||||
|
addEntity={addEntity}
|
||||||
|
addGroup={addGroup}
|
||||||
|
renameEntity={renameEntity}
|
||||||
|
deleteEntity={deleteEntity}
|
||||||
|
removeNode={removeNode}
|
||||||
|
renameGroup={renameGroup}
|
||||||
|
>
|
||||||
<SidebarTrigger className="w-full px-4">
|
<SidebarTrigger className="w-full px-4">
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<header className="w-full border-b-2 pb-3 pt-2 hover:bg-accent/20 flex items-center text-foreground/90">
|
<header className="w-full border-b pb-3 pt-2 hover:bg-accent/20 flex items-center text-foreground/90 gap-2">
|
||||||
{workspace.name}
|
<span className="font-semibold">{workspace.name}</span>
|
||||||
<ChevronRight className="h-5" />
|
<ChevronRight className="h-5 text-muted-foreground" />
|
||||||
{workspace.entities[workspace.route].name}
|
<span className="text-muted-foreground">
|
||||||
|
{workspace.entities[route.entity || ""]?.name || "Select an item"}
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
</SidebarTrigger>
|
</SidebarTrigger>
|
||||||
|
|
||||||
<Router workspace={workspace} />
|
<Router workspace={workspace} route={route} updateEntity={updateEntity} />
|
||||||
</AppSidebar>
|
</AppSidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-20
@@ -1,44 +1,122 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Workspace } from "@/hooks/workspace";
|
import {
|
||||||
import { Task } from "@/lib/types";
|
Entity,
|
||||||
|
EntityId,
|
||||||
|
Route,
|
||||||
|
Task,
|
||||||
|
TaskId,
|
||||||
|
TodoEntity,
|
||||||
|
Workspace,
|
||||||
|
} from "@/lib/types";
|
||||||
import TaskView from "./view/TaskView";
|
import TaskView from "./view/TaskView";
|
||||||
import DocView from "./view/DocView";
|
import DocView from "./view/DocView";
|
||||||
|
import { OutputData } from "@editorjs/editorjs";
|
||||||
|
|
||||||
const id = (prefix = "t") =>
|
const id = (prefix = "t") =>
|
||||||
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
export default function Router({ workspace }: { workspace: Workspace }) {
|
export default function Router({
|
||||||
const entity = workspace.entities[workspace.route];
|
route,
|
||||||
switch (entity.kind) {
|
workspace,
|
||||||
case "todo":
|
updateEntity,
|
||||||
const createTask = (status: string) => {
|
}: {
|
||||||
|
route: Route;
|
||||||
|
workspace: Workspace;
|
||||||
|
updateEntity: (entityId: EntityId, updater: (e: Entity) => Entity) => void;
|
||||||
|
}) {
|
||||||
|
const entityId = route.entity;
|
||||||
|
const entity = entityId ? workspace.entities[entityId] : null;
|
||||||
|
|
||||||
|
if (!entity || !entityId) return null;
|
||||||
|
|
||||||
|
switch (entity.type) {
|
||||||
|
case "todo": {
|
||||||
|
const createTask = (statusId: string) => {
|
||||||
|
const now = new Date().getTime();
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id: id(),
|
id: id("task"),
|
||||||
title: "New task",
|
title: "New task",
|
||||||
status: status,
|
statusId,
|
||||||
|
tagIds: [],
|
||||||
|
// initialize empty rich document notes
|
||||||
|
doc: { version: "2.28.0", time: now, blocks: [] },
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
entity.setTasks((c: Task[]) => [...c, task]);
|
|
||||||
|
updateEntity(entityId, (e) => ({
|
||||||
|
...e,
|
||||||
|
tasks: e.type === "todo" ? { ...e.tasks, [task.id]: task } : {},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTasks = (tasks: Record<TaskId, Task>) => {
|
||||||
|
updateEntity(entityId, (e) => ({
|
||||||
|
...e,
|
||||||
|
tasks: e.type === "todo" ? tasks : {},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTags = (tags: Record<string, any>) => {
|
||||||
|
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 (
|
return (
|
||||||
<TaskView
|
<TaskView
|
||||||
key={entity.name}
|
key={entity.id}
|
||||||
tasks={entity.tasks}
|
tasks={entity.tasks}
|
||||||
setTasks={entity.setTasks}
|
setTasks={setTasks}
|
||||||
createTask={createTask}
|
createTask={createTask}
|
||||||
tags={{
|
tags={entity.tags}
|
||||||
website: { color: "yellow", name: "Website" },
|
statuses={entity.config.statuses}
|
||||||
api: { color: "purple", name: "API" },
|
setTags={setTags}
|
||||||
app: { color: "blue", name: "App" },
|
setStatuses={setStatuses}
|
||||||
}}
|
entityName={entity.name}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "doc":
|
}
|
||||||
|
|
||||||
|
case "doc": {
|
||||||
|
const setDoc = (doc: OutputData) => {
|
||||||
|
updateEntity(entityId, (e) => ({
|
||||||
|
...e,
|
||||||
|
content: doc,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-3xl mx-auto bg-accent/20 rounded-2xl p-4">
|
<div className="h-full w-full md:w-3xl xl:w-4xl 2xl:w-5xl mx-auto py-8 px-10 md:border-x">
|
||||||
<DocView key={entity.name} doc={entity.doc} setDoc={entity.setDoc} />
|
<h1 className="text-5xl font-bold mb-7">{entity.name}</h1>
|
||||||
|
<DocView
|
||||||
|
key={entity.id}
|
||||||
|
doc={entity.content}
|
||||||
|
setDoc={setDoc}
|
||||||
|
docName={entity.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -8,74 +12,401 @@ import {
|
|||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import EntitySwitch from "./Entity";
|
import EntitySwitch from "./Entity";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { Workspace } from "@/hooks/workspace";
|
import { Route, SetState, Workspace } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
listWorkspaces,
|
||||||
|
saveWorkspaceToLocalStorage,
|
||||||
|
createDefaultWorkspace,
|
||||||
|
} from "@/lib/workspace";
|
||||||
|
import { openFileDialog } from "@/lib/import";
|
||||||
|
|
||||||
|
// Simple ID generator
|
||||||
|
const id = (prefix = "t") =>
|
||||||
|
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
type AppSidebarProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
workspace: Workspace;
|
||||||
|
setRoute: SetState<Route>;
|
||||||
|
/** optional setter for the current workspace, used for rename */
|
||||||
|
setWorkspace: React.Dispatch<React.SetStateAction<Workspace | undefined>>;
|
||||||
|
addEntity: (
|
||||||
|
type: "todo" | "doc",
|
||||||
|
parentGroupId: string | null,
|
||||||
|
name?: string,
|
||||||
|
) => string | undefined;
|
||||||
|
addGroup: (name: string | undefined, parentGroupId: string | null) => string;
|
||||||
|
renameEntity: (entityId: string, newName: string) => void;
|
||||||
|
deleteEntity: (entityId: string) => void;
|
||||||
|
removeNode: (id: string) => void;
|
||||||
|
renameGroup: (id: string, name: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export default function AppSidebar({
|
export default function AppSidebar({
|
||||||
children,
|
children,
|
||||||
workspace,
|
workspace,
|
||||||
}: Readonly<{
|
setRoute,
|
||||||
children: React.ReactNode;
|
setWorkspace,
|
||||||
workspace: Workspace;
|
addEntity,
|
||||||
}>) {
|
addGroup,
|
||||||
|
renameEntity,
|
||||||
|
deleteEntity,
|
||||||
|
removeNode,
|
||||||
|
renameGroup,
|
||||||
|
}: AppSidebarProps) {
|
||||||
|
// actions are passed from parent `useWorkspace` instance
|
||||||
|
|
||||||
|
const [availableWorkspaces, setAvailableWorkspaces] = React.useState<
|
||||||
|
Workspace[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const [isRenameOpen, setIsRenameOpen] = React.useState(false);
|
||||||
|
const [proposedName, setProposedName] = React.useState(workspace.name);
|
||||||
|
const [isWorkspaceCreateOpen, setIsWorkspaceCreateOpen] =
|
||||||
|
React.useState(false);
|
||||||
|
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||||
|
const [createMode, setCreateMode] = React.useState<"list" | "doc" | "group">(
|
||||||
|
"list",
|
||||||
|
);
|
||||||
|
const [createValue, setCreateValue] = React.useState("");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setProposedName(workspace.name);
|
||||||
|
}, [workspace.name]);
|
||||||
|
|
||||||
|
// reload list when component mounts or whenever the current workspace changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
setAvailableWorkspaces(listWorkspaces());
|
||||||
|
}, [workspace]);
|
||||||
|
|
||||||
|
const switchWorkspace = (id: string) => {
|
||||||
|
setRoute({ workspace: id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const importWorkspace = async () => {
|
||||||
|
try {
|
||||||
|
const { content } = await openFileDialog(".json");
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
if (parsed && typeof parsed.id === "string") {
|
||||||
|
saveWorkspaceToLocalStorage(parsed);
|
||||||
|
setAvailableWorkspaces(listWorkspaces());
|
||||||
|
switchWorkspace(parsed.id);
|
||||||
|
} else {
|
||||||
|
throw new Error("invalid shape");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to import workspace:", err);
|
||||||
|
window.alert("Import failed: invalid file");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportWorkspace = () => {
|
||||||
|
try {
|
||||||
|
const text = JSON.stringify(workspace, null, 2);
|
||||||
|
const blob = new Blob([text], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `workspace-${workspace.name}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Export failed", err);
|
||||||
|
window.alert("Failed to download workspace");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameWorkspace = (name: string) => {
|
||||||
|
if (!name || name === workspace.name) return;
|
||||||
|
|
||||||
|
const updated: Workspace = {
|
||||||
|
...workspace,
|
||||||
|
name,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
saveWorkspaceToLocalStorage(updated);
|
||||||
|
setAvailableWorkspaces(listWorkspaces());
|
||||||
|
if (setWorkspace) {
|
||||||
|
setWorkspace(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
|
{/* Header */}
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className="flex w-full items-center justify-between px-2 py-1 text-sm">
|
<Button className="w-full" variant='ghost'>
|
||||||
{workspace.name}
|
{workspace.name}
|
||||||
<ChevronDown className="ml-auto" />
|
<ChevronDown className="ml-auto h-4 w-4 opacity-60" />
|
||||||
</button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className="w-(--radix-popper-anchor-width)"
|
className="w-[--radix-popper-anchor-width]"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem>Acme Inc</DropdownMenuItem>
|
{/* list all known workspaces */}
|
||||||
<DropdownMenuItem>Acme Corp.</DropdownMenuItem>
|
{[
|
||||||
|
availableWorkspaces.find((w) => w.id === workspace.id),
|
||||||
|
...availableWorkspaces.filter((w) => w.id !== workspace.id),
|
||||||
|
].map((w) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={w?.id}
|
||||||
|
className={
|
||||||
|
w?.id === workspace.id ? "font-semibold" : undefined
|
||||||
|
}
|
||||||
|
onSelect={() => w && switchWorkspace(w?.id)}
|
||||||
|
>
|
||||||
|
{w?.name}
|
||||||
|
{w?.id === workspace.id ? " (current)" : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setCreateMode("list");
|
||||||
|
setCreateValue("New list");
|
||||||
|
setIsCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New list (root)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setCreateMode("doc");
|
||||||
|
setCreateValue("New doc");
|
||||||
|
setIsCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New doc (root)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setCreateMode("group");
|
||||||
|
setCreateValue("New group");
|
||||||
|
setIsCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New group (root)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onSelect={() => setIsRenameOpen(true)}>
|
||||||
|
Rename Workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={importWorkspace}>
|
||||||
|
Import Workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={exportWorkspace}>
|
||||||
|
Export Workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setNewWorkspaceName("New workspace");
|
||||||
|
setIsWorkspaceCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
localStorage.removeItem(workspace.id);
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
|
{/* Sidebar Content */}
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{workspace.entityLayout.map((entity, i) => (
|
{workspace.layout.map((node) => (
|
||||||
<EntitySwitch entity={entity} workspace={workspace} key={i} />
|
<EntitySwitch
|
||||||
|
key={node.type === "group" ? node.id : node.entityId}
|
||||||
|
node={node}
|
||||||
|
workspace={workspace}
|
||||||
|
setRoute={setRoute}
|
||||||
|
addEntity={addEntity}
|
||||||
|
addGroup={addGroup}
|
||||||
|
renameEntity={renameEntity}
|
||||||
|
deleteEntity={deleteEntity}
|
||||||
|
removeNode={removeNode}
|
||||||
|
renameGroup={renameGroup}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter />
|
<SidebarFooter />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<main className="w-svw">{children}</main>
|
|
||||||
|
<main className="w-svw flex-1 flex flex-col min-h-0">{children}</main>
|
||||||
|
|
||||||
|
{/* rename dialog */}
|
||||||
|
<Dialog open={isRenameOpen} onOpenChange={setIsRenameOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename workspace</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Provide a new name for the workspace.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={proposedName}
|
||||||
|
onChange={(e) => setProposedName(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsRenameOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
renameWorkspace(proposedName);
|
||||||
|
setIsRenameOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{/* create workspace dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={isWorkspaceCreateOpen}
|
||||||
|
onOpenChange={setIsWorkspaceCreateOpen}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create workspace</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Provide a name for the workspace.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={newWorkspaceName}
|
||||||
|
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsWorkspaceCreateOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const ws = createDefaultWorkspace();
|
||||||
|
ws.name = newWorkspaceName || ws.name;
|
||||||
|
ws.updatedAt = Date.now();
|
||||||
|
ws.createdAt = ws.createdAt || Date.now();
|
||||||
|
saveWorkspaceToLocalStorage(ws);
|
||||||
|
setAvailableWorkspaces(listWorkspaces());
|
||||||
|
switchWorkspace(ws.id);
|
||||||
|
if (setWorkspace) setWorkspace(ws);
|
||||||
|
setIsWorkspaceCreateOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{/* create dialog */}
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{createMode === "group"
|
||||||
|
? "Create group"
|
||||||
|
: createMode === "doc"
|
||||||
|
? "Create document"
|
||||||
|
: "Create list"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{createMode === "group" && "Provide a name for the new group."}
|
||||||
|
{createMode === "doc" && "Provide a name for the new document."}
|
||||||
|
{createMode === "list" && "Provide a name for the new list."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={createValue}
|
||||||
|
onChange={(e) => setCreateValue(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (createMode === "group") {
|
||||||
|
addGroup?.(createValue || "New group", null);
|
||||||
|
} else if (createMode === "doc") {
|
||||||
|
const id = addEntity?.("doc", null, createValue || "New doc");
|
||||||
|
if (id) setRoute({ workspace: workspace.id, entity: id });
|
||||||
|
} else {
|
||||||
|
const id = addEntity?.(
|
||||||
|
"todo",
|
||||||
|
null,
|
||||||
|
createValue || "New list",
|
||||||
|
);
|
||||||
|
if (id) setRoute({ workspace: workspace.id, entity: id });
|
||||||
|
}
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarTrigger({
|
export function SidebarTrigger(props: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
const { toggleSidebar } = useSidebar();
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-sidebar="trigger"
|
data-sidebar="trigger"
|
||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
onClick={(event) => {
|
onClick={toggleSidebar}
|
||||||
toggleSidebar();
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
@@ -7,63 +11,254 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "../ui/sidebar";
|
} from "../ui/sidebar";
|
||||||
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { Workspace } from "@/hooks/workspace";
|
|
||||||
|
|
||||||
export type SidebarEntity =
|
import {
|
||||||
| {
|
Collapsible,
|
||||||
kind: "group";
|
CollapsibleContent,
|
||||||
name: string;
|
CollapsibleTrigger,
|
||||||
children: SidebarEntity[];
|
} from "../ui/collapsible";
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind?: "item";
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EntitySwitch({
|
import {
|
||||||
entity,
|
ContextMenu,
|
||||||
workspace,
|
ContextMenuContent,
|
||||||
}: {
|
ContextMenuItem,
|
||||||
entity: SidebarEntity;
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "../ui/context-menu";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
import { Route, Workspace, LayoutNode } from "@/lib/types";
|
||||||
|
|
||||||
|
type DialogMode = "rename" | "add-item" | "add-group";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
node: LayoutNode;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
}) {
|
setRoute: (route: Route) => void;
|
||||||
if (entity.kind === "group") {
|
addEntity: (
|
||||||
return (
|
type: "todo" | "doc",
|
||||||
<Collapsible defaultOpen className="group/collapsible">
|
parentGroupId: string | null,
|
||||||
<SidebarGroup>
|
name?: string,
|
||||||
<SidebarGroupLabel asChild>
|
) => string | undefined;
|
||||||
<SidebarMenuButton asChild>
|
addGroup: (name: string, parentGroupId: string | null) => string;
|
||||||
<CollapsibleTrigger>
|
renameEntity: (entityId: string, newName: string) => void;
|
||||||
{entity.name}
|
deleteEntity: (entityId: string) => void;
|
||||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
removeNode: (id: string) => void;
|
||||||
</CollapsibleTrigger>
|
renameGroup: (id: string, name: string) => void;
|
||||||
</SidebarMenuButton>
|
};
|
||||||
</SidebarGroupLabel>
|
|
||||||
<CollapsibleContent>
|
export default function EntitySwitch(props: Props) {
|
||||||
<SidebarGroupContent>
|
const {
|
||||||
<SidebarMenu>
|
node,
|
||||||
{entity.children?.map((entity, i) => (
|
workspace,
|
||||||
<EntitySwitch entity={entity} workspace={workspace} key={i} />
|
setRoute,
|
||||||
))}
|
addEntity,
|
||||||
</SidebarMenu>
|
addGroup,
|
||||||
</SidebarGroupContent>
|
renameEntity,
|
||||||
</CollapsibleContent>
|
deleteEntity,
|
||||||
</SidebarGroup>
|
removeNode,
|
||||||
</Collapsible>
|
renameGroup,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [dialogValue, setDialogValue] = useState("");
|
||||||
|
const [dialogMode, setDialogMode] = useState<DialogMode | undefined>();
|
||||||
|
|
||||||
|
const parentId = node.type === "group" ? node.id : null;
|
||||||
|
|
||||||
|
const openDialog = (mode: DialogMode, initial = "") => {
|
||||||
|
setDialogMode(mode);
|
||||||
|
setDialogValue(
|
||||||
|
initial ||
|
||||||
|
(mode === "add-item"
|
||||||
|
? "New list"
|
||||||
|
: mode === "add-group"
|
||||||
|
? "New group"
|
||||||
|
: ""),
|
||||||
);
|
);
|
||||||
} else {
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => setDialogMode(undefined);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const value = dialogValue.trim();
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
switch (dialogMode) {
|
||||||
|
case "rename":
|
||||||
|
if (node.type === "group") renameGroup(node.id, value);
|
||||||
|
else if (node.type === "item") renameEntity(node.entityId, value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "add-item":
|
||||||
|
addEntity("todo", parentId, value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "add-group":
|
||||||
|
addGroup(value, parentId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (message: string, action: () => void) => {
|
||||||
|
if (window.confirm(message)) action();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogTitle = dialogMode?.startsWith("rename")
|
||||||
|
? "Rename"
|
||||||
|
: dialogMode === "add-item"
|
||||||
|
? "Add new list"
|
||||||
|
: "Add new group";
|
||||||
|
|
||||||
|
const dialog = (
|
||||||
|
<Dialog open={!!dialogMode} onOpenChange={(o) => !o && closeDialog()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
|
|
||||||
|
{(dialogMode === "add-item" || dialogMode === "add-group") && (
|
||||||
|
<DialogDescription>Enter a name below.</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={dialogValue}
|
||||||
|
onChange={(e) => setDialogValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ================= GROUP ================= */
|
||||||
|
|
||||||
|
if (node.type === "group") {
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem>
|
<>
|
||||||
<SidebarMenuButton
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
onClick={() => {
|
<SidebarGroup>
|
||||||
workspace.setRoute(entity.id);
|
<SidebarGroupLabel asChild>
|
||||||
}}
|
<ContextMenu>
|
||||||
>
|
<ContextMenuTrigger asChild>
|
||||||
{workspace.entities[entity.id].name}
|
<CollapsibleTrigger asChild>
|
||||||
</SidebarMenuButton>
|
<SidebarMenuButton className="w-full">
|
||||||
</SidebarMenuItem>
|
{node.name}
|
||||||
|
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onSelect={() => openDialog("add-item")}>
|
||||||
|
Add list
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem onSelect={() => openDialog("add-group")}>
|
||||||
|
Add group
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={() => openDialog("rename", node.name)}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() =>
|
||||||
|
confirmDelete("Delete this group and its children?", () =>
|
||||||
|
removeNode(node.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<EntitySwitch
|
||||||
|
key={child.type === "group" ? child.id : child.entityId}
|
||||||
|
{...props}
|
||||||
|
node={child}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</Collapsible>
|
||||||
|
{dialog}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= ITEM ================= */
|
||||||
|
|
||||||
|
const entity = workspace.entities[node.entityId];
|
||||||
|
if (!entity) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
onClick={() =>
|
||||||
|
setRoute({
|
||||||
|
workspace: workspace.id,
|
||||||
|
entity: node.entityId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{entity.name}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onSelect={() => openDialog("rename", entity.name)}>
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() =>
|
||||||
|
confirmDelete("Delete this list?", () =>
|
||||||
|
deleteEntity(node.entityId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
{dialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import Header from "@editorjs/header";
|
import Header from "@editorjs/header";
|
||||||
import List from "@editorjs/list";
|
import List from "@editorjs/list";
|
||||||
@@ -11,6 +11,21 @@ import Delimiter from "@editorjs/delimiter";
|
|||||||
import Warning from "@editorjs/warning";
|
import Warning from "@editorjs/warning";
|
||||||
import InlineCode from "@editorjs/inline-code";
|
import InlineCode from "@editorjs/inline-code";
|
||||||
import { OutputData } from "@editorjs/editorjs";
|
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 {
|
function toMarkdown(data: OutputData): string {
|
||||||
return data.blocks
|
return data.blocks
|
||||||
@@ -55,11 +70,48 @@ function toMarkdown(data: OutputData): string {
|
|||||||
export default function DocView({
|
export default function DocView({
|
||||||
doc,
|
doc,
|
||||||
setDoc,
|
setDoc,
|
||||||
|
docName = "Document",
|
||||||
}: {
|
}: {
|
||||||
doc: OutputData;
|
doc: OutputData;
|
||||||
setDoc: (data: OutputData) => void;
|
setDoc: (data: OutputData) => void;
|
||||||
|
docName?: string;
|
||||||
}) {
|
}) {
|
||||||
const holderRef = useRef<HTMLDivElement | null>(null);
|
const holderRef = useRef<HTMLDivElement | null>(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(() => {
|
useEffect(() => {
|
||||||
let editor: any;
|
let editor: any;
|
||||||
@@ -118,6 +170,10 @@ export default function DocView({
|
|||||||
},
|
},
|
||||||
data: doc,
|
data: doc,
|
||||||
async onChange() {
|
async onChange() {
|
||||||
|
if (!editor || !editor.save) {
|
||||||
|
console.warn("unable to save, editor.save is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const savedData = await editor.save();
|
const savedData = await editor.save();
|
||||||
console.log("Document data:", toMarkdown(savedData));
|
console.log("Document data:", toMarkdown(savedData));
|
||||||
setDoc(savedData);
|
setDoc(savedData);
|
||||||
@@ -131,5 +187,31 @@ export default function DocView({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div ref={holderRef} className="docs-view h-max w-full" />;
|
return (
|
||||||
|
<div className="relative flex flex-col gap-4">
|
||||||
|
<div className="absolute right-0 z-50 opacity-0 hover:opacity-100 transition-opacity duration-500">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={isExporting}>
|
||||||
|
<FileDown className="h-4 w-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("markdown")}>
|
||||||
|
Export as Markdown
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("html")}>
|
||||||
|
Export as HTML
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("json")}>
|
||||||
|
Export as JSON
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div ref={holderRef} className="docs-view h-max w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,161 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SetState, Task } from "@/lib/types";
|
import { Tag, TagId, Task, TaskId, Status } from "@/lib/types";
|
||||||
import { Color } from "@/lib/ui/color";
|
import { Color } from "@/lib/ui/color";
|
||||||
import BoardView from "./board/BoardView";
|
import BoardView from "./board/BoardView";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
import { TableView } from "./table/TableView";
|
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 = {
|
export type TaskViewProps = {
|
||||||
tags: Record<string, { color: Color; name: string }>;
|
tags: Record<TagId, Tag>;
|
||||||
tasks: Task[];
|
|
||||||
setTasks: SetState<Task[]>;
|
tasks: Record<TaskId, Task>;
|
||||||
createTask: (status: string) => void;
|
|
||||||
|
statuses: Status[];
|
||||||
|
|
||||||
|
/** Update entire tasks record */
|
||||||
|
setTasks: (tasks: Record<TaskId, Task>) => void;
|
||||||
|
|
||||||
|
/** Update tags */
|
||||||
|
setTags: (tags: Record<TagId, Tag>) => void;
|
||||||
|
|
||||||
|
/** Update statuses */
|
||||||
|
setStatuses: (statuses: Status[]) => void;
|
||||||
|
|
||||||
|
/** Create a new task with a status */
|
||||||
|
createTask: (statusId: string) => void;
|
||||||
|
|
||||||
|
/** Entity name for export */
|
||||||
|
entityName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TaskView({
|
export default function TaskView({
|
||||||
tags,
|
tags,
|
||||||
tasks,
|
tasks,
|
||||||
|
statuses,
|
||||||
setTasks,
|
setTasks,
|
||||||
|
setTags,
|
||||||
|
setStatuses,
|
||||||
createTask,
|
createTask,
|
||||||
|
entityName = "Tasks",
|
||||||
}: TaskViewProps) {
|
}: 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 (
|
return (
|
||||||
<div className="px-6 pt-3">
|
<div className="px-6 pt-2 pb-6 flex flex-col gap-4 h-full flex-1 min-h-0">
|
||||||
<Tabs defaultValue="board">
|
<div className="flex items-center justify-end"></div>
|
||||||
<TabsList>
|
<Tabs
|
||||||
<TabsTrigger value="board">Board</TabsTrigger>
|
defaultValue="board"
|
||||||
<TabsTrigger value="table">Table</TabsTrigger>
|
className="w-full h-full flex flex-col flex-1 min-h-0"
|
||||||
</TabsList>
|
>
|
||||||
<TabsContent value="board">
|
<div className="flex justify-between shrink-0">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="board">Board</TabsTrigger>
|
||||||
|
<TabsTrigger value="table">Table</TabsTrigger>
|
||||||
|
<TabsTrigger value="edit">Edit</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={isExporting}>
|
||||||
|
<FileDown className="h-4 w-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
||||||
|
Export as CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("markdown")}>
|
||||||
|
Export as Markdown
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("json")}>
|
||||||
|
Export as JSON
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="board" className="mt-0 h-full flex-1 min-h-0">
|
||||||
<BoardView
|
<BoardView
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
setTasks={setTasks}
|
setTasks={setTasks}
|
||||||
createTask={createTask}
|
createTask={createTask}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
setTags={setTags}
|
||||||
|
setStatuses={setStatuses}
|
||||||
|
statuses={statuses}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="table">
|
|
||||||
|
<TabsContent value="table" className="mt-0 h-full flex-1 min-h-0">
|
||||||
<TableView
|
<TableView
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
setTasks={setTasks}
|
setTasks={setTasks}
|
||||||
createTask={createTask}
|
createTask={createTask}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
setTags={setTags}
|
||||||
|
setStatuses={setStatuses}
|
||||||
|
statuses={statuses}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="edit" className="mt-0 h-full flex-1 min-h-0">
|
||||||
|
<EditView
|
||||||
|
tags={tags}
|
||||||
|
statuses={statuses}
|
||||||
|
setTags={setTags}
|
||||||
|
setStatuses={setStatuses}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -3,101 +3,333 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon, Trash2 } from "lucide-react";
|
||||||
import BoardTask from "./Task";
|
import BoardTask from "./Task";
|
||||||
import { TaskViewProps } from "../TaskView";
|
import { TaskViewProps } from "../TaskView";
|
||||||
|
import {
|
||||||
const statusLabels: Record<string, string> = {
|
ContextMenu,
|
||||||
todo: "To do",
|
ContextMenuContent,
|
||||||
inprogress: "In progress",
|
ContextMenuItem,
|
||||||
done: "Done",
|
ContextMenuTrigger,
|
||||||
};
|
} from "@/components/ui/context-menu";
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
export default function BoardView({
|
export default function BoardView({
|
||||||
tags,
|
tags,
|
||||||
tasks,
|
tasks,
|
||||||
setTasks,
|
setTasks,
|
||||||
createTask,
|
createTask,
|
||||||
|
statuses,
|
||||||
}: TaskViewProps) {
|
}: TaskViewProps) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [isDraggingSelection, setIsDraggingSelection] = useState(false);
|
||||||
|
const [selectionBox, setSelectionBox] = useState<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
} | null>(null);
|
||||||
|
const boardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const taskCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
function onDragEnd(result: any) {
|
function onDragEnd(result: any) {
|
||||||
const { draggableId, source, destination } = result;
|
const { draggableId, source, destination } = result;
|
||||||
if (!destination) return;
|
if (!destination) return;
|
||||||
|
|
||||||
setTasks((tasks) => {
|
const task = Object.values(tasks).find((t) => t.id === draggableId);
|
||||||
const taskIndex = tasks.findIndex((task) => task.id === draggableId);
|
if (!task) return;
|
||||||
const task = tasks[taskIndex];
|
|
||||||
tasks.splice(taskIndex, 1);
|
|
||||||
|
|
||||||
const destinationGroupStartIndex = tasks.findIndex(
|
setTasks({
|
||||||
(t) => t.status === destination.droppableId
|
...tasks,
|
||||||
);
|
[task.id]: {
|
||||||
|
|
||||||
const insertIndex =
|
|
||||||
destinationGroupStartIndex === -1
|
|
||||||
? tasks.length
|
|
||||||
: destinationGroupStartIndex + destination.index;
|
|
||||||
|
|
||||||
tasks.splice(insertIndex, 0, {
|
|
||||||
...task,
|
...task,
|
||||||
status: destination.droppableId,
|
statusId: destination.droppableId,
|
||||||
});
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
return 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]);
|
||||||
|
|
||||||
|
// Ctrl+A to select all, Backspace/Delete to delete selected
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "a" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIds(new Set(Object.keys(tasks)));
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace" || e.key === "Delete") {
|
||||||
|
// Don't fire if user is typing in an input/textarea
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
deleteSelected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [tasks, deleteSelected]);
|
||||||
|
|
||||||
|
// Mouse drag selection
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
// Only start selection if clicking on the board background (not a card)
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isCard = target.closest("[data-task-card]");
|
||||||
|
if (isCard) return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
|
mouseDownPos.current = { x: e.clientX, y: e.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
if (!mouseDownPos.current) return;
|
||||||
|
const dx = Math.abs(e.clientX - mouseDownPos.current.x);
|
||||||
|
const dy = Math.abs(e.clientY - mouseDownPos.current.y);
|
||||||
|
if (dx > 4 || dy > 4) {
|
||||||
|
setIsDraggingSelection(true);
|
||||||
|
setSelectionBox({
|
||||||
|
startX: mouseDownPos.current.x,
|
||||||
|
startY: mouseDownPos.current.y,
|
||||||
|
endX: e.clientX,
|
||||||
|
endY: e.clientY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp(e: MouseEvent) {
|
||||||
|
if (isDraggingSelection && selectionBox) {
|
||||||
|
// Find all task cards that intersect with the selection box
|
||||||
|
const minX = Math.min(selectionBox.startX, selectionBox.endX);
|
||||||
|
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
|
||||||
|
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
||||||
|
const maxY = Math.max(selectionBox.startY, selectionBox.endY);
|
||||||
|
|
||||||
|
const newSelected = new Set<string>();
|
||||||
|
Object.entries(taskCardRefs.current).forEach(([id, el]) => {
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
rect.left < maxX &&
|
||||||
|
rect.right > minX &&
|
||||||
|
rect.top < maxY &&
|
||||||
|
rect.bottom > minY
|
||||||
|
) {
|
||||||
|
newSelected.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
setSelectedIds((prev) => new Set([...prev, ...newSelected]));
|
||||||
|
} else {
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
}
|
||||||
|
} else if (!isDraggingSelection) {
|
||||||
|
// Click on empty area deselects
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isCard = target.closest("[data-task-card]");
|
||||||
|
if (!isCard) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseDownPos.current = null;
|
||||||
|
setIsDraggingSelection(false);
|
||||||
|
setSelectionBox(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("mousedown", handleMouseDown);
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDraggingSelection, selectionBox]);
|
||||||
|
|
||||||
|
function handleTaskClick(e: React.MouseEvent, taskId: string) {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(taskId)) next.delete(taskId);
|
||||||
|
else next.add(taskId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else if (e.shiftKey) {
|
||||||
|
setSelectedIds((prev) => new Set([...prev, taskId]));
|
||||||
|
}
|
||||||
|
// Normal click handled by SheetTrigger (task edit) if not multi-select
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksArray = Object.values(tasks);
|
||||||
|
const tasksByStatus = statuses.map((status) => ({
|
||||||
|
status,
|
||||||
|
tasks: tasksArray
|
||||||
|
.filter((t) => t.statusId === status.id)
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selRect = selectionBox
|
||||||
|
? {
|
||||||
|
left: Math.min(selectionBox.startX, selectionBox.endX),
|
||||||
|
top: Math.min(selectionBox.startY, selectionBox.endY),
|
||||||
|
width: Math.abs(selectionBox.endX - selectionBox.startX),
|
||||||
|
height: Math.abs(selectionBox.endY - selectionBox.startY),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col gap-6">
|
<div
|
||||||
|
ref={boardRef}
|
||||||
|
className="w-full h-full flex flex-col gap-6 overflow-hidden flex-1 min-h-0 relative"
|
||||||
|
>
|
||||||
|
{/* Selection box overlay */}
|
||||||
|
{isDraggingSelection && selRect && (
|
||||||
|
<div
|
||||||
|
className="fixed pointer-events-none z-50 border border-primary bg-primary/10 rounded"
|
||||||
|
style={{
|
||||||
|
left: selRect.left,
|
||||||
|
top: selRect.top,
|
||||||
|
width: selRect.width,
|
||||||
|
height: selRect.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete bar when items selected */}
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-destructive/10 border border-destructive/30 rounded-md text-sm text-destructive">
|
||||||
|
<span className="font-medium">
|
||||||
|
{selectedIds.size} task{selectedIds.size !== 1 ? "s" : ""} selected
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
— press Backspace to delete
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="ml-auto h-7"
|
||||||
|
onClick={deleteSelected}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-muted-foreground"
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
|
<div className="flex flex-col md:flex-row w-full gap-6 items-start flex-1 h-full min-h-0">
|
||||||
{Object.keys(statusLabels).map((status) => (
|
{tasksByStatus.map(({ status, tasks: statusTasks }) => (
|
||||||
<Droppable key={status} droppableId={status}>
|
<Droppable key={status.id} droppableId={status.id}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<Card
|
<Card
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.droppableProps}
|
{...provided.droppableProps}
|
||||||
className="bg-background"
|
className="bg-background w-full h-full flex flex-col overflow-hidden min-h-0"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<span>{statusLabels[status]}</span>
|
<span>{status.name}</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => createTask(status)}
|
onClick={() => createTask(status.id)}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 overflow-y-auto min-h-0">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tasks
|
{statusTasks.map((task, index) => (
|
||||||
.filter((task) => task.status === status)
|
<Draggable
|
||||||
.map((task, index) => (
|
key={task.id}
|
||||||
<Draggable
|
draggableId={task.id}
|
||||||
key={task.id}
|
index={index}
|
||||||
draggableId={task.id}
|
>
|
||||||
index={index}
|
{(provided) => (
|
||||||
>
|
<ContextMenu>
|
||||||
{(provided) => (
|
<ContextMenuTrigger asChild>
|
||||||
<BoardTask
|
<div
|
||||||
setTask={(t) =>
|
ref={(el) => {
|
||||||
setTasks((cols) =>
|
provided.innerRef(el);
|
||||||
cols.map((c) => (c.id === t.id ? t : c))
|
taskCardRefs.current[task.id] = el;
|
||||||
)
|
}}
|
||||||
}
|
{...provided.draggableProps}
|
||||||
provided={provided}
|
{...provided.dragHandleProps}
|
||||||
task={task}
|
data-task-card={task.id}
|
||||||
tags={tags}
|
onClick={(e) => handleTaskClick(e, task.id)}
|
||||||
/>
|
>
|
||||||
)}
|
<BoardTask
|
||||||
</Draggable>
|
setTask={(t) =>
|
||||||
))}
|
setTasks({
|
||||||
|
...tasks,
|
||||||
|
[t.id]: t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
const updated = { ...tasks };
|
||||||
|
delete updated[task.id];
|
||||||
|
setTasks(updated);
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(task.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="text-destructive" />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
{tasks.filter((task) => task.status === status).length ===
|
{statusTasks.length === 0 && (
|
||||||
0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No tasks
|
No tasks
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,60 +7,88 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { SheetTrigger } from "@/components/ui/sheet";
|
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 { Color, getColor } from "@/lib/ui/color";
|
||||||
import { DraggableProvided } from "@hello-pangea/dnd";
|
import { DraggableProvided } from "@hello-pangea/dnd";
|
||||||
import TaskEdit from "../edit/TaskEdit";
|
import TaskEdit from "../edit/TaskEdit";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { NotepadText } from "lucide-react";
|
import { NotepadText, Trash2 } from "lucide-react";
|
||||||
import DocDialog from "../edit/DocDialog";
|
import DocDialog from "../edit/DocDialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function BoardTask({
|
export default function BoardTask({
|
||||||
task,
|
task,
|
||||||
provided,
|
provided,
|
||||||
setTask,
|
setTask,
|
||||||
tags,
|
tags,
|
||||||
|
onDelete,
|
||||||
|
isSelected,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
setTask: (t: Task) => void;
|
setTask: (t: Task) => void;
|
||||||
provided: DraggableProvided;
|
provided: DraggableProvided;
|
||||||
tags: Record<string, { color: Color; name: string }>;
|
tags: Record<TagId, Tag>;
|
||||||
|
onDelete?: (taskId: string) => void;
|
||||||
|
isSelected?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Card
|
<Card
|
||||||
ref={provided.innerRef}
|
className={cn(
|
||||||
{...provided.draggableProps}
|
"bg-background cursor-grab active:cursor-grabbing transition-all duration-150",
|
||||||
{...provided.dragHandleProps}
|
isSelected &&
|
||||||
className="bg-background"
|
"ring-2 ring-primary ring-offset-1 bg-primary/5 mt-1"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between select-none">
|
||||||
<span>{task.title}</span>
|
<span className="text-sm font-semibold">{task.title}</span>
|
||||||
<DocDialog task={task} setTask={setTask} tags={tags}>
|
<div className="flex gap-1 ml-auto">
|
||||||
<Button
|
<DocDialog task={task} setTask={setTask} tags={tags}>
|
||||||
size="sm"
|
<Button
|
||||||
className="ml-auto"
|
size="sm"
|
||||||
variant="outline"
|
className="ml-auto"
|
||||||
onClick={(e) => {
|
variant="outline"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
}}
|
||||||
<NotepadText />
|
>
|
||||||
</Button>
|
<NotepadText className="h-4 w-4" />
|
||||||
</DocDialog>
|
</Button>
|
||||||
|
</DocDialog>
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{task.description}</CardDescription>
|
{task.description && (
|
||||||
|
<CardDescription className="text-xs line-clamp-2">
|
||||||
|
{task.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden">
|
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden select-none">
|
||||||
{task.tags?.map((tag, i) => (
|
{task.tagIds?.map((tagId) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tagId}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
style={getColor(tags[tag].color)}
|
style={
|
||||||
|
tags[tagId]
|
||||||
|
? getColor(tags[tagId].color || "#808080")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{tag}
|
{tags[tagId]?.name || tagId}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import DocView from "../DocView";
|
import DocView from "../DocView";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Task } from "@/lib/types";
|
import { Task, Tag, TagId } from "@/lib/types";
|
||||||
import { Color, getColor } from "@/lib/ui/color";
|
import { getColor } from "@/lib/ui/color";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { OutputData } from "@editorjs/editorjs";
|
import { OutputData } from "@editorjs/editorjs";
|
||||||
|
|
||||||
@@ -23,10 +23,19 @@ export default function DocDialog({
|
|||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
setTask: (t: Task) => void;
|
setTask: (t: Task) => void;
|
||||||
tags: Record<string, { color: Color; name: string }>;
|
tags: Record<TagId, Tag>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [doc, setDoc] = useState<OutputData>({ blocks: [] });
|
// if there is an existing document on the task use it, otherwise start with a blank one
|
||||||
|
const doc = task.doc || { version: "2.28.0", time: Date.now(), blocks: [] };
|
||||||
|
|
||||||
|
const handleDocChange = (newDoc: OutputData) => {
|
||||||
|
setTask({
|
||||||
|
...task,
|
||||||
|
doc: newDoc,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
@@ -41,20 +50,24 @@ export default function DocDialog({
|
|||||||
<DialogDescription>{task.description}</DialogDescription>
|
<DialogDescription>{task.description}</DialogDescription>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex flex-wrap gap-1.5 overflow-hidden">
|
<div className="flex flex-wrap gap-1.5 overflow-hidden">
|
||||||
{task.tags?.map((tag, i) => (
|
{task.tagIds?.map((tagId) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tagId}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
style={getColor(tags[tag].color)}
|
style={
|
||||||
|
tags[tagId]
|
||||||
|
? getColor(tags[tagId].color || "#808080")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{tag}
|
{tags[tagId]?.name || tagId}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ScrollArea className="h-[60vh]">
|
<ScrollArea className="h-[60vh]">
|
||||||
<DocView key={0} doc={doc} setDoc={setDoc} />
|
<DocView key={task.id} doc={doc} setDoc={handleDocChange} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -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<TagId, Tag>;
|
||||||
|
statuses: Status[];
|
||||||
|
setTags: (tags: Record<TagId, Tag>) => 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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Tags Section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Tags</h3>
|
||||||
|
<Card className="p-4 space-y-3">
|
||||||
|
{Object.entries(tags).map(([tagId, tag]) => (
|
||||||
|
<div
|
||||||
|
key={tagId}
|
||||||
|
className="flex items-center gap-3 p-3 bg-secondary rounded-lg"
|
||||||
|
>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-colors"
|
||||||
|
style={{ backgroundColor: tag.color || "gray" }}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-40 p-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{COLOR_OPTIONS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className="w-8 h-8 rounded-md border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor:
|
||||||
|
tag.color === color ? "black" : "transparent",
|
||||||
|
}}
|
||||||
|
onClick={() => handleUpdateTagColor(tagId, color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={tag.name}
|
||||||
|
onChange={(e) => handleUpdateTagName(tagId, e.target.value)}
|
||||||
|
placeholder="Tag name"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteTag(tagId)}
|
||||||
|
className="p-2 hover:bg-destructive/10 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-colors"
|
||||||
|
style={{ backgroundColor: newTagColor || "gray" }}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-40 p-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{COLOR_OPTIONS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className="w-8 h-8 rounded-md border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor:
|
||||||
|
newTagColor === color ? "black" : "transparent",
|
||||||
|
}}
|
||||||
|
onClick={() => setNewTagColor(color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
placeholder="New tag name"
|
||||||
|
className="flex-1"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleAddTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleAddTag} size="sm" className="gap-1">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statuses Section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Statuses</h3>
|
||||||
|
<Card className="p-4 space-y-3">
|
||||||
|
{statuses.map((status) => (
|
||||||
|
<div
|
||||||
|
key={status.id}
|
||||||
|
className="flex items-center gap-3 p-3 bg-secondary rounded-lg"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-colors"
|
||||||
|
style={{ backgroundColor: status.color || "gray" }}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-40 p-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{COLOR_OPTIONS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className="w-8 h-8 rounded-md border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor:
|
||||||
|
status.color === color ? "black" : "transparent",
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdateStatusColor(status.id, color)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={status.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateStatusName(status.id, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Status name"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteStatus(status.id)}
|
||||||
|
className="p-2 hover:bg-destructive/10 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-colors"
|
||||||
|
style={{ backgroundColor: newStatusColor || "gray" }}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-40 p-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{COLOR_OPTIONS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className="w-8 h-8 rounded-md border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor:
|
||||||
|
newStatusColor === color ? "black" : "transparent",
|
||||||
|
}}
|
||||||
|
onClick={() => setNewStatusColor(color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={newStatusName}
|
||||||
|
onChange={(e) => setNewStatusName(e.target.value)}
|
||||||
|
placeholder="New status name"
|
||||||
|
className="flex-1"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleAddStatus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleAddStatus} size="sm" className="gap-1">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,23 +8,9 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { Task } from "@/lib/types";
|
import { Task, Tag, TagId } from "@/lib/types";
|
||||||
import { Color, getColor } from "@/lib/ui/color";
|
import { Color, getColor } from "@/lib/ui/color";
|
||||||
import { DraggableProvided } from "@hello-pangea/dnd";
|
|
||||||
import { Button } from "../../ui/button";
|
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";
|
import DocDialog from "./DocDialog";
|
||||||
|
|
||||||
export default function TaskEdit({
|
export default function TaskEdit({
|
||||||
@@ -35,7 +21,7 @@ export default function TaskEdit({
|
|||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
setTask: (t: Task) => void;
|
setTask: (t: Task) => void;
|
||||||
tags: Record<string, { color: Color; name: string }>;
|
tags: Record<TagId, Tag>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -52,32 +38,48 @@ export default function TaskEdit({
|
|||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label>Title</Label>
|
<Label>Title</Label>
|
||||||
<Input
|
<Input
|
||||||
defaultValue={task.title}
|
value={task.title}
|
||||||
onChange={(e) => setTask({ ...task, title: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setTask({
|
||||||
|
...task,
|
||||||
|
title: e.target.value,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label>Description</Label>
|
<Label>Description</Label>
|
||||||
<Input
|
<Input
|
||||||
defaultValue={task.description}
|
value={task.description || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setTask({ ...task, description: e.target.value })
|
setTask({
|
||||||
|
...task,
|
||||||
|
description: e.target.value,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label>Status</Label>
|
<Label>Tags</Label>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={Object.entries(tags).map(([id, tag]) => ({
|
options={Object.entries(tags).map(([id, tag]) => ({
|
||||||
label: tag.name,
|
label: tag.name,
|
||||||
value: id,
|
value: id,
|
||||||
style: {
|
style: {
|
||||||
iconColor: getColor(tag.color).color,
|
iconColor: getColor(tag.color || "#808080").color,
|
||||||
badgeColor: getColor(tag.color).backgroundColor,
|
badgeColor: getColor(tag.color || "#808080").backgroundColor,
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
onValueChange={(tags) => setTask({ ...task, tags })}
|
onValueChange={(tagIds) =>
|
||||||
defaultValue={task.tags}
|
setTask({
|
||||||
|
...task,
|
||||||
|
tagIds: tagIds as TagId[],
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defaultValue={task.tagIds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { getColor } from "@/lib/ui/color";
|
|||||||
import TableTask from "./Task";
|
import TableTask from "./Task";
|
||||||
|
|
||||||
export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
||||||
|
const tasksArray = Object.values(tasks).sort(
|
||||||
|
(a, b) => b.createdAt - a.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -26,7 +30,7 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tasks.length === 0 ? (
|
{tasksArray.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={5}
|
colSpan={5}
|
||||||
@@ -36,12 +40,15 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
tasks.map((task) => (
|
tasksArray.map((task) => (
|
||||||
<TableTask
|
<TableTask
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
setTask={(t) =>
|
setTask={(t) =>
|
||||||
setTasks((tasks) => tasks.map((c) => (c.id === t.id ? t : c)))
|
setTasks({
|
||||||
|
...tasks,
|
||||||
|
[t.id]: t,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,50 +1,62 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { SheetTrigger } from "@/components/ui/sheet";
|
import { SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { TableCell, TableRow } from "@/components/ui/table";
|
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 { Color, getColor } from "@/lib/ui/color";
|
||||||
import TaskEdit from "../edit/TaskEdit";
|
import TaskEdit from "../edit/TaskEdit";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
export default function TableTask({
|
export default function TableTask({
|
||||||
task,
|
task,
|
||||||
setTask,
|
setTask,
|
||||||
tags,
|
tags,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
setTask: (t: Task) => void;
|
setTask: (t: Task) => void;
|
||||||
tags: Record<string, { color: Color; name: string }>;
|
tags: Record<TagId, Tag>;
|
||||||
|
onDelete?: (taskId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<TableRow>
|
<TableRow className="cursor-pointer hover:bg-accent">
|
||||||
<TableCell className="font-medium">{task.title}</TableCell>
|
<TableCell className="font-medium">{task.title}</TableCell>
|
||||||
<TableCell className="max-w-[250px] truncate">
|
<TableCell className="max-w-[250px] truncate">
|
||||||
{task.description || "-"}
|
{task.description || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge variant="outline">{task.statusId}</Badge>
|
||||||
variant={
|
|
||||||
task.status === "done"
|
|
||||||
? "default"
|
|
||||||
: task.status === "in-progress"
|
|
||||||
? "secondary"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{task.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="flex gap-1 items-center">
|
<TableCell className="flex gap-1 items-center justify-between">
|
||||||
{task.tags?.map((tag, i) => (
|
<div className="flex gap-1">
|
||||||
<Badge
|
{task.tagIds?.map((tagId) => (
|
||||||
key={tag}
|
<Badge
|
||||||
variant="outline"
|
key={tagId}
|
||||||
style={getColor(tags[tag].color)}
|
variant="outline"
|
||||||
|
style={
|
||||||
|
tags[tagId]
|
||||||
|
? getColor(tags[tagId].color || "#808080")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tags[tagId]?.name || tagId}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(task.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tag}
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
</Badge>
|
</Button>
|
||||||
))}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
|
|||||||
+147
-86
@@ -1,98 +1,159 @@
|
|||||||
import { SidebarEntity } from "@/components/sidebar/Entity";
|
import { Entity, EntityId, LayoutNode, Workspace } from "@/lib/types";
|
||||||
import { SetState, Task } from "@/lib/types";
|
import {
|
||||||
import { OutputData } from "@editorjs/editorjs";
|
loadWorkspaceFromLocalStorage,
|
||||||
|
saveWorkspaceToLocalStorage,
|
||||||
|
addLayoutItem,
|
||||||
|
addLayoutGroup,
|
||||||
|
removeLayoutNode,
|
||||||
|
renameLayoutGroup,
|
||||||
|
} from "@/lib/workspace";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export type Entity =
|
export function useWorkspace() {
|
||||||
| {
|
const [workspace, setWorkspace] = useState<Workspace>();
|
||||||
name: string;
|
|
||||||
kind: "todo";
|
const updateEntity = (entityId: EntityId, updater: (e: Entity) => Entity) => {
|
||||||
tasks: Task[];
|
setWorkspace((prev) => {
|
||||||
setTasks: SetState<Task[]>;
|
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) => {
|
||||||
|
updateEntity(entityId, (e) => ({ ...e, name: newName }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEntity = (entityId: EntityId) => {
|
||||||
|
setWorkspace((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const { [entityId]: _, ...rest } = prev.entities;
|
||||||
|
// also remove from layout
|
||||||
|
const { layout: newLayout } = removeLayoutNode(prev.layout, entityId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
entities: rest,
|
||||||
|
layout: newLayout,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEntity = (
|
||||||
|
type: "todo" | "doc",
|
||||||
|
parentGroupId: string | null,
|
||||||
|
name?: string,
|
||||||
|
) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
let entity: Entity;
|
||||||
|
if (type === "todo") {
|
||||||
|
entity = {
|
||||||
|
id,
|
||||||
|
type: "todo",
|
||||||
|
name: name || "New list",
|
||||||
|
config: { statuses: [] },
|
||||||
|
tags: {},
|
||||||
|
tasks: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} as any;
|
||||||
|
} else {
|
||||||
|
entity = {
|
||||||
|
id,
|
||||||
|
type: "doc",
|
||||||
|
name: name || "New doc",
|
||||||
|
content: { version: "2.28.0", time: now, blocks: [] },
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} as any;
|
||||||
}
|
}
|
||||||
| {
|
setWorkspace((prev) => {
|
||||||
name: string;
|
if (!prev) return prev;
|
||||||
kind: "doc";
|
const newEntities = { ...prev.entities, [id]: entity };
|
||||||
doc: OutputData;
|
const newLayout = addLayoutItem(prev.layout, parentGroupId, {
|
||||||
setDoc: SetState<OutputData>;
|
type: "item",
|
||||||
};
|
entityId: id,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
entities: newEntities,
|
||||||
|
layout: newLayout,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Workspace {
|
const addGroup = (name = "New group", parentGroupId: string | null) => {
|
||||||
name: string;
|
const id = crypto.randomUUID();
|
||||||
entityLayout: SidebarEntity[];
|
const group: LayoutNode = { type: "group", id, name, children: [] };
|
||||||
entities: Record<string, Entity>;
|
setWorkspace((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const newLayout = addLayoutGroup(prev.layout, parentGroupId, group);
|
||||||
|
return { ...prev, layout: newLayout, updatedAt: Date.now() };
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
route: string;
|
const removeNode = (id: string) => {
|
||||||
setRoute: SetState<string>;
|
setWorkspace((prev) => {
|
||||||
}
|
if (!prev) return prev;
|
||||||
|
const { layout: newLayout, removedEntityIds } = removeLayoutNode(
|
||||||
|
prev.layout,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
const newEntities = { ...prev.entities } as Record<string, Entity>;
|
||||||
|
removedEntityIds.forEach((eid) => delete newEntities[eid]);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
entities: newEntities,
|
||||||
|
layout: newLayout,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export default function useWorkspace(): Workspace {
|
const renameGroup = (id: string, newName: string) => {
|
||||||
const [route, setRoute] = useState("alpha_tasks");
|
setWorkspace((prev) => {
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
if (!prev) return prev;
|
||||||
const [doc, setDoc] = useState<OutputData>({
|
const newLayout = renameLayoutGroup(prev.layout, id, newName);
|
||||||
blocks: [],
|
return { ...prev, layout: newLayout, updatedAt: Date.now() };
|
||||||
});
|
});
|
||||||
const [doc2, setDoc2] = useState<OutputData>({
|
};
|
||||||
blocks: [],
|
|
||||||
});
|
const loadWorkspace = (workspaceId: string | undefined) => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return setWorkspace(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = loadWorkspaceFromLocalStorage(workspaceId);
|
||||||
|
setWorkspace(w);
|
||||||
|
return w;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTasks([
|
if (workspace) saveWorkspaceToLocalStorage(workspace);
|
||||||
{ id: "1", title: "Buy groceries", status: "todo" },
|
}, [workspace]);
|
||||||
{ id: "2", title: "Plan sprint", status: "todo" },
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
title: "Build App",
|
|
||||||
description: "A simple description",
|
|
||||||
tags: ["website", "api", "app"],
|
|
||||||
status: "inprogress",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "Personal Workspace",
|
workspace,
|
||||||
entities: {
|
loadWorkspace,
|
||||||
alpha_tasks: {
|
updateEntity,
|
||||||
name: "Alpha tasks",
|
renameEntity,
|
||||||
kind: "todo",
|
deleteEntity,
|
||||||
tasks,
|
addEntity,
|
||||||
setTasks,
|
addGroup,
|
||||||
},
|
removeNode,
|
||||||
beta_tasks: {
|
renameGroup,
|
||||||
name: "Beta tasks",
|
setWorkspace,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
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 `.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 `<h${(block.data as any).level}>${(block.data as any).text}</h${(block.data as any).level}>`;
|
||||||
|
|
||||||
|
case "paragraph":
|
||||||
|
return `<p>${(block.data as any).text}</p>`;
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
const items = ((block.data as any).items as Array<{ content: string }>)
|
||||||
|
.map((item) => `<li>${item.content}</li>`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if ((block.data as any).style === "ordered") {
|
||||||
|
return `<ol>\n${items}\n</ol>`;
|
||||||
|
} else {
|
||||||
|
return `<ul>\n${items}\n</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "code":
|
||||||
|
return `<pre><code>${(block.data as any).code}</code></pre>`;
|
||||||
|
|
||||||
|
case "quote":
|
||||||
|
return `<blockquote>${(block.data as any).text}</blockquote>`;
|
||||||
|
|
||||||
|
case "delimiter":
|
||||||
|
return `<hr />`;
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
return `<img src="${(block.data as any).file.url}" alt="${(block.data as any).caption || ""}" />`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; margin: 20px; }
|
||||||
|
h1, h2, h3 { margin-top: 20px; }
|
||||||
|
code { background-color: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
|
||||||
|
pre { background-color: #f0f0f0; padding: 10px; border-radius: 5px; overflow-x: auto; }
|
||||||
|
blockquote { border-left: 4px solid #ddd; padding-left: 10px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${htmlBlocks.join("\n")}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = `<h1>${doc.name}</h1><p>Created: ${new Date(doc.createdAt).toISOString()}\nUpdated: ${new Date(doc.updatedAt).toISOString()}</p><hr />`;
|
||||||
|
|
||||||
|
// Insert header before the first body element
|
||||||
|
return html
|
||||||
|
.replace("</head>", `<title>${doc.name}</title>\n</head>`)
|
||||||
|
.replace("<body>", `<body>\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",
|
||||||
|
"Doc",
|
||||||
|
"Status",
|
||||||
|
"Tags",
|
||||||
|
"Created",
|
||||||
|
"Updated",
|
||||||
|
];
|
||||||
|
const rows = Object.values(todo.tasks).map((task) => [
|
||||||
|
task.title,
|
||||||
|
task.description || "",
|
||||||
|
task.doc ? JSON.stringify(task.doc) : "",
|
||||||
|
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<string, typeof todo.tasks> = {};
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
if (task.doc) {
|
||||||
|
markdown += `\n\n${docToMarkdown(task.doc)}`;
|
||||||
|
}
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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<string, any>;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const lines = csvContent.trim().split("\n");
|
||||||
|
const tasks: Record<string, any> = {};
|
||||||
|
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");
|
||||||
|
const docIndex = headers.indexOf("doc");
|
||||||
|
|
||||||
|
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] : "",
|
||||||
|
doc:
|
||||||
|
docIndex !== -1 && values[docIndex]
|
||||||
|
? JSON.parse(values[docIndex])
|
||||||
|
: undefined,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
+90
-9
@@ -1,11 +1,92 @@
|
|||||||
import { Color } from "./ui/color";
|
import { OutputData } from "@editorjs/editorjs";
|
||||||
|
|
||||||
export type Task = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
description?: string;
|
|
||||||
tags?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
|
export type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
entities: Record<EntityId, Entity>;
|
||||||
|
layout: LayoutNode[];
|
||||||
|
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LayoutNode =
|
||||||
|
| {
|
||||||
|
type: "group";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
children: LayoutNode[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "item";
|
||||||
|
entityId: EntityId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Entity = TodoEntity | DocEntity;
|
||||||
|
|
||||||
|
export interface TodoEntity {
|
||||||
|
id: EntityId;
|
||||||
|
type: "todo";
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
config: {
|
||||||
|
statuses: Status[];
|
||||||
|
};
|
||||||
|
|
||||||
|
tags: Record<TagId, Tag>;
|
||||||
|
tasks: Record<TaskId, Task>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** rich notes/documentation for the task */
|
||||||
|
doc?: OutputData;
|
||||||
|
|
||||||
|
statusId: string;
|
||||||
|
tagIds: TagId[];
|
||||||
|
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocEntity {
|
||||||
|
id: EntityId;
|
||||||
|
type: "doc";
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
content: OutputData;
|
||||||
|
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -8,7 +8,8 @@ export type Color =
|
|||||||
| "blue"
|
| "blue"
|
||||||
| "purple"
|
| "purple"
|
||||||
| "pink"
|
| "pink"
|
||||||
| "red";
|
| "red"
|
||||||
|
| string;
|
||||||
|
|
||||||
interface ColorStyle {
|
interface ColorStyle {
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
@@ -59,5 +60,5 @@ export function getColor(color: Color): ColorStyle {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return colors[color];
|
return colors[color] || color;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { Workspace, TodoEntity, DocEntity, Status, LayoutNode } 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: "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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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.id !== "string"
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// layout helpers -------------------------------------------------------------------
|
||||||
|
|
||||||
|
type RemoveResult = { layout: LayoutNode[]; removedEntityIds: string[] };
|
||||||
|
|
||||||
|
function collectEntityIds(node: LayoutNode): string[] {
|
||||||
|
if (node.type === "item") return [node.entityId];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const child of node.children) {
|
||||||
|
ids.push(...collectEntityIds(child));
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLayoutNode(
|
||||||
|
layout: LayoutNode[],
|
||||||
|
id: string,
|
||||||
|
): RemoveResult {
|
||||||
|
let removed: string[] = [];
|
||||||
|
|
||||||
|
function recurse(nodes: LayoutNode[]): LayoutNode[] {
|
||||||
|
return nodes
|
||||||
|
.map((node) => {
|
||||||
|
if (node.type === "group") {
|
||||||
|
if (node.id === id) {
|
||||||
|
removed.push(...collectEntityIds(node));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const children = recurse(node.children);
|
||||||
|
if (children.length !== node.children.length) {
|
||||||
|
// some children removed
|
||||||
|
}
|
||||||
|
return { ...node, children } as LayoutNode;
|
||||||
|
} else {
|
||||||
|
if (node.entityId === id) {
|
||||||
|
removed.push(node.entityId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as LayoutNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLayout = recurse(layout);
|
||||||
|
return { layout: newLayout, removedEntityIds: removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameLayoutGroup(
|
||||||
|
layout: LayoutNode[],
|
||||||
|
id: string,
|
||||||
|
newName: string,
|
||||||
|
): LayoutNode[] {
|
||||||
|
return layout.map((node) => {
|
||||||
|
if (node.type === "group") {
|
||||||
|
if (node.id === id) {
|
||||||
|
return { ...node, name: newName };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: renameLayoutGroup(node.children, id, newName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLayoutGroup(
|
||||||
|
layout: LayoutNode[],
|
||||||
|
parentId: string | null,
|
||||||
|
group: LayoutNode,
|
||||||
|
): LayoutNode[] {
|
||||||
|
if (!parentId) {
|
||||||
|
return [...layout, group];
|
||||||
|
}
|
||||||
|
return layout.map((node) => {
|
||||||
|
if (node.type === "group") {
|
||||||
|
if (node.id === parentId) {
|
||||||
|
return { ...node, children: [...node.children, group] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: addLayoutGroup(node.children, parentId, group),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLayoutItem(
|
||||||
|
layout: LayoutNode[],
|
||||||
|
parentId: string | null,
|
||||||
|
item: LayoutNode,
|
||||||
|
): LayoutNode[] {
|
||||||
|
if (!parentId) {
|
||||||
|
return [...layout, item];
|
||||||
|
}
|
||||||
|
return layout.map((node) => {
|
||||||
|
if (node.type === "group") {
|
||||||
|
if (node.id === parentId) {
|
||||||
|
return { ...node, children: [...node.children, item] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: addLayoutItem(node.children, parentId, item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// entity creation helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
export function createTodoEntity(name = "New list"): TodoEntity {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "todo",
|
||||||
|
name,
|
||||||
|
config: { statuses: [] },
|
||||||
|
tags: {},
|
||||||
|
tasks: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocEntity(name = "New doc"): DocEntity {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "doc",
|
||||||
|
name,
|
||||||
|
content: { version: "2.28.0", time: now, blocks: [] },
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user