@stacksjs/defaults 0.75.89 → 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.
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.89",
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.89",
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 }}" />
@@ -47,6 +47,21 @@ route.post('/api/contact', 'Actions/ContactAction').name('contact.send').skipCsr
47
47
  route.get('/api/team-invitation-links/{token}', 'Actions/Teams/ShowInvitationAction').rateLimit(30, 'minute')
48
48
  route.post('/api/team-invitations/{token}/accept', 'Actions/Teams/AcceptInvitationAction').middleware('auth').rateLimit(10, 'minute')
49
49
 
50
+ // An external reviewer files feedback through a revocable link, without an
51
+ // account: the only unauthenticated write the dashboard has. Outside every
52
+ // `auth` group on purpose, and narrow because of it - the token names one
53
+ // board, adds a card to it, and reads nothing.
54
+ //
55
+ // CSRF is skipped for the same reason `/api/contact` above skips it, and more
56
+ // safely: there is no ambient authority to borrow here. The capability is the
57
+ // high-entropy token in the path, not a cookie the browser attaches, so a
58
+ // cross-site POST has nothing to forge with.
59
+ //
60
+ // Rate limited like the other token-bearing unauthenticated routes. The token
61
+ // is 256 bits, so this is about stopping a flood of cards from a link that
62
+ // leaked, not about guessing.
63
+ route.post('/api/feedback/{token}', 'Actions/Dashboard/Feedback/FeedbackStoreAction').name('feedback.store').skipCsrf().rateLimit(10, 'minute')
64
+
50
65
  // ============================================================================
51
66
  // Storefront (anonymous cart + multi-step checkout)
52
67
  //
@@ -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) {