@alliance-droid/status-feedback-system 1.2.1 → 1.3.1
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/CHANGELOG.md +25 -45
- package/LICENSE +11 -0
- package/README.md +7 -3
- package/dist/components/DevFeedback.svelte +229 -337
- package/dist/components/FeedbackBoard.svelte +32 -368
- package/dist/components/FeedbackBoard.svelte.d.ts +2 -5
- package/dist/components/FeedbackForm.svelte +14 -3
- package/dist/components/StatusBanner.svelte +48 -14
- package/dist/components/StatusBanner.svelte.d.ts +2 -0
- package/dist/components/StatusPage.svelte +43 -10
- package/dist/components/StatusPage.svelte.d.ts +2 -0
- package/dist/components/board/FeedbackDetail.svelte +7 -7
- package/dist/components/board/FeedbackItem.svelte +26 -12
- package/dist/components/board/FeedbackList.svelte +21 -1
- package/dist/components/board/FeedbackSidebar.svelte +3 -3
- package/dist/components/board/state.svelte.d.ts +6 -0
- package/dist/components/board/state.svelte.js +42 -2
- package/dist/components/dev/DevContextMenu.svelte +99 -0
- package/dist/components/dev/DevContextMenu.svelte.d.ts +11 -0
- package/dist/components/dev/DevOnboardingToast.svelte +73 -0
- package/dist/components/dev/DevOnboardingToast.svelte.d.ts +6 -0
- package/dist/components/dev/environment.d.ts +24 -0
- package/dist/components/dev/environment.js +93 -0
- package/dist/components/dev/screenshot.d.ts +6 -0
- package/dist/components/dev/screenshot.js +24 -0
- package/dist/components/shared/IncidentTimeline.svelte +3 -3
- package/dist/components/shared/StatusIndicator.svelte +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/utils.d.ts +9 -0
- package/dist/utils.js +18 -0
- package/package.json +11 -6
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from '@alliance-droid/svelte-component-library';
|
|
2
|
+
/**
|
|
3
|
+
* All-in-one feedback board component.
|
|
4
|
+
* Composes the board sub-components (FeedbackSidebar, FeedbackList,
|
|
5
|
+
* FeedbackSubmitForm) with a shared FeedbackBoardState.
|
|
6
|
+
*/
|
|
7
|
+
import { untrack } from 'svelte';
|
|
8
|
+
import { Button } from '@alliance-droid/svelte-component-library';
|
|
9
|
+
import { createFeedbackBoard, type CategoryConfig, type FeedbackBoardState } from './board/index.js';
|
|
10
|
+
import FeedbackSidebar from './board/FeedbackSidebar.svelte';
|
|
11
|
+
import FeedbackList from './board/FeedbackList.svelte';
|
|
12
|
+
import FeedbackSubmitForm from './board/FeedbackSubmitForm.svelte';
|
|
14
13
|
|
|
15
14
|
interface Props {
|
|
16
15
|
apiUrl: string;
|
|
17
16
|
apiKey: string;
|
|
18
17
|
/** Category labels and icons — order determines sidebar order */
|
|
19
|
-
categoryConfig?: Record<string,
|
|
18
|
+
categoryConfig?: Record<string, CategoryConfig>;
|
|
20
19
|
/** Title shown at the top */
|
|
21
20
|
title?: string;
|
|
22
21
|
/** Show the submit form inline */
|
|
@@ -34,220 +33,38 @@
|
|
|
34
33
|
submitCategories = [],
|
|
35
34
|
}: Props = $props();
|
|
36
35
|
|
|
37
|
-
let
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
let total = $state(0);
|
|
41
|
-
let loading = $state(true);
|
|
42
|
-
let error = $state('');
|
|
43
|
-
|
|
44
|
-
let activeCategory = $state<string | null>(null);
|
|
45
|
-
let statusFilter = $state<string>('open');
|
|
46
|
-
let sortBy = $state<string>('votes');
|
|
47
|
-
|
|
48
|
-
let expandedId = $state<string | null>(null);
|
|
49
|
-
let responses = $state<Record<string, { message: string; author: string | null; createdAt: number }[]>>({});
|
|
50
|
-
let loadingResponses = $state<Record<string, boolean>>({});
|
|
51
|
-
|
|
52
|
-
let showForm = $state(false);
|
|
53
|
-
let submitTitle = $state('');
|
|
54
|
-
let submitMessage = $state('');
|
|
55
|
-
let submitCategory = $state('');
|
|
56
|
-
let submitEmail = $state('');
|
|
57
|
-
let submitting = $state(false);
|
|
58
|
-
let submitSuccess = $state(false);
|
|
59
|
-
let submitError = $state('');
|
|
60
|
-
|
|
61
|
-
let votedItems = $state<Record<string, 'up' | 'down'>>({});
|
|
62
|
-
|
|
63
|
-
async function load() {
|
|
64
|
-
loading = true;
|
|
65
|
-
error = '';
|
|
66
|
-
try {
|
|
67
|
-
const data = await client.getBoard({
|
|
68
|
-
category: activeCategory || undefined,
|
|
69
|
-
status: statusFilter as 'open' | 'resolved' | 'all',
|
|
70
|
-
sort: sortBy as 'votes' | 'newest',
|
|
71
|
-
});
|
|
72
|
-
items = data.items;
|
|
73
|
-
total = data.total;
|
|
74
|
-
categories = data.categories;
|
|
75
|
-
} catch (e) {
|
|
76
|
-
error = e instanceof Error ? e.message : 'Failed to load';
|
|
77
|
-
} finally {
|
|
78
|
-
loading = false;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
36
|
+
let board: FeedbackBoardState = $state(
|
|
37
|
+
untrack(() => createFeedbackBoard({ apiUrl, apiKey, categoryConfig }))
|
|
38
|
+
);
|
|
81
39
|
|
|
40
|
+
// Recreate board only when connection details change
|
|
82
41
|
$effect(() => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
42
|
+
const url = apiUrl;
|
|
43
|
+
const key = apiKey;
|
|
44
|
+
const config = categoryConfig;
|
|
45
|
+
board = createFeedbackBoard({ apiUrl: url, apiKey: key, categoryConfig: config });
|
|
87
46
|
});
|
|
88
47
|
|
|
89
|
-
|
|
90
|
-
const prev = votedItems[itemId];
|
|
91
|
-
try {
|
|
92
|
-
const result = await client.vote(itemId, direction);
|
|
93
|
-
const item = items.find((i) => i.id === itemId);
|
|
94
|
-
if (item) item.votes = result.votes;
|
|
95
|
-
if (prev === direction) {
|
|
96
|
-
delete votedItems[itemId];
|
|
97
|
-
votedItems = { ...votedItems };
|
|
98
|
-
} else {
|
|
99
|
-
votedItems = { ...votedItems, [itemId]: direction };
|
|
100
|
-
}
|
|
101
|
-
} catch {
|
|
102
|
-
// Vote failures are silent — optimistic UI, vote just doesn't persist
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function toggleExpand(itemId: string) {
|
|
107
|
-
if (expandedId === itemId) {
|
|
108
|
-
expandedId = null;
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
expandedId = itemId;
|
|
112
|
-
if (!responses[itemId]) {
|
|
113
|
-
loadingResponses = { ...loadingResponses, [itemId]: true };
|
|
114
|
-
try {
|
|
115
|
-
const data = await client.getResponses(itemId);
|
|
116
|
-
responses = { ...responses, [itemId]: data.responses };
|
|
117
|
-
} catch {
|
|
118
|
-
// Show empty responses on failure — user can re-expand to retry
|
|
119
|
-
responses = { ...responses, [itemId]: [] };
|
|
120
|
-
} finally {
|
|
121
|
-
loadingResponses = { ...loadingResponses, [itemId]: false };
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function handleSubmit() {
|
|
127
|
-
if (!submitMessage.trim()) return;
|
|
128
|
-
submitting = true;
|
|
129
|
-
submitError = '';
|
|
130
|
-
try {
|
|
131
|
-
await client.submitFeedback({
|
|
132
|
-
title: submitTitle.trim() || undefined,
|
|
133
|
-
message: submitMessage.trim(),
|
|
134
|
-
category: submitCategory || undefined,
|
|
135
|
-
email: submitEmail.trim() || undefined,
|
|
136
|
-
pageUrl: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
137
|
-
});
|
|
138
|
-
submitSuccess = true;
|
|
139
|
-
submitTitle = '';
|
|
140
|
-
submitMessage = '';
|
|
141
|
-
submitCategory = '';
|
|
142
|
-
submitEmail = '';
|
|
143
|
-
setTimeout(() => {
|
|
144
|
-
submitSuccess = false;
|
|
145
|
-
showForm = false;
|
|
146
|
-
}, 2000);
|
|
147
|
-
await load();
|
|
148
|
-
} catch (err) {
|
|
149
|
-
submitError = err instanceof Error ? err.message : 'Failed to submit feedback';
|
|
150
|
-
} finally {
|
|
151
|
-
submitting = false;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function getCategoryIcon(name: string): string {
|
|
156
|
-
return categoryConfig[name]?.icon || 'fa-solid fa-circle';
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function getCategoryLabel(name: string): string {
|
|
160
|
-
return categoryConfig[name]?.label || name;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function getCategoryDescription(name: string): string {
|
|
164
|
-
return categoryConfig[name]?.description || '';
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function timeAgo(timestamp: number): string {
|
|
168
|
-
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
169
|
-
if (seconds < 60) return 'just now';
|
|
170
|
-
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
171
|
-
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
172
|
-
const days = Math.floor(seconds / 86400);
|
|
173
|
-
if (days < 30) return `${days}d ago`;
|
|
174
|
-
return new Date(timestamp).toLocaleDateString();
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function getStatusBadgeVariant(status: string): 'success' | 'neutral' | 'info' {
|
|
178
|
-
if (status === 'new' || status === 'reviewed') return 'success';
|
|
179
|
-
if (status === 'resolved') return 'neutral';
|
|
180
|
-
return 'info';
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const statusOptions = [
|
|
184
|
-
{ value: 'open', label: 'Open' },
|
|
185
|
-
{ value: 'resolved', label: 'Resolved' },
|
|
186
|
-
{ value: 'all', label: 'All' },
|
|
187
|
-
];
|
|
188
|
-
|
|
189
|
-
const sortOptions = [
|
|
190
|
-
{ value: 'votes', label: 'Most Votes' },
|
|
191
|
-
{ value: 'newest', label: 'Newest' },
|
|
192
|
-
];
|
|
193
|
-
|
|
194
|
-
let submitCategoryOptions = $derived(
|
|
195
|
-
submitCategories.map((c) => ({ value: c, label: getCategoryLabel(c) }))
|
|
196
|
-
);
|
|
48
|
+
let showForm = $state(false);
|
|
197
49
|
</script>
|
|
198
50
|
|
|
199
51
|
<div class="flex gap-6 min-h-[400px]">
|
|
200
52
|
<!-- Sidebar -->
|
|
201
|
-
<aside class="w-56 flex-shrink-0
|
|
202
|
-
<
|
|
203
|
-
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
|
|
204
|
-
{activeCategory === null ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
|
|
205
|
-
onclick={() => (activeCategory = null)}
|
|
206
|
-
>
|
|
207
|
-
<i class="fa-solid fa-layer-group"></i>
|
|
208
|
-
<span>All</span>
|
|
209
|
-
<span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{total}</span>
|
|
210
|
-
</button>
|
|
211
|
-
|
|
212
|
-
{#each categories as cat}
|
|
213
|
-
<button
|
|
214
|
-
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
|
|
215
|
-
{activeCategory === cat.name ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
|
|
216
|
-
onclick={() => (activeCategory = activeCategory === cat.name ? null : cat.name)}
|
|
217
|
-
>
|
|
218
|
-
<i class={getCategoryIcon(cat.name)}></i>
|
|
219
|
-
<span>{getCategoryLabel(cat.name)}</span>
|
|
220
|
-
<span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{cat.count}</span>
|
|
221
|
-
</button>
|
|
222
|
-
{/each}
|
|
223
|
-
|
|
224
|
-
<Divider class="my-2" />
|
|
225
|
-
|
|
226
|
-
<div class="px-3 space-y-3">
|
|
227
|
-
<Select
|
|
228
|
-
label="Status"
|
|
229
|
-
options={statusOptions}
|
|
230
|
-
bind:value={statusFilter}
|
|
231
|
-
/>
|
|
232
|
-
<Select
|
|
233
|
-
label="Sort By"
|
|
234
|
-
options={sortOptions}
|
|
235
|
-
bind:value={sortBy}
|
|
236
|
-
/>
|
|
237
|
-
</div>
|
|
53
|
+
<aside class="w-56 flex-shrink-0">
|
|
54
|
+
<FeedbackSidebar {board} />
|
|
238
55
|
</aside>
|
|
239
56
|
|
|
240
57
|
<!-- Main -->
|
|
241
58
|
<main class="flex-1 min-w-0">
|
|
242
59
|
<div class="flex items-start justify-between mb-4">
|
|
243
60
|
<div>
|
|
244
|
-
{#if activeCategory}
|
|
61
|
+
{#if board.activeCategory}
|
|
245
62
|
<h2 class="text-xl font-bold flex items-center gap-2">
|
|
246
|
-
<i class={getCategoryIcon(activeCategory)}></i>
|
|
247
|
-
{getCategoryLabel(activeCategory)}
|
|
63
|
+
<i class={board.getCategoryIcon(board.activeCategory)}></i>
|
|
64
|
+
{board.getCategoryLabel(board.activeCategory)}
|
|
248
65
|
</h2>
|
|
249
|
-
{#if getCategoryDescription(activeCategory)}
|
|
250
|
-
<p class="text-sm text-foreground-secondary mt-1">{getCategoryDescription(activeCategory)}</p>
|
|
66
|
+
{#if board.getCategoryDescription(board.activeCategory)}
|
|
67
|
+
<p class="text-sm text-foreground-secondary mt-1">{board.getCategoryDescription(board.activeCategory)}</p>
|
|
251
68
|
{/if}
|
|
252
69
|
{:else if title}
|
|
253
70
|
<h2 class="text-xl font-bold">{title}</h2>
|
|
@@ -262,165 +79,12 @@
|
|
|
262
79
|
|
|
263
80
|
<!-- Submit form -->
|
|
264
81
|
{#if showForm}
|
|
265
|
-
<
|
|
266
|
-
{
|
|
267
|
-
|
|
268
|
-
<i class="fa-solid fa-check-circle mr-1"></i> Submitted! It will appear once reviewed.
|
|
269
|
-
</div>
|
|
270
|
-
{:else}
|
|
271
|
-
<div class="space-y-3">
|
|
272
|
-
<Input
|
|
273
|
-
bind:value={submitTitle}
|
|
274
|
-
placeholder="Title (optional)"
|
|
275
|
-
/>
|
|
276
|
-
<Textarea
|
|
277
|
-
value={submitMessage} oninput={(e) => (submitMessage = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
278
|
-
placeholder="Describe your feedback..."
|
|
279
|
-
rows={3}
|
|
280
|
-
/>
|
|
281
|
-
{#if submitError}
|
|
282
|
-
<p class="text-sm text-red-400">
|
|
283
|
-
<i class="fa-solid fa-exclamation-circle mr-1"></i>
|
|
284
|
-
{submitError}
|
|
285
|
-
</p>
|
|
286
|
-
{/if}
|
|
287
|
-
<div class="flex gap-2 items-center flex-wrap">
|
|
288
|
-
{#if submitCategoryOptions.length > 0}
|
|
289
|
-
<div class="min-w-[140px]">
|
|
290
|
-
<Select
|
|
291
|
-
options={[{ value: '', label: 'Category...' }, ...submitCategoryOptions]}
|
|
292
|
-
bind:value={submitCategory}
|
|
293
|
-
/>
|
|
294
|
-
</div>
|
|
295
|
-
{/if}
|
|
296
|
-
<div class="flex-1">
|
|
297
|
-
<Input
|
|
298
|
-
type="email"
|
|
299
|
-
bind:value={submitEmail}
|
|
300
|
-
placeholder="Email (optional)"
|
|
301
|
-
/>
|
|
302
|
-
</div>
|
|
303
|
-
<Button
|
|
304
|
-
variant="primary"
|
|
305
|
-
disabled={submitting || !submitMessage.trim()}
|
|
306
|
-
loading={submitting}
|
|
307
|
-
onclick={handleSubmit}
|
|
308
|
-
>
|
|
309
|
-
Submit
|
|
310
|
-
</Button>
|
|
311
|
-
</div>
|
|
312
|
-
</div>
|
|
313
|
-
{/if}
|
|
314
|
-
</Card>
|
|
82
|
+
<div class="mb-4">
|
|
83
|
+
<FeedbackSubmitForm {board} categories={submitCategories} />
|
|
84
|
+
</div>
|
|
315
85
|
{/if}
|
|
316
86
|
|
|
317
87
|
<!-- Items -->
|
|
318
|
-
{
|
|
319
|
-
<div class="text-center py-12 text-foreground-tertiary">
|
|
320
|
-
<Spinner />
|
|
321
|
-
<p class="mt-2">Loading...</p>
|
|
322
|
-
</div>
|
|
323
|
-
{:else if error}
|
|
324
|
-
<div class="text-center py-12 text-foreground-tertiary">
|
|
325
|
-
<i class="fa-solid fa-exclamation-triangle text-2xl"></i>
|
|
326
|
-
<p class="mt-2">{error}</p>
|
|
327
|
-
</div>
|
|
328
|
-
{:else if items.length === 0}
|
|
329
|
-
<div class="text-center py-12 text-foreground-tertiary">
|
|
330
|
-
<i class="fa-solid fa-inbox text-3xl"></i>
|
|
331
|
-
<p class="mt-2">No feedback yet</p>
|
|
332
|
-
<p class="text-sm">Be the first to submit!</p>
|
|
333
|
-
</div>
|
|
334
|
-
{:else}
|
|
335
|
-
<div class="flex flex-col gap-2">
|
|
336
|
-
{#each items as item (item.id)}
|
|
337
|
-
<Card variant="outlined" padding="none" hover>
|
|
338
|
-
<div class="flex gap-3 p-4">
|
|
339
|
-
<!-- Vote column -->
|
|
340
|
-
<div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]">
|
|
341
|
-
<IconButton
|
|
342
|
-
size="sm"
|
|
343
|
-
variant={votedItems[item.id] === 'up' ? 'primary' : 'neutral'}
|
|
344
|
-
onclick={() => handleVote(item.id, 'up')}
|
|
345
|
-
label="Upvote"
|
|
346
|
-
>
|
|
347
|
-
<i class="fa-solid fa-chevron-up text-xs"></i>
|
|
348
|
-
</IconButton>
|
|
349
|
-
<span class="text-sm font-bold">{item.votes}</span>
|
|
350
|
-
<IconButton
|
|
351
|
-
size="sm"
|
|
352
|
-
variant={votedItems[item.id] === 'down' ? 'primary' : 'neutral'}
|
|
353
|
-
onclick={() => handleVote(item.id, 'down')}
|
|
354
|
-
label="Downvote"
|
|
355
|
-
>
|
|
356
|
-
<i class="fa-solid fa-chevron-down text-xs"></i>
|
|
357
|
-
</IconButton>
|
|
358
|
-
</div>
|
|
359
|
-
|
|
360
|
-
<!-- Content -->
|
|
361
|
-
<div class="flex-1 min-w-0">
|
|
362
|
-
<div class="flex items-center gap-2 flex-wrap">
|
|
363
|
-
{#if item.category}
|
|
364
|
-
<i class="{getCategoryIcon(item.category)} text-sm text-foreground-secondary"></i>
|
|
365
|
-
{/if}
|
|
366
|
-
<h3 class="text-[0.9375rem] font-semibold flex-1">
|
|
367
|
-
{item.title || item.message.slice(0, 80)}
|
|
368
|
-
</h3>
|
|
369
|
-
<Badge variant={getStatusBadgeVariant(item.status)} size="sm">
|
|
370
|
-
{item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
|
371
|
-
</Badge>
|
|
372
|
-
</div>
|
|
373
|
-
|
|
374
|
-
{#if item.title}
|
|
375
|
-
<p class="text-sm text-foreground-secondary mt-1.5 leading-relaxed">{item.message}</p>
|
|
376
|
-
{/if}
|
|
377
|
-
|
|
378
|
-
<div class="flex items-center gap-3 mt-2 text-[0.8125rem] text-foreground-tertiary">
|
|
379
|
-
<span>{timeAgo(item.createdAt)}</span>
|
|
380
|
-
{#if item.responseCount > 0}
|
|
381
|
-
<button
|
|
382
|
-
class="flex items-center gap-1 hover:text-foreground transition-colors bg-transparent border-none cursor-pointer text-[0.8125rem] text-foreground-tertiary p-0"
|
|
383
|
-
onclick={() => toggleExpand(item.id)}
|
|
384
|
-
>
|
|
385
|
-
<i class="fa-solid fa-comment"></i>
|
|
386
|
-
{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}
|
|
387
|
-
</button>
|
|
388
|
-
{/if}
|
|
389
|
-
{#if item.adminResponse}
|
|
390
|
-
<span class="text-xs text-primary">
|
|
391
|
-
<i class="fa-solid fa-shield"></i> Official response
|
|
392
|
-
</span>
|
|
393
|
-
{/if}
|
|
394
|
-
</div>
|
|
395
|
-
|
|
396
|
-
<!-- Expanded responses -->
|
|
397
|
-
{#if expandedId === item.id}
|
|
398
|
-
<div class="mt-3 pt-3 border-t border-border space-y-2">
|
|
399
|
-
{#if loadingResponses[item.id]}
|
|
400
|
-
<p class="text-sm text-foreground-tertiary"><Spinner /> Loading...</p>
|
|
401
|
-
{:else if responses[item.id]?.length}
|
|
402
|
-
{#each responses[item.id] as resp}
|
|
403
|
-
<div class="p-3 rounded-lg bg-surface">
|
|
404
|
-
<div class="flex items-center justify-between mb-1">
|
|
405
|
-
<span class="text-[0.8125rem] font-semibold text-primary">
|
|
406
|
-
<i class="fa-solid fa-shield mr-1"></i>
|
|
407
|
-
{resp.author || 'Team'}
|
|
408
|
-
</span>
|
|
409
|
-
<span class="text-xs text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
|
|
410
|
-
</div>
|
|
411
|
-
<p class="text-sm text-foreground-secondary leading-relaxed m-0">{resp.message}</p>
|
|
412
|
-
</div>
|
|
413
|
-
{/each}
|
|
414
|
-
{:else}
|
|
415
|
-
<p class="text-sm text-foreground-tertiary">No responses yet.</p>
|
|
416
|
-
{/if}
|
|
417
|
-
</div>
|
|
418
|
-
{/if}
|
|
419
|
-
</div>
|
|
420
|
-
</div>
|
|
421
|
-
</Card>
|
|
422
|
-
{/each}
|
|
423
|
-
</div>
|
|
424
|
-
{/if}
|
|
88
|
+
<FeedbackList {board} />
|
|
425
89
|
</main>
|
|
426
90
|
</div>
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
+
import { type CategoryConfig } from './board/index.js';
|
|
1
2
|
interface Props {
|
|
2
3
|
apiUrl: string;
|
|
3
4
|
apiKey: string;
|
|
4
5
|
/** Category labels and icons — order determines sidebar order */
|
|
5
|
-
categoryConfig?: Record<string,
|
|
6
|
-
label?: string;
|
|
7
|
-
icon?: string;
|
|
8
|
-
description?: string;
|
|
9
|
-
}>;
|
|
6
|
+
categoryConfig?: Record<string, CategoryConfig>;
|
|
10
7
|
/** Title shown at the top */
|
|
11
8
|
title?: string;
|
|
12
9
|
/** Show the submit form inline */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { untrack } from 'svelte';
|
|
3
|
-
import { createStatusClient } from '../api.js';
|
|
3
|
+
import { createStatusClient, type StatusClient } from '../api.js';
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
apiKey: string;
|
|
@@ -20,7 +20,18 @@
|
|
|
20
20
|
onError,
|
|
21
21
|
}: Props = $props();
|
|
22
22
|
|
|
23
|
-
let client = $
|
|
23
|
+
let client: StatusClient | null = $state(null);
|
|
24
|
+
let lastApiUrl = '';
|
|
25
|
+
let lastApiKey = '';
|
|
26
|
+
|
|
27
|
+
function getClient(): StatusClient {
|
|
28
|
+
if (!client || lastApiUrl !== apiUrl || lastApiKey !== apiKey) {
|
|
29
|
+
client = createStatusClient({ apiUrl, apiKey });
|
|
30
|
+
lastApiUrl = apiUrl;
|
|
31
|
+
lastApiKey = apiKey;
|
|
32
|
+
}
|
|
33
|
+
return client;
|
|
34
|
+
}
|
|
24
35
|
|
|
25
36
|
let category = $state(untrack(() => categories[0] || ''));
|
|
26
37
|
let message = $state('');
|
|
@@ -37,7 +48,7 @@
|
|
|
37
48
|
errorMsg = '';
|
|
38
49
|
|
|
39
50
|
try {
|
|
40
|
-
await
|
|
51
|
+
await getClient().submitFeedback({
|
|
41
52
|
category,
|
|
42
53
|
message: message.trim(),
|
|
43
54
|
email: email.trim() || undefined,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, untrack } from 'svelte';
|
|
3
3
|
import type { System, SystemStatus } from '../types.js';
|
|
4
|
-
import { createStatusClient } from '../api.js';
|
|
4
|
+
import { createStatusClient, type StatusClient } from '../api.js';
|
|
5
|
+
import { deriveOverallStatus } from '../utils.js';
|
|
5
6
|
import StatusIndicator from './shared/StatusIndicator.svelte';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
@@ -13,38 +14,63 @@
|
|
|
13
14
|
apiKey?: string;
|
|
14
15
|
/** Link to full status page */
|
|
15
16
|
statusUrl?: string;
|
|
17
|
+
/** Auto-refresh interval in seconds. 0 = disabled. Default: 60 */
|
|
18
|
+
refreshInterval?: number;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
let { systems: systemsProp, apiUrl, apiKey, statusUrl }: Props = $props();
|
|
21
|
+
let { systems: systemsProp, apiUrl, apiKey, statusUrl, refreshInterval = 60 }: Props = $props();
|
|
19
22
|
|
|
20
23
|
let systems: System[] = $state(untrack(() => systemsProp ?? []));
|
|
21
24
|
let loading = $state(untrack(() => !systemsProp && !!apiUrl && !!apiKey));
|
|
25
|
+
let fetchFailed = $state(false);
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
let usesApi = $derived(!systemsProp && !!apiUrl && !!apiKey);
|
|
28
|
+
|
|
29
|
+
let cachedClient: StatusClient | null = null;
|
|
30
|
+
let cachedApiUrl = '';
|
|
31
|
+
let cachedApiKey = '';
|
|
32
|
+
|
|
33
|
+
function getClient(): StatusClient {
|
|
34
|
+
if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== apiKey) {
|
|
35
|
+
cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: apiKey! });
|
|
36
|
+
cachedApiUrl = apiUrl!;
|
|
37
|
+
cachedApiKey = apiKey!;
|
|
38
|
+
}
|
|
39
|
+
return cachedClient;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetchData() {
|
|
43
|
+
if (!usesApi) return;
|
|
25
44
|
try {
|
|
26
|
-
const client =
|
|
45
|
+
const client = getClient();
|
|
27
46
|
const data = await client.getStatus();
|
|
28
47
|
systems = data.systems;
|
|
48
|
+
fetchFailed = false;
|
|
29
49
|
} catch {
|
|
30
|
-
|
|
31
|
-
// Consumer can use StatusPage for full error display
|
|
50
|
+
fetchFailed = true;
|
|
32
51
|
} finally {
|
|
33
52
|
loading = false;
|
|
34
53
|
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onMount(() => {
|
|
57
|
+
fetchData();
|
|
58
|
+
|
|
59
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
60
|
+
if (usesApi && refreshInterval > 0) {
|
|
61
|
+
timer = setInterval(fetchData, refreshInterval * 1000);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
if (timer) clearInterval(timer);
|
|
66
|
+
};
|
|
35
67
|
});
|
|
36
68
|
|
|
37
69
|
$effect(() => {
|
|
38
70
|
if (systemsProp) systems = systemsProp;
|
|
39
71
|
});
|
|
40
72
|
|
|
41
|
-
let overallStatus = $derived
|
|
42
|
-
if (systems.length === 0) return 'operational';
|
|
43
|
-
if (systems.some((s) => s.status === 'outage')) return 'outage';
|
|
44
|
-
if (systems.some((s) => s.status === 'degraded')) return 'degraded';
|
|
45
|
-
if (systems.some((s) => s.status === 'maintenance')) return 'maintenance';
|
|
46
|
-
return 'operational';
|
|
47
|
-
});
|
|
73
|
+
let overallStatus = $derived(deriveOverallStatus(systems));
|
|
48
74
|
|
|
49
75
|
const messages: Record<SystemStatus, string> = {
|
|
50
76
|
operational: 'All systems operational',
|
|
@@ -59,6 +85,14 @@
|
|
|
59
85
|
<div class="h-2 w-2 animate-pulse rounded-full bg-foreground-tertiary"></div>
|
|
60
86
|
<span>Loading...</span>
|
|
61
87
|
</div>
|
|
88
|
+
{:else if fetchFailed}
|
|
89
|
+
<button
|
|
90
|
+
onclick={() => { loading = true; fetchData(); }}
|
|
91
|
+
class="inline-flex items-center gap-2 rounded-full border border-red-500/30 px-3 py-1.5 text-xs text-red-400 transition-colors hover:bg-red-500/10"
|
|
92
|
+
>
|
|
93
|
+
<i class="fa-solid fa-circle-exclamation"></i>
|
|
94
|
+
<span>Status unavailable</span>
|
|
95
|
+
</button>
|
|
62
96
|
{:else if statusUrl}
|
|
63
97
|
<a
|
|
64
98
|
href={statusUrl}
|
|
@@ -8,6 +8,8 @@ interface Props {
|
|
|
8
8
|
apiKey?: string;
|
|
9
9
|
/** Link to full status page */
|
|
10
10
|
statusUrl?: string;
|
|
11
|
+
/** Auto-refresh interval in seconds. 0 = disabled. Default: 60 */
|
|
12
|
+
refreshInterval?: number;
|
|
11
13
|
}
|
|
12
14
|
declare const StatusBanner: import("svelte").Component<Props, {}, "">;
|
|
13
15
|
type StatusBanner = ReturnType<typeof StatusBanner>;
|