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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, type ReactNode } from "react"
|
import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode } from "react"
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@ -21,9 +21,9 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Label } from "@/components/ui/label"
|
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 { 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> = {
|
const typeColors: Record<TaskType, string> = {
|
||||||
idea: "bg-purple-500",
|
idea: "bg-purple-500",
|
||||||
@ -193,6 +193,7 @@ function KanbanTaskCard({
|
|||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
opacity: isDragging ? 0.6 : 1,
|
opacity: isDragging ? 0.6 : 1,
|
||||||
}
|
}
|
||||||
|
const attachmentCount = task.attachments?.length || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -254,6 +255,12 @@ function KanbanTaskCard({
|
|||||||
{task.comments.length}
|
{task.comments.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{attachmentCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-slate-500">
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
{attachmentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span className="text-slate-500 flex items-center gap-1">
|
<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)
|
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 labelUsage = useMemo(() => {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
@ -347,11 +384,15 @@ export default function Home() {
|
|||||||
|
|
||||||
const selectedTask = tasks.find((t) => t.id === selectedTaskId)
|
const selectedTask = tasks.find((t) => t.id === selectedTaskId)
|
||||||
const editedTaskTags = editedTask ? getTags(editedTask) : []
|
const editedTaskTags = editedTask ? getTags(editedTask) : []
|
||||||
|
const editedTaskAttachments = editedTask ? getAttachments(editedTask) : []
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTask) {
|
if (selectedTask) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
setEditedTask({
|
||||||
setEditedTask({ ...selectedTask, tags: getTags(selectedTask) })
|
...selectedTask,
|
||||||
|
tags: getTags(selectedTask),
|
||||||
|
attachments: getAttachments(selectedTask),
|
||||||
|
})
|
||||||
setEditedTaskLabelInput("")
|
setEditedTaskLabelInput("")
|
||||||
}
|
}
|
||||||
}, [selectedTask])
|
}, [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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -831,6 +903,7 @@ export default function Home() {
|
|||||||
{selectedTask && editedTask && (
|
{selectedTask && editedTask && (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle className="sr-only">Task Details</DialogTitle>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Badge className={`${typeColors[editedTask.type]} text-white border-0`}>
|
<Badge className={`${typeColors[editedTask.type]} text-white border-0`}>
|
||||||
{typeLabels[editedTask.type]}
|
{typeLabels[editedTask.type]}
|
||||||
@ -994,6 +1067,82 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Comments Section */}
|
||||||
<div className="border-t border-slate-800 pt-6">
|
<div className="border-t border-slate-800 pt-6">
|
||||||
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
<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 { mkdirSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
|
export interface TaskAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
dataUrl: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -16,6 +25,7 @@ export interface Task {
|
|||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
comments: { id: string; text: string; createdAt: string; author: "user" | "assistant" }[];
|
comments: { id: string; text: string; createdAt: string; author: "user" | "assistant" }[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
attachments: TaskAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
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 {
|
function normalizeTask(task: Partial<Task>): Task {
|
||||||
return {
|
return {
|
||||||
id: String(task.id ?? Date.now()),
|
id: String(task.id ?? Date.now()),
|
||||||
@ -87,6 +120,7 @@ function normalizeTask(task: Partial<Task>): Task {
|
|||||||
dueDate: task.dueDate || undefined,
|
dueDate: task.dueDate || undefined,
|
||||||
comments: Array.isArray(task.comments) ? task.comments : [],
|
comments: Array.isArray(task.comments) ? task.comments : [],
|
||||||
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
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)
|
VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt)
|
||||||
`);
|
`);
|
||||||
const insertTask = database.prepare(`
|
const insertTask = database.prepare(`
|
||||||
INSERT INTO tasks (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)
|
VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @dueDate, @comments, @tags, @attachments)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const project of payload.projects) {
|
for (const project of payload.projects) {
|
||||||
@ -155,6 +189,7 @@ function replaceAllData(database: SqliteDb, data: DataStore) {
|
|||||||
dueDate: task.dueDate ?? null,
|
dueDate: task.dueDate ?? null,
|
||||||
comments: JSON.stringify(task.comments ?? []),
|
comments: JSON.stringify(task.comments ?? []),
|
||||||
tags: JSON.stringify(task.tags ?? []),
|
tags: JSON.stringify(task.tags ?? []),
|
||||||
|
attachments: JSON.stringify(task.attachments ?? []),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,7 +254,8 @@ function getDb(): SqliteDb {
|
|||||||
updatedAt TEXT NOT NULL,
|
updatedAt TEXT NOT NULL,
|
||||||
dueDate TEXT,
|
dueDate TEXT,
|
||||||
comments TEXT NOT NULL DEFAULT '[]',
|
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 (
|
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);
|
seedIfEmpty(database);
|
||||||
db = database;
|
db = database;
|
||||||
return database;
|
return database;
|
||||||
@ -269,6 +310,7 @@ export function getData(): DataStore {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
comments: string | null;
|
comments: string | null;
|
||||||
tags: string | null;
|
tags: string | null;
|
||||||
|
attachments: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -303,6 +345,7 @@ export function getData(): DataStore {
|
|||||||
dueDate: task.dueDate ?? undefined,
|
dueDate: task.dueDate ?? undefined,
|
||||||
comments: safeParseArray(task.comments, []),
|
comments: safeParseArray(task.comments, []),
|
||||||
tags: safeParseArray(task.tags, []),
|
tags: safeParseArray(task.tags, []),
|
||||||
|
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
|
||||||
})),
|
})),
|
||||||
lastUpdated: getLastUpdated(database),
|
lastUpdated: getLastUpdated(database),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -24,6 +24,15 @@ export interface Comment {
|
|||||||
author: 'user' | 'assistant'
|
author: 'user' | 'assistant'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskAttachment {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
dataUrl: string
|
||||||
|
uploadedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@ -38,6 +47,7 @@ export interface Task {
|
|||||||
dueDate?: string
|
dueDate?: string
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
attachments?: TaskAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
@ -502,6 +512,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
comments: [],
|
comments: [],
|
||||||
|
attachments: task.attachments || [],
|
||||||
}
|
}
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTasks = [...state.tasks, newTask]
|
const newTasks = [...state.tasks, newTask]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user