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

View File

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

View File

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