@alliance-droid/status-feedback-system 1.3.0 → 1.3.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +25 -45
  2. package/LICENSE +11 -0
  3. package/README.md +7 -3
  4. package/dist/components/DevFeedback.svelte +162 -322
  5. package/dist/components/FeedbackBoard.svelte +32 -368
  6. package/dist/components/FeedbackBoard.svelte.d.ts +2 -5
  7. package/dist/components/FeedbackForm.svelte +14 -3
  8. package/dist/components/StatusBanner.svelte +48 -14
  9. package/dist/components/StatusBanner.svelte.d.ts +2 -0
  10. package/dist/components/StatusPage.svelte +43 -10
  11. package/dist/components/StatusPage.svelte.d.ts +2 -0
  12. package/dist/components/board/FeedbackDetail.svelte +7 -7
  13. package/dist/components/board/FeedbackItem.svelte +26 -12
  14. package/dist/components/board/FeedbackList.svelte +21 -1
  15. package/dist/components/board/FeedbackSidebar.svelte +3 -3
  16. package/dist/components/board/state.svelte.d.ts +6 -0
  17. package/dist/components/board/state.svelte.js +42 -2
  18. package/dist/components/dev/DevContextMenu.svelte +99 -0
  19. package/dist/components/dev/DevContextMenu.svelte.d.ts +11 -0
  20. package/dist/components/dev/DevOnboardingToast.svelte +73 -0
  21. package/dist/components/dev/DevOnboardingToast.svelte.d.ts +6 -0
  22. package/dist/components/dev/environment.d.ts +24 -0
  23. package/dist/components/dev/environment.js +93 -0
  24. package/dist/components/dev/screenshot.d.ts +6 -0
  25. package/dist/components/dev/screenshot.js +25 -0
  26. package/dist/components/shared/IncidentTimeline.svelte +3 -3
  27. package/dist/components/shared/StatusIndicator.svelte +2 -2
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.js +2 -0
  30. package/dist/utils.d.ts +9 -0
  31. package/dist/utils.js +18 -0
  32. package/package.json +11 -6
@@ -1,22 +1,21 @@
1
1
  <script lang="ts">
2
- import { createStatusClient, type BoardItem, type BoardCategory } from '../api.js';
3
- import {
4
- Card,
5
- Button,
6
- Badge,
7
- Select,
8
- Input,
9
- Textarea,
10
- Spinner,
11
- Divider,
12
- IconButton,
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, { label?: string; icon?: string; description?: 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 client = $derived(createStatusClient({ apiUrl, apiKey }));
38
- let items = $state<BoardItem[]>([]);
39
- let categories = $state<BoardCategory[]>([]);
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
- void activeCategory;
84
- void statusFilter;
85
- void sortBy;
86
- load();
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
- async function handleVote(itemId: string, direction: 'up' | 'down') {
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 flex flex-col gap-1">
202
- <button
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
- <Card variant="outlined" padding="md" class="mb-4">
266
- {#if submitSuccess}
267
- <div class="text-center py-2 text-green-400 font-semibold">
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
- {#if loading}
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 = $derived(createStatusClient({ apiUrl, apiKey }));
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 client.submitFeedback({
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
- onMount(async () => {
24
- if (systemsProp || !apiUrl || !apiKey) return;
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 = createStatusClient({ apiUrl, apiKey });
45
+ const client = getClient();
27
46
  const data = await client.getStatus();
28
47
  systems = data.systems;
48
+ fetchFailed = false;
29
49
  } catch {
30
- // Error is silent — banner simply stays in loading state
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.by((): SystemStatus => {
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>;