@alliance-droid/status-feedback-system 3.1.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 CHANGED
@@ -14,7 +14,8 @@ This is the **client package** — UI components that talk to a [status-feedback
14
14
  │ │ StatusPage │──┼───────┼──│ /api/status │ │
15
15
  │ │ FeedbackButton │ │ │ │ /api/feedback │ │
16
16
  │ │ DevFeedback │ │ │ │ /api/incidents │ │
17
- │ │ FeedbackBoard │ │ │ │ /api/board │ │
17
+ │ │ Feedback board │ │ │ │ /api/board │ │
18
+ │ │ (composable) │ │ │ │ │ │
18
19
  │ └───────────────────┘ │ │ └────────────────────┘ │
19
20
  │ │ │ │ │
20
21
  │ npm package │ │ MSSQL + Azure Blob │
@@ -152,32 +153,70 @@ Standalone feedback form — embed it wherever you need it.
152
153
  | `onSuccess` | `() => void` | — | Called after successful submission |
153
154
  | `onError` | `(error: string) => void` | — | Called on submission error |
154
155
 
155
- ### FeedbackBoard
156
+ ### Feedback board (composable)
156
157
 
157
- Public feedback board with voting, category filtering, and admin responses.
158
+ > 📖 **Full integration guide:** [docs/custom-feedback-integration.md](docs/custom-feedback-integration.md) — composing the parts into your own shell, status management via `getAuthToken`, list restyling, and the not-configured fallback.
159
+
160
+ The board is composed from parts around **one shared state** (`createFeedbackBoard`), so it drops into your own layout/shell — app rails, breadcrumb, a right-hand detail panel, whatever you have.
158
161
 
159
162
  ```svelte
160
- <FeedbackBoard
161
- apiUrl="https://status-admin.example.com"
162
- apiKey="sf_abc123..."
163
- title="Feature Requests"
164
- showSubmit={true}
165
- submitCategories={['Bug', 'Feature Request', 'Improvement']}
166
- categoryConfig={{
167
- Bug: { icon: 'fa-solid fa-bug', label: 'Bugs', description: 'Something broken' },
168
- 'Feature Request': { icon: 'fa-solid fa-lightbulb', label: 'Ideas' },
169
- }}
170
- />
163
+ <script lang="ts">
164
+ import {
165
+ createFeedbackBoard,
166
+ FeedbackSidebar,
167
+ FeedbackList,
168
+ FeedbackDetail,
169
+ FeedbackSubmitForm,
170
+ type BoardItem,
171
+ } from '@alliance-droid/status-feedback-system';
172
+
173
+ let { data } = $props();
174
+ const board = createFeedbackBoard({
175
+ apiUrl: 'https://status-admin.example.com',
176
+ apiKey: 'sf_abc123...',
177
+ userEmail: data.user?.email,
178
+ getAuthToken: () => auth.getAccessToken(), // optional — enables status management
179
+ categoryConfig: {
180
+ Bug: { icon: 'fa-solid fa-bug', label: 'Bugs' },
181
+ 'Feature Request': { icon: 'fa-solid fa-lightbulb', label: 'Ideas' },
182
+ },
183
+ });
184
+
185
+ let selected = $state<BoardItem | null>(null);
186
+ </script>
187
+
188
+ <YourSidebar><FeedbackSidebar {board} /></YourSidebar>
189
+ <YourMain><FeedbackList {board} onSelect={(i) => (selected = i)} /></YourMain>
190
+ <YourRail>
191
+ {#if selected}<FeedbackDetail {board} item={selected} />{/if}
192
+ </YourRail>
193
+ <!-- Render <FeedbackSubmitForm {board} /> wherever you want submission. -->
171
194
  ```
172
195
 
173
- | Prop | Type | Default | Description |
174
- |------|------|---------|-------------|
196
+ `createFeedbackBoard(options)` returns a reactive `FeedbackBoardState` (items, categories, filters, voting, `loadTransitions`/`setStatus`, …) shared by every sub-component.
197
+
198
+ | Option | Type | Default | Description |
199
+ |--------|------|---------|-------------|
175
200
  | `apiUrl` | `string` | required | Admin backend URL |
176
- | `apiKey` | `string` | required | Project API key |
177
- | `title` | `string` | `'Feedback Board'` | Header title |
178
- | `showSubmit` | `boolean` | `true` | Show inline submit form |
179
- | `submitCategories` | `string[]` | `[]` | Category options for submit form |
180
- | `categoryConfig` | `Record<string, { label?, icon?, description? }>` | `{}` | Customize sidebar categories |
201
+ | `apiKey` | `string` | required | Project API key (`sf_…`) |
202
+ | `userEmail` | `string` | — | Authenticated user (required to submit feedback/replies) |
203
+ | `categoryConfig` | `Record<string, { label?, icon?, description? }>` | `{}` | Relabel / add icons to sidebar categories |
204
+ | `getAuthToken` | `() => string \| Promise<string \| null>` | — | Bearer token so privileged users can set status (see below) |
205
+ | `pageSize` | `number` | `50` | Items per page |
206
+
207
+ Exported parts: `createFeedbackBoard`, `FeedbackSidebar`, `FeedbackList`, `FeedbackItem`, `FeedbackDetail`, `FeedbackSubmitForm`.
208
+
209
+ Categories are fixed by the system (**Bug**, **Feature Request**) — `categoryConfig` only relabels or adds icons.
210
+
211
+ #### Setting status (Admin / ProductOwner)
212
+
213
+ When you pass `getAuthToken`, a signed-in **Admin** or **ProductOwner** can change an item's status from the board (via the status badge → menu) — Open → Considering → Accepted, or a decline with a reason — no trip to the admin dashboard.
214
+
215
+ - `getAuthToken` must return the user's **Alliance OIDC access token** whose audience covers **`status-feedback-admin`** (an *id token*, whose `aud` is your app's client id, is rejected). It's called on demand; return `null` for anonymous / non-privileged users and the control simply won't appear.
216
+ - The token travels as `Authorization: Bearer …` to the admin backend, which **verifies it against the IdP** and derives the user's roles. Authorization (per-project access + which transitions a role may make, including reason prompts on declines/reopens) is enforced **server-side** by the same logic as the dashboard — the client never decides permissions.
217
+ - The available transitions are returned by the backend per item, so the menu always reflects exactly what that user may do.
218
+
219
+ > Security note: the project `apiKey` alone never grants status changes — it only identifies the project. Every mutation additionally requires a valid IdP token, so the public board key stays safe to embed.
181
220
 
182
221
  ### DevFeedback
183
222
 
@@ -253,10 +292,16 @@ import {
253
292
  ```typescript
254
293
  // Components
255
294
  import {
256
- StatusPage, StatusBanner, FeedbackForm, FeedbackButton,
257
- FeedbackBoard, DevFeedback,
258
- StatusIndicator, UptimeBar, IncidentTimeline, SystemCard,
295
+ StatusPage, StatusBanner, FeedbackForm, FeedbackButton, DevFeedback,
296
+ StatusIndicator, UptimeBar, IncidentTimeline, SystemCard, PasteImageInput,
297
+ } from '@alliance-droid/status-feedback-system';
298
+
299
+ // Feedback board (composable)
300
+ import {
301
+ createFeedbackBoard,
302
+ FeedbackSidebar, FeedbackList, FeedbackItem, FeedbackDetail, FeedbackSubmitForm,
259
303
  } from '@alliance-droid/status-feedback-system';
304
+ import type { FeedbackBoardState, FeedbackBoardOptions, CategoryConfig } from '@alliance-droid/status-feedback-system';
260
305
 
261
306
  // API Client
262
307
  import { createStatusClient } from '@alliance-droid/status-feedback-system';
package/dist/api.d.ts CHANGED
@@ -53,6 +53,30 @@ export interface BoardData {
53
53
  total: number;
54
54
  categories: BoardCategory[];
55
55
  }
56
+ /** A status a privileged user may move an item to, and whether it needs a reason. */
57
+ export interface BoardTransition {
58
+ /** Raw internal status value to submit. */
59
+ status: string;
60
+ /** Human label for the menu. */
61
+ display: string;
62
+ /** When true, the user must supply a reason to make this transition. */
63
+ requiresReason: boolean;
64
+ }
65
+ /** The current status of a board item plus the transitions the caller may make. */
66
+ export interface BoardTransitions {
67
+ current: {
68
+ status: string;
69
+ display: string;
70
+ publicStatus: PublicStatus;
71
+ };
72
+ targets: BoardTransition[];
73
+ }
74
+ /** Result of applying a status change from the board. */
75
+ export interface BoardStatusUpdate {
76
+ id: string;
77
+ status: string;
78
+ publicStatus: PublicStatus;
79
+ }
56
80
  export interface StatusClient {
57
81
  /** Get current system statuses */
58
82
  getStatus(): Promise<StatusData>;
@@ -82,6 +106,17 @@ export interface StatusClient {
82
106
  limit?: number;
83
107
  offset?: number;
84
108
  }): Promise<BoardData>;
109
+ /**
110
+ * Status transitions the token's user may make on a board item. Requires an
111
+ * Alliance id token; the backend returns an empty `targets` list for users
112
+ * without a qualifying role.
113
+ */
114
+ getBoardTransitions(feedbackId: string, token: string): Promise<BoardTransitions>;
115
+ /** Set a board item's status (privileged). Requires an Alliance id token. */
116
+ updateBoardStatus(feedbackId: string, data: {
117
+ status: string;
118
+ reason?: string;
119
+ }, token: string): Promise<BoardStatusUpdate>;
85
120
  /** Vote on a feedback item */
86
121
  vote(feedbackId: string, direction?: 'up' | 'down'): Promise<{
87
122
  votes: number;
package/dist/api.js CHANGED
@@ -87,6 +87,18 @@ export function createStatusClient(config) {
87
87
  const qs = params.toString();
88
88
  return request(`/api/board${qs ? `?${qs}` : ''}`);
89
89
  },
90
+ async getBoardTransitions(feedbackId, token) {
91
+ return request(`/api/board/feedback/${feedbackId}/transitions`, {
92
+ headers: { Authorization: `Bearer ${token}` },
93
+ });
94
+ },
95
+ async updateBoardStatus(feedbackId, data, token) {
96
+ return request(`/api/board/feedback/${feedbackId}/status`, {
97
+ method: 'PATCH',
98
+ headers: { Authorization: `Bearer ${token}` },
99
+ body: JSON.stringify(data),
100
+ });
101
+ },
90
102
  async vote(feedbackId, direction = 'up') {
91
103
  return request('/api/board/vote', {
92
104
  method: 'POST',
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { createStatusClient, type StatusClient } from '../api.js';
3
+ import { FEEDBACK_CATEGORIES } from '../types.js';
3
4
  import PasteImageInput from './shared/PasteImageInput.svelte';
4
5
  import {
5
6
  Card,
@@ -363,7 +364,7 @@
363
364
  await getClient().submitFeedback({
364
365
  title: formTitle.trim() || undefined,
365
366
  message: formMessage.trim(),
366
- category: formType === 'bug' ? 'bug' : 'feature',
367
+ category: formType === 'bug' ? FEEDBACK_CATEGORIES[0] : FEEDBACK_CATEGORIES[1],
367
368
  email: userEmail,
368
369
  pageUrl: window.location.href,
369
370
  metadata,
@@ -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, publicStatusLabel } 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.publicStatus)} size="sm">
118
- {publicStatusLabel(item.publicStatus)}
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>