Add task attachments with SQLite persistence
This commit is contained in:
parent
54a1a97e32
commit
002f380893
159
src/app/page.tsx
159
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<TaskType, string> = {
|
||||
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 (
|
||||
<Card
|
||||
@ -254,6 +255,12 @@ function KanbanTaskCard({
|
||||
{task.comments.length}
|
||||
</span>
|
||||
)}
|
||||
{attachmentCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-slate-500">
|
||||
<Paperclip className="w-3 h-3" />
|
||||
{attachmentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{task.dueDate && (
|
||||
<span className="text-slate-500 flex items-center gap-1">
|
||||
@ -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<TaskAttachment>
|
||||
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<string>((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<string, number>()
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
{/* Header */}
|
||||
@ -831,6 +903,7 @@ export default function Home() {
|
||||
{selectedTask && editedTask && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Task Details</DialogTitle>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={`${typeColors[editedTask.type]} text-white border-0`}>
|
||||
{typeLabels[editedTask.type]}
|
||||
@ -994,6 +1067,82 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<Label className="text-slate-400">Attachments</Label>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="task-attachment-upload"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-md border border-slate-700 bg-slate-800 text-sm text-slate-200 hover:border-slate-500 cursor-pointer"
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5" />
|
||||
Add Files
|
||||
</label>
|
||||
<input
|
||||
id="task-attachment-upload"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleAttachmentUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">
|
||||
{editedTaskAttachments.length} file{editedTaskAttachments.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editedTaskAttachments.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No attachments yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{editedTaskAttachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href={attachment.dataUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-blue-300 hover:text-blue-200 truncate block"
|
||||
>
|
||||
{attachment.name}
|
||||
</a>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<a
|
||||
href={attachment.dataUrl}
|
||||
download={attachment.name}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
|
||||
title="Download attachment"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditedTask({
|
||||
...editedTask,
|
||||
attachments: editedTaskAttachments.filter((item) => item.id !== attachment.id),
|
||||
})
|
||||
}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<div className="border-t border-slate-800 pt-6">
|
||||
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
||||
|
||||
@ -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<T>(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<TaskAttachment>;
|
||||
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>): Task {
|
||||
return {
|
||||
id: String(task.id ?? Date.now()),
|
||||
@ -87,6 +120,7 @@ function normalizeTask(task: Partial<Task>): 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),
|
||||
};
|
||||
|
||||
@ -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<TaskStore>()(
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [],
|
||||
attachments: task.attachments || [],
|
||||
}
|
||||
set((state) => {
|
||||
const newTasks = [...state.tasks, newTask]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user