Working system
This commit is contained in:
@@ -34,7 +34,7 @@ export default function Router({
|
||||
case "todo": {
|
||||
const createTask = (statusId: string) => {
|
||||
const task: Task = {
|
||||
id: id(),
|
||||
id: id("task"),
|
||||
title: "New task",
|
||||
statusId,
|
||||
tagIds: [],
|
||||
@@ -44,18 +44,14 @@ export default function Router({
|
||||
|
||||
updateEntity(entityId, (e) => ({
|
||||
...e,
|
||||
tasks: e.type === "todo" ? { ...e.tasks, task } : {},
|
||||
tasks: e.type === "todo" ? { ...e.tasks, [task.id]: task } : {},
|
||||
}));
|
||||
};
|
||||
|
||||
const setTasks = (tasks: Task[]) => {
|
||||
const tasksRecord = tasks.reduce(
|
||||
(acc, task) => ({ ...acc, [task.id]: task }),
|
||||
{} as Record<TaskId, Task>,
|
||||
);
|
||||
const setTasks = (tasks: Record<TaskId, Task>) => {
|
||||
updateEntity(entityId, (e) => ({
|
||||
...e,
|
||||
tasks: e.type === "todo" ? tasksRecord : {},
|
||||
tasks: e.type === "todo" ? tasks : {},
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
import EntitySwitch from "./Entity";
|
||||
const id = (prefix = "t") =>
|
||||
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -23,32 +20,27 @@ import {
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Entity, EntityId, Route, SetState, Workspace } from "@/lib/types";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import { Route, SetState, Workspace } from "@/lib/types";
|
||||
|
||||
// Simple ID generator
|
||||
const id = (prefix = "t") =>
|
||||
`${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
type AppSidebarProps = {
|
||||
children: React.ReactNode;
|
||||
workspace: Workspace;
|
||||
setRoute: SetState<Route>;
|
||||
updateEntity?: (entityId: EntityId, updater: (e: Entity) => Entity) => void;
|
||||
setWorkspace: SetState<Workspace | undefined>;
|
||||
};
|
||||
|
||||
export default function AppSidebar({
|
||||
children,
|
||||
workspace,
|
||||
setRoute,
|
||||
updateEntity,
|
||||
setWorkspace,
|
||||
}: AppSidebarProps) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
{/* Header */}
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -73,25 +65,18 @@ export default function AppSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<SidebarContent>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="h-full">
|
||||
<SidebarMenu>
|
||||
{workspace.layout.map((entity) => (
|
||||
<EntitySwitch
|
||||
key={entity.id}
|
||||
entity={entity}
|
||||
workspace={workspace}
|
||||
setRoute={setRoute}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent></ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<SidebarMenu>
|
||||
{workspace.layout.map((node) => (
|
||||
<EntitySwitch
|
||||
key={node.type === "group" ? node.id : node.entityId}
|
||||
node={node}
|
||||
workspace={workspace}
|
||||
setRoute={setRoute}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter />
|
||||
|
||||
@@ -11,40 +11,24 @@ import {
|
||||
} from "../ui/sidebar";
|
||||
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Route, Workspace } from "@/lib/types";
|
||||
|
||||
export type SidebarEntity =
|
||||
| {
|
||||
kind: "group";
|
||||
id: string; // ✅ add stable id
|
||||
name: string;
|
||||
children: SidebarEntity[];
|
||||
}
|
||||
| {
|
||||
kind?: "item";
|
||||
id: string;
|
||||
};
|
||||
import { Route, Workspace, LayoutNode } from "@/lib/types";
|
||||
|
||||
type Props = {
|
||||
entity: SidebarEntity;
|
||||
node: LayoutNode;
|
||||
workspace: Workspace;
|
||||
setRoute: (route: Route) => void;
|
||||
};
|
||||
|
||||
export default function EntitySwitch({
|
||||
entity,
|
||||
workspace,
|
||||
setRoute,
|
||||
}: Props) {
|
||||
export default function EntitySwitch({ node, workspace, setRoute }: Props) {
|
||||
// ---- GROUP ----
|
||||
if (entity.kind === "group") {
|
||||
if (node.type === "group") {
|
||||
return (
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<SidebarMenuButton asChild>
|
||||
<CollapsibleTrigger className="flex w-full items-center">
|
||||
{entity.name}
|
||||
{node.name}
|
||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarMenuButton>
|
||||
@@ -53,10 +37,10 @@ export default function EntitySwitch({
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{entity.children.map((child) => (
|
||||
{node.children.map((child) => (
|
||||
<EntitySwitch
|
||||
key={child.id} // ✅ stable key
|
||||
entity={child}
|
||||
key={child.type === "group" ? child.id : child.entityId}
|
||||
node={child}
|
||||
workspace={workspace}
|
||||
setRoute={setRoute}
|
||||
/>
|
||||
@@ -70,10 +54,10 @@ export default function EntitySwitch({
|
||||
}
|
||||
|
||||
// ---- ITEM ----
|
||||
const workspaceEntity = workspace.entities[entity.id];
|
||||
const workspaceEntity = workspace.entities[node.entityId];
|
||||
|
||||
if (!workspaceEntity) {
|
||||
return null; // defensive against stale layout
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -82,7 +66,7 @@ export default function EntitySwitch({
|
||||
onClick={() =>
|
||||
setRoute({
|
||||
workspace: workspace.id,
|
||||
entity: entity.id,
|
||||
entity: node.entityId,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -90,4 +74,4 @@ export default function EntitySwitch({
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
@@ -17,7 +17,7 @@ function ContextMenuTrigger({
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
@@ -25,7 +25,7 @@ function ContextMenuGroup({
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
@@ -33,13 +33,13 @@ function ContextMenuPortal({
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
@@ -50,7 +50,7 @@ function ContextMenuRadioGroup({
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
@@ -59,22 +59,22 @@ function ContextMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
@@ -85,12 +85,12 @@ function ContextMenuSubContent({
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
@@ -102,13 +102,13 @@ function ContextMenuContent({
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
@@ -117,8 +117,8 @@ function ContextMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
@@ -126,12 +126,12 @@ function ContextMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
@@ -144,8 +144,8 @@ function ContextMenuCheckboxItem({
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -157,7 +157,7 @@ function ContextMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
@@ -169,8 +169,8 @@ function ContextMenuRadioItem({
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -181,7 +181,7 @@ function ContextMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
@@ -189,19 +189,19 @@ function ContextMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
@@ -214,7 +214,7 @@ function ContextMenuSeparator({
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
@@ -226,11 +226,11 @@ function ContextMenuShortcut({
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -249,4 +249,4 @@ export {
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,11 +11,11 @@ export type TaskViewProps = {
|
||||
|
||||
tasks: Record<TaskId, Task>;
|
||||
|
||||
/** Replace entire task array */
|
||||
setTasks: (tasks: Task[]) => void;
|
||||
/** Update entire tasks record */
|
||||
setTasks: (tasks: Record<TaskId, Task>) => void;
|
||||
|
||||
/** Create a new task with a status */
|
||||
createTask: (status: string) => void;
|
||||
createTask: (statusId: string) => void;
|
||||
};
|
||||
|
||||
export default function TaskView({
|
||||
|
||||
@@ -7,12 +7,6 @@ import { PlusIcon } from "lucide-react";
|
||||
import BoardTask from "./Task";
|
||||
import { TaskViewProps } from "../TaskView";
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
todo: "To do",
|
||||
inprogress: "In progress",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
export default function BoardView({
|
||||
tags,
|
||||
tasks,
|
||||
@@ -23,35 +17,37 @@ export default function BoardView({
|
||||
const { draggableId, source, destination } = result;
|
||||
if (!destination) return;
|
||||
|
||||
setTasks((tasks) => {
|
||||
const taskIndex = tasks.findIndex((task) => task.id === draggableId);
|
||||
const task = tasks[taskIndex];
|
||||
tasks.splice(taskIndex, 1);
|
||||
const task = Object.values(tasks).find((t) => t.id === draggableId);
|
||||
if (!task) return;
|
||||
|
||||
const destinationGroupStartIndex = tasks.findIndex(
|
||||
(t) => t.status === destination.droppableId
|
||||
);
|
||||
|
||||
const insertIndex =
|
||||
destinationGroupStartIndex === -1
|
||||
? tasks.length
|
||||
: destinationGroupStartIndex + destination.index;
|
||||
|
||||
tasks.splice(insertIndex, 0, {
|
||||
setTasks({
|
||||
...tasks,
|
||||
[task.id]: {
|
||||
...task,
|
||||
status: destination.droppableId,
|
||||
});
|
||||
|
||||
return tasks;
|
||||
statusId: destination.droppableId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Group tasks by status, using statuses from first available todo entity
|
||||
const tasksArray = Object.values(tasks);
|
||||
const statusIds = new Set(tasksArray.map((t) => t.statusId));
|
||||
|
||||
// Sort tasks by creation date within each status
|
||||
const tasksByStatus = Array.from(statusIds).map((statusId) => ({
|
||||
statusId,
|
||||
tasks: tasksArray
|
||||
.filter((t) => t.statusId === statusId)
|
||||
.sort((a, b) => a.createdAt - b.createdAt),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col gap-6">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
|
||||
{Object.keys(statusLabels).map((status) => (
|
||||
<Droppable key={status} droppableId={status}>
|
||||
{tasksByStatus.map(({ statusId, tasks: statusTasks }) => (
|
||||
<Droppable key={statusId} droppableId={statusId}>
|
||||
{(provided) => (
|
||||
<Card
|
||||
ref={provided.innerRef}
|
||||
@@ -60,12 +56,12 @@ export default function BoardView({
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{statusLabels[status]}</span>
|
||||
<span>{statusId}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
variant="outline"
|
||||
onClick={() => createTask(status)}
|
||||
onClick={() => createTask(statusId)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
@@ -73,31 +69,29 @@ export default function BoardView({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3">
|
||||
{tasks
|
||||
.filter((task) => task.status === status)
|
||||
.map((task, index) => (
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided) => (
|
||||
<BoardTask
|
||||
setTask={(t) =>
|
||||
setTasks((cols) =>
|
||||
cols.map((c) => (c.id === t.id ? t : c))
|
||||
)
|
||||
}
|
||||
provided={provided}
|
||||
task={task}
|
||||
tags={tags}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{statusTasks.map((task, index) => (
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided) => (
|
||||
<BoardTask
|
||||
setTask={(t) =>
|
||||
setTasks({
|
||||
...tasks,
|
||||
[t.id]: t,
|
||||
})
|
||||
}
|
||||
provided={provided}
|
||||
task={task}
|
||||
tags={tags}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
{tasks.filter((task) => task.status === status).length ===
|
||||
0 && (
|
||||
{statusTasks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No tasks
|
||||
</p>
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Task } from "@/lib/types";
|
||||
import { Task, Tag, TagId } from "@/lib/types";
|
||||
import { Color, getColor } from "@/lib/ui/color";
|
||||
import { DraggableProvided } from "@hello-pangea/dnd";
|
||||
import TaskEdit from "../edit/TaskEdit";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotepadText } from "lucide-react";
|
||||
import { NotepadText, Trash2 } from "lucide-react";
|
||||
import DocDialog from "../edit/DocDialog";
|
||||
|
||||
export default function BoardTask({
|
||||
@@ -20,11 +20,13 @@ export default function BoardTask({
|
||||
provided,
|
||||
setTask,
|
||||
tags,
|
||||
onDelete,
|
||||
}: {
|
||||
task: Task;
|
||||
setTask: (t: Task) => void;
|
||||
provided: DraggableProvided;
|
||||
tags: Record<string, { color: Color; name: string }>;
|
||||
tags: Record<TagId, Tag>;
|
||||
onDelete?: (taskId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
||||
@@ -33,34 +35,56 @@ export default function BoardTask({
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="bg-background"
|
||||
className="bg-background cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{task.title}</span>
|
||||
<DocDialog task={task} setTask={setTask} tags={tags}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<NotepadText />
|
||||
</Button>
|
||||
</DocDialog>
|
||||
<span className="text-sm font-semibold">{task.title}</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<DocDialog task={task} setTask={setTask} tags={tags}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<NotepadText className="h-4 w-4" />
|
||||
</Button>
|
||||
</DocDialog>
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(task.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>{task.description}</CardDescription>
|
||||
{task.description && (
|
||||
<CardDescription className="text-xs line-clamp-2">
|
||||
{task.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden">
|
||||
{task.tags?.map((tag, i) => (
|
||||
{task.tagIds?.map((tagId) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
key={tagId}
|
||||
variant="outline"
|
||||
style={getColor(tags[tag].color)}
|
||||
style={
|
||||
tags[tagId]
|
||||
? getColor(tags[tagId].color || "#808080")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
{tags[tagId]?.name || tagId}
|
||||
</Badge>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import DocView from "../DocView";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Task } from "@/lib/types";
|
||||
import { Color, getColor } from "@/lib/ui/color";
|
||||
import { Task, Tag, TagId } from "@/lib/types";
|
||||
import { getColor } from "@/lib/ui/color";
|
||||
import { useState } from "react";
|
||||
import { OutputData } from "@editorjs/editorjs";
|
||||
|
||||
@@ -23,10 +23,14 @@ export default function DocDialog({
|
||||
}: {
|
||||
task: Task;
|
||||
setTask: (t: Task) => void;
|
||||
tags: Record<string, { color: Color; name: string }>;
|
||||
tags: Record<TagId, Tag>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [doc, setDoc] = useState<OutputData>({ blocks: [] });
|
||||
const [doc, setDoc] = useState<OutputData>({
|
||||
version: "2.28.0",
|
||||
time: Date.now(),
|
||||
blocks: [],
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -41,13 +45,17 @@ export default function DocDialog({
|
||||
<DialogDescription>{task.description}</DialogDescription>
|
||||
<DialogHeader>
|
||||
<div className="flex flex-wrap gap-1.5 overflow-hidden">
|
||||
{task.tags?.map((tag, i) => (
|
||||
{task.tagIds?.map((tagId) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
key={tagId}
|
||||
variant="outline"
|
||||
style={getColor(tags[tag].color)}
|
||||
style={
|
||||
tags[tagId]
|
||||
? getColor(tags[tagId].color || "#808080")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
{tags[tagId]?.name || tagId}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Task } from "@/lib/types";
|
||||
import { Task, Tag, TagId } from "@/lib/types";
|
||||
import { Color, getColor } from "@/lib/ui/color";
|
||||
import { Button } from "../../ui/button";
|
||||
import DocDialog from "./DocDialog";
|
||||
@@ -21,7 +21,7 @@ export default function TaskEdit({
|
||||
}: {
|
||||
task: Task;
|
||||
setTask: (t: Task) => void;
|
||||
tags: Record<string, { color: Color; name: string }>;
|
||||
tags: Record<TagId, Tag>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -38,16 +38,26 @@ export default function TaskEdit({
|
||||
<div className="grid gap-3">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
defaultValue={task.title}
|
||||
onChange={(e) => setTask({ ...task, title: e.target.value })}
|
||||
value={task.title}
|
||||
onChange={(e) =>
|
||||
setTask({
|
||||
...task,
|
||||
title: e.target.value,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
defaultValue={task.description}
|
||||
value={task.description || ""}
|
||||
onChange={(e) =>
|
||||
setTask({ ...task, description: e.target.value })
|
||||
setTask({
|
||||
...task,
|
||||
description: e.target.value,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -58,12 +68,18 @@ export default function TaskEdit({
|
||||
label: tag.name,
|
||||
value: id,
|
||||
style: {
|
||||
iconColor: getColor(tag.color).color,
|
||||
badgeColor: getColor(tag.color).backgroundColor,
|
||||
iconColor: getColor(tag.color || "#808080").color,
|
||||
badgeColor: getColor(tag.color || "#808080").backgroundColor,
|
||||
},
|
||||
}))}
|
||||
onValueChange={(tags) => setTask({ ...task, tags })}
|
||||
defaultValue={task.tags}
|
||||
onValueChange={(tagIds) =>
|
||||
setTask({
|
||||
...task,
|
||||
tagIds: tagIds as TagId[],
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
defaultValue={task.tagIds}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
|
||||
@@ -15,6 +15,10 @@ import { getColor } from "@/lib/ui/color";
|
||||
import TableTask from "./Task";
|
||||
|
||||
export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
||||
const tasksArray = Object.values(tasks).sort(
|
||||
(a, b) => b.createdAt - a.createdAt,
|
||||
);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -26,7 +30,7 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.length === 0 ? (
|
||||
{tasksArray.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
@@ -36,12 +40,15 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
tasksArray.map((task) => (
|
||||
<TableTask
|
||||
key={task.id}
|
||||
task={task}
|
||||
setTask={(t) =>
|
||||
setTasks((tasks) => tasks.map((c) => (c.id === t.id ? t : c)))
|
||||
setTasks({
|
||||
...tasks,
|
||||
[t.id]: t,
|
||||
})
|
||||
}
|
||||
tags={tags}
|
||||
/>
|
||||
|
||||
@@ -1,50 +1,62 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { Task } from "@/lib/types";
|
||||
import { Task, Tag, TagId } from "@/lib/types";
|
||||
import { Color, getColor } from "@/lib/ui/color";
|
||||
import TaskEdit from "../edit/TaskEdit";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export default function TableTask({
|
||||
task,
|
||||
setTask,
|
||||
tags,
|
||||
onDelete,
|
||||
}: {
|
||||
task: Task;
|
||||
setTask: (t: Task) => void;
|
||||
tags: Record<string, { color: Color; name: string }>;
|
||||
tags: Record<TagId, Tag>;
|
||||
onDelete?: (taskId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
||||
<SheetTrigger asChild>
|
||||
<TableRow>
|
||||
<TableRow className="cursor-pointer hover:bg-accent">
|
||||
<TableCell className="font-medium">{task.title}</TableCell>
|
||||
<TableCell className="max-w-[250px] truncate">
|
||||
{task.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
task.status === "done"
|
||||
? "default"
|
||||
: task.status === "in-progress"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{task.status}
|
||||
</Badge>
|
||||
<Badge variant="outline">{task.statusId}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="flex gap-1 items-center">
|
||||
{task.tags?.map((tag, i) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
style={getColor(tags[tag].color)}
|
||||
<TableCell className="flex gap-1 items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{task.tagIds?.map((tagId) => (
|
||||
<Badge
|
||||
key={tagId}
|
||||
variant="outline"
|
||||
style={
|
||||
tags[tagId]
|
||||
? getColor(tags[tagId].color || "#808080")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tags[tagId]?.name || tagId}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(task.id);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</SheetTrigger>
|
||||
|
||||
Reference in New Issue
Block a user