diff --git a/README.md b/README.md index c619a5f..9ea0661 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,19 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist ## Current Product Behavior +### Feb 20, 2026 updates + +- Added task attachments with SQLite persistence. +- Added URL-based task detail pages (`/tasks/{taskId}`) so tasks can be opened/shared by link. +- Replaced flat notes/comments with threaded comments. +- Added unlimited nested replies (reply to comment, reply to reply, no depth limit). +- Updated UI wording from "notes" to "comments". +- Improved attachment opening/rendering by coercing MIME types (including `.md` as text) and using blob URLs for reliable in-browser viewing. + ### Data model and status rules - Tasks use labels (`tags: string[]`) and can have multiple labels. +- Tasks support attachments (`attachments: TaskAttachment[]`). - There is no active `backlog` status in workflow logic. - A task is considered in Backlog when `sprintId` is empty. - Current status values: @@ -26,13 +36,44 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist - Project selection UI for tasks was removed in favor of labels. - You can add/remove labels inline in: - New Task modal - - Task Detail modal + - Task Detail page - Label entry supports: - Enter/comma to add - Existing-label suggestions - Quick-add chips - Case-insensitive de-duplication +### Task detail pages + +- Clicking a task from Kanban or Backlog opens a dedicated route: + - `/tasks/{taskId}` +- Task detail is no longer popup-only behavior. +- Each task now has a shareable deep link. + +### Attachments + +- Task detail page supports adding multiple attachments per task. +- Attachments are stored with each task in SQLite and survive refresh/restart. +- Attachment UI supports: + - Upload multiple files + - Open/view file + - Markdown (`.md`) preview rendering in browser tab + - Text/code preview for common file types including iOS project/code files (`.swift`, `.plist`, `.pbxproj`, `.storyboard`, `.xib`, `.xcconfig`, `.entitlements`) + - Syntax-highlighted source rendering (highlight.js) with copy-source action + - CSV table preview and sandboxed HTML preview with source view + - Download file + - Remove attachment + +### Threaded comments + +- Comments are now threaded, not flat. +- You can: + - Add top-level comments + - Reply to any comment + - Reply to replies recursively (unlimited depth) + - Delete any comment or reply from the thread +- Thread data is persisted in SQLite through the existing `/api/tasks` sync flow. + ### Backlog drag and drop Backlog view supports moving tasks between: @@ -85,6 +126,7 @@ npm run dev ``` Open `http://localhost:3000`. +Task URLs follow `http://localhost:3000/tasks/{taskId}`. ## Scripts diff --git a/src/app/page.tsx b/src/app/page.tsx index 7075700..3ab6549 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,6 +22,15 @@ 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 { + blobFromDataUrl, + coerceDataUrlMimeType, + inferAttachmentMimeType, + isMarkdownAttachment, + isTextPreviewAttachment, + markdownPreviewObjectUrl, + textPreviewObjectUrl, +} from "@/lib/attachments" import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore" import { BacklogView } from "@/components/BacklogView" import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react" @@ -552,14 +561,19 @@ export default function Home() { 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, - })) + files.map(async (file) => { + const type = inferAttachmentMimeType(file.name, file.type) + const rawDataUrl = await readFileAsDataUrl(file) + + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: file.name, + type, + size: file.size, + dataUrl: coerceDataUrlMimeType(rawDataUrl, type), + uploadedAt, + } + }) ) setEditedTask((prev) => { @@ -576,6 +590,38 @@ export default function Home() { } } + const openAttachment = async (attachment: TaskAttachment) => { + try { + const mimeType = inferAttachmentMimeType(attachment.name, attachment.type) + const objectUrl = isMarkdownAttachment(attachment.name, mimeType) + ? await markdownPreviewObjectUrl(attachment.name, attachment.dataUrl) + : isTextPreviewAttachment(attachment.name, mimeType) + ? await textPreviewObjectUrl(attachment.name, attachment.dataUrl, mimeType) + : URL.createObjectURL(await blobFromDataUrl(attachment.dataUrl, mimeType)) + window.open(objectUrl, "_blank", "noopener,noreferrer") + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) + } catch (error) { + console.error("Failed to open attachment:", error) + } + } + + const downloadAttachment = async (attachment: TaskAttachment) => { + try { + const mimeType = inferAttachmentMimeType(attachment.name, attachment.type) + const blob = await blobFromDataUrl(attachment.dataUrl, mimeType) + const objectUrl = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = objectUrl + link.download = attachment.name + document.body.appendChild(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) + } catch (error) { + console.error("Failed to download attachment:", error) + } + } + return (
{/* Header */} @@ -1109,27 +1155,26 @@ export default function Home() { className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50" >
- openAttachment(attachment)} className="text-sm text-blue-300 hover:text-blue-200 truncate block" > {attachment.name} - +

{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}

- downloadAttachment(attachment)} className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700" title="Download attachment" > - +

{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}

- downloadAttachment(attachment)} className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700" title="Download attachment" > - + + +
+
+
+
+ ${isHtml ? '' : ""} + + + + +` +} + +export function createMarkdownPreviewHtml(fileName: string, markdown: string): string { + const safeTitle = fileName.replace(/[<>&"]/g, (char) => { + if (char === "<") return "<" + if (char === ">") return ">" + if (char === "&") return "&" + return """ + }) + const markdownLiteral = safeScriptString(markdown) + + return ` + + + + + ${safeTitle} + + + + +
+
Markdown Preview: ${safeTitle}
+
+
+ + + + +`; +} + +export async function markdownPreviewObjectUrl(fileName: string, dataUrl: string): Promise { + const markdown = await textFromDataUrl(dataUrl) + const html = createMarkdownPreviewHtml(fileName, markdown) + return URL.createObjectURL(new Blob([html], { type: "text/html" })) +} + +export async function textPreviewObjectUrl(fileName: string, dataUrl: string, mimeType?: string): Promise { + const content = await textFromDataUrl(dataUrl) + const html = createTextPreviewHtml(fileName, content, mimeType) + return URL.createObjectURL(new Blob([html], { type: "text/html" })) +}