diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 11234a9..1ea4507 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Drift - Productivity App", + description: "A powerful client-side productivity application", }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index e2dd8d3..598f5de 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); + redirect("/workspace"); } diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 26b6639..b848bc8 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -31,35 +31,44 @@ export default function Workspace() { else Cookies.remove("workspace"); }, [currentWorkspace]); - return workspace ? ( - + if (!workspace) { + return ( +
{ + const w = createDefaultWorkspace(); + saveWorkspaceToLocalStorage(w); + setCurrentWorkspace(w.id); + }} + > +
+

Welcome to Drift

+

+ Your personal productivity workspace +

+ +
+
+ ); + } + + return ( +
-
- {workspace.name} - - {workspace.entities[route.entity || ""]?.name} +
+ {workspace.name} + + + {workspace.entities[route.entity || ""]?.name || "Select an item"} +
- ) : ( -
{ - const w = createDefaultWorkspace(); - saveWorkspaceToLocalStorage(w); - setCurrentWorkspace(w.id); - }} - > - -
); } diff --git a/src/components/Router.tsx b/src/components/Router.tsx index 1ae7161..10f39cb 100644 --- a/src/components/Router.tsx +++ b/src/components/Router.tsx @@ -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, - ); + const setTasks = (tasks: Record) => { updateEntity(entityId, (e) => ({ ...e, - tasks: e.type === "todo" ? tasksRecord : {}, + tasks: e.type === "todo" ? tasks : {}, })); }; diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 0f84be9..d81d4ae 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -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; - updateEntity?: (entityId: EntityId, updater: (e: Entity) => Entity) => void; - setWorkspace: SetState; }; export default function AppSidebar({ children, workspace, setRoute, - updateEntity, - setWorkspace, }: AppSidebarProps) { return ( + {/* Header */} @@ -73,25 +65,18 @@ export default function AppSidebar({ + {/* Sidebar Content */} - - -
- - {workspace.layout.map((entity) => ( - - ))} - -
-
- - -
+ + {workspace.layout.map((node) => ( + + ))} +
diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index bdc06e4..13305da 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -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 ( - {entity.name} + {node.name} @@ -53,10 +37,10 @@ export default function EntitySwitch({ - {entity.children.map((child) => ( + {node.children.map((child) => ( @@ -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({ ); -} \ No newline at end of file +} diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index e972fe5..183bdc3 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -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) { - return + return ; } function ContextMenuTrigger({ @@ -17,7 +17,7 @@ function ContextMenuTrigger({ }: React.ComponentProps) { return ( - ) + ); } function ContextMenuGroup({ @@ -25,7 +25,7 @@ function ContextMenuGroup({ }: React.ComponentProps) { return ( - ) + ); } function ContextMenuPortal({ @@ -33,13 +33,13 @@ function ContextMenuPortal({ }: React.ComponentProps) { return ( - ) + ); } function ContextMenuSub({ ...props }: React.ComponentProps) { - return + return ; } 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 & { - inset?: boolean + inset?: boolean; }) { return ( {children} - ) + ); } function ContextMenuSubContent({ @@ -85,12 +85,12 @@ function ContextMenuSubContent({ - ) + ); } function ContextMenuContent({ @@ -102,13 +102,13 @@ function ContextMenuContent({ - ) + ); } function ContextMenuItem({ @@ -117,8 +117,8 @@ function ContextMenuItem({ variant = "default", ...props }: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" + inset?: boolean; + variant?: "default" | "destructive"; }) { return ( - ) + ); } function ContextMenuCheckboxItem({ @@ -144,8 +144,8 @@ function ContextMenuCheckboxItem({ {children} - ) + ); } function ContextMenuRadioItem({ @@ -169,8 +169,8 @@ function ContextMenuRadioItem({ @@ -181,7 +181,7 @@ function ContextMenuRadioItem({ {children} - ) + ); } function ContextMenuLabel({ @@ -189,19 +189,19 @@ function ContextMenuLabel({ inset, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( - ) + ); } 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, -} +}; diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index 32f5320..a632b76 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -11,11 +11,11 @@ export type TaskViewProps = { tasks: Record; - /** Replace entire task array */ - setTasks: (tasks: Task[]) => void; + /** Update entire tasks record */ + setTasks: (tasks: Record) => void; /** Create a new task with a status */ - createTask: (status: string) => void; + createTask: (statusId: string) => void; }; export default function TaskView({ diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index 3418f57..bb4e752 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -7,12 +7,6 @@ import { PlusIcon } from "lucide-react"; import BoardTask from "./Task"; import { TaskViewProps } from "../TaskView"; -const statusLabels: Record = { - 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 (
- {Object.keys(statusLabels).map((status) => ( - + {tasksByStatus.map(({ statusId, tasks: statusTasks }) => ( + {(provided) => ( - {statusLabels[status]} + {statusId} @@ -73,31 +69,29 @@ export default function BoardView({
- {tasks - .filter((task) => task.status === status) - .map((task, index) => ( - - {(provided) => ( - - setTasks((cols) => - cols.map((c) => (c.id === t.id ? t : c)) - ) - } - provided={provided} - task={task} - tags={tags} - /> - )} - - ))} + {statusTasks.map((task, index) => ( + + {(provided) => ( + + setTasks({ + ...tasks, + [t.id]: t, + }) + } + provided={provided} + task={task} + tags={tags} + /> + )} + + ))} {provided.placeholder} - {tasks.filter((task) => task.status === status).length === - 0 && ( + {statusTasks.length === 0 && (

No tasks

diff --git a/src/components/view/board/Task.tsx b/src/components/view/board/Task.tsx index 6a5d96a..5f58544 100644 --- a/src/components/view/board/Task.tsx +++ b/src/components/view/board/Task.tsx @@ -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; + tags: Record; + onDelete?: (taskId: string) => void; }) { return ( @@ -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" > - {task.title} - - - + {task.title} +
+ + + + {onDelete && ( + + )} +
- {task.description} + {task.description && ( + + {task.description} + + )}
- {task.tags?.map((tag, i) => ( + {task.tagIds?.map((tagId) => ( - {tag} + {tags[tagId]?.name || tagId} ))} diff --git a/src/components/view/edit/DocDialog.tsx b/src/components/view/edit/DocDialog.tsx index e4cb881..43058eb 100644 --- a/src/components/view/edit/DocDialog.tsx +++ b/src/components/view/edit/DocDialog.tsx @@ -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; + tags: Record; children: React.ReactNode; }) { - const [doc, setDoc] = useState({ blocks: [] }); + const [doc, setDoc] = useState({ + version: "2.28.0", + time: Date.now(), + blocks: [], + }); return ( @@ -41,13 +45,17 @@ export default function DocDialog({ {task.description}
- {task.tags?.map((tag, i) => ( + {task.tagIds?.map((tagId) => ( - {tag} + {tags[tagId]?.name || tagId} ))}
diff --git a/src/components/view/edit/TaskEdit.tsx b/src/components/view/edit/TaskEdit.tsx index 81ca719..45abb0b 100644 --- a/src/components/view/edit/TaskEdit.tsx +++ b/src/components/view/edit/TaskEdit.tsx @@ -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; + tags: Record; children: React.ReactNode; }) { return ( @@ -38,16 +38,26 @@ export default function TaskEdit({
setTask({ ...task, title: e.target.value })} + value={task.title} + onChange={(e) => + setTask({ + ...task, + title: e.target.value, + updatedAt: Date.now(), + }) + } />
- setTask({ ...task, description: e.target.value }) + setTask({ + ...task, + description: e.target.value, + updatedAt: Date.now(), + }) } />
@@ -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} />
diff --git a/src/components/view/table/TableView.tsx b/src/components/view/table/TableView.tsx index 282e88a..248030d 100644 --- a/src/components/view/table/TableView.tsx +++ b/src/components/view/table/TableView.tsx @@ -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 ( @@ -26,7 +30,7 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) { - {tasks.length === 0 ? ( + {tasksArray.length === 0 ? ( ) : ( - tasks.map((task) => ( + tasksArray.map((task) => ( - setTasks((tasks) => tasks.map((c) => (c.id === t.id ? t : c))) + setTasks({ + ...tasks, + [t.id]: t, + }) } tags={tags} /> diff --git a/src/components/view/table/Task.tsx b/src/components/view/table/Task.tsx index 089fd18..3682d62 100644 --- a/src/components/view/table/Task.tsx +++ b/src/components/view/table/Task.tsx @@ -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; + tags: Record; + onDelete?: (taskId: string) => void; }) { return ( - + {task.title} {task.description || "-"} - - {task.status} - + {task.statusId} - - {task.tags?.map((tag, i) => ( - +
+ {task.tagIds?.map((tagId) => ( + + {tags[tagId]?.name || tagId} + + ))} +
+ {onDelete && ( + + )}
diff --git a/src/lib/types.ts b/src/lib/types.ts index 70f2463..e25eb75 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -22,16 +22,17 @@ export interface Workspace { updatedAt: number; } -export type LayoutNode = { - type: "group"; - id: string; - name: string; - children: LayoutItem[]; -}; - -export type LayoutItem = { - entityId: EntityId; -}; +export type LayoutNode = + | { + type: "group"; + id: string; + name: string; + children: LayoutNode[]; + } + | { + type: "item"; + entityId: EntityId; + }; export type Entity = TodoEntity | DocEntity; diff --git a/src/lib/ui/color.ts b/src/lib/ui/color.ts index d628397..6611575 100644 --- a/src/lib/ui/color.ts +++ b/src/lib/ui/color.ts @@ -8,7 +8,8 @@ export type Color = | "blue" | "purple" | "pink" - | "red"; + | "red" + | string; interface ColorStyle { backgroundColor: string; @@ -59,5 +60,5 @@ export function getColor(color: Color): ColorStyle { }, }; - return colors[color]; + return colors[color] || color; } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index 44d6e4e..01ab95f 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -1,13 +1,60 @@ -import { Workspace } from "./types"; +import { Workspace, TodoEntity, DocEntity, Status } from "./types"; export function createDefaultWorkspace(): Workspace { const now = Date.now(); + const todoId = crypto.randomUUID(); + const docId = crypto.randomUUID(); + + const defaultStatuses: Status[] = [ + { id: "status_todo", name: "To Do", order: 0, color: "#808080" }, + { + id: "status_inprogress", + name: "In Progress", + order: 1, + color: "#0ea5e9", + }, + { id: "status_done", name: "Done", order: 2, color: "#10b981" }, + ]; + + const todoEntity: TodoEntity = { + id: todoId, + type: "todo", + name: "Tasks", + config: { statuses: defaultStatuses }, + tags: {}, + tasks: {}, + createdAt: now, + updatedAt: now, + }; + + const docEntity: DocEntity = { + id: docId, + type: "doc", + name: "Notes", + content: { version: "2.28.0", time: now, blocks: [] }, + createdAt: now, + updatedAt: now, + }; + return { id: crypto.randomUUID(), - name: "New Workspace", - entities: {}, - layout: [], + name: "My Workspace", + entities: { + [todoId]: todoEntity, + [docId]: docEntity, + }, + layout: [ + { + type: "group", + id: crypto.randomUUID(), + name: "Productivity", + children: [ + { entityId: todoId, type: "item" }, + { entityId: docId, type: "item" }, + ], + }, + ], createdAt: now, updatedAt: now, }; @@ -27,7 +74,7 @@ export function loadWorkspaceFromLocalStorage(id: string): Workspace { if ( typeof parsed !== "object" || parsed === null || - typeof parsed.version !== "number" + typeof parsed.id !== "string" ) { console.warn("Invalid workspace shape. Resetting."); return createDefaultWorkspace();