@alliance-droid/status-feedback-system 4.0.0 → 4.0.2
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/dist/api.d.ts +8 -4
- package/dist/components/DevFeedback.svelte +0 -1
- package/dist/components/board/FeedbackList.svelte +7 -1
- package/dist/components/board/FeedbackSubmitForm.svelte +239 -239
- package/dist/components/board/StatusBadgeMenu.svelte +266 -264
- package/dist/components/board/state.svelte.d.ts +13 -5
- package/dist/components/board/state.svelte.js +34 -7
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -107,12 +107,16 @@ export interface StatusClient {
|
|
|
107
107
|
offset?: number;
|
|
108
108
|
}): Promise<BoardData>;
|
|
109
109
|
/**
|
|
110
|
-
* Status transitions the token's user may make on a board item.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
110
|
+
* Status transitions the token's user may make on a board item. `token` must
|
|
111
|
+
* be an Alliance OIDC **access token** whose audience covers
|
|
112
|
+
* `status-feedback-admin` (an id token is rejected by the backend). The
|
|
113
|
+
* backend returns an empty `targets` list for users without a qualifying role.
|
|
113
114
|
*/
|
|
114
115
|
getBoardTransitions(feedbackId: string, token: string): Promise<BoardTransitions>;
|
|
115
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Set a board item's status (privileged). `token` must be an Alliance OIDC
|
|
118
|
+
* **access token** scoped to `status-feedback-admin` (not an id token).
|
|
119
|
+
*/
|
|
116
120
|
updateBoardStatus(feedbackId: string, data: {
|
|
117
121
|
status: string;
|
|
118
122
|
reason?: string;
|
|
@@ -64,7 +64,6 @@
|
|
|
64
64
|
// Dev-mode warning when userEmail is empty
|
|
65
65
|
$effect(() => {
|
|
66
66
|
if (!userEmail && typeof console !== 'undefined') {
|
|
67
|
-
// eslint-disable-next-line no-restricted-globals -- safe typeof guard for universal code
|
|
68
67
|
const proc = typeof globalThis !== 'undefined' ? (globalThis as Record<string, unknown>).process as { env?: Record<string, string> } | undefined : undefined;
|
|
69
68
|
const env = proc?.env?.NODE_ENV || 'development';
|
|
70
69
|
if (env !== 'production') {
|
|
@@ -44,6 +44,12 @@
|
|
|
44
44
|
|
|
45
45
|
{#if board.hasMore}
|
|
46
46
|
<div class="mt-4 text-center">
|
|
47
|
+
{#if board.loadMoreError}
|
|
48
|
+
<p class="mb-2 text-sm text-red-400" role="alert">
|
|
49
|
+
<i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
|
|
50
|
+
{board.loadMoreError}
|
|
51
|
+
</p>
|
|
52
|
+
{/if}
|
|
47
53
|
<Button
|
|
48
54
|
variant="neutral"
|
|
49
55
|
style="outline"
|
|
@@ -51,7 +57,7 @@
|
|
|
51
57
|
loading={board.loadingMore}
|
|
52
58
|
onclick={() => board.loadMore()}
|
|
53
59
|
>
|
|
54
|
-
Load More ({board.items.length} of {board.total})
|
|
60
|
+
{board.loadMoreError ? 'Retry' : `Load More (${board.items.length} of ${board.total})`}
|
|
55
61
|
</Button>
|
|
56
62
|
</div>
|
|
57
63
|
{/if}
|
|
@@ -1,239 +1,239 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
|
|
3
|
-
import { FEEDBACK_CATEGORIES } from '../../types.js';
|
|
4
|
-
import type { FeedbackBoardState } from './state.svelte.js';
|
|
5
|
-
|
|
6
|
-
interface Props {
|
|
7
|
-
board: FeedbackBoardState;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
let { board }: Props = $props();
|
|
11
|
-
|
|
12
|
-
let title = $state('');
|
|
13
|
-
let message = $state('');
|
|
14
|
-
let category = $state('');
|
|
15
|
-
let submitting = $state(false);
|
|
16
|
-
let success = $state(false);
|
|
17
|
-
let error = $state('');
|
|
18
|
-
let pastedImage = $state<string | null>(null);
|
|
19
|
-
let pastedFilename = $state('');
|
|
20
|
-
let fileInputEl: HTMLInputElement | undefined = $state();
|
|
21
|
-
let dragActive = $state(false);
|
|
22
|
-
let textareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
23
|
-
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
|
24
|
-
|
|
25
|
-
const MAX_BYTES = 5 * 1024 * 1024;
|
|
26
|
-
const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
27
|
-
|
|
28
|
-
$effect(() => {
|
|
29
|
-
if (textareaWrapperEl) {
|
|
30
|
-
textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
$effect(() => {
|
|
35
|
-
if (!textareaEl) return;
|
|
36
|
-
const handler = (e: ClipboardEvent) => handlePaste(e);
|
|
37
|
-
textareaEl.addEventListener('paste', handler);
|
|
38
|
-
return () => textareaEl?.removeEventListener('paste', handler);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
let isAuthenticated = $derived(!!board.userEmail);
|
|
42
|
-
|
|
43
|
-
// Labels are the canonical system category names — not the consumer's
|
|
44
|
-
// `categoryConfig` (which only customizes the sidebar).
|
|
45
|
-
let categoryOptions = FEEDBACK_CATEGORIES.map((c) => ({ value: c, label: c }));
|
|
46
|
-
|
|
47
|
-
/** Validate and load an image file (shared by paste, file-pick, and drop). */
|
|
48
|
-
function processFile(file: File) {
|
|
49
|
-
if (!ALLOWED_MIME.includes(file.type)) {
|
|
50
|
-
error = 'Unsupported image type — PNG, JPEG, GIF, or WebP only';
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (file.size > MAX_BYTES) {
|
|
54
|
-
error = 'Image too large — max 5 MB';
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
const reader = new FileReader();
|
|
58
|
-
reader.onload = () => {
|
|
59
|
-
pastedImage = reader.result as string;
|
|
60
|
-
const ext = file.type.split('/')[1] || 'png';
|
|
61
|
-
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
62
|
-
pastedFilename = file.name || `screenshot-${stamp}.${ext}`;
|
|
63
|
-
error = '';
|
|
64
|
-
};
|
|
65
|
-
reader.readAsDataURL(file);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function handlePaste(e: ClipboardEvent) {
|
|
69
|
-
const clipItems = Array.from(e.clipboardData?.items ?? []);
|
|
70
|
-
const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
|
|
71
|
-
if (!imageItem) return;
|
|
72
|
-
const file = imageItem.getAsFile();
|
|
73
|
-
if (!file) return;
|
|
74
|
-
e.preventDefault();
|
|
75
|
-
processFile(file);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function handleFileSelect(e: Event) {
|
|
79
|
-
const input = e.currentTarget as HTMLInputElement;
|
|
80
|
-
const file = input.files?.[0];
|
|
81
|
-
if (file) processFile(file);
|
|
82
|
-
input.value = '';
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function handleDrop(e: DragEvent) {
|
|
86
|
-
e.preventDefault();
|
|
87
|
-
dragActive = false;
|
|
88
|
-
const file = e.dataTransfer?.files?.[0];
|
|
89
|
-
if (file) processFile(file);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function formatSize(dataUrl: string): string {
|
|
93
|
-
const comma = dataUrl.indexOf(',');
|
|
94
|
-
if (comma === -1) return '';
|
|
95
|
-
const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
|
|
96
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
97
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
98
|
-
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function handleSubmit() {
|
|
102
|
-
if (!message.trim() || !category || !isAuthenticated) return;
|
|
103
|
-
submitting = true;
|
|
104
|
-
error = '';
|
|
105
|
-
try {
|
|
106
|
-
await board.submitFeedback({
|
|
107
|
-
title: title.trim() || undefined,
|
|
108
|
-
message: message.trim(),
|
|
109
|
-
category,
|
|
110
|
-
pastedImage,
|
|
111
|
-
});
|
|
112
|
-
success = true;
|
|
113
|
-
title = '';
|
|
114
|
-
message = '';
|
|
115
|
-
category = '';
|
|
116
|
-
pastedImage = null;
|
|
117
|
-
pastedFilename = '';
|
|
118
|
-
setTimeout(() => { success = false; }, 2000);
|
|
119
|
-
} catch (err) {
|
|
120
|
-
error = err instanceof Error ? err.message : 'Failed to submit';
|
|
121
|
-
} finally {
|
|
122
|
-
submitting = false;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
</script>
|
|
126
|
-
|
|
127
|
-
<div>
|
|
128
|
-
{#if !isAuthenticated}
|
|
129
|
-
<div class="py-3 text-center text-foreground-tertiary">
|
|
130
|
-
<i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
|
|
131
|
-
<span class="text-sm">Sign in to submit feedback</span>
|
|
132
|
-
</div>
|
|
133
|
-
{:else if success}
|
|
134
|
-
<div class="py-2 text-center font-semibold text-green-400">
|
|
135
|
-
<i class="fa-solid fa-check-circle mr-1"></i> Submitted!
|
|
136
|
-
</div>
|
|
137
|
-
{:else}
|
|
138
|
-
<div class="space-y-2">
|
|
139
|
-
<!-- Composer card: title + description + attachment + toolbar -->
|
|
140
|
-
<div
|
|
141
|
-
class="overflow-hidden rounded-xl border bg-background shadow-sm transition-all {dragActive
|
|
142
|
-
? 'border-primary ring-2 ring-primary/30'
|
|
143
|
-
: 'border-border focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20'}"
|
|
144
|
-
role="group"
|
|
145
|
-
ondragover={(e) => { e.preventDefault(); dragActive = true; }}
|
|
146
|
-
ondragleave={() => (dragActive = false)}
|
|
147
|
-
ondrop={handleDrop}
|
|
148
|
-
>
|
|
149
|
-
<div class="border-b border-border">
|
|
150
|
-
<Input
|
|
151
|
-
class="rounded-none border-0 bg-transparent px-4 py-3 text-sm font-medium hover:border-transparent focus:border-transparent"
|
|
152
|
-
bind:value={title}
|
|
153
|
-
placeholder="Title (optional)"
|
|
154
|
-
/>
|
|
155
|
-
</div>
|
|
156
|
-
|
|
157
|
-
<div bind:this={textareaWrapperEl}>
|
|
158
|
-
<Textarea
|
|
159
|
-
class="resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm hover:border-transparent focus:border-transparent"
|
|
160
|
-
value={message}
|
|
161
|
-
oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
162
|
-
placeholder="Describe your feedback…"
|
|
163
|
-
rows={4}
|
|
164
|
-
/>
|
|
165
|
-
</div>
|
|
166
|
-
|
|
167
|
-
{#if pastedImage}
|
|
168
|
-
<div class="flex items-center gap-3 border-t border-border px-3 py-2">
|
|
169
|
-
<img
|
|
170
|
-
src={pastedImage}
|
|
171
|
-
alt={pastedFilename}
|
|
172
|
-
class="h-11 w-11 flex-shrink-0 rounded border border-border object-cover"
|
|
173
|
-
/>
|
|
174
|
-
<div class="min-w-0 flex-1">
|
|
175
|
-
<p class="truncate text-xs font-medium text-foreground">{pastedFilename}</p>
|
|
176
|
-
<p class="text-[11px] text-foreground-tertiary">{formatSize(pastedImage)}</p>
|
|
177
|
-
</div>
|
|
178
|
-
<button
|
|
179
|
-
type="button"
|
|
180
|
-
class="flex-shrink-0 rounded p-1.5 text-foreground-tertiary transition-colors hover:bg-surface-hover hover:text-foreground"
|
|
181
|
-
onclick={() => { pastedImage = null; pastedFilename = ''; }}
|
|
182
|
-
aria-label="Remove image"
|
|
183
|
-
>
|
|
184
|
-
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
|
185
|
-
</button>
|
|
186
|
-
</div>
|
|
187
|
-
{/if}
|
|
188
|
-
|
|
189
|
-
<!-- Toolbar -->
|
|
190
|
-
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-2.5">
|
|
191
|
-
<div class="flex items-center gap-2">
|
|
192
|
-
<div class="min-w-[150px]">
|
|
193
|
-
<Select
|
|
194
|
-
class="border-0 bg-transparent"
|
|
195
|
-
size="sm"
|
|
196
|
-
placeholder="Category…"
|
|
197
|
-
options={categoryOptions}
|
|
198
|
-
required
|
|
199
|
-
bind:value={category}
|
|
200
|
-
/>
|
|
201
|
-
</div>
|
|
202
|
-
<button
|
|
203
|
-
type="button"
|
|
204
|
-
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"
|
|
205
|
-
onclick={() => fileInputEl?.click()}
|
|
206
|
-
>
|
|
207
|
-
<i class="fa-solid fa-image" aria-hidden="true"></i>
|
|
208
|
-
Attach screenshot
|
|
209
|
-
</button>
|
|
210
|
-
</div>
|
|
211
|
-
<Button
|
|
212
|
-
variant="primary"
|
|
213
|
-
size="sm"
|
|
214
|
-
disabled={submitting || !message.trim() || !category}
|
|
215
|
-
loading={submitting}
|
|
216
|
-
onclick={handleSubmit}
|
|
217
|
-
>
|
|
218
|
-
Submit
|
|
219
|
-
</Button>
|
|
220
|
-
</div>
|
|
221
|
-
|
|
222
|
-
<input
|
|
223
|
-
bind:this={fileInputEl}
|
|
224
|
-
type="file"
|
|
225
|
-
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
226
|
-
class="hidden"
|
|
227
|
-
onchange={handleFileSelect}
|
|
228
|
-
/>
|
|
229
|
-
</div>
|
|
230
|
-
|
|
231
|
-
{#if error}
|
|
232
|
-
<p class="text-sm text-red-400">
|
|
233
|
-
<i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
|
|
234
|
-
{error}
|
|
235
|
-
</p>
|
|
236
|
-
{/if}
|
|
237
|
-
</div>
|
|
238
|
-
{/if}
|
|
239
|
-
</div>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
|
|
3
|
+
import { FEEDBACK_CATEGORIES } from '../../types.js';
|
|
4
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
board: FeedbackBoardState;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { board }: Props = $props();
|
|
11
|
+
|
|
12
|
+
let title = $state('');
|
|
13
|
+
let message = $state('');
|
|
14
|
+
let category = $state('');
|
|
15
|
+
let submitting = $state(false);
|
|
16
|
+
let success = $state(false);
|
|
17
|
+
let error = $state('');
|
|
18
|
+
let pastedImage = $state<string | null>(null);
|
|
19
|
+
let pastedFilename = $state('');
|
|
20
|
+
let fileInputEl: HTMLInputElement | undefined = $state();
|
|
21
|
+
let dragActive = $state(false);
|
|
22
|
+
let textareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
23
|
+
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
|
24
|
+
|
|
25
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
26
|
+
const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
27
|
+
|
|
28
|
+
$effect(() => {
|
|
29
|
+
if (textareaWrapperEl) {
|
|
30
|
+
textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
$effect(() => {
|
|
35
|
+
if (!textareaEl) return;
|
|
36
|
+
const handler = (e: ClipboardEvent) => handlePaste(e);
|
|
37
|
+
textareaEl.addEventListener('paste', handler);
|
|
38
|
+
return () => textareaEl?.removeEventListener('paste', handler);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let isAuthenticated = $derived(!!board.userEmail);
|
|
42
|
+
|
|
43
|
+
// Labels are the canonical system category names — not the consumer's
|
|
44
|
+
// `categoryConfig` (which only customizes the sidebar).
|
|
45
|
+
let categoryOptions = FEEDBACK_CATEGORIES.map((c) => ({ value: c, label: c }));
|
|
46
|
+
|
|
47
|
+
/** Validate and load an image file (shared by paste, file-pick, and drop). */
|
|
48
|
+
function processFile(file: File) {
|
|
49
|
+
if (!ALLOWED_MIME.includes(file.type)) {
|
|
50
|
+
error = 'Unsupported image type — PNG, JPEG, GIF, or WebP only';
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (file.size > MAX_BYTES) {
|
|
54
|
+
error = 'Image too large — max 5 MB';
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const reader = new FileReader();
|
|
58
|
+
reader.onload = () => {
|
|
59
|
+
pastedImage = reader.result as string;
|
|
60
|
+
const ext = file.type.split('/')[1] || 'png';
|
|
61
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
62
|
+
pastedFilename = file.name || `screenshot-${stamp}.${ext}`;
|
|
63
|
+
error = '';
|
|
64
|
+
};
|
|
65
|
+
reader.readAsDataURL(file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handlePaste(e: ClipboardEvent) {
|
|
69
|
+
const clipItems = Array.from(e.clipboardData?.items ?? []);
|
|
70
|
+
const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
|
|
71
|
+
if (!imageItem) return;
|
|
72
|
+
const file = imageItem.getAsFile();
|
|
73
|
+
if (!file) return;
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
processFile(file);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handleFileSelect(e: Event) {
|
|
79
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
80
|
+
const file = input.files?.[0];
|
|
81
|
+
if (file) processFile(file);
|
|
82
|
+
input.value = '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleDrop(e: DragEvent) {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
dragActive = false;
|
|
88
|
+
const file = e.dataTransfer?.files?.[0];
|
|
89
|
+
if (file) processFile(file);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatSize(dataUrl: string): string {
|
|
93
|
+
const comma = dataUrl.indexOf(',');
|
|
94
|
+
if (comma === -1) return '';
|
|
95
|
+
const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
|
|
96
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
97
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
98
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleSubmit() {
|
|
102
|
+
if (!message.trim() || !category || !isAuthenticated) return;
|
|
103
|
+
submitting = true;
|
|
104
|
+
error = '';
|
|
105
|
+
try {
|
|
106
|
+
await board.submitFeedback({
|
|
107
|
+
title: title.trim() || undefined,
|
|
108
|
+
message: message.trim(),
|
|
109
|
+
category,
|
|
110
|
+
pastedImage,
|
|
111
|
+
});
|
|
112
|
+
success = true;
|
|
113
|
+
title = '';
|
|
114
|
+
message = '';
|
|
115
|
+
category = '';
|
|
116
|
+
pastedImage = null;
|
|
117
|
+
pastedFilename = '';
|
|
118
|
+
setTimeout(() => { success = false; }, 2000);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
error = err instanceof Error ? err.message : 'Failed to submit';
|
|
121
|
+
} finally {
|
|
122
|
+
submitting = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<div>
|
|
128
|
+
{#if !isAuthenticated}
|
|
129
|
+
<div class="py-3 text-center text-foreground-tertiary">
|
|
130
|
+
<i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
|
|
131
|
+
<span class="text-sm">Sign in to submit feedback</span>
|
|
132
|
+
</div>
|
|
133
|
+
{:else if success}
|
|
134
|
+
<div class="py-2 text-center font-semibold text-green-400">
|
|
135
|
+
<i class="fa-solid fa-check-circle mr-1"></i> Submitted!
|
|
136
|
+
</div>
|
|
137
|
+
{:else}
|
|
138
|
+
<div class="space-y-2">
|
|
139
|
+
<!-- Composer card: title + description + attachment + toolbar -->
|
|
140
|
+
<div
|
|
141
|
+
class="overflow-hidden rounded-xl border bg-background shadow-sm transition-all {dragActive
|
|
142
|
+
? 'border-primary ring-2 ring-primary/30'
|
|
143
|
+
: 'border-border focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20'}"
|
|
144
|
+
role="group"
|
|
145
|
+
ondragover={(e) => { e.preventDefault(); dragActive = true; }}
|
|
146
|
+
ondragleave={() => (dragActive = false)}
|
|
147
|
+
ondrop={handleDrop}
|
|
148
|
+
>
|
|
149
|
+
<div class="border-b border-border">
|
|
150
|
+
<Input
|
|
151
|
+
class="rounded-none border-0 bg-transparent px-4 py-3 text-sm font-medium hover:border-transparent focus:border-transparent"
|
|
152
|
+
bind:value={title}
|
|
153
|
+
placeholder="Title (optional)"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div bind:this={textareaWrapperEl}>
|
|
158
|
+
<Textarea
|
|
159
|
+
class="resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm hover:border-transparent focus:border-transparent"
|
|
160
|
+
value={message}
|
|
161
|
+
oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
162
|
+
placeholder="Describe your feedback…"
|
|
163
|
+
rows={4}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{#if pastedImage}
|
|
168
|
+
<div class="flex items-center gap-3 border-t border-border px-3 py-2">
|
|
169
|
+
<img
|
|
170
|
+
src={pastedImage}
|
|
171
|
+
alt={pastedFilename}
|
|
172
|
+
class="h-11 w-11 flex-shrink-0 rounded border border-border object-cover"
|
|
173
|
+
/>
|
|
174
|
+
<div class="min-w-0 flex-1">
|
|
175
|
+
<p class="truncate text-xs font-medium text-foreground">{pastedFilename}</p>
|
|
176
|
+
<p class="text-[11px] text-foreground-tertiary">{formatSize(pastedImage)}</p>
|
|
177
|
+
</div>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class="flex-shrink-0 rounded p-1.5 text-foreground-tertiary transition-colors hover:bg-surface-hover hover:text-foreground"
|
|
181
|
+
onclick={() => { pastedImage = null; pastedFilename = ''; }}
|
|
182
|
+
aria-label="Remove image"
|
|
183
|
+
>
|
|
184
|
+
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
{/if}
|
|
188
|
+
|
|
189
|
+
<!-- Toolbar -->
|
|
190
|
+
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-2.5">
|
|
191
|
+
<div class="flex items-center gap-2">
|
|
192
|
+
<div class="min-w-[150px]">
|
|
193
|
+
<Select
|
|
194
|
+
class="border-0 bg-transparent"
|
|
195
|
+
size="sm"
|
|
196
|
+
placeholder="Category…"
|
|
197
|
+
options={categoryOptions}
|
|
198
|
+
required
|
|
199
|
+
bind:value={category}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
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"
|
|
205
|
+
onclick={() => fileInputEl?.click()}
|
|
206
|
+
>
|
|
207
|
+
<i class="fa-solid fa-image" aria-hidden="true"></i>
|
|
208
|
+
Attach screenshot
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
<Button
|
|
212
|
+
variant="primary"
|
|
213
|
+
size="sm"
|
|
214
|
+
disabled={submitting || !message.trim() || !category}
|
|
215
|
+
loading={submitting}
|
|
216
|
+
onclick={handleSubmit}
|
|
217
|
+
>
|
|
218
|
+
Submit
|
|
219
|
+
</Button>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<input
|
|
223
|
+
bind:this={fileInputEl}
|
|
224
|
+
type="file"
|
|
225
|
+
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
226
|
+
class="hidden"
|
|
227
|
+
onchange={handleFileSelect}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{#if error}
|
|
232
|
+
<p class="text-sm text-red-400">
|
|
233
|
+
<i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
|
|
234
|
+
{error}
|
|
235
|
+
</p>
|
|
236
|
+
{/if}
|
|
237
|
+
</div>
|
|
238
|
+
{/if}
|
|
239
|
+
</div>
|
|
@@ -1,264 +1,266 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Status badge that doubles as a status-change control for privileged users.
|
|
4
|
-
*
|
|
5
|
-
* For a viewer without a token (board.canManageStatus === false) it's just a
|
|
6
|
-
* read-only badge. For a privileged user the badge is a button: clicking it
|
|
7
|
-
* loads the transitions they may make and shows them in a menu; picking one
|
|
8
|
-
* applies it (prompting for a reason first when the transition requires one).
|
|
9
|
-
*
|
|
10
|
-
* The menu is teleported to <body> (the `portal` action) and positioned with
|
|
11
|
-
* fixed coordinates from the trigger, so it never gets clipped by an overflow
|
|
12
|
-
* or transformed ancestor (e.g. a sliding side rail). Same approach the
|
|
13
|
-
* library's Select uses.
|
|
14
|
-
*
|
|
15
|
-
* Used in both the detail panel and the list row, so a ProductOwner can change
|
|
16
|
-
* status without opening the item. Transitions load on demand (on first open)
|
|
17
|
-
* unless `loadOnMount` is set — the list stays lazy so it doesn't fire a
|
|
18
|
-
* request per row.
|
|
19
|
-
*/
|
|
20
|
-
import { Badge, Button, Textarea, Spinner } from '@alliance-droid/svelte-component-library';
|
|
21
|
-
import { onMount } from 'svelte';
|
|
22
|
-
import type { BoardItem, BoardTransition } from '../../api.js';
|
|
23
|
-
import type { FeedbackBoardState } from './state.svelte.js';
|
|
24
|
-
import { getStatusBadgeVariant, publicStatusLabel } from './utils.js';
|
|
25
|
-
|
|
26
|
-
interface Props {
|
|
27
|
-
board: FeedbackBoardState;
|
|
28
|
-
item: BoardItem;
|
|
29
|
-
/** Eagerly load transitions on mount (detail view). The list leaves this off. */
|
|
30
|
-
loadOnMount?: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
let { board, item, loadOnMount = false }: Props = $props();
|
|
34
|
-
|
|
35
|
-
let menuOpen = $state(false);
|
|
36
|
-
let pendingTarget = $state<BoardTransition | null>(null);
|
|
37
|
-
let reason = $state('');
|
|
38
|
-
let applying = $state(false);
|
|
39
|
-
let loading = $state(false);
|
|
40
|
-
/** True when the last load attempt failed (token rejected / network) vs. genuinely returned no targets. */
|
|
41
|
-
let loadFailed = $state(false);
|
|
42
|
-
let error = $state('');
|
|
43
|
-
let triggerEl: HTMLButtonElement | undefined = $state();
|
|
44
|
-
let menuEl: HTMLDivElement | undefined = $state();
|
|
45
|
-
|
|
46
|
-
// Read from the board's shared cache so detail + list reuse the same fetch.
|
|
47
|
-
let txns = $derived(board.transitions[item.id] ?? null);
|
|
48
|
-
let badgeLabel = $derived(txns?.current.display ?? publicStatusLabel(item.publicStatus));
|
|
49
|
-
// Changes whenever the menu's content (and thus its size) changes — drives a
|
|
50
|
-
// reposition so the teleported menu stays anchored after it loads/expands.
|
|
51
|
-
let posKey = $derived(`${menuOpen}:${loading}:${pendingTarget?.status ?? ''}:${txns?.targets.length ?? 0}`);
|
|
52
|
-
|
|
53
|
-
onMount(() => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
node.style.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
{
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
disabled
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
{
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
{
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
{
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Status badge that doubles as a status-change control for privileged users.
|
|
4
|
+
*
|
|
5
|
+
* For a viewer without a token (board.canManageStatus === false) it's just a
|
|
6
|
+
* read-only badge. For a privileged user the badge is a button: clicking it
|
|
7
|
+
* loads the transitions they may make and shows them in a menu; picking one
|
|
8
|
+
* applies it (prompting for a reason first when the transition requires one).
|
|
9
|
+
*
|
|
10
|
+
* The menu is teleported to <body> (the `portal` action) and positioned with
|
|
11
|
+
* fixed coordinates from the trigger, so it never gets clipped by an overflow
|
|
12
|
+
* or transformed ancestor (e.g. a sliding side rail). Same approach the
|
|
13
|
+
* library's Select uses.
|
|
14
|
+
*
|
|
15
|
+
* Used in both the detail panel and the list row, so a ProductOwner can change
|
|
16
|
+
* status without opening the item. Transitions load on demand (on first open)
|
|
17
|
+
* unless `loadOnMount` is set — the list stays lazy so it doesn't fire a
|
|
18
|
+
* request per row.
|
|
19
|
+
*/
|
|
20
|
+
import { Badge, Button, Textarea, Spinner } from '@alliance-droid/svelte-component-library';
|
|
21
|
+
import { onMount } from 'svelte';
|
|
22
|
+
import type { BoardItem, BoardTransition } from '../../api.js';
|
|
23
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
24
|
+
import { getStatusBadgeVariant, publicStatusLabel } from './utils.js';
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
board: FeedbackBoardState;
|
|
28
|
+
item: BoardItem;
|
|
29
|
+
/** Eagerly load transitions on mount (detail view). The list leaves this off. */
|
|
30
|
+
loadOnMount?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let { board, item, loadOnMount = false }: Props = $props();
|
|
34
|
+
|
|
35
|
+
let menuOpen = $state(false);
|
|
36
|
+
let pendingTarget = $state<BoardTransition | null>(null);
|
|
37
|
+
let reason = $state('');
|
|
38
|
+
let applying = $state(false);
|
|
39
|
+
let loading = $state(false);
|
|
40
|
+
/** True when the last load attempt failed (token rejected / network) vs. genuinely returned no targets. */
|
|
41
|
+
let loadFailed = $state(false);
|
|
42
|
+
let error = $state('');
|
|
43
|
+
let triggerEl: HTMLButtonElement | undefined = $state();
|
|
44
|
+
let menuEl: HTMLDivElement | undefined = $state();
|
|
45
|
+
|
|
46
|
+
// Read from the board's shared cache so detail + list reuse the same fetch.
|
|
47
|
+
let txns = $derived(board.transitions[item.id] ?? null);
|
|
48
|
+
let badgeLabel = $derived(txns?.current.display ?? publicStatusLabel(item.publicStatus));
|
|
49
|
+
// Changes whenever the menu's content (and thus its size) changes — drives a
|
|
50
|
+
// reposition so the teleported menu stays anchored after it loads/expands.
|
|
51
|
+
let posKey = $derived(`${menuOpen}:${loading}:${pendingTarget?.status ?? ''}:${txns?.targets.length ?? 0}`);
|
|
52
|
+
|
|
53
|
+
onMount(() => {
|
|
54
|
+
// Eagerly load in the detail view: this both readies the menu and lets the
|
|
55
|
+
// board confirm privilege (loadTransitions is a no-op without a token).
|
|
56
|
+
if (loadOnMount) void board.loadTransitions(item.id);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Teleport the menu to <body> and keep it positioned under the trigger.
|
|
60
|
+
* The second arg (`posKey`) is unused directly — changing it just triggers
|
|
61
|
+
* the action's `update()` so the menu re-positions when its content resizes. */
|
|
62
|
+
function portal(node: HTMLElement, _posKey?: string) {
|
|
63
|
+
document.body.appendChild(node);
|
|
64
|
+
|
|
65
|
+
function position() {
|
|
66
|
+
if (!triggerEl) return;
|
|
67
|
+
const rect = triggerEl.getBoundingClientRect();
|
|
68
|
+
node.style.position = 'fixed';
|
|
69
|
+
node.style.top = `${rect.bottom + 6}px`;
|
|
70
|
+
node.style.zIndex = '9999';
|
|
71
|
+
// Left-align to the trigger, then clamp into the viewport.
|
|
72
|
+
let left = rect.left;
|
|
73
|
+
const width = node.offsetWidth;
|
|
74
|
+
if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
|
|
75
|
+
if (left < 8) left = 8;
|
|
76
|
+
node.style.left = `${left}px`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
position();
|
|
80
|
+
requestAnimationFrame(position);
|
|
81
|
+
|
|
82
|
+
const onScroll = () => position();
|
|
83
|
+
window.addEventListener('scroll', onScroll, true);
|
|
84
|
+
window.addEventListener('resize', onScroll);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
update() {
|
|
88
|
+
position();
|
|
89
|
+
requestAnimationFrame(position);
|
|
90
|
+
},
|
|
91
|
+
destroy() {
|
|
92
|
+
window.removeEventListener('scroll', onScroll, true);
|
|
93
|
+
window.removeEventListener('resize', onScroll);
|
|
94
|
+
node.remove();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Close on outside click / Escape while open.
|
|
100
|
+
$effect(() => {
|
|
101
|
+
if (!menuOpen) return;
|
|
102
|
+
const onDocClick = (e: MouseEvent) => {
|
|
103
|
+
const target = e.target as Node;
|
|
104
|
+
if (triggerEl?.contains(target) || menuEl?.contains(target)) return;
|
|
105
|
+
closeMenu();
|
|
106
|
+
};
|
|
107
|
+
const onKey = (e: KeyboardEvent) => {
|
|
108
|
+
if (e.key === 'Escape') closeMenu();
|
|
109
|
+
};
|
|
110
|
+
document.addEventListener('click', onDocClick, true);
|
|
111
|
+
document.addEventListener('keydown', onKey, true);
|
|
112
|
+
return () => {
|
|
113
|
+
document.removeEventListener('click', onDocClick, true);
|
|
114
|
+
document.removeEventListener('keydown', onKey, true);
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
async function toggleMenu(e: MouseEvent) {
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
if (menuOpen) {
|
|
121
|
+
closeMenu();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
error = '';
|
|
125
|
+
pendingTarget = null;
|
|
126
|
+
reason = '';
|
|
127
|
+
// Open immediately so the spinner is visible while transitions load.
|
|
128
|
+
menuOpen = true;
|
|
129
|
+
if (!txns) await reload();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function reload() {
|
|
133
|
+
loading = true;
|
|
134
|
+
loadFailed = false;
|
|
135
|
+
try {
|
|
136
|
+
// null = couldn't load (no token / rejected / network), not "no transitions".
|
|
137
|
+
loadFailed = (await board.loadTransitions(item.id)) === null;
|
|
138
|
+
} finally {
|
|
139
|
+
loading = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function closeMenu() {
|
|
144
|
+
menuOpen = false;
|
|
145
|
+
pendingTarget = null;
|
|
146
|
+
reason = '';
|
|
147
|
+
error = '';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function chooseStatus(target: BoardTransition) {
|
|
151
|
+
error = '';
|
|
152
|
+
if (target.requiresReason) {
|
|
153
|
+
pendingTarget = target;
|
|
154
|
+
reason = '';
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await applyStatus(target);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function applyStatus(target: BoardTransition, withReason?: string) {
|
|
161
|
+
applying = true;
|
|
162
|
+
error = '';
|
|
163
|
+
try {
|
|
164
|
+
await board.setStatus(item.id, target.status, withReason);
|
|
165
|
+
closeMenu();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
error = err instanceof Error ? err.message : 'Failed to update status';
|
|
168
|
+
} finally {
|
|
169
|
+
applying = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function confirmReason() {
|
|
174
|
+
if (!pendingTarget) return;
|
|
175
|
+
if (!reason.trim()) {
|
|
176
|
+
error = 'A reason is required for this change';
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
applyStatus(pendingTarget, reason.trim());
|
|
180
|
+
}
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
{#if board.canManageStatus}
|
|
184
|
+
<button
|
|
185
|
+
bind:this={triggerEl}
|
|
186
|
+
type="button"
|
|
187
|
+
class="inline-flex items-center gap-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
|
|
188
|
+
onclick={toggleMenu}
|
|
189
|
+
aria-haspopup="listbox"
|
|
190
|
+
aria-expanded={menuOpen}
|
|
191
|
+
disabled={applying}
|
|
192
|
+
title="Change status"
|
|
193
|
+
>
|
|
194
|
+
<Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">{badgeLabel}</Badge>
|
|
195
|
+
<i class="fa-solid fa-chevron-down text-[0.625rem] text-foreground-tertiary" aria-hidden="true"></i>
|
|
196
|
+
</button>
|
|
197
|
+
|
|
198
|
+
{#if menuOpen}
|
|
199
|
+
<!-- Teleported to <body>; posKey re-positions it when its size changes. -->
|
|
200
|
+
<div
|
|
201
|
+
bind:this={menuEl}
|
|
202
|
+
use:portal={posKey}
|
|
203
|
+
class="max-h-[60vh] w-max min-w-[10rem] overflow-y-auto overflow-x-hidden rounded-lg border border-border bg-surface py-1 shadow-lg"
|
|
204
|
+
role="listbox"
|
|
205
|
+
>
|
|
206
|
+
{#if loading}
|
|
207
|
+
<div class="flex items-center gap-2 px-3 py-3 text-sm text-foreground-tertiary">
|
|
208
|
+
<Spinner /> Loading…
|
|
209
|
+
</div>
|
|
210
|
+
{:else if pendingTarget}
|
|
211
|
+
<div class="w-64 space-y-2 p-3">
|
|
212
|
+
<p class="text-xs font-medium text-foreground">Reason for “{pendingTarget.display}”</p>
|
|
213
|
+
<Textarea
|
|
214
|
+
value={reason}
|
|
215
|
+
oninput={(e) => (reason = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
216
|
+
placeholder="Why are you making this change?"
|
|
217
|
+
rows={2}
|
|
218
|
+
/>
|
|
219
|
+
{#if error}
|
|
220
|
+
<p class="text-xs text-red-400">{error}</p>
|
|
221
|
+
{/if}
|
|
222
|
+
<div class="flex justify-end gap-2">
|
|
223
|
+
<Button variant="neutral" style="ghost" size="sm" onclick={() => { pendingTarget = null; reason = ''; error = ''; }}>
|
|
224
|
+
Back
|
|
225
|
+
</Button>
|
|
226
|
+
<Button variant="primary" size="sm" loading={applying} disabled={applying} onclick={confirmReason}>
|
|
227
|
+
Apply
|
|
228
|
+
</Button>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
{:else if txns && txns.targets.length > 0}
|
|
232
|
+
{#each txns.targets as target (target.status)}
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
role="option"
|
|
236
|
+
aria-selected="false"
|
|
237
|
+
class="flex w-full items-center justify-between gap-3 whitespace-nowrap px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-surface-hover disabled:opacity-60"
|
|
238
|
+
onclick={() => chooseStatus(target)}
|
|
239
|
+
disabled={applying}
|
|
240
|
+
>
|
|
241
|
+
<span>{target.display}</span>
|
|
242
|
+
{#if target.requiresReason}
|
|
243
|
+
<i class="fa-solid fa-pen text-[0.625rem] text-foreground-tertiary" title="Requires a reason" aria-hidden="true"></i>
|
|
244
|
+
{/if}
|
|
245
|
+
</button>
|
|
246
|
+
{/each}
|
|
247
|
+
{:else if loadFailed}
|
|
248
|
+
<div class="w-56 space-y-2 p-3 text-sm">
|
|
249
|
+
<p class="text-foreground-secondary">
|
|
250
|
+
Couldn't load status options — your session may have expired.
|
|
251
|
+
</p>
|
|
252
|
+
<Button variant="neutral" style="ghost" size="sm" onclick={reload}>Try again</Button>
|
|
253
|
+
</div>
|
|
254
|
+
{:else}
|
|
255
|
+
<p class="whitespace-nowrap px-3 py-3 text-sm text-foreground-tertiary">No status changes available.</p>
|
|
256
|
+
{/if}
|
|
257
|
+
{#if error && !pendingTarget && !loading}
|
|
258
|
+
<p class="px-3 pb-2 text-xs text-red-400">{error}</p>
|
|
259
|
+
{/if}
|
|
260
|
+
</div>
|
|
261
|
+
{/if}
|
|
262
|
+
{:else}
|
|
263
|
+
<Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
|
|
264
|
+
{publicStatusLabel(item.publicStatus)}
|
|
265
|
+
</Badge>
|
|
266
|
+
{/if}
|
|
@@ -20,10 +20,11 @@ export interface FeedbackBoardOptions {
|
|
|
20
20
|
/** Authenticated user email. Required for submitting feedback and replies. */
|
|
21
21
|
userEmail?: string;
|
|
22
22
|
/**
|
|
23
|
-
* Returns the current user's Alliance OIDC
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* Returns the current user's Alliance OIDC **access token** — whose audience
|
|
24
|
+
* covers `status-feedback-admin` (an id token is rejected by the backend) — or
|
|
25
|
+
* null if they aren't signed in / not privileged. When provided, privileged
|
|
26
|
+
* users (Admin / ProductOwner) can set feedback status from the board. Called
|
|
27
|
+
* on demand so the host controls token refresh.
|
|
27
28
|
*/
|
|
28
29
|
getAuthToken?: () => string | Promise<string | null>;
|
|
29
30
|
/** Items per page. Default: 50 */
|
|
@@ -39,6 +40,8 @@ export interface FeedbackBoardState {
|
|
|
39
40
|
readonly loading: boolean;
|
|
40
41
|
readonly loadingMore: boolean;
|
|
41
42
|
readonly error: string;
|
|
43
|
+
/** Error from the most recent `loadMore()`, or '' — cleared on the next attempt. */
|
|
44
|
+
readonly loadMoreError: string;
|
|
42
45
|
readonly hasMore: boolean;
|
|
43
46
|
activeCategory: string | null;
|
|
44
47
|
statusFilter: string;
|
|
@@ -56,7 +59,12 @@ export interface FeedbackBoardState {
|
|
|
56
59
|
pastedImage?: string | null;
|
|
57
60
|
}): Promise<void>;
|
|
58
61
|
submitResponse(itemId: string, message: string, pastedImage?: string | null): Promise<void>;
|
|
59
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Whether the current user may manage status — resolved against the backend
|
|
64
|
+
* (valid token AND a qualifying role + project access), not merely whether a
|
|
65
|
+
* token getter was provided. Starts `false` and flips `true` once confirmed,
|
|
66
|
+
* so UI can gate the status control by actual privilege.
|
|
67
|
+
*/
|
|
60
68
|
readonly canManageStatus: boolean;
|
|
61
69
|
/** Cached allowed transitions per item id, populated by loadTransitions. */
|
|
62
70
|
readonly transitions: Record<string, BoardTransitions>;
|
|
@@ -24,10 +24,16 @@ export function createFeedbackBoard(options) {
|
|
|
24
24
|
let sortBy = $state('votes');
|
|
25
25
|
let votedItems = $state({});
|
|
26
26
|
let voteErrors = $state({});
|
|
27
|
+
let loadMoreError = $state('');
|
|
27
28
|
let transitions = $state({});
|
|
29
|
+
// Resolved status-management privilege (one-time backend probe, not token presence).
|
|
30
|
+
let statusPrivilege = $state(false);
|
|
31
|
+
let privilegeProbed = false;
|
|
28
32
|
// Response cache
|
|
29
33
|
const responseCache = {};
|
|
30
|
-
// Track in-flight votes to prevent duplicate requests
|
|
34
|
+
// Track in-flight votes to prevent duplicate requests. A plain Set is correct
|
|
35
|
+
// here — an internal dedup guard, not reactive UI state.
|
|
36
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
31
37
|
const pendingVotes = new Set();
|
|
32
38
|
async function load() {
|
|
33
39
|
loading = true;
|
|
@@ -43,6 +49,7 @@ export function createFeedbackBoard(options) {
|
|
|
43
49
|
items = data.items;
|
|
44
50
|
total = data.total;
|
|
45
51
|
categories = data.categories;
|
|
52
|
+
void ensureStatusPrivilege();
|
|
46
53
|
}
|
|
47
54
|
catch (e) {
|
|
48
55
|
error = e instanceof Error ? e.message : 'Failed to load';
|
|
@@ -55,6 +62,7 @@ export function createFeedbackBoard(options) {
|
|
|
55
62
|
if (loadingMore || items.length >= total)
|
|
56
63
|
return;
|
|
57
64
|
loadingMore = true;
|
|
65
|
+
loadMoreError = '';
|
|
58
66
|
try {
|
|
59
67
|
const data = await client.getBoard({
|
|
60
68
|
category: activeCategory || undefined,
|
|
@@ -66,8 +74,8 @@ export function createFeedbackBoard(options) {
|
|
|
66
74
|
items = [...items, ...data.items];
|
|
67
75
|
total = data.total;
|
|
68
76
|
}
|
|
69
|
-
catch {
|
|
70
|
-
|
|
77
|
+
catch (e) {
|
|
78
|
+
loadMoreError = e instanceof Error ? e.message : 'Failed to load more';
|
|
71
79
|
}
|
|
72
80
|
finally {
|
|
73
81
|
loadingMore = false;
|
|
@@ -87,7 +95,8 @@ export function createFeedbackBoard(options) {
|
|
|
87
95
|
const prev = votedItems[itemId];
|
|
88
96
|
// Clear any prior error for this item
|
|
89
97
|
if (voteErrors[itemId]) {
|
|
90
|
-
const
|
|
98
|
+
const rest = { ...voteErrors };
|
|
99
|
+
delete rest[itemId];
|
|
91
100
|
voteErrors = rest;
|
|
92
101
|
}
|
|
93
102
|
try {
|
|
@@ -107,8 +116,9 @@ export function createFeedbackBoard(options) {
|
|
|
107
116
|
voteErrors = { ...voteErrors, [itemId]: 'Vote failed' };
|
|
108
117
|
// Auto-dismiss after 3 seconds
|
|
109
118
|
setTimeout(() => {
|
|
110
|
-
const
|
|
111
|
-
|
|
119
|
+
const next = { ...voteErrors };
|
|
120
|
+
delete next[itemId];
|
|
121
|
+
voteErrors = next;
|
|
112
122
|
}, 3000);
|
|
113
123
|
}
|
|
114
124
|
finally {
|
|
@@ -174,6 +184,7 @@ export function createFeedbackBoard(options) {
|
|
|
174
184
|
try {
|
|
175
185
|
const result = await client.getBoardTransitions(itemId, token);
|
|
176
186
|
transitions = { ...transitions, [itemId]: result };
|
|
187
|
+
statusPrivilege = true; // a successful load means the backend authorized this user
|
|
177
188
|
return result;
|
|
178
189
|
}
|
|
179
190
|
catch {
|
|
@@ -181,6 +192,21 @@ export function createFeedbackBoard(options) {
|
|
|
181
192
|
return null;
|
|
182
193
|
}
|
|
183
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* One-time probe so the UI can gate the status control by *actual* privilege
|
|
197
|
+
* (valid token + qualifying role + project access), not merely token presence.
|
|
198
|
+
* Loads the first item's transitions once; a non-null result confirms the
|
|
199
|
+
* backend authorized the user. No token / no items / a 403 leaves it `false`.
|
|
200
|
+
*/
|
|
201
|
+
async function ensureStatusPrivilege() {
|
|
202
|
+
if (privilegeProbed || !getAuthToken)
|
|
203
|
+
return;
|
|
204
|
+
const sample = items[0];
|
|
205
|
+
if (!sample)
|
|
206
|
+
return;
|
|
207
|
+
privilegeProbed = true;
|
|
208
|
+
statusPrivilege = (await loadTransitions(sample.id)) !== null;
|
|
209
|
+
}
|
|
184
210
|
async function setStatus(itemId, status, reason) {
|
|
185
211
|
const token = await resolveToken();
|
|
186
212
|
if (!token)
|
|
@@ -204,6 +230,7 @@ export function createFeedbackBoard(options) {
|
|
|
204
230
|
get loading() { return loading; },
|
|
205
231
|
get loadingMore() { return loadingMore; },
|
|
206
232
|
get error() { return error; },
|
|
233
|
+
get loadMoreError() { return loadMoreError; },
|
|
207
234
|
get hasMore() { return items.length < total; },
|
|
208
235
|
get activeCategory() { return activeCategory; },
|
|
209
236
|
set activeCategory(v) { activeCategory = v; },
|
|
@@ -219,7 +246,7 @@ export function createFeedbackBoard(options) {
|
|
|
219
246
|
getResponses,
|
|
220
247
|
submitFeedback,
|
|
221
248
|
submitResponse,
|
|
222
|
-
get canManageStatus() { return
|
|
249
|
+
get canManageStatus() { return statusPrivilege; },
|
|
223
250
|
get transitions() { return transitions; },
|
|
224
251
|
loadTransitions,
|
|
225
252
|
setStatus,
|
package/package.json
CHANGED