Merge pull request #3 from orus-dev/mvp

Mvp
This commit is contained in:
2026-02-28 05:30:26 -08:00
committed by GitHub
25 changed files with 4122 additions and 488 deletions
+947
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -32,11 +32,14 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-cookie": "^3.0.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.553.0",
"next": "16.0.1",
"radix-ui": "^1.4.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"tailwind-merge": "^3.4.0"
@@ -46,6 +49,7 @@
"@types/node": "^20.19.25",
"@types/react": "^19",
"@types/react-dom": "^19",
"baseline-browser-mapping": "^2.10.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
+94 -62
View File
@@ -46,72 +46,99 @@
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--radius: 0.75rem;
--background: oklch(0.97 0.01 240);
--foreground: oklch(0.22 0.02 240);
--card: oklch(0.99 0.005 240);
--card-foreground: oklch(0.22 0.02 240);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--popover-foreground: oklch(0.22 0.02 240);
/* Primary = architectural blue */
--primary: oklch(0.55 0.16 250);
--primary-foreground: oklch(0.98 0.01 250);
--secondary: oklch(0.93 0.02 240);
--secondary-foreground: oklch(0.28 0.02 240);
--muted: oklch(0.94 0.015 240);
--muted-foreground: oklch(0.55 0.02 240);
/* Accent = subtle violet for graph highlights */
--accent: oklch(0.92 0.03 270);
--accent-foreground: oklch(0.30 0.04 270);
--destructive: oklch(0.62 0.22 25);
--border: oklch(0.90 0.015 240);
--input: oklch(0.90 0.015 240);
--ring: oklch(0.55 0.16 250);
/* Charts (clean + modern) */
--chart-1: oklch(0.55 0.16 250);
--chart-2: oklch(0.60 0.14 200);
--chart-3: oklch(0.62 0.18 280);
--chart-4: oklch(0.70 0.15 140);
--chart-5: oklch(0.65 0.20 30);
--sidebar: oklch(0.95 0.01 240);
--sidebar-foreground: oklch(0.25 0.02 240);
--sidebar-primary: oklch(0.55 0.16 250);
--sidebar-primary-foreground: oklch(0.98 0.01 250);
--sidebar-accent: oklch(0.92 0.02 250);
--sidebar-accent-foreground: oklch(0.30 0.02 250);
--sidebar-border: oklch(0.88 0.015 240);
--sidebar-ring: oklch(0.55 0.16 250);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.17 0.02 250);
--foreground: oklch(0.94 0.01 240);
--card: oklch(0.21 0.02 250);
--card-foreground: oklch(0.94 0.01 240);
--popover: oklch(0.23 0.02 250);
--popover-foreground: oklch(0.94 0.01 240);
/* Strong but not neon */
--primary: oklch(0.65 0.17 250);
--primary-foreground: oklch(0.12 0.02 250);
--secondary: oklch(0.27 0.02 250);
--secondary-foreground: oklch(0.94 0.01 240);
--muted: oklch(0.25 0.02 250);
--muted-foreground: oklch(0.70 0.02 240);
/* Accent for graph edges / highlights */
--accent: oklch(0.60 0.18 280);
--accent-foreground: oklch(0.96 0.01 240);
--destructive: oklch(0.68 0.20 25);
--border: oklch(0.30 0.02 250);
--input: oklch(0.30 0.02 250);
--ring: oklch(0.65 0.17 250);
/* Chart colors (balanced + readable on dark canvas) */
--chart-1: oklch(0.65 0.17 250);
--chart-2: oklch(0.70 0.14 180);
--chart-3: oklch(0.72 0.18 300);
--chart-4: oklch(0.75 0.15 140);
--chart-5: oklch(0.72 0.20 40);
--sidebar: oklch(0.19 0.02 250);
--sidebar-foreground: oklch(0.92 0.01 240);
--sidebar-primary: oklch(0.65 0.17 250);
--sidebar-primary-foreground: oklch(0.12 0.02 250);
--sidebar-accent: oklch(0.27 0.02 250);
--sidebar-accent-foreground: oklch(0.92 0.01 240);
--sidebar-border: oklch(0.30 0.02 250);
--sidebar-ring: oklch(0.65 0.17 250);
}
@layer base {
@@ -205,3 +232,8 @@ h3.ce-header {
color: var(--popover-foreground) !important;
background-color: var(--accent) !important;
}
.ce-block, .ce-block__content {
width: 100% !important;
max-width: 100% !important;
}
+4 -4
View File
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Drift - Productivity App",
description: "A powerful client-side productivity application",
};
export default function RootLayout({
@@ -23,9 +23,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className="h-full">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased dark`}
className={`${geistSans.variable} ${geistMono.variable} antialiased dark h-screen flex flex-col`}
>
{children}
</body>
+2 -62
View File
@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<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>
);
redirect("/workspace");
}
+137 -8
View File
@@ -2,25 +2,154 @@
import Router from "@/components/Router";
import AppSidebar, { SidebarTrigger } from "@/components/sidebar/AppSidebar";
import useWorkspace from "@/hooks/workspace";
import { useWorkspace } from "@/hooks/workspace";
import { ChevronRight } from "lucide-react";
import Cookies from "js-cookie";
import { useEffect, useState } from "react";
import { Route } from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
createDefaultWorkspace,
saveWorkspaceToLocalStorage,
} from "@/lib/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 (
<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">
<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">
{workspace.name}
<ChevronRight className="h-5" />
{workspace.entities[workspace.route].name}
<header className="w-full border-b pb-3 pt-2 hover:bg-accent/20 flex items-center text-foreground/90 gap-2">
<span className="font-semibold">{workspace.name}</span>
<ChevronRight className="h-5 text-muted-foreground" />
<span className="text-muted-foreground">
{workspace.entities[route.entity || ""]?.name || "Select an item"}
</span>
</header>
</div>
</SidebarTrigger>
<Router workspace={workspace} />
<Router workspace={workspace} route={route} updateEntity={updateEntity} />
</AppSidebar>
);
}
+98 -20
View File
@@ -1,44 +1,122 @@
"use client";
import { Workspace } from "@/hooks/workspace";
import { Task } from "@/lib/types";
import {
Entity,
EntityId,
Route,
Task,
TaskId,
TodoEntity,
Workspace,
} from "@/lib/types";
import TaskView from "./view/TaskView";
import DocView from "./view/DocView";
import { OutputData } from "@editorjs/editorjs";
const id = (prefix = "t") =>
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
export default function Router({ workspace }: { workspace: Workspace }) {
const entity = workspace.entities[workspace.route];
switch (entity.kind) {
case "todo":
const createTask = (status: string) => {
export default function Router({
route,
workspace,
updateEntity,
}: {
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 = {
id: id(),
id: id("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 (
<TaskView
key={entity.name}
key={entity.id}
tasks={entity.tasks}
setTasks={entity.setTasks}
setTasks={setTasks}
createTask={createTask}
tags={{
website: { color: "yellow", name: "Website" },
api: { color: "purple", name: "API" },
app: { color: "blue", name: "App" },
}}
tags={entity.tags}
statuses={entity.config.statuses}
setTags={setTags}
setStatuses={setStatuses}
entityName={entity.name}
/>
);
case "doc":
}
case "doc": {
const setDoc = (doc: OutputData) => {
updateEntity(entityId, (e) => ({
...e,
content: doc,
}));
};
return (
<div className="h-full w-3xl mx-auto bg-accent/20 rounded-2xl p-4">
<DocView key={entity.name} doc={entity.doc} setDoc={entity.setDoc} />
<div className="h-full w-full md:w-3xl xl:w-4xl 2xl:w-5xl mx-auto py-8 px-10 md:border-x">
<h1 className="text-5xl font-bold mb-7">{entity.name}</h1>
<DocView
key={entity.id}
doc={entity.content}
setDoc={setDoc}
docName={entity.name}
/>
</div>
);
}
default:
return null;
}
}
+352 -21
View File
@@ -1,3 +1,7 @@
"use client";
import * as React from "react";
import {
Sidebar,
SidebarContent,
@@ -8,74 +12,401 @@ import {
SidebarProvider,
useSidebar,
} from "@/components/ui/sidebar";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import EntitySwitch from "./Entity";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
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({
children,
workspace,
}: Readonly<{
children: React.ReactNode;
workspace: Workspace;
}>) {
setRoute,
setWorkspace,
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 (
<SidebarProvider>
<Sidebar>
{/* Header */}
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<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}
<ChevronDown className="ml-auto" />
</button>
<ChevronDown className="ml-auto h-4 w-4 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-(--radix-popper-anchor-width)"
className="w-[--radix-popper-anchor-width]"
>
<DropdownMenuItem>Acme Inc</DropdownMenuItem>
<DropdownMenuItem>Acme Corp.</DropdownMenuItem>
{/* list all known workspaces */}
{[
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>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
{/* Sidebar Content */}
<SidebarContent>
<SidebarMenu>
{workspace.entityLayout.map((entity, i) => (
<EntitySwitch entity={entity} workspace={workspace} key={i} />
{workspace.layout.map((node) => (
<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>
</SidebarContent>
<SidebarFooter />
</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>
);
}
export function SidebarTrigger({
className,
...props
}: React.ComponentProps<"div">) {
export function SidebarTrigger(props: React.ComponentProps<"div">) {
const { toggleSidebar } = useSidebar();
return (
<div
data-sidebar="trigger"
data-slot="sidebar-trigger"
onClick={(event) => {
toggleSidebar();
}}
onClick={toggleSidebar}
{...props}
/>
);
+235 -40
View File
@@ -1,4 +1,8 @@
import { CollapsibleTrigger } from "@radix-ui/react-collapsible";
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
@@ -7,63 +11,254 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "../ui/sidebar";
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { ChevronDown } from "lucide-react";
import { Workspace } from "@/hooks/workspace";
export type SidebarEntity =
| {
kind: "group";
name: string;
children: SidebarEntity[];
}
| {
kind?: "item";
id: string;
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { Route, Workspace, LayoutNode } from "@/lib/types";
type DialogMode = "rename" | "add-item" | "add-group";
type Props = {
node: LayoutNode;
workspace: Workspace;
setRoute: (route: Route) => void;
addEntity: (
type: "todo" | "doc",
parentGroupId: string | null,
name?: string,
) => string | undefined;
addGroup: (name: string, parentGroupId: string | null) => string;
renameEntity: (entityId: string, newName: string) => void;
deleteEntity: (entityId: string) => void;
removeNode: (id: string) => void;
renameGroup: (id: string, name: string) => void;
};
export default function EntitySwitch(props: Props) {
const {
node,
workspace,
setRoute,
addEntity,
addGroup,
renameEntity,
deleteEntity,
removeNode,
renameGroup,
} = props;
const [dialogValue, setDialogValue] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | undefined>();
const parentId = node.type === "group" ? node.id : null;
const openDialog = (mode: DialogMode, initial = "") => {
setDialogMode(mode);
setDialogValue(
initial ||
(mode === "add-item"
? "New list"
: mode === "add-group"
? "New group"
: ""),
);
};
export default function EntitySwitch({
entity,
workspace,
}: {
entity: SidebarEntity;
workspace: Workspace;
}) {
if (entity.kind === "group") {
const closeDialog = () => setDialogMode(undefined);
const handleSubmit = () => {
const value = dialogValue.trim();
if (!value) return;
switch (dialogMode) {
case "rename":
if (node.type === "group") renameGroup(node.id, value);
else if (node.type === "item") renameEntity(node.entityId, value);
break;
case "add-item":
addEntity("todo", parentId, value);
break;
case "add-group":
addGroup(value, parentId);
break;
}
closeDialog();
};
const confirmDelete = (message: string, action: () => void) => {
if (window.confirm(message)) action();
};
const dialogTitle = dialogMode?.startsWith("rename")
? "Rename"
: dialogMode === "add-item"
? "Add new list"
: "Add new group";
const dialog = (
<Dialog open={!!dialogMode} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
{(dialogMode === "add-item" || dialogMode === "add-group") && (
<DialogDescription>Enter a name below.</DialogDescription>
)}
</DialogHeader>
<Input
autoFocus
value={dialogValue}
onChange={(e) => setDialogValue(e.target.value)}
/>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button onClick={handleSubmit}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
/* ================= GROUP ================= */
if (node.type === "group") {
return (
<>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel asChild>
<SidebarMenuButton asChild>
<CollapsibleTrigger>
{entity.name}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
<ContextMenu>
<ContextMenuTrigger asChild>
<CollapsibleTrigger asChild>
<SidebarMenuButton className="w-full">
{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>
{entity.children?.map((entity, i) => (
<EntitySwitch entity={entity} workspace={workspace} key={i} />
{node.children.map((child) => (
<EntitySwitch
key={child.type === "group" ? child.id : child.entityId}
{...props}
node={child}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
);
} else {
return (
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => {
workspace.setRoute(entity.id);
}}
>
{workspace.entities[entity.id].name}
</SidebarMenuButton>
</SidebarMenuItem>
{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}
</>
);
}
+252
View File
@@ -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,
};
+84 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import Header from "@editorjs/header";
import List from "@editorjs/list";
@@ -11,6 +11,21 @@ import Delimiter from "@editorjs/delimiter";
import Warning from "@editorjs/warning";
import InlineCode from "@editorjs/inline-code";
import { OutputData } from "@editorjs/editorjs";
import {
downloadFile,
exportDocAsJson,
exportDocAsMarkdown,
exportDocAsHtml,
} from "@/lib/export";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { FileDown } from "lucide-react";
function toMarkdown(data: OutputData): string {
return data.blocks
@@ -55,11 +70,48 @@ function toMarkdown(data: OutputData): string {
export default function DocView({
doc,
setDoc,
docName = "Document",
}: {
doc: OutputData;
setDoc: (data: OutputData) => void;
docName?: string;
}) {
const holderRef = useRef<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(() => {
let editor: any;
@@ -118,6 +170,10 @@ export default function DocView({
},
data: doc,
async onChange() {
if (!editor || !editor.save) {
console.warn("unable to save, editor.save is null");
return;
}
const savedData = await editor.save();
console.log("Document data:", toMarkdown(savedData));
setDoc(savedData);
@@ -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>
);
}
+126 -10
View File
@@ -1,45 +1,161 @@
"use client";
import { SetState, Task } from "@/lib/types";
import { Tag, TagId, Task, TaskId, Status } from "@/lib/types";
import { Color } from "@/lib/ui/color";
import BoardView from "./board/BoardView";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { TableView } from "./table/TableView";
import EditView from "./edit/EditView";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { FileDown } from "lucide-react";
import {
downloadFile,
exportTodoAsJson,
exportTodoAsCsv,
exportTodoAsMarkdown,
} from "@/lib/export";
import { useEffect, useState } from "react";
export type TaskViewProps = {
tags: Record<string, { color: Color; name: string }>;
tasks: Task[];
setTasks: SetState<Task[]>;
createTask: (status: string) => void;
tags: Record<TagId, Tag>;
tasks: Record<TaskId, Task>;
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({
tags,
tasks,
statuses,
setTasks,
setTags,
setStatuses,
createTask,
entityName = "Tasks",
}: TaskViewProps) {
const [isExporting, setIsExporting] = useState(false);
const handleExport = (format: "json" | "csv" | "markdown") => {
const todoEntity = {
id: "",
type: "todo" as const,
name: entityName,
config: { statuses },
tags,
tasks,
createdAt: Date.now(),
updatedAt: Date.now(),
};
let content = "";
let filename = `${entityName.replace(/\s+/g, "_")}_${Date.now()}`;
switch (format) {
case "json":
content = exportTodoAsJson(todoEntity);
filename += ".json";
break;
case "csv":
content = exportTodoAsCsv(todoEntity);
filename += ".csv";
break;
case "markdown":
content = exportTodoAsMarkdown(todoEntity);
filename += ".md";
break;
}
downloadFile(content, filename, "text/plain");
};
return (
<div className="px-6 pt-3">
<Tabs defaultValue="board">
<TabsList>
<div className="px-6 pt-2 pb-6 flex flex-col gap-4 h-full flex-1 min-h-0">
<div className="flex items-center justify-end"></div>
<Tabs
defaultValue="board"
className="w-full h-full flex flex-col flex-1 min-h-0"
>
<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>
<TabsContent value="board">
<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
tasks={tasks}
setTasks={setTasks}
createTask={createTask}
tags={tags}
setTags={setTags}
setStatuses={setStatuses}
statuses={statuses}
/>
</TabsContent>
<TabsContent value="table">
<TabsContent value="table" className="mt-0 h-full flex-1 min-h-0">
<TableView
tasks={tasks}
setTasks={setTasks}
createTask={createTask}
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>
</Tabs>
+273 -41
View File
@@ -3,101 +3,333 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import { PlusIcon } from "lucide-react";
import { PlusIcon, Trash2 } from "lucide-react";
import BoardTask from "./Task";
import { TaskViewProps } from "../TaskView";
const statusLabels: Record<string, string> = {
todo: "To do",
inprogress: "In progress",
done: "Done",
};
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useState, useEffect, useRef, useCallback } from "react";
export default function BoardView({
tags,
tasks,
setTasks,
createTask,
statuses,
}: 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) {
const { draggableId, source, destination } = result;
if (!destination) return;
setTasks((tasks) => {
const taskIndex = tasks.findIndex((task) => task.id === draggableId);
const task = tasks[taskIndex];
tasks.splice(taskIndex, 1);
const task = Object.values(tasks).find((t) => t.id === draggableId);
if (!task) return;
const destinationGroupStartIndex = tasks.findIndex(
(t) => t.status === destination.droppableId
);
const insertIndex =
destinationGroupStartIndex === -1
? tasks.length
: destinationGroupStartIndex + destination.index;
tasks.splice(insertIndex, 0, {
setTasks({
...tasks,
[task.id]: {
...task,
status: destination.droppableId,
});
return tasks;
statusId: destination.droppableId,
updatedAt: Date.now(),
},
});
}
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 (
<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}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
{Object.keys(statusLabels).map((status) => (
<Droppable key={status} droppableId={status}>
<div className="flex flex-col md:flex-row w-full gap-6 items-start flex-1 h-full min-h-0">
{tasksByStatus.map(({ status, tasks: statusTasks }) => (
<Droppable key={status.id} droppableId={status.id}>
{(provided) => (
<Card
ref={provided.innerRef}
{...provided.droppableProps}
className="bg-background"
className="bg-background w-full h-full flex flex-col overflow-hidden min-h-0"
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{statusLabels[status]}</span>
<span>{status.name}</span>
<Button
size="sm"
className="ml-auto"
variant="outline"
onClick={() => createTask(status)}
onClick={() => createTask(status.id)}
>
<PlusIcon />
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col gap-3">
{tasks
.filter((task) => task.status === status)
.map((task, index) => (
{statusTasks.map((task, index) => (
<Draggable
key={task.id}
draggableId={task.id}
index={index}
>
{(provided) => (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={(el) => {
provided.innerRef(el);
taskCardRefs.current[task.id] = el;
}}
{...provided.draggableProps}
{...provided.dragHandleProps}
data-task-card={task.id}
onClick={(e) => handleTaskClick(e, task.id)}
>
<BoardTask
setTask={(t) =>
setTasks((cols) =>
cols.map((c) => (c.id === t.id ? t : c))
)
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}
{tasks.filter((task) => task.status === status).length ===
0 && (
{statusTasks.length === 0 && (
<p className="text-sm text-muted-foreground">
No tasks
</p>
+47 -19
View File
@@ -7,37 +7,44 @@ import {
CardTitle,
} from "@/components/ui/card";
import { SheetTrigger } from "@/components/ui/sheet";
import { Task } from "@/lib/types";
import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color";
import { DraggableProvided } from "@hello-pangea/dnd";
import TaskEdit from "../edit/TaskEdit";
import { Button } from "@/components/ui/button";
import { NotepadText } from "lucide-react";
import { NotepadText, Trash2 } from "lucide-react";
import DocDialog from "../edit/DocDialog";
import { cn } from "@/lib/utils";
export default function BoardTask({
task,
provided,
setTask,
tags,
onDelete,
isSelected,
}: {
task: Task;
setTask: (t: Task) => void;
provided: DraggableProvided;
tags: Record<string, { color: Color; name: string }>;
tags: Record<TagId, Tag>;
onDelete?: (taskId: string) => void;
isSelected?: boolean;
}) {
return (
<TaskEdit task={task} tags={tags} setTask={setTask}>
<SheetTrigger asChild>
<Card
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="bg-background"
className={cn(
"bg-background cursor-grab active:cursor-grabbing transition-all duration-150",
isSelected &&
"ring-2 ring-primary ring-offset-1 bg-primary/5 mt-1"
)}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{task.title}</span>
<CardTitle className="flex items-center justify-between select-none">
<span className="text-sm font-semibold">{task.title}</span>
<div className="flex gap-1 ml-auto">
<DocDialog task={task} setTask={setTask} tags={tags}>
<Button
size="sm"
@@ -47,20 +54,41 @@ export default function BoardTask({
e.stopPropagation();
}}
>
<NotepadText />
<NotepadText className="h-4 w-4" />
</Button>
</DocDialog>
</CardTitle>
<CardDescription>{task.description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden">
{task.tags?.map((tag, i) => (
<Badge
key={tag}
{onDelete && (
<Button
size="sm"
variant="outline"
style={getColor(tags[tag].color)}
onClick={(e) => {
e.stopPropagation();
onDelete(task.id);
}}
>
{tag}
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</CardTitle>
{task.description && (
<CardDescription className="text-xs line-clamp-2">
{task.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden select-none">
{task.tagIds?.map((tagId) => (
<Badge
key={tagId}
variant="outline"
style={
tags[tagId]
? getColor(tags[tagId].color || "#808080")
: undefined
}
>
{tags[tagId]?.name || tagId}
</Badge>
))}
</CardContent>
+22 -9
View File
@@ -10,8 +10,8 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import DocView from "../DocView";
import { Badge } from "@/components/ui/badge";
import { Task } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color";
import { Task, Tag, TagId } from "@/lib/types";
import { getColor } from "@/lib/ui/color";
import { useState } from "react";
import { OutputData } from "@editorjs/editorjs";
@@ -23,10 +23,19 @@ export default function DocDialog({
}: {
task: Task;
setTask: (t: Task) => void;
tags: Record<string, { color: Color; name: string }>;
tags: Record<TagId, Tag>;
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 (
<Dialog>
@@ -41,20 +50,24 @@ export default function DocDialog({
<DialogDescription>{task.description}</DialogDescription>
<DialogHeader>
<div className="flex flex-wrap gap-1.5 overflow-hidden">
{task.tags?.map((tag, i) => (
{task.tagIds?.map((tagId) => (
<Badge
key={tag}
key={tagId}
variant="outline"
style={getColor(tags[tag].color)}
style={
tags[tagId]
? getColor(tags[tagId].color || "#808080")
: undefined
}
>
{tag}
{tags[tagId]?.name || tagId}
</Badge>
))}
</div>
</DialogHeader>
<ScrollArea className="h-[60vh]">
<DocView key={0} doc={doc} setDoc={setDoc} />
<DocView key={task.id} doc={doc} setDoc={handleDocChange} />
</ScrollArea>
</DialogContent>
</Dialog>
+318
View File
@@ -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>
);
}
+27 -25
View File
@@ -8,23 +8,9 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Task } from "@/lib/types";
import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color";
import { DraggableProvided } from "@hello-pangea/dnd";
import { Button } from "../../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTrigger,
} from "../../ui/dialog";
import DocView from "../DocView";
import { useState } from "react";
import { OutputData } from "@editorjs/editorjs";
import { DialogTitle } from "@radix-ui/react-dialog";
import { ScrollArea } from "../../ui/scroll-area";
import { Badge } from "../../ui/badge";
import DocDialog from "./DocDialog";
export default function TaskEdit({
@@ -35,7 +21,7 @@ export default function TaskEdit({
}: {
task: Task;
setTask: (t: Task) => void;
tags: Record<string, { color: Color; name: string }>;
tags: Record<TagId, Tag>;
children: React.ReactNode;
}) {
return (
@@ -52,32 +38,48 @@ export default function TaskEdit({
<div className="grid gap-3">
<Label>Title</Label>
<Input
defaultValue={task.title}
onChange={(e) => setTask({ ...task, title: e.target.value })}
value={task.title}
onChange={(e) =>
setTask({
...task,
title: e.target.value,
updatedAt: Date.now(),
})
}
/>
</div>
<div className="grid gap-3">
<Label>Description</Label>
<Input
defaultValue={task.description}
value={task.description || ""}
onChange={(e) =>
setTask({ ...task, description: e.target.value })
setTask({
...task,
description: e.target.value,
updatedAt: Date.now(),
})
}
/>
</div>
<div className="grid gap-3">
<Label>Status</Label>
<Label>Tags</Label>
<MultiSelect
options={Object.entries(tags).map(([id, tag]) => ({
label: tag.name,
value: id,
style: {
iconColor: getColor(tag.color).color,
badgeColor: getColor(tag.color).backgroundColor,
iconColor: getColor(tag.color || "#808080").color,
badgeColor: getColor(tag.color || "#808080").backgroundColor,
},
}))}
onValueChange={(tags) => setTask({ ...task, tags })}
defaultValue={task.tags}
onValueChange={(tagIds) =>
setTask({
...task,
tagIds: tagIds as TagId[],
updatedAt: Date.now(),
})
}
defaultValue={task.tagIds}
/>
</div>
<div className="grid gap-3">
+10 -3
View File
@@ -15,6 +15,10 @@ import { getColor } from "@/lib/ui/color";
import TableTask from "./Task";
export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
const tasksArray = Object.values(tasks).sort(
(a, b) => b.createdAt - a.createdAt,
);
return (
<Table>
<TableHeader>
@@ -26,7 +30,7 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
</TableRow>
</TableHeader>
<TableBody>
{tasks.length === 0 ? (
{tasksArray.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
@@ -36,12 +40,15 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
</TableCell>
</TableRow>
) : (
tasks.map((task) => (
tasksArray.map((task) => (
<TableTask
key={task.id}
task={task}
setTask={(t) =>
setTasks((tasks) => tasks.map((c) => (c.id === t.id ? t : c)))
setTasks({
...tasks,
[t.id]: t,
})
}
tags={tags}
/>
+32 -20
View File
@@ -1,50 +1,62 @@
import { Badge } from "@/components/ui/badge";
import { SheetTrigger } from "@/components/ui/sheet";
import { TableCell, TableRow } from "@/components/ui/table";
import { Task } from "@/lib/types";
import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color";
import TaskEdit from "../edit/TaskEdit";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
export default function TableTask({
task,
setTask,
tags,
onDelete,
}: {
task: Task;
setTask: (t: Task) => void;
tags: Record<string, { color: Color; name: string }>;
tags: Record<TagId, Tag>;
onDelete?: (taskId: string) => void;
}) {
return (
<TaskEdit task={task} tags={tags} setTask={setTask}>
<SheetTrigger asChild>
<TableRow>
<TableRow className="cursor-pointer hover:bg-accent">
<TableCell className="font-medium">{task.title}</TableCell>
<TableCell className="max-w-[250px] truncate">
{task.description || "-"}
</TableCell>
<TableCell>
<Badge variant="outline">{task.statusId}</Badge>
</TableCell>
<TableCell className="flex gap-1 items-center justify-between">
<div className="flex gap-1">
{task.tagIds?.map((tagId) => (
<Badge
variant={
task.status === "done"
? "default"
: task.status === "in-progress"
? "secondary"
: "outline"
key={tagId}
variant="outline"
style={
tags[tagId]
? getColor(tags[tagId].color || "#808080")
: undefined
}
>
{task.status}
</Badge>
</TableCell>
<TableCell className="flex gap-1 items-center">
{task.tags?.map((tag, i) => (
<Badge
key={tag}
variant="outline"
style={getColor(tags[tag].color)}
>
{tag}
{tags[tagId]?.name || tagId}
</Badge>
))}
</div>
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onDelete(task.id);
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</TableCell>
</TableRow>
</SheetTrigger>
+145 -84
View File
@@ -1,98 +1,159 @@
import { SidebarEntity } from "@/components/sidebar/Entity";
import { SetState, Task } from "@/lib/types";
import { OutputData } from "@editorjs/editorjs";
import { Entity, EntityId, LayoutNode, Workspace } from "@/lib/types";
import {
loadWorkspaceFromLocalStorage,
saveWorkspaceToLocalStorage,
addLayoutItem,
addLayoutGroup,
removeLayoutNode,
renameLayoutGroup,
} from "@/lib/workspace";
import { useEffect, useState } from "react";
export type Entity =
| {
name: string;
kind: "todo";
tasks: Task[];
setTasks: SetState<Task[]>;
}
| {
name: string;
kind: "doc";
doc: OutputData;
setDoc: SetState<OutputData>;
export function useWorkspace() {
const [workspace, setWorkspace] = useState<Workspace>();
const updateEntity = (entityId: EntityId, updater: (e: Entity) => Entity) => {
setWorkspace((prev) => {
if (!prev) return prev;
const current = prev.entities[entityId];
if (!current) return prev;
return {
...prev,
entities: {
...prev.entities,
[entityId]: updater(current),
},
updatedAt: Date.now(),
};
});
};
export interface Workspace {
name: string;
entityLayout: SidebarEntity[];
entities: Record<string, Entity>;
const renameEntity = (entityId: EntityId, newName: string) => {
updateEntity(entityId, (e) => ({ ...e, name: newName }));
};
route: string;
setRoute: SetState<string>;
}
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(),
};
});
};
export default function useWorkspace(): Workspace {
const [route, setRoute] = useState("alpha_tasks");
const [tasks, setTasks] = useState<Task[]>([]);
const [doc, setDoc] = useState<OutputData>({
blocks: [],
const addEntity = (
type: "todo" | "doc",
parentGroupId: string | null,
name?: string,
) => {
const now = Date.now();
const id = crypto.randomUUID();
let entity: Entity;
if (type === "todo") {
entity = {
id,
type: "todo",
name: name || "New list",
config: { statuses: [] },
tags: {},
tasks: {},
createdAt: now,
updatedAt: now,
} as any;
} else {
entity = {
id,
type: "doc",
name: name || "New doc",
content: { version: "2.28.0", time: now, blocks: [] },
createdAt: now,
updatedAt: now,
} as any;
}
setWorkspace((prev) => {
if (!prev) return prev;
const newEntities = { ...prev.entities, [id]: entity };
const newLayout = addLayoutItem(prev.layout, parentGroupId, {
type: "item",
entityId: id,
});
const [doc2, setDoc2] = useState<OutputData>({
blocks: [],
return {
...prev,
entities: newEntities,
layout: newLayout,
updatedAt: Date.now(),
};
});
return id;
};
const addGroup = (name = "New group", parentGroupId: string | null) => {
const id = crypto.randomUUID();
const group: LayoutNode = { type: "group", id, name, children: [] };
setWorkspace((prev) => {
if (!prev) return prev;
const newLayout = addLayoutGroup(prev.layout, parentGroupId, group);
return { ...prev, layout: newLayout, updatedAt: Date.now() };
});
return id;
};
const removeNode = (id: string) => {
setWorkspace((prev) => {
if (!prev) return prev;
const { layout: newLayout, removedEntityIds } = removeLayoutNode(
prev.layout,
id,
);
const newEntities = { ...prev.entities } as Record<string, Entity>;
removedEntityIds.forEach((eid) => delete newEntities[eid]);
return {
...prev,
entities: newEntities,
layout: newLayout,
updatedAt: Date.now(),
};
});
};
const renameGroup = (id: string, newName: string) => {
setWorkspace((prev) => {
if (!prev) return prev;
const newLayout = renameLayoutGroup(prev.layout, id, newName);
return { ...prev, layout: newLayout, updatedAt: Date.now() };
});
};
const loadWorkspace = (workspaceId: string | undefined) => {
if (!workspaceId) {
return setWorkspace(undefined);
}
const w = loadWorkspaceFromLocalStorage(workspaceId);
setWorkspace(w);
return w;
};
useEffect(() => {
setTasks([
{ id: "1", title: "Buy groceries", status: "todo" },
{ id: "2", title: "Plan sprint", status: "todo" },
{
id: "3",
title: "Build App",
description: "A simple description",
tags: ["website", "api", "app"],
status: "inprogress",
},
]);
}, []);
if (workspace) saveWorkspaceToLocalStorage(workspace);
}, [workspace]);
return {
name: "Personal Workspace",
entities: {
alpha_tasks: {
name: "Alpha tasks",
kind: "todo",
tasks,
setTasks,
},
beta_tasks: {
name: "Beta tasks",
kind: "todo",
tasks,
setTasks,
},
alpha_doc: {
name: "Alpha docs",
kind: "doc",
doc: doc,
setDoc: setDoc,
},
beta_doc: {
name: "Beta docs",
kind: "doc",
doc: doc2,
setDoc: setDoc2,
},
},
entityLayout: [
{
kind: "group",
name: "Project alpha",
children: [{ id: "alpha_tasks" }, { id: "alpha_doc" }],
},
{
kind: "group",
name: "Project beta",
children: [{ id: "beta_tasks" }, { id: "beta_doc" }],
},
],
route,
setRoute,
workspace,
loadWorkspace,
updateEntity,
renameEntity,
deleteEntity,
addEntity,
addGroup,
removeNode,
renameGroup,
setWorkspace,
};
}
+303
View File
@@ -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 `![${(block.data as any).caption || ""}](${(block.data as any).file.url})`;
default:
return "";
}
})
.join("\n\n");
}
/**
* Convert EditorJS OutputData to HTML
*/
export function docToHtml(content: OutputData): string {
const htmlBlocks = content.blocks.map((block) => {
switch (block.type) {
case "header":
return `<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 "";
}
+199
View File
@@ -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
View File
@@ -1,11 +1,92 @@
import { Color } from "./ui/color";
export type Task = {
id: string;
title: string;
status: string;
description?: string;
tags?: string[];
};
import { OutputData } from "@editorjs/editorjs";
export type SetState<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
View File
@@ -8,7 +8,8 @@ export type Color =
| "blue"
| "purple"
| "pink"
| "red";
| "red"
| string;
interface ColorStyle {
backgroundColor: string;
@@ -59,5 +60,5 @@ export function getColor(color: Color): ColorStyle {
},
};
return colors[color];
return colors[color] || color;
}
+271
View File
@@ -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,
};
}