@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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/app/Actions/Dashboard/Feedback/FeedbackStoreAction.ts +102 -7
- package/app/Actions/Dashboard/Feedback/feedback-attachment.test.ts +192 -0
- package/app/Actions/Dashboard/Feedback/feedback-attachment.ts +161 -0
- package/app/Actions/Dashboard/Feedback/feedback-notifier.test.ts +144 -0
- package/app/Actions/Dashboard/Feedback/feedback-notifier.ts +211 -0
- package/app/Actions/Dashboard/Feedback/feedback-token.ts +13 -0
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +12 -6
- package/app/Actions/Dashboard/Kanban/card-attachments.test.ts +125 -0
- package/app/Actions/Dashboard/Kanban/card-attachments.ts +107 -0
- package/app/Models/CardAttachment.ts +103 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Feedback/FeedbackForm.stx +278 -0
- package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +30 -0
- package/resources/views/feedback/[token].stx +25 -0
- package/views/dashboard/stores/kanban.ts +25 -1
|
@@ -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)
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) {
|