Add task attachments with SQLite persistence

This commit is contained in:
OpenClaw Bot 2026-02-20 12:23:58 -06:00
parent 54a1a97e32
commit 002f380893
3 changed files with 211 additions and 8 deletions

View File

@ -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">

View File

@ -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),
}; };

View File

@ -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]