@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 +69 -24
- package/dist/api.d.ts +35 -0
- 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/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/types.d.ts +12 -0
- 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
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
|
-
│ │
|
|
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
|
-
###
|
|
156
|
+
### Feedback board (composable)
|
|
156
157
|
|
|
157
|
-
|
|
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
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
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
|
-
|
|
258
|
-
|
|
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' ?
|
|
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
|
|
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>
|