@alliance-droid/status-feedback-system 3.0.0 → 4.0.0
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/README.md +69 -24
- package/dist/api.d.ts +38 -1
- package/dist/api.js +12 -0
- package/dist/components/DevFeedback.svelte +2 -1
- package/dist/components/board/FeedbackDetail.svelte +205 -171
- package/dist/components/board/FeedbackItem.svelte +50 -46
- package/dist/components/board/FeedbackSidebar.svelte +58 -35
- package/dist/components/board/FeedbackSidebar.svelte.d.ts +1 -1
- package/dist/components/board/FeedbackSubmitForm.svelte +239 -123
- package/dist/components/board/FeedbackSubmitForm.svelte.d.ts +0 -2
- package/dist/components/board/StatusBadgeMenu.svelte +264 -0
- package/dist/components/board/StatusBadgeMenu.svelte.d.ts +11 -0
- package/dist/components/board/state.svelte.d.ts +21 -3
- package/dist/components/board/state.svelte.js +49 -4
- package/dist/components/board/utils.d.ts +5 -2
- package/dist/components/board/utils.js +20 -6
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.js +14 -1
- package/package.json +1 -1
- package/dist/components/FeedbackBoard.svelte +0 -110
- package/dist/components/FeedbackBoard.svelte.d.ts +0 -18
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { onMount
|
|
3
|
-
import {
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { Spinner, Button, Textarea, Divider } from '@alliance-droid/svelte-component-library';
|
|
4
4
|
import type { BoardItem } from '../../api.js';
|
|
5
5
|
import type { FeedbackResponse } from '../../types.js';
|
|
6
6
|
import type { FeedbackBoardState } from './state.svelte.js';
|
|
7
|
-
import { timeAgo
|
|
7
|
+
import { timeAgo } from './utils.js';
|
|
8
|
+
import StatusBadgeMenu from './StatusBadgeMenu.svelte';
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
board: FeedbackBoardState;
|
|
@@ -26,6 +27,8 @@
|
|
|
26
27
|
let pastedImage = $state<string | null>(null);
|
|
27
28
|
let pastedFilename = $state('');
|
|
28
29
|
let pasteError = $state('');
|
|
30
|
+
let fileInputEl: HTMLInputElement | undefined = $state();
|
|
31
|
+
let dragActive = $state(false);
|
|
29
32
|
|
|
30
33
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
31
34
|
const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
@@ -43,15 +46,8 @@
|
|
|
43
46
|
return () => replyTextareaEl?.removeEventListener('paste', handler);
|
|
44
47
|
});
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
|
|
49
|
-
if (!imageItem) return;
|
|
50
|
-
|
|
51
|
-
e.preventDefault();
|
|
52
|
-
const file = imageItem.getAsFile();
|
|
53
|
-
if (!file) return;
|
|
54
|
-
|
|
49
|
+
/** Validate and load an image file (shared by paste, file-pick, and drop). */
|
|
50
|
+
function processFile(file: File) {
|
|
55
51
|
if (!ALLOWED_MIME.includes(file.type)) {
|
|
56
52
|
pasteError = 'Unsupported image type \u2014 PNG, JPEG, GIF, or WebP only';
|
|
57
53
|
return;
|
|
@@ -60,18 +56,41 @@
|
|
|
60
56
|
pasteError = 'Image too large \u2014 max 5 MB';
|
|
61
57
|
return;
|
|
62
58
|
}
|
|
63
|
-
|
|
64
59
|
const reader = new FileReader();
|
|
65
60
|
reader.onload = () => {
|
|
66
61
|
pastedImage = reader.result as string;
|
|
67
62
|
const ext = file.type.split('/')[1] || 'png';
|
|
68
63
|
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
69
|
-
pastedFilename = `
|
|
64
|
+
pastedFilename = file.name || `screenshot-${stamp}.${ext}`;
|
|
70
65
|
pasteError = '';
|
|
71
66
|
};
|
|
72
67
|
reader.readAsDataURL(file);
|
|
73
68
|
}
|
|
74
69
|
|
|
70
|
+
function handlePaste(e: ClipboardEvent) {
|
|
71
|
+
const clipItems = Array.from(e.clipboardData?.items ?? []);
|
|
72
|
+
const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
|
|
73
|
+
if (!imageItem) return;
|
|
74
|
+
const file = imageItem.getAsFile();
|
|
75
|
+
if (!file) return;
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
processFile(file);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function handleFileSelect(e: Event) {
|
|
81
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
82
|
+
const file = input.files?.[0];
|
|
83
|
+
if (file) processFile(file);
|
|
84
|
+
input.value = '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleDrop(e: DragEvent) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
dragActive = false;
|
|
90
|
+
const file = e.dataTransfer?.files?.[0];
|
|
91
|
+
if (file) processFile(file);
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
function formatSize(dataUrl: string): string {
|
|
76
95
|
const comma = dataUrl.indexOf(',');
|
|
77
96
|
if (comma === -1) return '';
|
|
@@ -108,141 +127,201 @@
|
|
|
108
127
|
}
|
|
109
128
|
</script>
|
|
110
129
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
</div>
|
|
127
|
-
|
|
128
|
-
<!-- Submitter email -->
|
|
129
|
-
{#if item.email}
|
|
130
|
-
<p class="text-sm text-foreground-tertiary flex items-center gap-1">
|
|
131
|
-
<i class="fa-solid fa-envelope" aria-hidden="true"></i>
|
|
132
|
-
{item.email}
|
|
133
|
-
</p>
|
|
134
|
-
{/if}
|
|
130
|
+
<!--
|
|
131
|
+
Fills the height of its container so a long thread scrolls while the composer
|
|
132
|
+
stays pinned to the bottom. If the host doesn't constrain the height, it
|
|
133
|
+
simply flows (composer sits below the thread). The host should give this a
|
|
134
|
+
bounded height (e.g. a flex child of a full-height rail) for sticky behavior.
|
|
135
|
+
-->
|
|
136
|
+
<div class="flex h-full flex-col">
|
|
137
|
+
<!-- Scrollable conversation: status, content, responses -->
|
|
138
|
+
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
139
|
+
<!-- 1. Status line -->
|
|
140
|
+
<div class="px-5 py-4">
|
|
141
|
+
<div class="flex items-center gap-2.5">
|
|
142
|
+
{#if item.category}
|
|
143
|
+
<i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-tertiary" aria-hidden="true"></i>
|
|
144
|
+
{/if}
|
|
135
145
|
|
|
136
|
-
|
|
137
|
-
{#if item.title}
|
|
138
|
-
<h3 class="text-lg font-semibold text-foreground">{item.title}</h3>
|
|
139
|
-
{/if}
|
|
140
|
-
<p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{item.message}</p>
|
|
146
|
+
<StatusBadgeMenu {board} {item} loadOnMount />
|
|
141
147
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
<div class="ml-auto flex items-center gap-4 text-xs text-foreground-tertiary">
|
|
149
|
+
<span>{timeAgo(item.createdAt)}</span>
|
|
150
|
+
{#if item.responseCount > 0}
|
|
151
|
+
<span class="inline-flex items-center gap-1.5">
|
|
152
|
+
<i class="fa-solid fa-comment" aria-hidden="true"></i>
|
|
153
|
+
{item.responseCount}
|
|
154
|
+
</span>
|
|
155
|
+
{/if}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
147
158
|
</div>
|
|
148
159
|
|
|
149
|
-
|
|
150
|
-
{#if item.adminResponse}
|
|
151
|
-
<div class="rounded-lg bg-primary/5 border border-primary/20 p-3">
|
|
152
|
-
<p class="text-xs font-semibold text-primary mb-1">
|
|
153
|
-
<i class="fa-solid fa-shield mr-1" aria-hidden="true"></i> Official Response
|
|
154
|
-
</p>
|
|
155
|
-
<p class="text-sm text-foreground-secondary">{item.adminResponse}</p>
|
|
156
|
-
</div>
|
|
157
|
-
{/if}
|
|
160
|
+
<Divider />
|
|
158
161
|
|
|
159
|
-
<!--
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<
|
|
162
|
+
<!-- 2. Poster, content, votes -->
|
|
163
|
+
<div class="space-y-4 px-5 py-6">
|
|
164
|
+
{#if item.email}
|
|
165
|
+
<div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
|
|
166
|
+
<i class="fa-solid fa-circle-user" aria-hidden="true"></i>
|
|
167
|
+
<span>{item.email}</span>
|
|
168
|
+
</div>
|
|
169
|
+
{/if}
|
|
170
|
+
{#if item.title}
|
|
171
|
+
<h3 class="text-base font-semibold text-foreground">{item.title}</h3>
|
|
172
|
+
{/if}
|
|
173
|
+
<p class="text-sm leading-relaxed text-foreground whitespace-pre-wrap">{item.message}</p>
|
|
174
|
+
<div class="flex items-center gap-1.5 pt-1 text-xs text-foreground-tertiary" aria-label="{item.votes} votes">
|
|
175
|
+
<i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
|
|
176
|
+
<span class="font-medium text-foreground-secondary">{item.votes}</span>
|
|
177
|
+
<span>votes</span>
|
|
163
178
|
</div>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
<
|
|
168
|
-
</
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
{#if item.adminResponse}
|
|
180
|
+
<div class="rounded-lg border-l-2 border-primary/60 bg-primary/5 px-3 py-2.5">
|
|
181
|
+
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-primary">Official response</p>
|
|
182
|
+
<p class="text-sm text-foreground-secondary leading-relaxed">{item.adminResponse}</p>
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<Divider />
|
|
188
|
+
|
|
189
|
+
<!-- 3. Responses -->
|
|
190
|
+
<div class="px-5 py-4 space-y-3">
|
|
191
|
+
<h4 class="text-xs font-semibold uppercase tracking-wide text-foreground-tertiary">Responses</h4>
|
|
192
|
+
{#if loadingResponses}
|
|
193
|
+
<div class="text-foreground-tertiary text-sm flex items-center gap-2">
|
|
194
|
+
<Spinner /> Loading responses…
|
|
195
|
+
</div>
|
|
196
|
+
{:else if responses.length > 0}
|
|
197
|
+
<div class="space-y-4">
|
|
198
|
+
{#each responses as resp (resp.id)}
|
|
199
|
+
<div class="flex gap-3">
|
|
200
|
+
<div
|
|
201
|
+
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-xs font-semibold
|
|
202
|
+
{resp.author
|
|
203
|
+
? 'border-border bg-surface-secondary text-foreground-secondary'
|
|
204
|
+
: 'border-primary/30 bg-primary/10 text-primary'}"
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
>
|
|
207
|
+
{#if resp.author}{resp.author.charAt(0).toUpperCase()}{:else}<i class="fa-solid fa-shield"></i>{/if}
|
|
208
|
+
</div>
|
|
209
|
+
<div class="min-w-0 flex-1">
|
|
210
|
+
<div class="flex items-baseline justify-between gap-2">
|
|
211
|
+
<span class="text-sm font-medium {resp.author ? 'text-foreground' : 'text-primary'}">
|
|
212
|
+
{resp.author ?? 'Team'}
|
|
213
|
+
</span>
|
|
214
|
+
<span class="flex-shrink-0 text-xs text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
|
|
215
|
+
</div>
|
|
216
|
+
<p class="mt-1 text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{resp.message}</p>
|
|
217
|
+
{#if resp.imageUrl}
|
|
218
|
+
<img src={resp.imageUrl} alt="Pasted screenshot" class="mt-2 max-w-full rounded border border-border" />
|
|
219
|
+
{/if}
|
|
220
|
+
</div>
|
|
182
221
|
</div>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
222
|
+
{/each}
|
|
223
|
+
</div>
|
|
224
|
+
{:else}
|
|
225
|
+
<p class="text-sm text-foreground-tertiary">No responses yet.</p>
|
|
226
|
+
{/if}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<Divider />
|
|
191
231
|
|
|
192
|
-
<!--
|
|
193
|
-
<div class="
|
|
194
|
-
<h4 class="text-sm font-semibold text-foreground">
|
|
195
|
-
<i class="fa-solid fa-reply mr-1" aria-hidden="true"></i> Add a Response
|
|
196
|
-
</h4>
|
|
232
|
+
<!-- 4. Message composer (pinned footer) -->
|
|
233
|
+
<div class="shrink-0 px-5 py-5">
|
|
197
234
|
{#if !isAuthenticated}
|
|
198
235
|
<p class="text-sm text-foreground-tertiary">
|
|
199
236
|
<i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
|
|
200
237
|
Sign in to respond
|
|
201
238
|
</p>
|
|
202
239
|
{:else if replySuccess}
|
|
203
|
-
<p class="text-sm text-green-400
|
|
240
|
+
<p class="text-sm font-semibold text-green-400">
|
|
204
241
|
<i class="fa-solid fa-check-circle mr-1" aria-hidden="true"></i> Response submitted!
|
|
205
242
|
</p>
|
|
206
243
|
{:else}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
244
|
+
<!-- Composer card: textarea + attachment + toolbar -->
|
|
245
|
+
<div
|
|
246
|
+
class="overflow-hidden rounded-xl border bg-background shadow-sm transition-all {dragActive
|
|
247
|
+
? 'border-primary ring-2 ring-primary/30'
|
|
248
|
+
: 'border-border focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20'}"
|
|
249
|
+
role="group"
|
|
250
|
+
ondragover={(e) => { e.preventDefault(); dragActive = true; }}
|
|
251
|
+
ondragleave={() => (dragActive = false)}
|
|
252
|
+
ondrop={handleDrop}
|
|
253
|
+
>
|
|
254
|
+
<div bind:this={replyTextareaWrapperEl}>
|
|
255
|
+
<Textarea
|
|
256
|
+
class="resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm hover:border-transparent focus:border-transparent"
|
|
257
|
+
value={replyMessage}
|
|
258
|
+
oninput={(e) => (replyMessage = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
259
|
+
placeholder="Share your thoughts…"
|
|
260
|
+
rows={3}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
219
263
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
264
|
+
{#if pastedImage}
|
|
265
|
+
<div class="flex items-center gap-3 border-t border-border px-3 py-2">
|
|
266
|
+
<img
|
|
267
|
+
src={pastedImage}
|
|
268
|
+
alt={pastedFilename}
|
|
269
|
+
class="h-11 w-11 flex-shrink-0 rounded border border-border object-cover"
|
|
270
|
+
/>
|
|
271
|
+
<div class="min-w-0 flex-1">
|
|
272
|
+
<p class="truncate text-xs font-medium text-foreground">{pastedFilename}</p>
|
|
273
|
+
<p class="text-[11px] text-foreground-tertiary">{formatSize(pastedImage)}</p>
|
|
274
|
+
</div>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
class="flex-shrink-0 rounded p-1.5 text-foreground-tertiary transition-colors hover:bg-surface-hover hover:text-foreground"
|
|
278
|
+
onclick={() => { pastedImage = null; pastedFilename = ''; }}
|
|
279
|
+
aria-label="Remove image"
|
|
280
|
+
>
|
|
281
|
+
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
|
282
|
+
</button>
|
|
226
283
|
</div>
|
|
227
|
-
|
|
284
|
+
{/if}
|
|
285
|
+
|
|
286
|
+
<!-- Toolbar -->
|
|
287
|
+
<div class="flex items-center justify-between gap-2 border-t border-border px-3 py-2.5">
|
|
288
|
+
<div class="flex items-center gap-2.5">
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-secondary transition-colors hover:border-border-strong hover:text-foreground"
|
|
292
|
+
onclick={() => fileInputEl?.click()}
|
|
293
|
+
>
|
|
294
|
+
<i class="fa-solid fa-image" aria-hidden="true"></i>
|
|
295
|
+
Attach screenshot
|
|
296
|
+
</button>
|
|
297
|
+
<span class="hidden text-[11px] text-foreground-tertiary sm:inline">or paste / drop</span>
|
|
298
|
+
</div>
|
|
299
|
+
<Button
|
|
300
|
+
variant="primary"
|
|
301
|
+
size="sm"
|
|
302
|
+
disabled={submittingReply || !replyMessage.trim()}
|
|
303
|
+
loading={submittingReply}
|
|
304
|
+
onclick={handleSubmitReply}
|
|
305
|
+
>
|
|
306
|
+
Submit
|
|
307
|
+
</Button>
|
|
228
308
|
</div>
|
|
229
|
-
|
|
309
|
+
|
|
310
|
+
<input
|
|
311
|
+
bind:this={fileInputEl}
|
|
312
|
+
type="file"
|
|
313
|
+
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
314
|
+
class="hidden"
|
|
315
|
+
onchange={handleFileSelect}
|
|
316
|
+
/>
|
|
317
|
+
</div>
|
|
230
318
|
|
|
231
319
|
{#if pasteError}
|
|
232
|
-
<p class="text-sm
|
|
320
|
+
<p class="text-sm text-red-400">
|
|
321
|
+
<i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
|
|
322
|
+
{pasteError}
|
|
323
|
+
</p>
|
|
233
324
|
{/if}
|
|
234
|
-
|
|
235
|
-
<div class="flex gap-2 items-center justify-end">
|
|
236
|
-
<Button
|
|
237
|
-
variant="primary"
|
|
238
|
-
size="sm"
|
|
239
|
-
disabled={submittingReply || !replyMessage.trim()}
|
|
240
|
-
loading={submittingReply}
|
|
241
|
-
onclick={handleSubmitReply}
|
|
242
|
-
>
|
|
243
|
-
Submit
|
|
244
|
-
</Button>
|
|
245
|
-
</div>
|
|
246
325
|
{#if replyError}
|
|
247
326
|
<p class="text-sm text-red-400">
|
|
248
327
|
<i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
|
|
@@ -252,48 +331,3 @@
|
|
|
252
331
|
{/if}
|
|
253
332
|
</div>
|
|
254
333
|
</div>
|
|
255
|
-
|
|
256
|
-
<style>
|
|
257
|
-
.sf-paste-preview {
|
|
258
|
-
display: flex;
|
|
259
|
-
align-items: center;
|
|
260
|
-
gap: 0.75rem;
|
|
261
|
-
padding: 0.5rem;
|
|
262
|
-
border: 1px solid var(--color-border, #2a2f3a);
|
|
263
|
-
border-radius: 0.5rem;
|
|
264
|
-
background: var(--color-surface-secondary, rgba(255, 255, 255, 0.04));
|
|
265
|
-
}
|
|
266
|
-
.sf-paste-thumb {
|
|
267
|
-
width: 120px;
|
|
268
|
-
height: 80px;
|
|
269
|
-
object-fit: cover;
|
|
270
|
-
border-radius: 0.25rem;
|
|
271
|
-
flex-shrink: 0;
|
|
272
|
-
}
|
|
273
|
-
.sf-paste-meta {
|
|
274
|
-
flex: 1;
|
|
275
|
-
display: flex;
|
|
276
|
-
flex-direction: column;
|
|
277
|
-
min-width: 0;
|
|
278
|
-
}
|
|
279
|
-
.sf-paste-name {
|
|
280
|
-
font-size: 0.8125rem;
|
|
281
|
-
color: var(--color-foreground, #e5e7eb);
|
|
282
|
-
white-space: nowrap;
|
|
283
|
-
overflow: hidden;
|
|
284
|
-
text-overflow: ellipsis;
|
|
285
|
-
}
|
|
286
|
-
.sf-paste-size {
|
|
287
|
-
font-size: 0.75rem;
|
|
288
|
-
color: var(--color-foreground-tertiary, #9ca3af);
|
|
289
|
-
}
|
|
290
|
-
.sf-paste-remove {
|
|
291
|
-
background: transparent;
|
|
292
|
-
border: none;
|
|
293
|
-
color: var(--color-foreground-tertiary, #9ca3af);
|
|
294
|
-
font-size: 1.25rem;
|
|
295
|
-
cursor: pointer;
|
|
296
|
-
padding: 0.25rem 0.5rem;
|
|
297
|
-
}
|
|
298
|
-
.sf-paste-remove:hover { color: var(--color-danger, #ef4444); }
|
|
299
|
-
</style>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { IconButton } from '@alliance-droid/svelte-component-library';
|
|
3
3
|
import type { BoardItem } from '../../api.js';
|
|
4
4
|
import type { FeedbackBoardState } from './state.svelte.js';
|
|
5
|
-
import { timeAgo
|
|
5
|
+
import { timeAgo } from './utils.js';
|
|
6
|
+
import StatusBadgeMenu from './StatusBadgeMenu.svelte';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
board: FeedbackBoardState;
|
|
@@ -13,26 +14,20 @@
|
|
|
13
14
|
let { board, item, onclick }: Props = $props();
|
|
14
15
|
|
|
15
16
|
let isClickable = $derived(!!onclick);
|
|
16
|
-
let voteLabel = $derived.by(() => {
|
|
17
|
-
const current = board.votedItems[item.id];
|
|
18
|
-
if (current === 'up') return 'Remove upvote';
|
|
19
|
-
if (current === 'down') return 'Remove downvote';
|
|
20
|
-
return undefined;
|
|
21
|
-
});
|
|
22
17
|
</script>
|
|
23
18
|
|
|
24
19
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
25
20
|
<article
|
|
26
|
-
class="rounded-xl border border-border bg-surface transition-
|
|
21
|
+
class="rounded-xl border border-border bg-surface p-4 transition-all {isClickable ? 'cursor-pointer hover:border-border-strong hover:bg-surface-hover' : ''}"
|
|
27
22
|
role={isClickable ? 'button' : undefined}
|
|
28
23
|
tabindex={isClickable ? 0 : undefined}
|
|
29
24
|
aria-label={isClickable ? (item.title || item.message.slice(0, 80)) : undefined}
|
|
30
25
|
onclick={() => onclick?.(item)}
|
|
31
26
|
onkeydown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onclick?.(item); } }}
|
|
32
27
|
>
|
|
33
|
-
<div class="flex gap-
|
|
28
|
+
<div class="flex gap-4">
|
|
34
29
|
<!-- Vote column -->
|
|
35
|
-
<div class="flex flex-col items-center gap-0.5
|
|
30
|
+
<div class="flex flex-col items-center gap-1 pt-0.5" role="group" aria-label="Vote on this feedback">
|
|
36
31
|
<div role="presentation" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
|
37
32
|
<IconButton
|
|
38
33
|
size="sm"
|
|
@@ -43,7 +38,7 @@
|
|
|
43
38
|
<i class="fa-solid fa-chevron-up text-xs"></i>
|
|
44
39
|
</IconButton>
|
|
45
40
|
</div>
|
|
46
|
-
<span class="text-sm font-
|
|
41
|
+
<span class="text-sm font-semibold tabular-nums text-foreground" aria-label="{item.votes} votes">{item.votes}</span>
|
|
47
42
|
{#if board.voteErrors[item.id]}
|
|
48
43
|
<span class="text-[0.625rem] text-red-400" role="alert">
|
|
49
44
|
<i class="fa-solid fa-exclamation-circle" aria-hidden="true"></i>
|
|
@@ -62,41 +57,43 @@
|
|
|
62
57
|
</div>
|
|
63
58
|
|
|
64
59
|
<!-- Content -->
|
|
65
|
-
<div class="
|
|
66
|
-
<div class="flex items-
|
|
67
|
-
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
{#if item.title}
|
|
79
|
-
<p class="text-sm text-foreground-secondary mt-1.5 leading-relaxed line-clamp-2">{item.message}</p>
|
|
80
|
-
{/if}
|
|
81
|
-
|
|
82
|
-
{#if item.email}
|
|
83
|
-
<p class="text-[0.8125rem] text-foreground-tertiary mt-1.5 flex items-center gap-1">
|
|
84
|
-
<i class="fa-solid fa-envelope" aria-hidden="true"></i>
|
|
85
|
-
{item.email}
|
|
86
|
-
</p>
|
|
87
|
-
{/if}
|
|
88
|
-
|
|
89
|
-
<div class="flex items-center justify-between mt-2 text-[0.8125rem] text-foreground-tertiary">
|
|
90
|
-
<div class="flex items-center gap-3">
|
|
91
|
-
<span>{timeAgo(item.createdAt)}</span>
|
|
92
|
-
{#if item.adminResponse}
|
|
93
|
-
<span class="text-xs text-primary">
|
|
94
|
-
<i class="fa-solid fa-shield" aria-hidden="true"></i> Official response
|
|
95
|
-
</span>
|
|
60
|
+
<div class="min-w-0 flex-1">
|
|
61
|
+
<div class="flex items-start gap-3">
|
|
62
|
+
<div class="min-w-0 flex-1">
|
|
63
|
+
<div class="flex items-center gap-2">
|
|
64
|
+
{#if item.category}
|
|
65
|
+
<i class="{board.getCategoryIcon(item.category)} flex-shrink-0 text-sm text-foreground-tertiary" aria-hidden="true"></i>
|
|
66
|
+
{/if}
|
|
67
|
+
<h3 class="line-clamp-1 text-[0.9375rem] font-semibold leading-snug text-foreground">
|
|
68
|
+
{item.title || item.message}
|
|
69
|
+
</h3>
|
|
70
|
+
</div>
|
|
71
|
+
{#if item.title}
|
|
72
|
+
<p class="mt-1 line-clamp-2 text-sm leading-relaxed text-foreground-secondary">{item.message}</p>
|
|
96
73
|
{/if}
|
|
97
74
|
</div>
|
|
75
|
+
<StatusBadgeMenu {board} {item} />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Meta -->
|
|
79
|
+
<div class="mt-3 flex items-center gap-3 text-xs text-foreground-tertiary">
|
|
80
|
+
{#if item.email}
|
|
81
|
+
<span class="inline-flex min-w-0 flex-1 items-center gap-1.5">
|
|
82
|
+
<i class="fa-solid fa-circle-user flex-shrink-0" aria-hidden="true"></i>
|
|
83
|
+
<span class="truncate">{item.email}</span>
|
|
84
|
+
</span>
|
|
85
|
+
{/if}
|
|
86
|
+
<span class="flex-shrink-0 whitespace-nowrap">{timeAgo(item.createdAt)}</span>
|
|
87
|
+
{#if item.adminResponse}
|
|
88
|
+
<span class="inline-flex flex-shrink-0 items-center gap-1 whitespace-nowrap text-primary">
|
|
89
|
+
<i class="fa-solid fa-shield" aria-hidden="true"></i> Official response
|
|
90
|
+
</span>
|
|
91
|
+
{/if}
|
|
98
92
|
{#if item.responseCount > 0}
|
|
99
|
-
<span
|
|
93
|
+
<span
|
|
94
|
+
class="ml-auto inline-flex flex-shrink-0 items-center gap-1"
|
|
95
|
+
aria-label="{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}"
|
|
96
|
+
>
|
|
100
97
|
<i class="fa-solid fa-comment" aria-hidden="true"></i>
|
|
101
98
|
{item.responseCount}
|
|
102
99
|
</span>
|
|
@@ -107,11 +104,18 @@
|
|
|
107
104
|
</article>
|
|
108
105
|
|
|
109
106
|
<style>
|
|
107
|
+
.line-clamp-1,
|
|
110
108
|
.line-clamp-2 {
|
|
111
109
|
display: -webkit-box;
|
|
112
|
-
-webkit-line-clamp: 2;
|
|
113
|
-
line-clamp: 2;
|
|
114
110
|
-webkit-box-orient: vertical;
|
|
115
111
|
overflow: hidden;
|
|
116
112
|
}
|
|
113
|
+
.line-clamp-1 {
|
|
114
|
+
-webkit-line-clamp: 1;
|
|
115
|
+
line-clamp: 1;
|
|
116
|
+
}
|
|
117
|
+
.line-clamp-2 {
|
|
118
|
+
-webkit-line-clamp: 2;
|
|
119
|
+
line-clamp: 2;
|
|
120
|
+
}
|
|
117
121
|
</style>
|