@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.
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
3
- import { Badge, Spinner, Button, Textarea } from '@alliance-droid/svelte-component-library';
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, getStatusBadgeVariant } from './utils.js';
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
- async function handlePaste(e: ClipboardEvent) {
47
- const clipItems = Array.from(e.clipboardData?.items ?? []);
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 = `pasted-image-${stamp}.${ext}`;
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
- <div class="space-y-4">
112
- <!-- Header -->
113
- <div class="flex items-center gap-2 flex-wrap">
114
- {#if item.category}
115
- <i class="{board.getCategoryIcon(item.category)} text-foreground-secondary" aria-hidden="true"></i>
116
- {/if}
117
- <Badge variant={getStatusBadgeVariant(item.status)} size="sm">
118
- {item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
119
- </Badge>
120
- <span class="text-xs text-foreground-tertiary">{timeAgo(item.createdAt)}</span>
121
- {#if item.responseCount > 0}
122
- <span class="text-xs text-foreground-tertiary flex items-center gap-1">
123
- <i class="fa-solid fa-comment" aria-hidden="true"></i> {item.responseCount}
124
- </span>
125
- {/if}
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
- <!-- Title + Message -->
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
- <!-- Votes -->
143
- <div class="flex items-center gap-2 text-sm text-foreground-tertiary" aria-label="{item.votes} votes">
144
- <i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
145
- <span class="font-semibold">{item.votes}</span>
146
- <span>votes</span>
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
- <!-- Admin response -->
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
- <!-- Responses -->
160
- {#if loadingResponses}
161
- <div class="text-foreground-tertiary text-sm">
162
- <Spinner /> Loading responses...
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
- {:else if responses.length > 0}
165
- <div class="space-y-3">
166
- <h4 class="text-sm font-semibold text-foreground">
167
- <i class="fa-solid fa-comments mr-1" aria-hidden="true"></i> Responses
168
- </h4>
169
- {#each responses as resp (resp.id)}
170
- <div class="rounded-lg bg-surface-secondary p-3">
171
- <div class="flex items-center justify-between mb-1">
172
- {#if !resp.author}
173
- <span class="text-xs font-semibold text-primary">
174
- <i class="fa-solid fa-shield mr-1" aria-hidden="true"></i>Team
175
- </span>
176
- {:else}
177
- <span class="text-xs font-semibold text-foreground-secondary">
178
- <i class="fa-solid fa-user mr-1" aria-hidden="true"></i>{resp.author}
179
- </span>
180
- {/if}
181
- <span class="text-[0.6875rem] text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
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
- <p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{resp.message}</p>
184
- {#if resp.imageUrl}
185
- <img src={resp.imageUrl} alt="Pasted screenshot" class="mt-2 max-w-full rounded border border-border" />
186
- {/if}
187
- </div>
188
- {/each}
189
- </div>
190
- {/if}
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
- <!-- Reply form -->
193
- <div class="border-t border-border pt-4 space-y-2">
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 font-semibold">
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
- <div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
208
- <i class="fa-solid fa-user" aria-hidden="true"></i>
209
- <span>Replying as <span class="font-medium text-foreground-secondary">{board.userEmail}</span></span>
210
- </div>
211
- <div bind:this={replyTextareaWrapperEl}>
212
- <Textarea
213
- value={replyMessage}
214
- oninput={(e) => (replyMessage = (e.currentTarget as HTMLTextAreaElement).value)}
215
- placeholder="Share your thoughts (paste an image to attach it)\u2026"
216
- rows={3}
217
- />
218
- </div>
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
- {#if pastedImage}
221
- <div class="sf-paste-preview">
222
- <img src={pastedImage} alt={pastedFilename} class="sf-paste-thumb" />
223
- <div class="sf-paste-meta">
224
- <span class="sf-paste-name">{pastedFilename}</span>
225
- <span class="sf-paste-size">{formatSize(pastedImage)}</span>
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
- <button type="button" class="sf-paste-remove" onclick={() => { pastedImage = null; pastedFilename = ''; }} aria-label="Remove image">&times;</button>
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
- {/if}
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" style="color: var(--color-danger, #ef4444);">{pasteError}</p>
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 { Badge, IconButton } from '@alliance-droid/svelte-component-library';
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, getStatusBadgeVariant } from './utils.js';
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-colors {isClickable ? 'cursor-pointer hover:bg-surface-hover' : ''}"
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-3 p-4">
28
+ <div class="flex gap-4">
34
29
  <!-- Vote column -->
35
- <div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]" role="group" aria-label="Vote on this feedback">
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-bold tabular-nums" aria-label="{item.votes} votes">{item.votes}</span>
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="flex-1 min-w-0">
66
- <div class="flex items-center gap-2 flex-wrap">
67
- {#if item.category}
68
- <i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-secondary" aria-hidden="true"></i>
69
- {/if}
70
- <h3 class="text-[0.9375rem] font-semibold flex-1 leading-snug">
71
- {item.title || item.message.slice(0, 80)}
72
- </h3>
73
- <Badge variant={getStatusBadgeVariant(item.status)} size="sm">
74
- {item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
75
- </Badge>
76
- </div>
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 class="flex items-center gap-1" aria-label="{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}">
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>