@stacksjs/defaults 0.75.90 → 0.75.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation/runtime'
3
+
4
+ /**
5
+ * A file attached to a card. Today that means one screenshot on a card filed
6
+ * through a feedback link (stacksjs/stacks#2872).
7
+ *
8
+ * The bytes live on a disk and this row is the only thing that knows they
9
+ * belong to a card: a disk knows a path and some bytes, not which card a
10
+ * screenshot illustrates. Same division as `StorageItem`, for the same
11
+ * reason, and deliberately a separate table from it - `storage_items` is
12
+ * advisory metadata keyed by `(disk, path)` for the file manager, while this
13
+ * is a card's own content and is authoritative about what is attached to
14
+ * what.
15
+ *
16
+ * ## The disk is chosen here, not by the uploader
17
+ *
18
+ * `disk` is recorded rather than assumed so a row written while an app used
19
+ * local storage still resolves after it moves to S3. It is written by the
20
+ * action from configuration; nothing a submitter sends reaches it, and
21
+ * neither does any part of `path`, which is generated. See
22
+ * `feedback-attachment.ts`, which holds the rules.
23
+ *
24
+ * A feedback attachment is written to a PRIVATE disk. An unauthenticated
25
+ * stranger's upload landing under `public/` would be world-readable at a
26
+ * path the dashboard also prints.
27
+ *
28
+ * ## Erasure removes the row, not yet the bytes
29
+ *
30
+ * The `gdpr` trait deletes this row, and a screenshot is likely to contain
31
+ * personal data - it is a picture of somebody's screen. Deleting the row
32
+ * leaves the file behind, because nothing here sweeps a disk. Stated rather
33
+ * than implied, and tracked separately: a half-done erasure that looks done
34
+ * is worse than one that says what it did.
35
+ *
36
+ * No `useApi`. These rows are written by the feedback intake action and read
37
+ * by the dashboard's own board actions; a REST surface over them would
38
+ * publish a list of every uploaded file.
39
+ */
40
+ export default defineModel({
41
+ name: 'CardAttachment',
42
+ table: 'card_attachments',
43
+ primaryKey: 'id',
44
+ autoIncrement: true,
45
+
46
+ traits: {
47
+ gdpr: { erasure: 'delete', basis: 'legitimate_interests', purpose: 'A screenshot attached to a card, usually illustrating a bug report' },
48
+ useTimestamps: true,
49
+ useUuid: true,
50
+ },
51
+
52
+ // Cascade: an attachment to a card that no longer exists is a file nothing
53
+ // can reach and a row nothing can render. Declared, and not relied on -
54
+ // SQLite enforces it only with `foreign_keys = ON`, which the framework
55
+ // sets while renaming and not otherwise.
56
+ belongsTo: [{ model: 'Card', onDelete: 'cascade' }],
57
+
58
+ attributes: {
59
+ /** Which configured disk holds the bytes, e.g. `local`. */
60
+ disk: {
61
+ required: true,
62
+ order: 1,
63
+ fillable: true,
64
+ validation: { rule: schema.string().max(40) },
65
+ factory: () => 'local',
66
+ },
67
+
68
+ /**
69
+ * The path on that disk. Unique, so the same bytes cannot be claimed by
70
+ * two cards, and generated in full by `feedbackAttachmentPath`.
71
+ */
72
+ path: {
73
+ required: true,
74
+ order: 2,
75
+ fillable: true,
76
+ unique: true,
77
+ validation: { rule: schema.string().max(400) },
78
+ factory: faker => `feedback/1/${faker.string.uuid()}.png`,
79
+ },
80
+
81
+ /**
82
+ * What the bytes actually are, read from the file's own leading bytes
83
+ * rather than from the declared content type, which an uploader writes.
84
+ * The dashboard serves the file as this.
85
+ */
86
+ mimeType: {
87
+ required: true,
88
+ order: 3,
89
+ fillable: true,
90
+ validation: { rule: schema.string().max(80) },
91
+ factory: () => 'image/png',
92
+ },
93
+
94
+ /** Size as stored, so a listing does not have to stat every file. */
95
+ sizeBytes: {
96
+ required: true,
97
+ order: 4,
98
+ fillable: true,
99
+ validation: { rule: schema.number().min(0) },
100
+ factory: faker => faker.number.int({ min: 1000, max: 500_000 }),
101
+ },
102
+ },
103
+ } as const)
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.90",
5
+ "version": "0.75.91",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.90",
5
+ "version": "0.75.91",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "^0.75.90",
58
+ "@stacksjs/mobile": "^0.75.91",
59
59
  "@stacksjs/sanitizer": "^0.2.395",
60
60
  "@xterm/addon-fit": "^0.11.0",
61
61
  "@xterm/xterm": "^6.0.0",
@@ -0,0 +1,278 @@
1
+ {{--
2
+ What an external reviewer sees at `/feedback/{token}`.
3
+
4
+ Deliberately standalone rather than built out of the Dashboard UI
5
+ components: the person opening this has no account and no reason to load the
6
+ dashboard's bundle, and the closest precedent (MagicLink, the other public
7
+ token page) is plain markup for the same reason.
8
+
9
+ The link is never validated before a submit, and cannot be. Telling the page
10
+ up front whether a token is good would need a read endpoint for feedback
11
+ tokens, which is exactly what the capability design refuses - the token may
12
+ add one card and learn nothing. So the form always renders and the verdict
13
+ arrives with the first submit (stacksjs/stacks#2872).
14
+ --}}
15
+
16
+ <script client>
17
+ // All four arrive as strings: they are the action's own constants, decided
18
+ // server-side so the limits the reviewer types against are the ones the
19
+ // submission is judged by, and a client script cannot read them itself.
20
+ const maxTitle = useReactiveProp('maxTitle', '300')
21
+ const maxDescription = useReactiveProp('maxDescription', '10000')
22
+ const maxBytes = useReactiveProp('maxBytes', '5242880')
23
+ const accept = useReactiveProp('accept', 'image/png,image/jpeg,image/webp,image/gif')
24
+ const route = useRoute()
25
+ const picker = useRef<HTMLInputElement>('feedbackScreenshotInput')
26
+
27
+ /** ready | sending | filed | dead */
28
+ const status = state('ready')
29
+ const title = state('')
30
+ const description = state('')
31
+ const error = state('')
32
+ const screenshot = state<File | null>(null)
33
+ const preview = state('')
34
+
35
+ const sizeLimit = derived(() => Number(maxBytes()) || 5 * 1024 * 1024)
36
+ const acceptedTypes = derived(() => accept().split(',').map(type => type.trim()).filter(Boolean))
37
+
38
+ function megabytes(bytes: number): string {
39
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`
40
+ }
41
+
42
+ /**
43
+ * Hold a chosen screenshot, or say why it cannot be held.
44
+ *
45
+ * Checked here as well as on the server, so a reviewer finds out before
46
+ * uploading five megabytes rather than after. The server checks the bytes
47
+ * themselves and is the one that decides; this only saves the round trip, so
48
+ * it reads the declared type, which is all a browser offers.
49
+ */
50
+ function choose(file: File | null | undefined): void {
51
+ releasePreview()
52
+
53
+ if (!file) {
54
+ screenshot.set(null)
55
+ return
56
+ }
57
+
58
+ if (!acceptedTypes().includes(file.type)) {
59
+ screenshot.set(null)
60
+ error.set('Attach a PNG, JPEG, WebP or GIF image.')
61
+ return
62
+ }
63
+
64
+ if (file.size > sizeLimit()) {
65
+ screenshot.set(null)
66
+ error.set(`A screenshot must be ${megabytes(sizeLimit())} or smaller. That one is ${megabytes(file.size)}.`)
67
+ return
68
+ }
69
+
70
+ error.set('')
71
+ screenshot.set(file)
72
+ preview.set(URL.createObjectURL(file))
73
+ }
74
+
75
+ function releasePreview(): void {
76
+ const url = preview()
77
+ if (url) {
78
+ // Each object URL pins the whole file in memory until it is revoked, and
79
+ // somebody filing several reports in a row would pin every screenshot
80
+ // they had chosen and changed their mind about.
81
+ URL.revokeObjectURL(url)
82
+ preview.set('')
83
+ }
84
+ }
85
+
86
+ function pick(event: Event): void {
87
+ choose((event.target as HTMLInputElement).files?.[0])
88
+ }
89
+
90
+ function drop(event: DragEvent): void {
91
+ choose(event.dataTransfer?.files?.[0])
92
+ }
93
+
94
+ function paste(event: ClipboardEvent): void {
95
+ // A screenshot usually arrives on the clipboard rather than as a file on
96
+ // disk, so pasting into the form is the shortest path there is.
97
+ const file = event.clipboardData?.files?.[0]
98
+ if (file)
99
+ choose(file)
100
+ }
101
+
102
+ function clear(): void {
103
+ releasePreview()
104
+ screenshot.set(null)
105
+ if (picker.current)
106
+ picker.current.value = ''
107
+ }
108
+
109
+ function canSubmit() {
110
+ return status() !== 'sending' && title().trim().length > 0
111
+ }
112
+
113
+ async function submit() {
114
+ if (!canSubmit())
115
+ return
116
+
117
+ status.set('sending')
118
+ error.set('')
119
+
120
+ try {
121
+ // Multipart whether or not a file came with it. The action reads its
122
+ // fields through `request.all()`, which merges a form body the same way
123
+ // it merges JSON, so one shape covers both.
124
+ const body = new FormData()
125
+ body.set('title', title().trim())
126
+ if (description().trim())
127
+ body.set('description', description().trim())
128
+ const file = screenshot()
129
+ if (file)
130
+ body.set('screenshot', file, file.name || 'screenshot')
131
+
132
+ const reply = await fetch(`/api/feedback/${encodeURIComponent(String(route.params.token || ''))}`, {
133
+ method: 'POST',
134
+ body,
135
+ })
136
+ const payload = await reply.json().catch(() => ({}))
137
+
138
+ if (reply.ok) {
139
+ releasePreview()
140
+ status.set('filed')
141
+ return
142
+ }
143
+
144
+ // 404 is the link itself: unknown, revoked or expired, all answered the
145
+ // same so a guessed token learns nothing. Retrying cannot help, so the
146
+ // form goes away rather than inviting the reviewer to type it all again.
147
+ // A 400 is about this submission, so the fields are kept and the reason
148
+ // shown above them.
149
+ if (reply.status === 404) {
150
+ status.set('dead')
151
+ return
152
+ }
153
+
154
+ status.set('ready')
155
+ error.set(String(payload.message || payload.error || 'That did not go through. Try again in a moment.'))
156
+ }
157
+ catch {
158
+ status.set('ready')
159
+ error.set('Could not reach the server. Check your connection and try again.')
160
+ }
161
+ }
162
+
163
+ function another() {
164
+ title.set('')
165
+ description.set('')
166
+ error.set('')
167
+ clear()
168
+ status.set('ready')
169
+ }
170
+ </script>
171
+
172
+ <template>
173
+ <main class="flex items-center justify-center px-4 py-12 min-h-[100dvh] bg-neutral-50 dark:bg-neutral-950">
174
+ <div class="max-w-xl w-full">
175
+ <div :if="status() === 'dead'" class="p-8 text-center bg-white dark:bg-neutral-900 border border-neutral-200/60 rounded-2xl dark:border-neutral-800">
176
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-red-100 dark:bg-red-900/30 rounded-full">
177
+ <span class="h-6 w-6 text-red-600 dark:text-red-400 i-hugeicons-alert-circle"></span>
178
+ </div>
179
+ <h1 class="mb-2 font-semibold text-xl text-neutral-900 tracking-tight dark:text-white">This feedback link is not valid</h1>
180
+ <p class="text-[15px] text-neutral-500 dark:text-neutral-400">
181
+ It may have been turned off, or it may have expired. Ask whoever sent it for a fresh one.
182
+ </p>
183
+ </div>
184
+
185
+ <div :else-if="status() === 'filed'" class="p-8 text-center bg-white dark:bg-neutral-900 border border-neutral-200/60 rounded-2xl dark:border-neutral-800">
186
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-green-100 dark:bg-green-900/30 rounded-full">
187
+ <span class="h-6 w-6 text-green-600 dark:text-green-400 i-hugeicons-checkmark-circle-02"></span>
188
+ </div>
189
+ <h1 class="mb-2 font-semibold text-xl text-neutral-900 tracking-tight dark:text-white">Thank you</h1>
190
+ <p class="mb-6 text-[15px] text-neutral-500 dark:text-neutral-400">Your feedback is on the board.</p>
191
+ <button type="button" @click="another" class="px-5 py-2.5 font-medium text-sm text-white bg-neutral-900 rounded-lg transition-transform dark:bg-white dark:text-neutral-900 active:scale-[0.98]">
192
+ Send another
193
+ </button>
194
+ </div>
195
+
196
+ <div :else>
197
+ <div class="mb-6">
198
+ <h1 class="font-semibold text-2xl text-neutral-900 tracking-tight dark:text-white">Send feedback</h1>
199
+ <p class="mt-2 text-[15px] text-neutral-500 dark:text-neutral-400">
200
+ One thing per message, so each one can be picked up on its own.
201
+ </p>
202
+ </div>
203
+
204
+ <form @submit.prevent="submit" @paste="paste($event)" class="p-6 bg-white dark:bg-neutral-900 border border-neutral-200/60 rounded-2xl dark:border-neutral-800 shadow-black/5 shadow-sm">
205
+ <div :if="error()" role="alert" class="flex gap-2 items-center mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-lg dark:border-red-800">
206
+ <span class="h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400 i-hugeicons-alert-circle"></span>
207
+ <p class="text-red-600 text-sm dark:text-red-400">{{ error() }}</p>
208
+ </div>
209
+
210
+ <label class="block mb-4">
211
+ <span class="block mb-1.5 font-medium text-neutral-700 text-sm dark:text-neutral-200">What happened</span>
212
+ <input
213
+ type="text"
214
+ x-model="title"
215
+ :maxlength="maxTitle()"
216
+ :disabled="status() === 'sending'"
217
+ required
218
+ placeholder="A short summary"
219
+ class="block px-3 py-2.5 w-full text-neutral-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-neutral-300 rounded-lg transition-colors dark:border-neutral-600 placeholder:text-neutral-400 hover:border-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
220
+ />
221
+ </label>
222
+
223
+ <label class="block mb-4">
224
+ <span class="block mb-1.5 font-medium text-neutral-700 text-sm dark:text-neutral-200">Details <span class="font-normal text-neutral-400">(optional)</span></span>
225
+ <textarea
226
+ x-model="description"
227
+ rows="6"
228
+ :maxlength="maxDescription()"
229
+ :disabled="status() === 'sending'"
230
+ placeholder="Where you were, what you expected, and what you got instead"
231
+ class="block px-3 py-2.5 w-full text-neutral-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-neutral-300 rounded-lg resize-y transition-colors dark:border-neutral-600 placeholder:text-neutral-400 hover:border-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
232
+ ></textarea>
233
+ </label>
234
+
235
+ <div class="mb-5">
236
+ <span class="block mb-1.5 font-medium text-neutral-700 text-sm dark:text-neutral-200">Screenshot <span class="font-normal text-neutral-400">(optional)</span></span>
237
+
238
+ <div :if="screenshot()" class="flex gap-3 items-center p-3 bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-200 rounded-lg dark:border-neutral-700">
239
+ <img :src="preview()" alt="The screenshot you chose" class="h-14 w-14 bg-white dark:bg-neutral-900 border border-neutral-200 rounded object-cover dark:border-neutral-700" />
240
+ <span class="flex-1 min-w-0 text-neutral-600 text-sm truncate dark:text-neutral-300">{{ screenshot()?.name || 'Pasted image' }}</span>
241
+ <button type="button" @click="clear" class="flex-shrink-0 px-2.5 py-1.5 font-medium text-neutral-600 text-xs dark:text-neutral-300 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-700">
242
+ Remove
243
+ </button>
244
+ </div>
245
+
246
+ <label :else class="flex flex-col items-center justify-center px-4 py-6 text-center border border-neutral-300 border-dashed rounded-lg cursor-pointer transition-colors dark:border-neutral-600 hover:border-blue-400 dark:hover:border-blue-600" @dragover.prevent @drop.prevent="drop($event)">
247
+ <span class="h-6 w-6 text-neutral-400 i-hugeicons-image-upload"></span>
248
+ <span class="mt-2 font-medium text-neutral-700 text-sm dark:text-neutral-200">Choose an image, drop it here, or paste it</span>
249
+ <span class="mt-1 text-neutral-400 text-xs">PNG, JPEG, WebP or GIF, up to {{ megabytes(sizeLimit()) }}</span>
250
+ <input
251
+ ref="feedbackScreenshotInput"
252
+ type="file"
253
+ name="screenshot"
254
+ class="sr-only"
255
+ :accept="accept()"
256
+ :disabled="status() === 'sending'"
257
+ @change="pick($event)"
258
+ />
259
+ </label>
260
+ </div>
261
+
262
+ <button
263
+ type="submit"
264
+ :disabled="!canSubmit()"
265
+ class="flex gap-2 items-center justify-center px-5 py-2.5 w-full font-medium text-sm text-white bg-neutral-900 rounded-lg transition-transform dark:bg-white dark:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 active:scale-[0.99]"
266
+ >
267
+ <span :if="status() === 'sending'" class="h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
268
+ <span>{{ status() === 'sending' ? 'Sending' : 'Send feedback' }}</span>
269
+ </button>
270
+ </form>
271
+
272
+ <p class="mt-4 text-center text-neutral-400 text-xs dark:text-neutral-500">
273
+ This link lets you send feedback. It does not sign you in, and it cannot read anything back.
274
+ </p>
275
+ </div>
276
+ </div>
277
+ </main>
278
+ </template>
@@ -8,6 +8,7 @@ const loadingCard = derived(() => kanban.loadingCard())
8
8
  const errorCard = derived(() => kanban.errorCard())
9
9
  const allLabels = derived(() => kanban.currentLabels())
10
10
  const cardComments = derived(() => openCard()?.comments ?? [])
11
+ const cardAttachments = derived(() => openCard()?.attachments ?? [])
11
12
  const cardLabels = derived(() => openCard()?.labels ?? [])
12
13
  const cardAssignees = derived(() => openCard()?.assignees ?? [])
13
14
  const titleDraft = state('')
@@ -215,6 +216,15 @@ function initialsOf(name: string | null, email: string | null): string {
215
216
  return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
216
217
  }
217
218
 
219
+ function formatBytes(bytes: number): string {
220
+ const value = Number(bytes)
221
+ if (!Number.isFinite(value) || value <= 0)
222
+ return ''
223
+ const units = ['B', 'KB', 'MB', 'GB']
224
+ const index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)))
225
+ return `${Math.round((value / 1024 ** index) * 10) / 10} ${units[index]}`
226
+ }
227
+
218
228
  function formatTimestamp(iso: string | null | undefined): string {
219
229
  if (!iso)
220
230
  return ''
@@ -270,6 +280,26 @@ function formatTimestamp(iso: string | null | undefined): string {
270
280
  ></textarea>
271
281
  </section>
272
282
 
283
+ <section :if="cardAttachments().length">
284
+ <h4 class="mb-2 font-medium text-zinc-500 text-xs tracking-wide uppercase">Attachments</h4>
285
+ <ul class="grid gap-3 grid-cols-2 sm:grid-cols-3">
286
+ <li :for="attachment in cardAttachments" class="overflow-hidden bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800">
287
+ <!-- The URL is signed and expires, so it is opened in a new tab rather than linked anywhere that outlives the modal. -->
288
+ <a :if="attachment.url" :href="attachment.url" target="_blank" rel="noopener noreferrer" class="block">
289
+ <img :src="attachment.url" :alt="'Attachment ' + attachment.id" loading="lazy" class="h-28 w-full bg-zinc-50 dark:bg-zinc-900 object-cover" />
290
+ </a>
291
+ <!-- Listed even without a URL: a screenshot nobody can open is worth chasing, and is not the same as no screenshot. -->
292
+ <div :else class="flex items-center justify-center h-28 bg-zinc-50 dark:bg-zinc-900">
293
+ <span class="h-5 w-5 text-zinc-400 i-hugeicons-image-not-found-01"></span>
294
+ </div>
295
+ <div class="px-2 py-1.5">
296
+ <p class="text-[10px] text-zinc-500 dark:text-zinc-400" :text="attachment.mimeType"></p>
297
+ <p class="text-[10px] text-zinc-400" :text="formatBytes(attachment.sizeBytes)"></p>
298
+ </div>
299
+ </li>
300
+ </ul>
301
+ </section>
302
+
273
303
  <section>
274
304
  <h4 class="mb-2 font-medium text-zinc-500 text-xs tracking-wide uppercase">Activity</h4>
275
305
  <div class="mb-4">
@@ -0,0 +1,25 @@
1
+ <script server>
2
+ import { FEEDBACK_ATTACHMENT_MAX_BYTES, FEEDBACK_ATTACHMENT_TYPES } from '../../../app/Actions/Dashboard/Feedback/feedback-attachment'
3
+ import { FEEDBACK_MAX_DESCRIPTION, FEEDBACK_MAX_TITLE } from '../../../app/Actions/Dashboard/Feedback/feedback-token'
4
+
5
+ /** The accepted image types, as the file picker's `accept` attribute wants them. */
6
+ const accept = Object.keys(FEEDBACK_ATTACHMENT_TYPES).join(',')
7
+
8
+ /**
9
+ * Where a feedback link lands, at `/feedback/{token}`.
10
+ *
11
+ * The logic sits in the component, like the other public token pages here.
12
+ *
13
+ * The limits are passed down because they are the action's own constants and
14
+ * a client script cannot read them. A `maxlength` the server disagrees with
15
+ * either stops the reviewer typing something that would have been accepted,
16
+ * or lets them write a report that is refused on submit.
17
+ *
18
+ * Nothing here looks the token up. Answering "is this link good?" before a
19
+ * submit would need a read endpoint for feedback tokens, and a capability
20
+ * that may add one card and learn nothing is the whole point of the design
21
+ * (stacksjs/stacks#2872).
22
+ */
23
+ </script>
24
+
25
+ <FeedbackForm maxTitle="{{ FEEDBACK_MAX_TITLE }}" maxDescription="{{ FEEDBACK_MAX_DESCRIPTION }}" maxBytes="{{ FEEDBACK_ATTACHMENT_MAX_BYTES }}" accept="{{ accept }}" />
@@ -28,6 +28,24 @@ interface CardComment {
28
28
  updatedAt: string | null
29
29
  }
30
30
 
31
+ /**
32
+ * A file on a card. Today that is a screenshot from a feedback link
33
+ * (stacksjs/stacks#2872).
34
+ *
35
+ * `url` is signed and expires, because the bytes live on a private disk and
36
+ * there is no path a browser could fetch. It is null when one could not be
37
+ * minted, which is a listed attachment nobody can open and a different
38
+ * problem from a card with no attachment.
39
+ */
40
+ interface CardAttachment {
41
+ id: number
42
+ uuid: string | null
43
+ mimeType: string
44
+ sizeBytes: number
45
+ createdAt: string | null
46
+ url: string | null
47
+ }
48
+
31
49
  interface CardRecord {
32
50
  id: number
33
51
  uuid: string | null
@@ -53,6 +71,7 @@ interface CardDetail extends CardRecord {
53
71
  labels: CardLabel[]
54
72
  assignees: CardAssignee[]
55
73
  comments: CardComment[]
74
+ attachments: CardAttachment[]
56
75
  }
57
76
 
58
77
  interface ColumnRecord {
@@ -373,7 +392,10 @@ export const kanbanStore = defineStore('kanban', () => {
373
392
  ...c,
374
393
  labels: c.labels ?? [],
375
394
  assignees: c.assignees ?? [],
376
- comments: [], // hydrated by openCardDetail
395
+ // Both hydrated by openCardDetail. A signed URL cannot be seeded from
396
+ // the board view, which never carries one.
397
+ comments: [],
398
+ attachments: [],
377
399
  }
378
400
  }
379
401
 
@@ -400,6 +422,7 @@ export const kanbanStore = defineStore('kanban', () => {
400
422
  labels?: CardLabel[]
401
423
  assignees?: CardAssignee[]
402
424
  comments?: CardComment[]
425
+ attachments?: CardAttachment[]
403
426
  error?: string
404
427
  }>(`/api/dashboard/kanban/cards/${cardId}`)
405
428
  if (data.error || !data.card)
@@ -409,6 +432,7 @@ export const kanbanStore = defineStore('kanban', () => {
409
432
  labels: data.labels ?? [],
410
433
  assignees: data.assignees ?? [],
411
434
  comments: data.comments ?? [],
435
+ attachments: data.attachments ?? [],
412
436
  })
413
437
  }
414
438
  catch (e) {