-
Notifications
You must be signed in to change notification settings - Fork 0
feat(kanban): card attachments #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| import { Fragment, useCallback, useEffect, useRef, useState } from "react"; | ||
| import { t } from "@lingui/core/macro"; | ||
| import { Trans } from "@lingui/react/macro"; | ||
| import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon, ChatBubbleLeftIcon, ClockIcon } from "@heroicons/react/24/outline"; | ||
| import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon, PaperClipIcon, ArrowUpTrayIcon, ChatBubbleLeftIcon, ClockIcon } from "@heroicons/react/24/outline"; | ||
| import { renderToContainer } from "../../lib/markdown"; | ||
| import { PRIORITIES, type BoardViewCard } from "../../lib/board"; | ||
| import { PRIORITIES, attachmentName, isSafeAttachmentUrl, type BoardViewCard } from "../../lib/board"; | ||
| import { fieldCls, EmojiField, ListboxSelect, TagMultiSelect } from "./controls"; | ||
| import type { BoardOption } from "./types"; | ||
| import type { BoardTag, BoardFieldDef, BoardComment, BoardActivityEvent } from "../../lib/board"; | ||
|
|
@@ -22,6 +22,7 @@ export function BoardPeek({ | |
| onAddField, | ||
| dependencyCards, | ||
| loadNotes, | ||
| onUploadAttachment, | ||
| loadComments, | ||
| addComment, | ||
| deleteComment, | ||
|
|
@@ -43,6 +44,7 @@ export function BoardPeek({ | |
| /** Sibling cards (excluding this one) offered as dependency targets. */ | ||
| dependencyCards?: { slug: string; title: string }[]; | ||
| loadNotes?: (id: string) => Promise<string>; | ||
| onUploadAttachment?: (file: File) => Promise<string>; | ||
| loadComments?: (id: string) => Promise<BoardComment[]>; | ||
| addComment?: (id: string, body: string) => Promise<BoardComment>; | ||
| deleteComment?: (commentId: string) => Promise<void>; | ||
|
|
@@ -57,6 +59,8 @@ export function BoardPeek({ | |
| const [draft, setDraft] = useState<BoardViewCard>(card); | ||
| const [notes, setNotes] = useState(card.notes ?? ""); | ||
| const [mode, setMode] = useState<"write" | "preview">("write"); | ||
| const [newAttach, setNewAttach] = useState(""); | ||
| const [uploading, setUploading] = useState(false); | ||
| const [comments, setComments] = useState<BoardComment[]>([]); | ||
| const [newComment, setNewComment] = useState(""); | ||
| const [activity, setActivity] = useState<BoardActivityEvent[]>([]); | ||
|
|
@@ -167,6 +171,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); | ||
| } | ||
| }; | ||
|
|
||
| // Dependency editing maps slug<->title so the picker shows titles while the | ||
| // card stores slugs. Unresolved slugs (renamed/cross-board) are preserved as-is. | ||
| const slugToTitle = new Map((dependencyCards ?? []).map((c) => [c.slug, c.title])); | ||
|
|
@@ -354,6 +374,65 @@ export function BoardPeek({ | |
| )} | ||
| </div> | ||
|
|
||
| <div className="mt-4"> | ||
| <span className="text-xs font-medium text-brand-gray"> | ||
| <Trans>Attachments</Trans> | ||
| </span> | ||
| {attachments.length > 0 && ( | ||
| <div className="mt-1 space-y-1"> | ||
| {attachments.map((url) => ( | ||
| <div key={url} className="flex items-center gap-1.5 rounded border border-stone-200 px-2 py-1 text-xs"> | ||
| <PaperClipIcon className="h-3.5 w-3.5 shrink-0 text-stone-400" /> | ||
| {isSafeAttachmentUrl(url) ? ( | ||
| <a href={url} target="_blank" rel="noreferrer" className="flex-1 truncate text-brand-dark hover:underline" title={url}> | ||
| {attachmentName(url)} | ||
| </a> | ||
| ) : ( | ||
| <span className="flex-1 truncate text-stone-500" title={t`Unsafe link blocked: ${url}`}> | ||
| {attachmentName(url)} <span className="text-red-500">({t`unsafe`})</span> | ||
| </span> | ||
| )} | ||
|
Comment on lines
+386
to
+394
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Reject
🤖 Prompt for AI Agents |
||
| <button | ||
| type="button" | ||
| onClick={() => setAttachments(attachments.filter((a) => a !== url))} | ||
| title={t`Remove`} | ||
| className="rounded p-0.5 text-stone-400 hover:text-red-600" | ||
| > | ||
| <XMarkIcon className="h-3.5 w-3.5" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
| <div className="mt-1.5 flex items-center gap-1.5"> | ||
| <form | ||
| className="flex-1" | ||
| onSubmit={(e) => { | ||
| e.preventDefault(); | ||
| addAttachment(newAttach); | ||
| setNewAttach(""); | ||
| }} | ||
| > | ||
| <input className={fieldCls} placeholder={t`Paste a URL or path`} value={newAttach} onChange={(e) => setNewAttach(e.target.value)} /> | ||
| </form> | ||
| {onUploadAttachment && ( | ||
| <label className="inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-xs text-stone-600 hover:border-brand/40 hover:text-brand-dark"> | ||
| <ArrowUpTrayIcon className="h-3.5 w-3.5" /> | ||
| {uploading ? <Trans>Uploading…</Trans> : <Trans>Upload</Trans>} | ||
| <input | ||
| type="file" | ||
| className="hidden" | ||
| disabled={uploading} | ||
| onChange={(e) => { | ||
| void handleUpload(e.target.files?.[0]); | ||
| e.target.value = ""; | ||
| }} | ||
|
Comment on lines
+418
to
+429
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Handle upload failures before discarding the promise.
🤖 Prompt for AI Agents |
||
| /> | ||
| </label> | ||
| )} | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="mt-4 flex items-center justify-between"> | ||
| <span className="text-xs font-medium text-brand-gray"> | ||
| <Trans>Notes</Trans> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| /*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"Notes\"],\"1YABGm\":[\"Link (Ctrl+K)\"],\"1hKEom\":[\"Priority\"],\"2wxgft\":[\"Rename\"],\"3qkggm\":[\"Fullscreen\"],\"4gdyen\":[\"Local (yours)\"],\"4hJhzz\":[\"Table\"],\"54sFiP\":[\"flowchart TD\\n A[Start] --> B[End]\"],\"5Q_DQ6\":[\"Inline Code\"],\"7VpPHA\":[\"Confirm\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid diagram\"],\"8hSn0h\":[\"Result (editable)\"],\"8lE269\":[\"Sort: Manual\"],\"9gxam6\":[\"Could not render this Draw.io diagram.\"],\"AC9Gkf\":[\"Expand column\"],\"AS5WO9\":[\"Could not render this PDF.\"],\"AVreQ5\":[\"Drag to resize\"],\"AgvHni\":[\"Add column\"],\"AxAubu\":[\"Group: Assignee\"],\"BfMZ7w\":[\"Accept cloud\"],\"BnmEvM\":[\"Save as template\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"Code\"],\"EbMPZJ\":[\"Unassigned\"],\"G4qrLy\":[\"Unset done column\"],\"GKu3m4\":[\"No labels\"],\"Gpfctt\":[\"Due\"],\"H_SQFv\":[\"No color\"],\"I6SWEy\":[\"Split\"],\"ICip_B\":[\"Cloud (remote)\"],\"Ik60OC\":[\"Open in editor\"],\"Iw6WJa\":[\"Set WIP limit\"],\"JTYvAw\":[\"Search cards\"],\"K_F6pa\":[\"Saving…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"Bold\"],\"KvW1VO\":[\"Draw.io diagram\"],\"LQn6-8\":[\"Accept local\"],\"MHrjPM\":[\"Title\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"OYHzN1\":[\"Tags\"],\"OepdfE\":[\"Group: Status\"],\"Q2mGA7\":[\"Clear filter\"],\"QD8opX\":[\"Board\"],\"QlsPZy\":[\"Write Mermaid syntax to see the diagram.\"],\"S5Qbb1\":[\"comma, separated\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"Blank card\"],\"VNa_N2\":[\"This file type can not be previewed yet.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"Sort: Priority\"],\"X03-eC\":[\"Please enter a value.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"Diagram error\"],\"Zot9XS\":[\"No cards\"],\"_5CsXX\":[\"Done column\"],\"_EsjyQ\":[\"Use this\"],\"a6uhHr\":[\"Bold (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"Add details...\"],\"agOeRN\":[\"Could not render this API specification.\"],\"b4hVKD\":[\"Color columns\"],\"cfaWH-\":[\"Add labels\"],\"cnGeoo\":[\"Delete\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP limit \",[\"0\"]],\"dEgA5A\":[\"Cancel\"],\"euc6Ns\":[\"Duplicate\"],\"fYcKtB\":[\"Sort: Due\"],\"gLDJuJ\":[\"Untitled card\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF document\"],\"i4_LY_\":[\"Write\"],\"iTylMl\":[\"Templates\"],\"iYVqZq\":[\"Column name\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"Color\"],\"kZlRKE\":[\"Mermaid source\"],\"kryGs-\":[\"Card\"],\"lCF0wC\":[\"Refresh\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"Save merged result\"],\"nabda1\":[\"Delete card\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"Filter\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"Assignee\"],\"p9yTeb\":[\"Sort: Title\"],\"pKztsX\":[\"Open in full editor\"],\"pnrmSP\":[\"New card\"],\"pwN6Ae\":[\"Collapse column\"],\"pzutoc\":[\"Italic\"],\"rdUucN\":[\"Preview\"],\"sCzmvQ\":[\"cards\"],\"sQpDn6\":[\"Exit fullscreen\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" Conflict\",[\"1\"],\" to Resolve\"],\"u2IprG\":[\"Card title (Enter to add, Esc to cancel)\"],\"uAQUqI\":[\"Status\"],\"ucJg3u\":[\"Swimlane: Status\"],\"wf6Djn\":[\"Italic (Ctrl+I)\"],\"wtw-au\":[\"Set as done column\"],\"wwu18a\":[\"Icon\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"Copy link\"],\"y9cj46\":[\"Group: Priority\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}"); | ||
| /*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"Notes\"],\"1YABGm\":[\"Link (Ctrl+K)\"],\"1hKEom\":[\"Priority\"],\"1lWHP7\":[\"unsafe\"],\"2wxgft\":[\"Rename\"],\"3qkggm\":[\"Fullscreen\"],\"4gdyen\":[\"Local (yours)\"],\"4hJhzz\":[\"Table\"],\"54sFiP\":[\"flowchart TD\\n A[Start] --> B[End]\"],\"5Q_DQ6\":[\"Inline Code\"],\"7VpPHA\":[\"Confirm\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid diagram\"],\"8hSn0h\":[\"Result (editable)\"],\"8lE269\":[\"Sort: Manual\"],\"9gxam6\":[\"Could not render this Draw.io diagram.\"],\"AC9Gkf\":[\"Expand column\"],\"AS5WO9\":[\"Could not render this PDF.\"],\"AVreQ5\":[\"Drag to resize\"],\"AgvHni\":[\"Add column\"],\"AxAubu\":[\"Group: Assignee\"],\"BfMZ7w\":[\"Accept cloud\"],\"BnmEvM\":[\"Save as template\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"Code\"],\"EbMPZJ\":[\"Unassigned\"],\"G4qrLy\":[\"Unset done column\"],\"GKu3m4\":[\"No labels\"],\"Gpfctt\":[\"Due\"],\"H_SQFv\":[\"No color\"],\"I6SWEy\":[\"Split\"],\"ICip_B\":[\"Cloud (remote)\"],\"Ik60OC\":[\"Open in editor\"],\"Iw6WJa\":[\"Set WIP limit\"],\"JTYvAw\":[\"Search cards\"],\"K_F6pa\":[\"Saving…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"Bold\"],\"KvW1VO\":[\"Draw.io diagram\"],\"LQn6-8\":[\"Accept local\"],\"MHrjPM\":[\"Title\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"ONWvwQ\":[\"Upload\"],\"OYHzN1\":[\"Tags\"],\"OepdfE\":[\"Group: Status\"],\"Pvpx7b\":[\"Paste a URL or path\"],\"Q2mGA7\":[\"Clear filter\"],\"QD8opX\":[\"Board\"],\"QlsPZy\":[\"Write Mermaid syntax to see the diagram.\"],\"S5Qbb1\":[\"comma, separated\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"Blank card\"],\"VNa_N2\":[\"This file type can not be previewed yet.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"Sort: Priority\"],\"X03-eC\":[\"Please enter a value.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"Diagram error\"],\"Zot9XS\":[\"No cards\"],\"_5CsXX\":[\"Done column\"],\"_EsjyQ\":[\"Use this\"],\"a6uhHr\":[\"Bold (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"Add details...\"],\"agOeRN\":[\"Could not render this API specification.\"],\"b4hVKD\":[\"Color columns\"],\"cfaWH-\":[\"Add labels\"],\"cnGeoo\":[\"Delete\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP limit \",[\"0\"]],\"dEgA5A\":[\"Cancel\"],\"euc6Ns\":[\"Duplicate\"],\"fYcKtB\":[\"Sort: Due\"],\"gANddk\":[\"Uploading…\"],\"gLDJuJ\":[\"Untitled card\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF document\"],\"i4_LY_\":[\"Write\"],\"iTylMl\":[\"Templates\"],\"iYVqZq\":[\"Column name\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"Color\"],\"kZlRKE\":[\"Mermaid source\"],\"kryGs-\":[\"Card\"],\"lCF0wC\":[\"Refresh\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"Save merged result\"],\"nabda1\":[\"Delete card\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"Filter\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"Assignee\"],\"p9yTeb\":[\"Sort: Title\"],\"pKztsX\":[\"Open in full editor\"],\"pnrmSP\":[\"New card\"],\"pwN6Ae\":[\"Collapse column\"],\"pzutoc\":[\"Italic\"],\"rdUucN\":[\"Preview\"],\"sCzmvQ\":[\"cards\"],\"sQpDn6\":[\"Exit fullscreen\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" Conflict\",[\"1\"],\" to Resolve\"],\"t_YqKh\":[\"Remove\"],\"u2IprG\":[\"Card title (Enter to add, Esc to cancel)\"],\"uAQUqI\":[\"Status\"],\"ucJg3u\":[\"Swimlane: Status\"],\"w7E-FA\":[\"Unsafe link blocked: \",[\"url\"]],\"w_Sphq\":[\"Attachments\"],\"wf6Djn\":[\"Italic (Ctrl+I)\"],\"wtw-au\":[\"Set as done column\"],\"wwu18a\":[\"Icon\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"Copy link\"],\"y9cj46\":[\"Group: Priority\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Delete
attachmentswhen empty instead of serializing to""(Line 232).Writing an empty string for cleared attachments can produce inconsistent round-trips across consumers. Remove the key when list length is zero.
Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents