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