diff --git a/src/app/page.tsx b/src/app/page.tsx index 151fda3..b16921d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useMemo, type ReactNode } from "react" +import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode } from "react" import { DndContext, DragEndEvent, @@ -21,9 +21,9 @@ import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Textarea } from "@/components/ui/textarea" import { Label } from "@/components/ui/label" -import { useTaskStore, Task, TaskType, TaskStatus, Priority } from "@/stores/useTaskStore" +import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore" import { BacklogView } from "@/components/BacklogView" -import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical } from "lucide-react" +import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react" const typeColors: Record = { idea: "bg-purple-500", @@ -193,6 +193,7 @@ function KanbanTaskCard({ transform: CSS.Translate.toString(transform), opacity: isDragging ? 0.6 : 1, } + const attachmentCount = task.attachments?.length || 0 return ( )} + {attachmentCount > 0 && ( + + + {attachmentCount} + + )} {task.dueDate && ( @@ -320,6 +327,36 @@ export default function Home() { return taskLike.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) } + const getAttachments = (taskLike: { attachments?: unknown }) => { + if (!Array.isArray(taskLike.attachments)) return [] as TaskAttachment[] + return taskLike.attachments.filter((attachment): attachment is TaskAttachment => { + if (!attachment || typeof attachment !== "object") return false + const candidate = attachment as Partial + return typeof candidate.id === "string" + && typeof candidate.name === "string" + && typeof candidate.dataUrl === "string" + && typeof candidate.uploadedAt === "string" + && typeof candidate.type === "string" + && typeof candidate.size === "number" + }) + } + + const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B" + const units = ["B", "KB", "MB", "GB"] + const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + const value = bytes / 1024 ** unitIndex + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}` + } + + const readFileAsDataUrl = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result || "")) + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)) + reader.readAsDataURL(file) + }) + const labelUsage = useMemo(() => { const counts = new Map() tasks.forEach((task) => { @@ -347,11 +384,15 @@ export default function Home() { const selectedTask = tasks.find((t) => t.id === selectedTaskId) const editedTaskTags = editedTask ? getTags(editedTask) : [] + const editedTaskAttachments = editedTask ? getAttachments(editedTask) : [] useEffect(() => { if (selectedTask) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setEditedTask({ ...selectedTask, tags: getTags(selectedTask) }) + setEditedTask({ + ...selectedTask, + tags: getTags(selectedTask), + attachments: getAttachments(selectedTask), + }) setEditedTaskLabelInput("") } }, [selectedTask]) @@ -496,6 +537,37 @@ export default function Home() { } } + const handleAttachmentUpload = async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []) + if (files.length === 0) return + + try { + const uploadedAt = new Date().toISOString() + const attachments = await Promise.all( + files.map(async (file) => ({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: file.name, + type: file.type || "application/octet-stream", + size: file.size, + dataUrl: await readFileAsDataUrl(file), + uploadedAt, + })) + ) + + setEditedTask((prev) => { + if (!prev) return prev + return { + ...prev, + attachments: [...getAttachments(prev), ...attachments], + } + }) + } catch (error) { + console.error("Failed to upload attachment:", error) + } finally { + event.target.value = "" + } + } + return (
{/* Header */} @@ -831,6 +903,7 @@ export default function Home() { {selectedTask && editedTask && ( <> + Task Details
{typeLabels[editedTask.type]} @@ -994,6 +1067,82 @@ export default function Home() {
+ {/* Attachments */} +
+ +
+
+ + + + {editedTaskAttachments.length} file{editedTaskAttachments.length === 1 ? "" : "s"} + +
+ + {editedTaskAttachments.length === 0 ? ( +

No attachments yet.

+ ) : ( +
+ {editedTaskAttachments.map((attachment) => ( +
+
+ + {attachment.name} + +

+ {formatBytes(attachment.size)} ยท {new Date(attachment.uploadedAt).toLocaleString()} +

+
+
+ + + + +
+
+ ))} +
+ )} +
+
+ {/* Comments Section */}

diff --git a/src/lib/server/taskDb.ts b/src/lib/server/taskDb.ts index 660cdd3..ca0e277 100644 --- a/src/lib/server/taskDb.ts +++ b/src/lib/server/taskDb.ts @@ -2,6 +2,15 @@ import Database from "better-sqlite3"; import { mkdirSync } from "fs"; import { join } from "path"; +export interface TaskAttachment { + id: string; + name: string; + type: string; + size: number; + dataUrl: string; + uploadedAt: string; +} + export interface Task { id: string; title: string; @@ -16,6 +25,7 @@ export interface Task { dueDate?: string; comments: { id: string; text: string; createdAt: string; author: "user" | "assistant" }[]; tags: string[]; + attachments: TaskAttachment[]; } export interface Project { @@ -72,6 +82,29 @@ function safeParseArray(value: string | null, fallback: T[]): T[] { } } +function normalizeAttachments(attachments: unknown): TaskAttachment[] { + if (!Array.isArray(attachments)) return []; + + return attachments + .map((attachment) => { + if (!attachment || typeof attachment !== "object") return null; + const value = attachment as Partial; + const name = typeof value.name === "string" ? value.name.trim() : ""; + const dataUrl = typeof value.dataUrl === "string" ? value.dataUrl : ""; + if (!name || !dataUrl) return null; + + return { + id: typeof value.id === "string" && value.id ? value.id : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name, + type: typeof value.type === "string" ? value.type : "application/octet-stream", + size: typeof value.size === "number" && Number.isFinite(value.size) ? value.size : 0, + dataUrl, + uploadedAt: typeof value.uploadedAt === "string" && value.uploadedAt ? value.uploadedAt : new Date().toISOString(), + }; + }) + .filter((attachment): attachment is TaskAttachment => attachment !== null); +} + function normalizeTask(task: Partial): Task { return { id: String(task.id ?? Date.now()), @@ -87,6 +120,7 @@ function normalizeTask(task: Partial): Task { dueDate: task.dueDate || undefined, comments: Array.isArray(task.comments) ? task.comments : [], tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [], + attachments: normalizeAttachments(task.attachments), }; } @@ -121,8 +155,8 @@ function replaceAllData(database: SqliteDb, data: DataStore) { VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt) `); const insertTask = database.prepare(` - INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, dueDate, comments, tags) - VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @dueDate, @comments, @tags) + INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, dueDate, comments, tags, attachments) + VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @dueDate, @comments, @tags, @attachments) `); for (const project of payload.projects) { @@ -155,6 +189,7 @@ function replaceAllData(database: SqliteDb, data: DataStore) { dueDate: task.dueDate ?? null, comments: JSON.stringify(task.comments ?? []), tags: JSON.stringify(task.tags ?? []), + attachments: JSON.stringify(task.attachments ?? []), }); } @@ -219,7 +254,8 @@ function getDb(): SqliteDb { updatedAt TEXT NOT NULL, dueDate TEXT, comments TEXT NOT NULL DEFAULT '[]', - tags TEXT NOT NULL DEFAULT '[]' + tags TEXT NOT NULL DEFAULT '[]', + attachments TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS meta ( @@ -228,6 +264,11 @@ function getDb(): SqliteDb { ); `); + const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + if (!taskColumns.some((column) => column.name === "attachments")) { + database.exec("ALTER TABLE tasks ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';"); + } + seedIfEmpty(database); db = database; return database; @@ -269,6 +310,7 @@ export function getData(): DataStore { dueDate: string | null; comments: string | null; tags: string | null; + attachments: string | null; }>; return { @@ -303,6 +345,7 @@ export function getData(): DataStore { dueDate: task.dueDate ?? undefined, comments: safeParseArray(task.comments, []), tags: safeParseArray(task.tags, []), + attachments: normalizeAttachments(safeParseArray(task.attachments, [])), })), lastUpdated: getLastUpdated(database), }; diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index 2e45a70..e7b72d3 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -24,6 +24,15 @@ export interface Comment { author: 'user' | 'assistant' } +export interface TaskAttachment { + id: string + name: string + type: string + size: number + dataUrl: string + uploadedAt: string +} + export interface Task { id: string title: string @@ -38,6 +47,7 @@ export interface Task { dueDate?: string comments: Comment[] tags: string[] + attachments?: TaskAttachment[] } export interface Project { @@ -502,6 +512,7 @@ export const useTaskStore = create()( createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [], + attachments: task.attachments || [], } set((state) => { const newTasks = [...state.tasks, newTask]