From 09acc721a4594c04a1098608658fa8c4f03f62f0 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 22 Jun 2026 22:13:52 +0800 Subject: [PATCH 1/2] feat(kanban): card attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Attachments section to the card peek: a list of attachment links (URLs or vault paths) with add / remove, plus a file upload where the platform provides an uploader. File-board values live in frontmatter `attachments` (so they ride document sync to desktop + web); DB-board values live in properties_extra. - shared/lib/board.ts: card.attachments + parseAttachments / serializeAttachments / attachmentName helpers. - jtype-core: scan_board_cards parses `attachments` frontmatter into BoardCardInfo (parse_attachments — comma-split, kept verbatim, no #/[] stripping). - BoardPeek: Attachments list (clickable, basename label, remove ✕) + "paste URL or path" input + an Upload button when onUploadAttachment is supplied. - BoardSurface/types: thread onUploadAttachment through to the peek. - Adapters: desktop + web file boards read/write the `attachments` frontmatter; the DB board reads/writes properties_extra. Web boards wire upload to api.uploadAsset; desktop uses URL/path entry. - tests/unit/boardAttachments.spec.ts + i18n (zh). Verified: cargo check + 34 jtype-core tests, root+web tsc, unit tests, and a throwaway harness confirmed the attachment list (correct basenames + links), add-via-input, remove, and the upload control. Follow-up: desktop file upload via the blob channel; inline image previews. Co-Authored-By: Claude Opus 4.8 --- services/jtype-core/src/lib.rs | 13 ++++ .../jtype-web/frontend/src/pages/Kanban.tsx | 16 +++- .../frontend/src/pages/WebBoardView.tsx | 5 ++ shared/components/board/BoardPeek.tsx | 77 ++++++++++++++++++- shared/components/board/BoardSurface.tsx | 2 + shared/components/board/types.ts | 2 + shared/i18n/locales/en/messages.mjs | 2 +- shared/i18n/locales/en/messages.po | 20 +++++ shared/i18n/locales/ja/messages.mjs | 2 +- shared/i18n/locales/ja/messages.po | 20 +++++ shared/i18n/locales/ko/messages.mjs | 2 +- shared/i18n/locales/ko/messages.po | 20 +++++ shared/i18n/locales/zh/messages.mjs | 2 +- shared/i18n/locales/zh/messages.po | 20 +++++ shared/lib/board.ts | 25 ++++++ src/components/BoardView.tsx | 3 + src/lib/types.ts | 1 + tests/unit/boardAttachments.spec.ts | 25 ++++++ 18 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 tests/unit/boardAttachments.spec.ts diff --git a/services/jtype-core/src/lib.rs b/services/jtype-core/src/lib.rs index ef9ef31..4ad5818 100644 --- a/services/jtype-core/src/lib.rs +++ b/services/jtype-core/src/lib.rs @@ -1349,6 +1349,18 @@ pub struct BoardCardInfo { pub task_total: i64, pub icon: Option, pub excerpt: Option, + /// Attachment URLs/paths (frontmatter `attachments`, comma-separated). + pub attachments: Vec, +} + +/// Parse a frontmatter `attachments` value (comma-separated URLs/paths) into a +/// list. Unlike tags, values are kept verbatim (no `#`/`[]` stripping). +fn parse_attachments(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() } /// The body content after the frontmatter block (for previews/excerpts). @@ -1482,6 +1494,7 @@ fn scan_board_cards_inner( task_total, icon: fm.get("icon").cloned().filter(|v| !v.is_empty()), excerpt: body_excerpt(&content), + attachments: fm.get("attachments").map(|v| parse_attachments(v)).unwrap_or_default(), }); } } diff --git a/services/jtype-web/frontend/src/pages/Kanban.tsx b/services/jtype-web/frontend/src/pages/Kanban.tsx index 225c182..5409822 100644 --- a/services/jtype-web/frontend/src/pages/Kanban.tsx +++ b/services/jtype-web/frontend/src/pages/Kanban.tsx @@ -136,6 +136,9 @@ export function Kanban() { taskDone: tasks.done, taskTotal: tasks.total, excerpt: bodyExcerpt(c.description ?? ''), + attachments: c.propertiesExtra && typeof c.propertiesExtra === 'object' && Array.isArray((c.propertiesExtra as Record).attachments) + ? ((c.propertiesExtra as Record).attachments as unknown[]).filter((x): x is string => typeof x === 'string') + : [], } }) }, [board, memberName]) @@ -242,10 +245,16 @@ export function Kanban() { if (patch.due !== undefined) body.dueAt = patch.due ? `${patch.due} 00:00:00` : null if (patch.tags !== undefined) body.labelIds = patch.tags.map(t => t.id).filter(Boolean) as string[] if (patch.notes !== undefined) body.description = patch.notes - if (patch.icon !== undefined) { + if (patch.icon !== undefined || patch.attachments !== undefined) { const cur = raw.propertiesExtra && typeof raw.propertiesExtra === 'object' ? { ...(raw.propertiesExtra as Record) } : {} - if (patch.icon) cur.icon = patch.icon - else delete cur.icon + if (patch.icon !== undefined) { + if (patch.icon) cur.icon = patch.icon + else delete cur.icon + } + if (patch.attachments !== undefined) { + if (patch.attachments.length) cur.attachments = patch.attachments + else delete cur.attachments + } body.propertiesExtra = cur } try { @@ -385,6 +394,7 @@ export function Kanban() { error={error} assigneeOptions={assigneeOptions} tagOptions={tagOptions} + onUploadAttachment={workspaceId ? (file) => api.uploadAsset(workspaceId, file).then((a) => a.url) : undefined} /> )} diff --git a/services/jtype-web/frontend/src/pages/WebBoardView.tsx b/services/jtype-web/frontend/src/pages/WebBoardView.tsx index 3cc3c9c..872dd97 100644 --- a/services/jtype-web/frontend/src/pages/WebBoardView.tsx +++ b/services/jtype-web/frontend/src/pages/WebBoardView.tsx @@ -7,7 +7,9 @@ import { DEFAULT_DONE_COLUMN, bodyExcerpt, countTasks, + parseAttachments, parseTagList, + serializeAttachments, slugify, type BoardViewCard, type BoardViewConfig, @@ -92,6 +94,7 @@ export function WebBoardView({ taskDone: tasks.done, taskTotal: tasks.total, excerpt: bodyExcerpt(fm.body), + attachments: fm.data.attachments ? parseAttachments(fm.data.attachments) : [], }) } setMetaByPath(nextMeta) @@ -215,6 +218,7 @@ export function WebBoardView({ if (patch.due !== undefined) next.due = patch.due ?? '' if (patch.icon !== undefined) next.icon = patch.icon ?? '' if (patch.tags !== undefined) next.tags = patch.tags.map((t) => t.label).join(', ') + if (patch.attachments !== undefined) next.attachments = serializeAttachments(patch.attachments) const newBody = patch.notes !== undefined ? patch.notes : body try { await saveCard(id, next, newBody) @@ -353,6 +357,7 @@ export function WebBoardView({ cards={cards} actions={actions} error={error} + onUploadAttachment={(file) => api.uploadAsset(workspaceId, file).then((a) => a.url)} fullscreen={fullscreen} onToggleFullscreen={onToggleFullscreen} /> diff --git a/shared/components/board/BoardPeek.tsx b/shared/components/board/BoardPeek.tsx index 83bd58e..12c22ca 100644 --- a/shared/components/board/BoardPeek.tsx +++ b/shared/components/board/BoardPeek.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon } from "@heroicons/react/24/outline"; +import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon, PaperClipIcon, ArrowUpTrayIcon } from "@heroicons/react/24/outline"; import { renderToContainer } from "../../lib/markdown"; -import { PRIORITIES, type BoardViewCard } from "../../lib/board"; +import { PRIORITIES, attachmentName, type BoardViewCard } from "../../lib/board"; import { fieldCls, EmojiField, ListboxSelect, TagMultiSelect } from "./controls"; import type { BoardOption } from "./types"; import type { BoardTag } from "../../lib/board"; @@ -19,6 +19,7 @@ export function BoardPeek({ assigneeOptions, tagOptions, loadNotes, + onUploadAttachment, onChange, onClose, onDelete, @@ -29,6 +30,7 @@ export function BoardPeek({ assigneeOptions?: BoardOption[]; tagOptions?: BoardTag[]; loadNotes?: (id: string) => Promise; + onUploadAttachment?: (file: File) => Promise; onChange: (patch: Partial) => void; onClose: () => void; onDelete: () => void; @@ -37,6 +39,8 @@ export function BoardPeek({ const [draft, setDraft] = useState(card); const [notes, setNotes] = useState(card.notes ?? ""); const [mode, setMode] = useState<"write" | "preview">("write"); + const [newAttach, setNewAttach] = useState(""); + const [uploading, setUploading] = useState(false); const previewRef = useRef(null); const saveTimer = useRef | null>(null); @@ -86,6 +90,22 @@ export function BoardPeek({ const tagLabels = draft.tags.map((t2) => t2.label); + const attachments = draft.attachments ?? []; + const setAttachments = (next: string[]) => setField({ attachments: next }, true); + const addAttachment = (url: string) => { + const u = url.trim(); + if (u && !attachments.includes(u)) setAttachments([...attachments, u]); + }; + const handleUpload = async (file: File | undefined) => { + if (!file || !onUploadAttachment) return; + setUploading(true); + try { + addAttachment(await onUploadAttachment(file)); + } finally { + setUploading(false); + } + }; + return (