Working system

This commit is contained in:
2026-02-27 22:51:02 +01:00
parent 162e2b3d3f
commit 5895974af2
17 changed files with 356 additions and 332 deletions
+2 -2
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({
+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");
}
+31 -22
View File
@@ -31,35 +31,44 @@ export default function Workspace() {
else Cookies.remove("workspace");
}, [currentWorkspace]);
return workspace ? (
<AppSidebar
workspace={workspace}
setRoute={setRoute}
updateEntity={updateEntity}
setWorkspace={setWorkspace}
>
<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[route.entity || ""]?.name}
</header>
</div>
</SidebarTrigger>
<Router workspace={workspace} route={route} updateEntity={updateEntity} />
</AppSidebar>
) : (
if (!workspace) {
return (
<main
className="flex h-svh items-center justify-center"
className="flex h-svh items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-slate-900 dark:to-slate-800"
onClick={() => {
const w = createDefaultWorkspace();
saveWorkspaceToLocalStorage(w);
setCurrentWorkspace(w.id);
}}
>
<Button>Create a new workspace</Button>
<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} setRoute={setRoute}>
<SidebarTrigger className="w-full px-4">
<div className="px-4">
<header className="w-full border-b pb-3 pt-2 hover:bg-accent/20 flex items-center text-foreground/90 gap-2">
<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} route={route} updateEntity={updateEntity} />
</AppSidebar>
);
}
+4 -8
View File
@@ -34,7 +34,7 @@ export default function Router({
case "todo": {
const createTask = (statusId: string) => {
const task: Task = {
id: id(),
id: id("task"),
title: "New task",
statusId,
tagIds: [],
@@ -44,18 +44,14 @@ export default function Router({
updateEntity(entityId, (e) => ({
...e,
tasks: e.type === "todo" ? { ...e.tasks, task } : {},
tasks: e.type === "todo" ? { ...e.tasks, [task.id]: task } : {},
}));
};
const setTasks = (tasks: Task[]) => {
const tasksRecord = tasks.reduce(
(acc, task) => ({ ...acc, [task.id]: task }),
{} as Record<TaskId, Task>,
);
const setTasks = (tasks: Record<TaskId, Task>) => {
updateEntity(entityId, (e) => ({
...e,
tasks: e.type === "todo" ? tasksRecord : {},
tasks: e.type === "todo" ? tasks : {},
}));
};
+10 -25
View File
@@ -12,9 +12,6 @@ import {
} from "@/components/ui/sidebar";
import EntitySwitch from "./Entity";
const id = (prefix = "t") =>
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,32 +20,27 @@ import {
} from "../ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
import { Entity, EntityId, Route, SetState, Workspace } from "@/lib/types";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import { Route, SetState, Workspace } from "@/lib/types";
// Simple ID generator
const id = (prefix = "t") =>
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
type AppSidebarProps = {
children: React.ReactNode;
workspace: Workspace;
setRoute: SetState<Route>;
updateEntity?: (entityId: EntityId, updater: (e: Entity) => Entity) => void;
setWorkspace: SetState<Workspace | undefined>;
};
export default function AppSidebar({
children,
workspace,
setRoute,
updateEntity,
setWorkspace,
}: AppSidebarProps) {
return (
<SidebarProvider>
<Sidebar>
{/* Header */}
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -73,25 +65,18 @@ export default function AppSidebar({
</SidebarMenu>
</SidebarHeader>
{/* Sidebar Content */}
<SidebarContent>
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="h-full">
<SidebarMenu>
{workspace.layout.map((entity) => (
{workspace.layout.map((node) => (
<EntitySwitch
key={entity.id}
entity={entity}
key={node.type === "group" ? node.id : node.entityId}
node={node}
workspace={workspace}
setRoute={setRoute}
/>
))}
</SidebarMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent></ContextMenuContent>
</ContextMenu>
</SidebarContent>
<SidebarFooter />
+11 -27
View File
@@ -11,40 +11,24 @@ import {
} from "../ui/sidebar";
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { ChevronDown } from "lucide-react";
import { Route, Workspace } from "@/lib/types";
export type SidebarEntity =
| {
kind: "group";
id: string; // ✅ add stable id
name: string;
children: SidebarEntity[];
}
| {
kind?: "item";
id: string;
};
import { Route, Workspace, LayoutNode } from "@/lib/types";
type Props = {
entity: SidebarEntity;
node: LayoutNode;
workspace: Workspace;
setRoute: (route: Route) => void;
};
export default function EntitySwitch({
entity,
workspace,
setRoute,
}: Props) {
export default function EntitySwitch({ node, workspace, setRoute }: Props) {
// ---- GROUP ----
if (entity.kind === "group") {
if (node.type === "group") {
return (
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel asChild>
<SidebarMenuButton asChild>
<CollapsibleTrigger className="flex w-full items-center">
{entity.name}
{node.name}
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
</SidebarMenuButton>
@@ -53,10 +37,10 @@ export default function EntitySwitch({
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{entity.children.map((child) => (
{node.children.map((child) => (
<EntitySwitch
key={child.id} // ✅ stable key
entity={child}
key={child.type === "group" ? child.id : child.entityId}
node={child}
workspace={workspace}
setRoute={setRoute}
/>
@@ -70,10 +54,10 @@ export default function EntitySwitch({
}
// ---- ITEM ----
const workspaceEntity = workspace.entities[entity.id];
const workspaceEntity = workspace.entities[node.entityId];
if (!workspaceEntity) {
return null; // defensive against stale layout
return null;
}
return (
@@ -82,7 +66,7 @@ export default function EntitySwitch({
onClick={() =>
setRoute({
workspace: workspace.id,
entity: entity.id,
entity: node.entityId,
})
}
>
+40 -40
View File
@@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
@@ -17,7 +17,7 @@ function ContextMenuTrigger({
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
);
}
function ContextMenuGroup({
@@ -25,7 +25,7 @@ function ContextMenuGroup({
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
);
}
function ContextMenuPortal({
@@ -33,13 +33,13 @@ function ContextMenuPortal({
}: 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} />
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
@@ -50,7 +50,7 @@ function ContextMenuRadioGroup({
data-slot="context-menu-radio-group"
{...props}
/>
)
);
}
function ContextMenuSubTrigger({
@@ -59,22 +59,22 @@ function ContextMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
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
"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({
@@ -85,12 +85,12 @@ function ContextMenuSubContent({
<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-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
"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({
@@ -102,13 +102,13 @@ function ContextMenuContent({
<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-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
"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({
@@ -117,8 +117,8 @@ function ContextMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
@@ -126,12 +126,12 @@ function ContextMenuItem({
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
"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({
@@ -144,8 +144,8 @@ function ContextMenuCheckboxItem({
<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
"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}
@@ -157,7 +157,7 @@ function ContextMenuCheckboxItem({
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
);
}
function ContextMenuRadioItem({
@@ -169,8 +169,8 @@ function ContextMenuRadioItem({
<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
"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}
>
@@ -181,7 +181,7 @@ function ContextMenuRadioItem({
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
);
}
function ContextMenuLabel({
@@ -189,19 +189,19 @@ function ContextMenuLabel({
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
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
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
className,
)}
{...props}
/>
)
);
}
function ContextMenuSeparator({
@@ -214,7 +214,7 @@ function ContextMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function ContextMenuShortcut({
@@ -226,11 +226,11 @@ function ContextMenuShortcut({
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -249,4 +249,4 @@ export {
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
};
+3 -3
View File
@@ -11,11 +11,11 @@ export type TaskViewProps = {
tasks: Record<TaskId, Task>;
/** Replace entire task array */
setTasks: (tasks: Task[]) => void;
/** Update entire tasks record */
setTasks: (tasks: Record<TaskId, Task>) => void;
/** Create a new task with a status */
createTask: (status: string) => void;
createTask: (statusId: string) => void;
};
export default function TaskView({
+30 -36
View File
@@ -7,12 +7,6 @@ import { PlusIcon } from "lucide-react";
import BoardTask from "./Task";
import { TaskViewProps } from "../TaskView";
const statusLabels: Record<string, string> = {
todo: "To do",
inprogress: "In progress",
done: "Done",
};
export default function BoardView({
tags,
tasks,
@@ -23,35 +17,37 @@ export default function BoardView({
const { draggableId, source, destination } = result;
if (!destination) return;
setTasks((tasks) => {
const taskIndex = tasks.findIndex((task) => task.id === draggableId);
const task = tasks[taskIndex];
tasks.splice(taskIndex, 1);
const task = Object.values(tasks).find((t) => t.id === draggableId);
if (!task) return;
const destinationGroupStartIndex = tasks.findIndex(
(t) => t.status === destination.droppableId
);
const insertIndex =
destinationGroupStartIndex === -1
? tasks.length
: destinationGroupStartIndex + destination.index;
tasks.splice(insertIndex, 0, {
setTasks({
...tasks,
[task.id]: {
...task,
status: destination.droppableId,
});
return tasks;
statusId: destination.droppableId,
updatedAt: Date.now(),
},
});
}
// Group tasks by status, using statuses from first available todo entity
const tasksArray = Object.values(tasks);
const statusIds = new Set(tasksArray.map((t) => t.statusId));
// Sort tasks by creation date within each status
const tasksByStatus = Array.from(statusIds).map((statusId) => ({
statusId,
tasks: tasksArray
.filter((t) => t.statusId === statusId)
.sort((a, b) => a.createdAt - b.createdAt),
}));
return (
<div className="w-full h-full flex flex-col gap-6">
<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}>
{tasksByStatus.map(({ statusId, tasks: statusTasks }) => (
<Droppable key={statusId} droppableId={statusId}>
{(provided) => (
<Card
ref={provided.innerRef}
@@ -60,12 +56,12 @@ export default function BoardView({
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{statusLabels[status]}</span>
<span>{statusId}</span>
<Button
size="sm"
className="ml-auto"
variant="outline"
onClick={() => createTask(status)}
onClick={() => createTask(statusId)}
>
<PlusIcon />
</Button>
@@ -73,9 +69,7 @@ export default function BoardView({
</CardHeader>
<CardContent>
<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}
@@ -84,9 +78,10 @@ export default function BoardView({
{(provided) => (
<BoardTask
setTask={(t) =>
setTasks((cols) =>
cols.map((c) => (c.id === t.id ? t : c))
)
setTasks({
...tasks,
[t.id]: t,
})
}
provided={provided}
task={task}
@@ -96,8 +91,7 @@ export default function BoardView({
</Draggable>
))}
{provided.placeholder}
{tasks.filter((task) => task.status === status).length ===
0 && (
{statusTasks.length === 0 && (
<p className="text-sm text-muted-foreground">
No tasks
</p>
+35 -11
View File
@@ -7,12 +7,12 @@ import {
CardTitle,
} from "@/components/ui/card";
import { SheetTrigger } from "@/components/ui/sheet";
import { Task } from "@/lib/types";
import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color";
import { DraggableProvided } from "@hello-pangea/dnd";
import TaskEdit from "../edit/TaskEdit";
import { Button } from "@/components/ui/button";
import { NotepadText } from "lucide-react";
import { NotepadText, Trash2 } from "lucide-react";
import DocDialog from "../edit/DocDialog";
export default function BoardTask({
@@ -20,11 +20,13 @@ export default function BoardTask({
provided,
setTask,
tags,
onDelete,
}: {
task: Task;
setTask: (t: Task) => void;
provided: DraggableProvided;
tags: Record<string, { color: Color; name: string }>;
tags: Record<TagId, Tag>;
onDelete?: (taskId: string) => void;
}) {
return (
<TaskEdit task={task} tags={tags} setTask={setTask}>
@@ -33,11 +35,12 @@ export default function BoardTask({
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="bg-background"
className="bg-background cursor-grab active:cursor-grabbing"
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{task.title}</span>
<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 +50,41 @@ export default function BoardTask({
e.stopPropagation();
}}
>
<NotepadText />
<NotepadText className="h-4 w-4" />
</Button>
</DocDialog>
{onDelete && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
onDelete(task.id);
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</CardTitle>
<CardDescription>{task.description}</CardDescription>
{task.description && (
<CardDescription className="text-xs line-clamp-2">
{task.description}
</CardDescription>
)}
</CardHeader>
<CardContent 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>
))}
</CardContent>
+16 -8
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,14 @@ 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: [] });
const [doc, setDoc] = useState<OutputData>({
version: "2.28.0",
time: Date.now(),
blocks: [],
});
return (
<Dialog>
@@ -41,13 +45,17 @@ 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>
+26 -10
View File
@@ -8,7 +8,7 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Task } from "@/lib/types";
import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color";
import { Button } from "../../ui/button";
import DocDialog from "./DocDialog";
@@ -21,7 +21,7 @@ export default function TaskEdit({
}: {
task: Task;
setTask: (t: Task) => void;
tags: Record<string, { color: Color; name: string }>;
tags: Record<TagId, Tag>;
children: React.ReactNode;
}) {
return (
@@ -38,16 +38,26 @@ 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>
@@ -58,12 +68,18 @@ export default function TaskEdit({
label: tag.name,
value: id,
style: {
iconColor: getColor(tag.color).color,
badgeColor: getColor(tag.color).backgroundColor,
iconColor: getColor(tag.color || "#808080").color,
badgeColor: getColor(tag.color || "#808080").backgroundColor,
},
}))}
onValueChange={(tags) => setTask({ ...task, tags })}
defaultValue={task.tags}
onValueChange={(tagIds) =>
setTask({
...task,
tagIds: tagIds as TagId[],
updatedAt: Date.now(),
})
}
defaultValue={task.tagIds}
/>
</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>
+7 -6
View File
@@ -22,16 +22,17 @@ export interface Workspace {
updatedAt: number;
}
export type LayoutNode = {
export type LayoutNode =
| {
type: "group";
id: string;
name: string;
children: LayoutItem[];
};
export type LayoutItem = {
children: LayoutNode[];
}
| {
type: "item";
entityId: EntityId;
};
};
export type Entity = TodoEntity | DocEntity;
+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;
}
+52 -5
View File
@@ -1,13 +1,60 @@
import { Workspace } from "./types";
import { Workspace, TodoEntity, DocEntity, Status } from "./types";
export function createDefaultWorkspace(): Workspace {
const now = Date.now();
const todoId = crypto.randomUUID();
const docId = crypto.randomUUID();
const defaultStatuses: Status[] = [
{ id: "status_todo", name: "To Do", order: 0, color: "#808080" },
{
id: "status_inprogress",
name: "In Progress",
order: 1,
color: "#0ea5e9",
},
{ id: "status_done", name: "Done", order: 2, color: "#10b981" },
];
const todoEntity: TodoEntity = {
id: todoId,
type: "todo",
name: "Tasks",
config: { statuses: defaultStatuses },
tags: {},
tasks: {},
createdAt: now,
updatedAt: now,
};
const docEntity: DocEntity = {
id: docId,
type: "doc",
name: "Notes",
content: { version: "2.28.0", time: now, blocks: [] },
createdAt: now,
updatedAt: now,
};
return {
id: crypto.randomUUID(),
name: "New Workspace",
entities: {},
layout: [],
name: "My Workspace",
entities: {
[todoId]: todoEntity,
[docId]: docEntity,
},
layout: [
{
type: "group",
id: crypto.randomUUID(),
name: "Productivity",
children: [
{ entityId: todoId, type: "item" },
{ entityId: docId, type: "item" },
],
},
],
createdAt: now,
updatedAt: now,
};
@@ -27,7 +74,7 @@ export function loadWorkspaceFromLocalStorage(id: string): Workspace {
if (
typeof parsed !== "object" ||
parsed === null ||
typeof parsed.version !== "number"
typeof parsed.id !== "string"
) {
console.warn("Invalid workspace shape. Resetting.");
return createDefaultWorkspace();