Working system
This commit is contained in:
+2
-2
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 : {},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { getColor } from "@/lib/ui/color";
|
|||||||
import TableTask from "./Task";
|
import TableTask from "./Task";
|
||||||
|
|
||||||
export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
||||||
|
const tasksArray = Object.values(tasks).sort(
|
||||||
|
(a, b) => b.createdAt - a.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -26,7 +30,7 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tasks.length === 0 ? (
|
{tasksArray.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={5}
|
colSpan={5}
|
||||||
@@ -36,12 +40,15 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
tasks.map((task) => (
|
tasksArray.map((task) => (
|
||||||
<TableTask
|
<TableTask
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
setTask={(t) =>
|
setTask={(t) =>
|
||||||
setTasks((tasks) => tasks.map((c) => (c.id === t.id ? t : c)))
|
setTasks({
|
||||||
|
...tasks,
|
||||||
|
[t.id]: t,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,50 +1,62 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { SheetTrigger } from "@/components/ui/sheet";
|
import { SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { TableCell, TableRow } from "@/components/ui/table";
|
import { TableCell, TableRow } from "@/components/ui/table";
|
||||||
import { Task } from "@/lib/types";
|
import { Task, Tag, TagId } from "@/lib/types";
|
||||||
import { Color, getColor } from "@/lib/ui/color";
|
import { Color, getColor } from "@/lib/ui/color";
|
||||||
import TaskEdit from "../edit/TaskEdit";
|
import TaskEdit from "../edit/TaskEdit";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
export default function TableTask({
|
export default function TableTask({
|
||||||
task,
|
task,
|
||||||
setTask,
|
setTask,
|
||||||
tags,
|
tags,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
setTask: (t: Task) => void;
|
setTask: (t: Task) => void;
|
||||||
tags: Record<string, { color: Color; name: string }>;
|
tags: Record<TagId, Tag>;
|
||||||
|
onDelete?: (taskId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<TableRow>
|
<TableRow className="cursor-pointer hover:bg-accent">
|
||||||
<TableCell className="font-medium">{task.title}</TableCell>
|
<TableCell className="font-medium">{task.title}</TableCell>
|
||||||
<TableCell className="max-w-[250px] truncate">
|
<TableCell className="max-w-[250px] truncate">
|
||||||
{task.description || "-"}
|
{task.description || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<Badge 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
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user