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 = { export const metadata: Metadata = {
title: "Create Next App", title: "Drift - Productivity App",
description: "Generated by create next app", description: "A powerful client-side productivity application",
}; };
export default function RootLayout({ export default function RootLayout({
+2 -62
View File
@@ -1,65 +1,5 @@
import Image from "next/image"; import { redirect } from "next/navigation";
export default function Home() { export default function Home() {
return ( redirect("/workspace");
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/8 px-5 transition-colors hover:border-transparent hover:bg-black/4 dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
} }
+31 -22
View File
@@ -31,35 +31,44 @@ export default function Workspace() {
else Cookies.remove("workspace"); else Cookies.remove("workspace");
}, [currentWorkspace]); }, [currentWorkspace]);
return workspace ? ( if (!workspace) {
<AppSidebar return (
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>
) : (
<main <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={() => { onClick={() => {
const w = createDefaultWorkspace(); const w = createDefaultWorkspace();
saveWorkspaceToLocalStorage(w); saveWorkspaceToLocalStorage(w);
setCurrentWorkspace(w.id); 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> </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": { case "todo": {
const createTask = (statusId: string) => { const createTask = (statusId: string) => {
const task: Task = { const task: Task = {
id: id(), id: id("task"),
title: "New task", title: "New task",
statusId, statusId,
tagIds: [], tagIds: [],
@@ -44,18 +44,14 @@ export default function Router({
updateEntity(entityId, (e) => ({ updateEntity(entityId, (e) => ({
...e, ...e,
tasks: e.type === "todo" ? { ...e.tasks, task } : {}, tasks: e.type === "todo" ? { ...e.tasks, [task.id]: task } : {},
})); }));
}; };
const setTasks = (tasks: Task[]) => { const setTasks = (tasks: Record<TaskId, Task>) => {
const tasksRecord = tasks.reduce(
(acc, task) => ({ ...acc, [task.id]: task }),
{} as Record<TaskId, Task>,
);
updateEntity(entityId, (e) => ({ updateEntity(entityId, (e) => ({
...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"; } from "@/components/ui/sidebar";
import EntitySwitch from "./Entity"; import EntitySwitch from "./Entity";
const id = (prefix = "t") =>
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -23,32 +20,27 @@ import {
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { Entity, EntityId, Route, SetState, Workspace } from "@/lib/types"; import { Route, SetState, Workspace } from "@/lib/types";
import {
ContextMenu, // Simple ID generator
ContextMenuContent, const id = (prefix = "t") =>
ContextMenuItem, `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
ContextMenuTrigger,
} from "../ui/context-menu";
type AppSidebarProps = { type AppSidebarProps = {
children: React.ReactNode; children: React.ReactNode;
workspace: Workspace; workspace: Workspace;
setRoute: SetState<Route>; setRoute: SetState<Route>;
updateEntity?: (entityId: EntityId, updater: (e: Entity) => Entity) => void;
setWorkspace: SetState<Workspace | undefined>;
}; };
export default function AppSidebar({ export default function AppSidebar({
children, children,
workspace, workspace,
setRoute, setRoute,
updateEntity,
setWorkspace,
}: AppSidebarProps) { }: AppSidebarProps) {
return ( return (
<SidebarProvider> <SidebarProvider>
<Sidebar> <Sidebar>
{/* Header */}
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
@@ -73,25 +65,18 @@ export default function AppSidebar({
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
{/* Sidebar Content */}
<SidebarContent> <SidebarContent>
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="h-full">
<SidebarMenu> <SidebarMenu>
{workspace.layout.map((entity) => ( {workspace.layout.map((node) => (
<EntitySwitch <EntitySwitch
key={entity.id} key={node.type === "group" ? node.id : node.entityId}
entity={entity} node={node}
workspace={workspace} workspace={workspace}
setRoute={setRoute} setRoute={setRoute}
/> />
))} ))}
</SidebarMenu> </SidebarMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent></ContextMenuContent>
</ContextMenu>
</SidebarContent> </SidebarContent>
<SidebarFooter /> <SidebarFooter />
+11 -27
View File
@@ -11,40 +11,24 @@ import {
} from "../ui/sidebar"; } from "../ui/sidebar";
import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { Route, Workspace } from "@/lib/types"; import { Route, Workspace, LayoutNode } from "@/lib/types";
export type SidebarEntity =
| {
kind: "group";
id: string; // ✅ add stable id
name: string;
children: SidebarEntity[];
}
| {
kind?: "item";
id: string;
};
type Props = { type Props = {
entity: SidebarEntity; node: LayoutNode;
workspace: Workspace; workspace: Workspace;
setRoute: (route: Route) => void; setRoute: (route: Route) => void;
}; };
export default function EntitySwitch({ export default function EntitySwitch({ node, workspace, setRoute }: Props) {
entity,
workspace,
setRoute,
}: Props) {
// ---- GROUP ---- // ---- GROUP ----
if (entity.kind === "group") { if (node.type === "group") {
return ( return (
<Collapsible defaultOpen className="group/collapsible"> <Collapsible defaultOpen className="group/collapsible">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel asChild> <SidebarGroupLabel asChild>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<CollapsibleTrigger className="flex w-full items-center"> <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" /> <ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger> </CollapsibleTrigger>
</SidebarMenuButton> </SidebarMenuButton>
@@ -53,10 +37,10 @@ export default function EntitySwitch({
<CollapsibleContent> <CollapsibleContent>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{entity.children.map((child) => ( {node.children.map((child) => (
<EntitySwitch <EntitySwitch
key={child.id} // ✅ stable key key={child.type === "group" ? child.id : child.entityId}
entity={child} node={child}
workspace={workspace} workspace={workspace}
setRoute={setRoute} setRoute={setRoute}
/> />
@@ -70,10 +54,10 @@ export default function EntitySwitch({
} }
// ---- ITEM ---- // ---- ITEM ----
const workspaceEntity = workspace.entities[entity.id]; const workspaceEntity = workspace.entities[node.entityId];
if (!workspaceEntity) { if (!workspaceEntity) {
return null; // defensive against stale layout return null;
} }
return ( return (
@@ -82,7 +66,7 @@ export default function EntitySwitch({
onClick={() => onClick={() =>
setRoute({ setRoute({
workspace: workspace.id, 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 * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui" import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function ContextMenu({ function ContextMenu({
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} /> return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
} }
function ContextMenuTrigger({ function ContextMenuTrigger({
@@ -17,7 +17,7 @@ function ContextMenuTrigger({
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return ( return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} /> <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
) );
} }
function ContextMenuGroup({ function ContextMenuGroup({
@@ -25,7 +25,7 @@ function ContextMenuGroup({
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return ( return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} /> <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
) );
} }
function ContextMenuPortal({ function ContextMenuPortal({
@@ -33,13 +33,13 @@ function ContextMenuPortal({
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return ( return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} /> <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
) );
} }
function ContextMenuSub({ function ContextMenuSub({
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) { }: 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({ function ContextMenuRadioGroup({
@@ -50,7 +50,7 @@ function ContextMenuRadioGroup({
data-slot="context-menu-radio-group" data-slot="context-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function ContextMenuSubTrigger({ function ContextMenuSubTrigger({
@@ -59,22 +59,22 @@ function ContextMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.SubTrigger <ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger" data-slot="context-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
) );
} }
function ContextMenuSubContent({ function ContextMenuSubContent({
@@ -85,12 +85,12 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content" data-slot="context-menu-sub-content"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuContent({ function ContextMenuContent({
@@ -102,13 +102,13 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
data-slot="context-menu-content" data-slot="context-menu-content"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
</ContextMenuPrimitive.Portal> </ContextMenuPrimitive.Portal>
) );
} }
function ContextMenuItem({ function ContextMenuItem({
@@ -117,8 +117,8 @@ function ContextMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
@@ -126,12 +126,12 @@ function ContextMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuCheckboxItem({ function ContextMenuCheckboxItem({
@@ -144,8 +144,8 @@ function ContextMenuCheckboxItem({
<ContextMenuPrimitive.CheckboxItem <ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item" data-slot="context-menu-checkbox-item"
className={cn( 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", "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 className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -157,7 +157,7 @@ function ContextMenuCheckboxItem({
</span> </span>
{children} {children}
</ContextMenuPrimitive.CheckboxItem> </ContextMenuPrimitive.CheckboxItem>
) );
} }
function ContextMenuRadioItem({ function ContextMenuRadioItem({
@@ -169,8 +169,8 @@ function ContextMenuRadioItem({
<ContextMenuPrimitive.RadioItem <ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item" data-slot="context-menu-radio-item"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
> >
@@ -181,7 +181,7 @@ function ContextMenuRadioItem({
</span> </span>
{children} {children}
</ContextMenuPrimitive.RadioItem> </ContextMenuPrimitive.RadioItem>
) );
} }
function ContextMenuLabel({ function ContextMenuLabel({
@@ -189,19 +189,19 @@ function ContextMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.Label <ContextMenuPrimitive.Label
data-slot="context-menu-label" data-slot="context-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuSeparator({ function ContextMenuSeparator({
@@ -214,7 +214,7 @@ function ContextMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function ContextMenuShortcut({ function ContextMenuShortcut({
@@ -226,11 +226,11 @@ function ContextMenuShortcut({
data-slot="context-menu-shortcut" data-slot="context-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -249,4 +249,4 @@ export {
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuRadioGroup, ContextMenuRadioGroup,
} };
+3 -3
View File
@@ -11,11 +11,11 @@ export type TaskViewProps = {
tasks: Record<TaskId, Task>; tasks: Record<TaskId, Task>;
/** Replace entire task array */ /** Update entire tasks record */
setTasks: (tasks: Task[]) => void; setTasks: (tasks: Record<TaskId, Task>) => void;
/** Create a new task with a status */ /** Create a new task with a status */
createTask: (status: string) => void; createTask: (statusId: string) => void;
}; };
export default function TaskView({ export default function TaskView({
+30 -36
View File
@@ -7,12 +7,6 @@ import { PlusIcon } from "lucide-react";
import BoardTask from "./Task"; import BoardTask from "./Task";
import { TaskViewProps } from "../TaskView"; import { TaskViewProps } from "../TaskView";
const statusLabels: Record<string, string> = {
todo: "To do",
inprogress: "In progress",
done: "Done",
};
export default function BoardView({ export default function BoardView({
tags, tags,
tasks, tasks,
@@ -23,35 +17,37 @@ export default function BoardView({
const { draggableId, source, destination } = result; const { draggableId, source, destination } = result;
if (!destination) return; if (!destination) return;
setTasks((tasks) => { const task = Object.values(tasks).find((t) => t.id === draggableId);
const taskIndex = tasks.findIndex((task) => task.id === draggableId); if (!task) return;
const task = tasks[taskIndex];
tasks.splice(taskIndex, 1);
const destinationGroupStartIndex = tasks.findIndex( setTasks({
(t) => t.status === destination.droppableId ...tasks,
); [task.id]: {
const insertIndex =
destinationGroupStartIndex === -1
? tasks.length
: destinationGroupStartIndex + destination.index;
tasks.splice(insertIndex, 0, {
...task, ...task,
status: destination.droppableId, statusId: destination.droppableId,
}); updatedAt: Date.now(),
},
return tasks;
}); });
} }
// 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 ( return (
<div className="w-full h-full flex flex-col gap-6"> <div className="w-full h-full flex flex-col gap-6">
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
{Object.keys(statusLabels).map((status) => ( {tasksByStatus.map(({ statusId, tasks: statusTasks }) => (
<Droppable key={status} droppableId={status}> <Droppable key={statusId} droppableId={statusId}>
{(provided) => ( {(provided) => (
<Card <Card
ref={provided.innerRef} ref={provided.innerRef}
@@ -60,12 +56,12 @@ export default function BoardView({
> >
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span>{statusLabels[status]}</span> <span>{statusId}</span>
<Button <Button
size="sm" size="sm"
className="ml-auto" className="ml-auto"
variant="outline" variant="outline"
onClick={() => createTask(status)} onClick={() => createTask(statusId)}
> >
<PlusIcon /> <PlusIcon />
</Button> </Button>
@@ -73,9 +69,7 @@ export default function BoardView({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tasks {statusTasks.map((task, index) => (
.filter((task) => task.status === status)
.map((task, index) => (
<Draggable <Draggable
key={task.id} key={task.id}
draggableId={task.id} draggableId={task.id}
@@ -84,9 +78,10 @@ export default function BoardView({
{(provided) => ( {(provided) => (
<BoardTask <BoardTask
setTask={(t) => setTask={(t) =>
setTasks((cols) => setTasks({
cols.map((c) => (c.id === t.id ? t : c)) ...tasks,
) [t.id]: t,
})
} }
provided={provided} provided={provided}
task={task} task={task}
@@ -96,8 +91,7 @@ export default function BoardView({
</Draggable> </Draggable>
))} ))}
{provided.placeholder} {provided.placeholder}
{tasks.filter((task) => task.status === status).length === {statusTasks.length === 0 && (
0 && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No tasks No tasks
</p> </p>
+35 -11
View File
@@ -7,12 +7,12 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { SheetTrigger } from "@/components/ui/sheet"; import { SheetTrigger } from "@/components/ui/sheet";
import { Task } from "@/lib/types"; import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color"; import { Color, getColor } from "@/lib/ui/color";
import { DraggableProvided } from "@hello-pangea/dnd"; import { DraggableProvided } from "@hello-pangea/dnd";
import TaskEdit from "../edit/TaskEdit"; import TaskEdit from "../edit/TaskEdit";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { NotepadText } from "lucide-react"; import { NotepadText, Trash2 } from "lucide-react";
import DocDialog from "../edit/DocDialog"; import DocDialog from "../edit/DocDialog";
export default function BoardTask({ export default function BoardTask({
@@ -20,11 +20,13 @@ export default function BoardTask({
provided, provided,
setTask, setTask,
tags, tags,
onDelete,
}: { }: {
task: Task; task: Task;
setTask: (t: Task) => void; setTask: (t: Task) => void;
provided: DraggableProvided; provided: DraggableProvided;
tags: Record<string, { color: Color; name: string }>; tags: Record<TagId, Tag>;
onDelete?: (taskId: string) => void;
}) { }) {
return ( return (
<TaskEdit task={task} tags={tags} setTask={setTask}> <TaskEdit task={task} tags={tags} setTask={setTask}>
@@ -33,11 +35,12 @@ export default function BoardTask({
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
className="bg-background" className="bg-background cursor-grab active:cursor-grabbing"
> >
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <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}> <DocDialog task={task} setTask={setTask} tags={tags}>
<Button <Button
size="sm" size="sm"
@@ -47,20 +50,41 @@ export default function BoardTask({
e.stopPropagation(); e.stopPropagation();
}} }}
> >
<NotepadText /> <NotepadText className="h-4 w-4" />
</Button> </Button>
</DocDialog> </DocDialog>
{onDelete && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
onDelete(task.id);
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</CardTitle> </CardTitle>
<CardDescription>{task.description}</CardDescription> {task.description && (
<CardDescription className="text-xs line-clamp-2">
{task.description}
</CardDescription>
)}
</CardHeader> </CardHeader>
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden"> <CardContent className="flex flex-wrap gap-1.5 overflow-hidden">
{task.tags?.map((tag, i) => ( {task.tagIds?.map((tagId) => (
<Badge <Badge
key={tag} key={tagId}
variant="outline" variant="outline"
style={getColor(tags[tag].color)} style={
tags[tagId]
? getColor(tags[tagId].color || "#808080")
: undefined
}
> >
{tag} {tags[tagId]?.name || tagId}
</Badge> </Badge>
))} ))}
</CardContent> </CardContent>
+16 -8
View File
@@ -10,8 +10,8 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import DocView from "../DocView"; import DocView from "../DocView";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Task } from "@/lib/types"; import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color"; import { getColor } from "@/lib/ui/color";
import { useState } from "react"; import { useState } from "react";
import { OutputData } from "@editorjs/editorjs"; import { OutputData } from "@editorjs/editorjs";
@@ -23,10 +23,14 @@ export default function DocDialog({
}: { }: {
task: Task; task: Task;
setTask: (t: Task) => void; setTask: (t: Task) => void;
tags: Record<string, { color: Color; name: string }>; tags: Record<TagId, Tag>;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [doc, setDoc] = useState<OutputData>({ blocks: [] }); const [doc, setDoc] = useState<OutputData>({
version: "2.28.0",
time: Date.now(),
blocks: [],
});
return ( return (
<Dialog> <Dialog>
@@ -41,13 +45,17 @@ export default function DocDialog({
<DialogDescription>{task.description}</DialogDescription> <DialogDescription>{task.description}</DialogDescription>
<DialogHeader> <DialogHeader>
<div className="flex flex-wrap gap-1.5 overflow-hidden"> <div className="flex flex-wrap gap-1.5 overflow-hidden">
{task.tags?.map((tag, i) => ( {task.tagIds?.map((tagId) => (
<Badge <Badge
key={tag} key={tagId}
variant="outline" variant="outline"
style={getColor(tags[tag].color)} style={
tags[tagId]
? getColor(tags[tagId].color || "#808080")
: undefined
}
> >
{tag} {tags[tagId]?.name || tagId}
</Badge> </Badge>
))} ))}
</div> </div>
+26 -10
View File
@@ -8,7 +8,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Task } from "@/lib/types"; import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color"; import { Color, getColor } from "@/lib/ui/color";
import { Button } from "../../ui/button"; import { Button } from "../../ui/button";
import DocDialog from "./DocDialog"; import DocDialog from "./DocDialog";
@@ -21,7 +21,7 @@ export default function TaskEdit({
}: { }: {
task: Task; task: Task;
setTask: (t: Task) => void; setTask: (t: Task) => void;
tags: Record<string, { color: Color; name: string }>; tags: Record<TagId, Tag>;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
@@ -38,16 +38,26 @@ export default function TaskEdit({
<div className="grid gap-3"> <div className="grid gap-3">
<Label>Title</Label> <Label>Title</Label>
<Input <Input
defaultValue={task.title} value={task.title}
onChange={(e) => setTask({ ...task, title: e.target.value })} onChange={(e) =>
setTask({
...task,
title: e.target.value,
updatedAt: Date.now(),
})
}
/> />
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
<Label>Description</Label> <Label>Description</Label>
<Input <Input
defaultValue={task.description} value={task.description || ""}
onChange={(e) => onChange={(e) =>
setTask({ ...task, description: e.target.value }) setTask({
...task,
description: e.target.value,
updatedAt: Date.now(),
})
} }
/> />
</div> </div>
@@ -58,12 +68,18 @@ export default function TaskEdit({
label: tag.name, label: tag.name,
value: id, value: id,
style: { style: {
iconColor: getColor(tag.color).color, iconColor: getColor(tag.color || "#808080").color,
badgeColor: getColor(tag.color).backgroundColor, badgeColor: getColor(tag.color || "#808080").backgroundColor,
}, },
}))} }))}
onValueChange={(tags) => setTask({ ...task, tags })} onValueChange={(tagIds) =>
defaultValue={task.tags} setTask({
...task,
tagIds: tagIds as TagId[],
updatedAt: Date.now(),
})
}
defaultValue={task.tagIds}
/> />
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
+10 -3
View File
@@ -15,6 +15,10 @@ import { getColor } from "@/lib/ui/color";
import TableTask from "./Task"; import TableTask from "./Task";
export function TableView({ tasks, setTasks, tags }: TaskViewProps) { export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
const tasksArray = Object.values(tasks).sort(
(a, b) => b.createdAt - a.createdAt,
);
return ( return (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -26,7 +30,7 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{tasks.length === 0 ? ( {tasksArray.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={5} colSpan={5}
@@ -36,12 +40,15 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
tasks.map((task) => ( tasksArray.map((task) => (
<TableTask <TableTask
key={task.id} key={task.id}
task={task} task={task}
setTask={(t) => setTask={(t) =>
setTasks((tasks) => tasks.map((c) => (c.id === t.id ? t : c))) setTasks({
...tasks,
[t.id]: t,
})
} }
tags={tags} tags={tags}
/> />
+32 -20
View File
@@ -1,50 +1,62 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { SheetTrigger } from "@/components/ui/sheet"; import { SheetTrigger } from "@/components/ui/sheet";
import { TableCell, TableRow } from "@/components/ui/table"; import { TableCell, TableRow } from "@/components/ui/table";
import { Task } from "@/lib/types"; import { Task, Tag, TagId } from "@/lib/types";
import { Color, getColor } from "@/lib/ui/color"; import { Color, getColor } from "@/lib/ui/color";
import TaskEdit from "../edit/TaskEdit"; import TaskEdit from "../edit/TaskEdit";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
export default function TableTask({ export default function TableTask({
task, task,
setTask, setTask,
tags, tags,
onDelete,
}: { }: {
task: Task; task: Task;
setTask: (t: Task) => void; setTask: (t: Task) => void;
tags: Record<string, { color: Color; name: string }>; tags: Record<TagId, Tag>;
onDelete?: (taskId: string) => void;
}) { }) {
return ( return (
<TaskEdit task={task} tags={tags} setTask={setTask}> <TaskEdit task={task} tags={tags} setTask={setTask}>
<SheetTrigger asChild> <SheetTrigger asChild>
<TableRow> <TableRow className="cursor-pointer hover:bg-accent">
<TableCell className="font-medium">{task.title}</TableCell> <TableCell className="font-medium">{task.title}</TableCell>
<TableCell className="max-w-[250px] truncate"> <TableCell className="max-w-[250px] truncate">
{task.description || "-"} {task.description || "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge 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 <Badge
variant={ key={tagId}
task.status === "done" variant="outline"
? "default" style={
: task.status === "in-progress" tags[tagId]
? "secondary" ? getColor(tags[tagId].color || "#808080")
: "outline" : undefined
} }
> >
{task.status} {tags[tagId]?.name || tagId}
</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}
</Badge> </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> </TableCell>
</TableRow> </TableRow>
</SheetTrigger> </SheetTrigger>
+6 -5
View File
@@ -22,14 +22,15 @@ export interface Workspace {
updatedAt: number; updatedAt: number;
} }
export type LayoutNode = { export type LayoutNode =
| {
type: "group"; type: "group";
id: string; id: string;
name: string; name: string;
children: LayoutItem[]; children: LayoutNode[];
}; }
| {
export type LayoutItem = { type: "item";
entityId: EntityId; entityId: EntityId;
}; };
+3 -2
View File
@@ -8,7 +8,8 @@ export type Color =
| "blue" | "blue"
| "purple" | "purple"
| "pink" | "pink"
| "red"; | "red"
| string;
interface ColorStyle { interface ColorStyle {
backgroundColor: string; backgroundColor: string;
@@ -59,5 +60,5 @@ export function getColor(color: Color): ColorStyle {
}, },
}; };
return colors[color]; return colors[color] || color;
} }
+52 -5
View File
@@ -1,13 +1,60 @@
import { Workspace } from "./types"; import { Workspace, TodoEntity, DocEntity, Status } from "./types";
export function createDefaultWorkspace(): Workspace { export function createDefaultWorkspace(): Workspace {
const now = Date.now(); 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 { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: "New Workspace", name: "My Workspace",
entities: {}, entities: {
layout: [], [todoId]: todoEntity,
[docId]: docEntity,
},
layout: [
{
type: "group",
id: crypto.randomUUID(),
name: "Productivity",
children: [
{ entityId: todoId, type: "item" },
{ entityId: docId, type: "item" },
],
},
],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -27,7 +74,7 @@ export function loadWorkspaceFromLocalStorage(id: string): Workspace {
if ( if (
typeof parsed !== "object" || typeof parsed !== "object" ||
parsed === null || parsed === null ||
typeof parsed.version !== "number" typeof parsed.id !== "string"
) { ) {
console.warn("Invalid workspace shape. Resetting."); console.warn("Invalid workspace shape. Resetting.");
return createDefaultWorkspace(); return createDefaultWorkspace();