@alliance-droid/status-feedback-system 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * const { systems, incidents } = await client.getStatus();
13
13
  */
14
- import type { System, Incident, StatusHistoryEntry, Feedback } from './types.js';
14
+ import type { System, Incident, StatusHistoryEntry, Feedback, FeedbackResponse } from './types.js';
15
15
  export interface StatusClientConfig {
16
16
  /** Base URL of the status-feedback-admin app */
17
17
  apiUrl: string;
@@ -30,6 +30,26 @@ export interface IncidentData {
30
30
  export interface UptimeData {
31
31
  history: Record<string, StatusHistoryEntry[]>;
32
32
  }
33
+ export interface BoardCategory {
34
+ name: string;
35
+ count: number;
36
+ }
37
+ export interface BoardItem {
38
+ id: string;
39
+ title: string | null;
40
+ category: string | null;
41
+ message: string;
42
+ status: string;
43
+ votes: number;
44
+ adminResponse: string | null;
45
+ responseCount: number;
46
+ createdAt: number;
47
+ }
48
+ export interface BoardData {
49
+ items: BoardItem[];
50
+ total: number;
51
+ categories: BoardCategory[];
52
+ }
33
53
  export interface StatusClient {
34
54
  /** Get current system statuses */
35
55
  getStatus(): Promise<StatusData>;
@@ -45,11 +65,28 @@ export interface StatusClient {
45
65
  /** Submit user feedback */
46
66
  submitFeedback(data: {
47
67
  message: string;
68
+ title?: string;
48
69
  category?: string;
49
70
  email?: string;
50
71
  pageUrl?: string;
51
72
  metadata?: Record<string, unknown>;
52
73
  }): Promise<Feedback>;
74
+ /** Get public feedback board */
75
+ getBoard(options?: {
76
+ category?: string;
77
+ status?: 'open' | 'resolved' | 'all';
78
+ sort?: 'votes' | 'newest';
79
+ limit?: number;
80
+ offset?: number;
81
+ }): Promise<BoardData>;
82
+ /** Vote on a feedback item */
83
+ vote(feedbackId: string, direction?: 'up' | 'down'): Promise<{
84
+ votes: number;
85
+ }>;
86
+ /** Get responses for a feedback item */
87
+ getResponses(feedbackId: string): Promise<{
88
+ responses: FeedbackResponse[];
89
+ }>;
53
90
  }
54
91
  /**
55
92
  * Create a status API client instance
package/dist/api.js CHANGED
@@ -72,5 +72,29 @@ export function createStatusClient(config) {
72
72
  body: JSON.stringify(data),
73
73
  });
74
74
  },
75
+ async getBoard(options) {
76
+ const params = new URLSearchParams();
77
+ if (options?.category)
78
+ params.set('category', options.category);
79
+ if (options?.status)
80
+ params.set('status', options.status);
81
+ if (options?.sort)
82
+ params.set('sort', options.sort);
83
+ if (options?.limit)
84
+ params.set('limit', String(options.limit));
85
+ if (options?.offset)
86
+ params.set('offset', String(options.offset));
87
+ const qs = params.toString();
88
+ return request(`/api/board${qs ? `?${qs}` : ''}`);
89
+ },
90
+ async vote(feedbackId, direction = 'up') {
91
+ return request('/api/board/vote', {
92
+ method: 'POST',
93
+ body: JSON.stringify({ feedbackId, direction }),
94
+ });
95
+ },
96
+ async getResponses(feedbackId) {
97
+ return request(`/api/board/${feedbackId}/responses`);
98
+ },
75
99
  };
76
100
  }
@@ -0,0 +1,842 @@
1
+ <script lang="ts">
2
+ import { createStatusClient, type BoardItem, type BoardCategory } from '../api.js';
3
+
4
+ interface Props {
5
+ apiUrl: string;
6
+ apiKey: string;
7
+ /** Category labels and icons — order determines sidebar order */
8
+ categoryConfig?: Record<string, { label?: string; icon?: string; description?: string }>;
9
+ /** Title shown at the top */
10
+ title?: string;
11
+ /** Show the submit form inline */
12
+ showSubmit?: boolean;
13
+ /** Available categories for the submit form */
14
+ submitCategories?: string[];
15
+ }
16
+
17
+ let {
18
+ apiUrl,
19
+ apiKey,
20
+ categoryConfig = {},
21
+ title = 'Feedback Board',
22
+ showSubmit = true,
23
+ submitCategories = [],
24
+ }: Props = $props();
25
+
26
+ let client = $derived(createStatusClient({ apiUrl, apiKey }));
27
+ let items = $state<BoardItem[]>([]);
28
+ let categories = $state<BoardCategory[]>([]);
29
+ let total = $state(0);
30
+ let loading = $state(true);
31
+ let error = $state('');
32
+
33
+ let activeCategory = $state<string | null>(null);
34
+ let statusFilter = $state<'open' | 'resolved' | 'all'>('open');
35
+ let sortBy = $state<'votes' | 'newest'>('votes');
36
+
37
+ // Expanded item for viewing responses
38
+ let expandedId = $state<string | null>(null);
39
+ let responses = $state<Record<string, { message: string; author: string | null; createdAt: number }[]>>({});
40
+ let loadingResponses = $state<Record<string, boolean>>({});
41
+
42
+ // Submit form
43
+ let showForm = $state(false);
44
+ let submitTitle = $state('');
45
+ let submitMessage = $state('');
46
+ let submitCategory = $state('');
47
+ let submitEmail = $state('');
48
+ let submitting = $state(false);
49
+ let submitSuccess = $state(false);
50
+
51
+ // Voting state (tracks which items the user voted on this session)
52
+ let votedItems = $state<Record<string, 'up' | 'down'>>({});
53
+
54
+ async function load() {
55
+ loading = true;
56
+ error = '';
57
+ try {
58
+ const data = await client.getBoard({
59
+ category: activeCategory || undefined,
60
+ status: statusFilter,
61
+ sort: sortBy,
62
+ });
63
+ items = data.items;
64
+ total = data.total;
65
+ categories = data.categories;
66
+ } catch (e) {
67
+ error = e instanceof Error ? e.message : 'Failed to load';
68
+ } finally {
69
+ loading = false;
70
+ }
71
+ }
72
+
73
+ $effect(() => {
74
+ // Re-fetch when filters change
75
+ void activeCategory;
76
+ void statusFilter;
77
+ void sortBy;
78
+ load();
79
+ });
80
+
81
+ async function handleVote(itemId: string, direction: 'up' | 'down') {
82
+ const prev = votedItems[itemId];
83
+ try {
84
+ const result = await client.vote(itemId, direction);
85
+ // Update local state
86
+ const item = items.find((i) => i.id === itemId);
87
+ if (item) item.votes = result.votes;
88
+
89
+ if (prev === direction) {
90
+ // Toggled off
91
+ delete votedItems[itemId];
92
+ votedItems = { ...votedItems };
93
+ } else {
94
+ votedItems = { ...votedItems, [itemId]: direction };
95
+ }
96
+ } catch {
97
+ // silently fail
98
+ }
99
+ }
100
+
101
+ async function toggleExpand(itemId: string) {
102
+ if (expandedId === itemId) {
103
+ expandedId = null;
104
+ return;
105
+ }
106
+ expandedId = itemId;
107
+ if (!responses[itemId]) {
108
+ loadingResponses = { ...loadingResponses, [itemId]: true };
109
+ try {
110
+ const data = await client.getResponses(itemId);
111
+ responses = { ...responses, [itemId]: data.responses };
112
+ } catch {
113
+ responses = { ...responses, [itemId]: [] };
114
+ } finally {
115
+ loadingResponses = { ...loadingResponses, [itemId]: false };
116
+ }
117
+ }
118
+ }
119
+
120
+ async function handleSubmit() {
121
+ if (!submitMessage.trim()) return;
122
+ submitting = true;
123
+ try {
124
+ await client.submitFeedback({
125
+ title: submitTitle.trim() || undefined,
126
+ message: submitMessage.trim(),
127
+ category: submitCategory || undefined,
128
+ email: submitEmail.trim() || undefined,
129
+ pageUrl: window.location.href,
130
+ });
131
+ submitSuccess = true;
132
+ submitTitle = '';
133
+ submitMessage = '';
134
+ submitCategory = '';
135
+ submitEmail = '';
136
+ setTimeout(() => {
137
+ submitSuccess = false;
138
+ showForm = false;
139
+ }, 2000);
140
+ // Reload board
141
+ await load();
142
+ } catch {
143
+ // ignore
144
+ } finally {
145
+ submitting = false;
146
+ }
147
+ }
148
+
149
+ function getCategoryIcon(name: string): string {
150
+ return categoryConfig[name]?.icon || 'fa-solid fa-circle';
151
+ }
152
+
153
+ function getCategoryLabel(name: string): string {
154
+ return categoryConfig[name]?.label || name;
155
+ }
156
+
157
+ function getCategoryDescription(name: string): string {
158
+ return categoryConfig[name]?.description || '';
159
+ }
160
+
161
+ function timeAgo(timestamp: number): string {
162
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
163
+ if (seconds < 60) return 'just now';
164
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
165
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
166
+ const days = Math.floor(seconds / 86400);
167
+ if (days < 30) return `${days}d ago`;
168
+ return new Date(timestamp).toLocaleDateString();
169
+ }
170
+ </script>
171
+
172
+ <div class="sf-board">
173
+ <div class="sf-board-layout">
174
+ <!-- Sidebar -->
175
+ <aside class="sf-board-sidebar">
176
+ <button
177
+ class="sf-sidebar-item"
178
+ class:sf-sidebar-active={activeCategory === null}
179
+ onclick={() => (activeCategory = null)}
180
+ >
181
+ <i class="fa-solid fa-layer-group"></i>
182
+ <span>All</span>
183
+ <span class="sf-sidebar-count">{total}</span>
184
+ </button>
185
+
186
+ {#each categories as cat}
187
+ <button
188
+ class="sf-sidebar-item"
189
+ class:sf-sidebar-active={activeCategory === cat.name}
190
+ onclick={() => (activeCategory = activeCategory === cat.name ? null : cat.name)}
191
+ >
192
+ <i class={getCategoryIcon(cat.name)}></i>
193
+ <span>{getCategoryLabel(cat.name)}</span>
194
+ <span class="sf-sidebar-count">{cat.count}</span>
195
+ </button>
196
+ {/each}
197
+
198
+ <hr class="sf-sidebar-divider" />
199
+
200
+ <div class="sf-sidebar-section">
201
+ <label class="sf-sidebar-label">Status</label>
202
+ <select bind:value={statusFilter} class="sf-select">
203
+ <option value="open">Open</option>
204
+ <option value="resolved">Resolved</option>
205
+ <option value="all">All</option>
206
+ </select>
207
+ </div>
208
+
209
+ <div class="sf-sidebar-section">
210
+ <label class="sf-sidebar-label">Sort By</label>
211
+ <select bind:value={sortBy} class="sf-select">
212
+ <option value="votes">Most Votes</option>
213
+ <option value="newest">Newest</option>
214
+ </select>
215
+ </div>
216
+ </aside>
217
+
218
+ <!-- Main content -->
219
+ <main class="sf-board-main">
220
+ <div class="sf-board-header">
221
+ <div>
222
+ <h2 class="sf-board-title">
223
+ {#if activeCategory}
224
+ <i class={getCategoryIcon(activeCategory)}></i>
225
+ {getCategoryLabel(activeCategory)}
226
+ {:else}
227
+ {title}
228
+ {/if}
229
+ </h2>
230
+ {#if activeCategory && getCategoryDescription(activeCategory)}
231
+ <p class="sf-board-subtitle">{getCategoryDescription(activeCategory)}</p>
232
+ {/if}
233
+ </div>
234
+ {#if showSubmit}
235
+ <button class="sf-submit-btn" onclick={() => (showForm = !showForm)}>
236
+ <i class="fa-solid fa-plus"></i> Submit
237
+ </button>
238
+ {/if}
239
+ </div>
240
+
241
+ <!-- Submit form -->
242
+ {#if showForm}
243
+ <div class="sf-submit-form">
244
+ {#if submitSuccess}
245
+ <div class="sf-success">
246
+ <i class="fa-solid fa-check-circle"></i> Submitted! It will appear once reviewed.
247
+ </div>
248
+ {:else}
249
+ <input
250
+ type="text"
251
+ bind:value={submitTitle}
252
+ placeholder="Title (optional)"
253
+ class="sf-input"
254
+ />
255
+ <textarea
256
+ bind:value={submitMessage}
257
+ placeholder="Describe your feedback..."
258
+ rows="3"
259
+ class="sf-textarea"
260
+ ></textarea>
261
+ <div class="sf-submit-row">
262
+ {#if submitCategories.length > 0}
263
+ <select bind:value={submitCategory} class="sf-select">
264
+ <option value="">Category...</option>
265
+ {#each submitCategories as cat}
266
+ <option value={cat}>{getCategoryLabel(cat)}</option>
267
+ {/each}
268
+ </select>
269
+ {/if}
270
+ <input
271
+ type="email"
272
+ bind:value={submitEmail}
273
+ placeholder="Email (optional)"
274
+ class="sf-input sf-input-sm"
275
+ />
276
+ <button
277
+ class="sf-submit-btn"
278
+ disabled={submitting || !submitMessage.trim()}
279
+ onclick={handleSubmit}
280
+ >
281
+ {submitting ? 'Sending...' : 'Submit'}
282
+ </button>
283
+ </div>
284
+ {/if}
285
+ </div>
286
+ {/if}
287
+
288
+ <!-- Items -->
289
+ {#if loading}
290
+ <div class="sf-empty">
291
+ <i class="fa-solid fa-spinner fa-spin"></i>
292
+ <p>Loading...</p>
293
+ </div>
294
+ {:else if error}
295
+ <div class="sf-empty">
296
+ <i class="fa-solid fa-exclamation-triangle"></i>
297
+ <p>{error}</p>
298
+ </div>
299
+ {:else if items.length === 0}
300
+ <div class="sf-empty">
301
+ <i class="fa-solid fa-inbox"></i>
302
+ <p>No feedback yet</p>
303
+ <p class="sf-empty-sub">Be the first to submit!</p>
304
+ </div>
305
+ {:else}
306
+ <div class="sf-items">
307
+ {#each items as item (item.id)}
308
+ <div class="sf-item">
309
+ <!-- Vote column -->
310
+ <div class="sf-vote-col">
311
+ <button
312
+ class="sf-vote-btn"
313
+ class:sf-voted={votedItems[item.id] === 'up'}
314
+ onclick={() => handleVote(item.id, 'up')}
315
+ aria-label="Upvote"
316
+ >
317
+ <i class="fa-solid fa-chevron-up"></i>
318
+ </button>
319
+ <span class="sf-vote-count">{item.votes}</span>
320
+ <button
321
+ class="sf-vote-btn"
322
+ class:sf-voted={votedItems[item.id] === 'down'}
323
+ onclick={() => handleVote(item.id, 'down')}
324
+ aria-label="Downvote"
325
+ >
326
+ <i class="fa-solid fa-chevron-down"></i>
327
+ </button>
328
+ </div>
329
+
330
+ <!-- Content -->
331
+ <div class="sf-item-content">
332
+ <div class="sf-item-header">
333
+ {#if item.category}
334
+ <span class="sf-item-category">
335
+ <i class={getCategoryIcon(item.category)}></i>
336
+ </span>
337
+ {/if}
338
+ <h3 class="sf-item-title">
339
+ {item.title || item.message.slice(0, 80)}
340
+ </h3>
341
+ <span class="sf-item-status" data-status={item.status}>
342
+ {item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
343
+ </span>
344
+ </div>
345
+
346
+ {#if item.title}
347
+ <p class="sf-item-message">{item.message}</p>
348
+ {/if}
349
+
350
+ <div class="sf-item-footer">
351
+ <span class="sf-item-time">{timeAgo(item.createdAt)}</span>
352
+ {#if item.responseCount > 0}
353
+ <button class="sf-item-responses" onclick={() => toggleExpand(item.id)}>
354
+ <i class="fa-solid fa-comment"></i>
355
+ {item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}
356
+ </button>
357
+ {/if}
358
+ {#if item.adminResponse}
359
+ <span class="sf-item-badge">
360
+ <i class="fa-solid fa-shield-check"></i> Official response
361
+ </span>
362
+ {/if}
363
+ </div>
364
+
365
+ <!-- Expanded responses -->
366
+ {#if expandedId === item.id}
367
+ <div class="sf-responses">
368
+ {#if loadingResponses[item.id]}
369
+ <p class="sf-responses-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading...</p>
370
+ {:else if responses[item.id]?.length}
371
+ {#each responses[item.id] as resp}
372
+ <div class="sf-response">
373
+ <div class="sf-response-header">
374
+ <span class="sf-response-author">
375
+ {#if resp.author}
376
+ <i class="fa-solid fa-shield"></i> {resp.author}
377
+ {:else}
378
+ Team
379
+ {/if}
380
+ </span>
381
+ <span class="sf-response-time">{timeAgo(resp.createdAt)}</span>
382
+ </div>
383
+ <p class="sf-response-message">{resp.message}</p>
384
+ </div>
385
+ {/each}
386
+ {:else}
387
+ <p class="sf-responses-empty">No responses yet.</p>
388
+ {/if}
389
+ </div>
390
+ {/if}
391
+ </div>
392
+ </div>
393
+ {/each}
394
+ </div>
395
+ {/if}
396
+ </main>
397
+ </div>
398
+ </div>
399
+
400
+ <style>
401
+ .sf-board {
402
+ --sf-bg: var(--color-surface, #0f172a);
403
+ --sf-bg-secondary: var(--color-surface-secondary, #1e293b);
404
+ --sf-bg-hover: var(--color-surface-hover, #334155);
405
+ --sf-text: var(--color-foreground, #e2e8f0);
406
+ --sf-text-secondary: var(--color-foreground-secondary, #94a3b8);
407
+ --sf-text-tertiary: var(--color-foreground-tertiary, #64748b);
408
+ --sf-border: var(--color-border, #334155);
409
+ --sf-primary: var(--color-primary, #3b82f6);
410
+ --sf-primary-text: var(--color-primary-foreground, #ffffff);
411
+ --sf-success: #22c55e;
412
+ --sf-radius: 0.5rem;
413
+ font-family: inherit;
414
+ color: var(--sf-text);
415
+ }
416
+
417
+ .sf-board-layout {
418
+ display: flex;
419
+ gap: 1.5rem;
420
+ min-height: 400px;
421
+ }
422
+
423
+ /* Sidebar */
424
+ .sf-board-sidebar {
425
+ width: 220px;
426
+ flex-shrink: 0;
427
+ display: flex;
428
+ flex-direction: column;
429
+ gap: 0.25rem;
430
+ }
431
+
432
+ .sf-sidebar-item {
433
+ display: flex;
434
+ align-items: center;
435
+ gap: 0.5rem;
436
+ padding: 0.5rem 0.75rem;
437
+ border-radius: var(--sf-radius);
438
+ border: none;
439
+ background: transparent;
440
+ color: var(--sf-text-secondary);
441
+ cursor: pointer;
442
+ font-size: 0.875rem;
443
+ text-align: left;
444
+ width: 100%;
445
+ transition: all 0.15s;
446
+ }
447
+
448
+ .sf-sidebar-item:hover {
449
+ background: var(--sf-bg-hover);
450
+ color: var(--sf-text);
451
+ }
452
+
453
+ .sf-sidebar-active {
454
+ background: var(--sf-bg-secondary);
455
+ color: var(--sf-text);
456
+ font-weight: 600;
457
+ }
458
+
459
+ .sf-sidebar-count {
460
+ margin-left: auto;
461
+ background: var(--sf-bg-hover);
462
+ padding: 0.125rem 0.5rem;
463
+ border-radius: 999px;
464
+ font-size: 0.75rem;
465
+ }
466
+
467
+ .sf-sidebar-divider {
468
+ border: none;
469
+ border-top: 1px solid var(--sf-border);
470
+ margin: 0.5rem 0;
471
+ }
472
+
473
+ .sf-sidebar-section {
474
+ padding: 0.25rem 0.75rem;
475
+ }
476
+
477
+ .sf-sidebar-label {
478
+ display: block;
479
+ font-size: 0.75rem;
480
+ color: var(--sf-text-tertiary);
481
+ margin-bottom: 0.25rem;
482
+ text-transform: uppercase;
483
+ letter-spacing: 0.05em;
484
+ }
485
+
486
+ /* Main */
487
+ .sf-board-main {
488
+ flex: 1;
489
+ min-width: 0;
490
+ }
491
+
492
+ .sf-board-header {
493
+ display: flex;
494
+ align-items: flex-start;
495
+ justify-content: space-between;
496
+ margin-bottom: 1rem;
497
+ }
498
+
499
+ .sf-board-title {
500
+ font-size: 1.25rem;
501
+ font-weight: 700;
502
+ margin: 0;
503
+ display: flex;
504
+ align-items: center;
505
+ gap: 0.5rem;
506
+ }
507
+
508
+ .sf-board-subtitle {
509
+ font-size: 0.875rem;
510
+ color: var(--sf-text-secondary);
511
+ margin: 0.25rem 0 0;
512
+ }
513
+
514
+ /* Items */
515
+ .sf-items {
516
+ display: flex;
517
+ flex-direction: column;
518
+ gap: 0.5rem;
519
+ }
520
+
521
+ .sf-item {
522
+ display: flex;
523
+ gap: 0.75rem;
524
+ padding: 1rem;
525
+ background: var(--sf-bg-secondary);
526
+ border: 1px solid var(--sf-border);
527
+ border-radius: var(--sf-radius);
528
+ transition: border-color 0.15s;
529
+ }
530
+
531
+ .sf-item:hover {
532
+ border-color: var(--sf-text-tertiary);
533
+ }
534
+
535
+ /* Vote column */
536
+ .sf-vote-col {
537
+ display: flex;
538
+ flex-direction: column;
539
+ align-items: center;
540
+ gap: 0.125rem;
541
+ min-width: 2.5rem;
542
+ }
543
+
544
+ .sf-vote-btn {
545
+ background: none;
546
+ border: 1px solid transparent;
547
+ color: var(--sf-text-tertiary);
548
+ cursor: pointer;
549
+ padding: 0.25rem;
550
+ border-radius: 0.25rem;
551
+ font-size: 0.75rem;
552
+ transition: all 0.15s;
553
+ line-height: 1;
554
+ }
555
+
556
+ .sf-vote-btn:hover {
557
+ color: var(--sf-primary);
558
+ border-color: var(--sf-primary);
559
+ }
560
+
561
+ .sf-voted {
562
+ color: var(--sf-primary) !important;
563
+ border-color: var(--sf-primary) !important;
564
+ background: color-mix(in srgb, var(--sf-primary) 10%, transparent);
565
+ }
566
+
567
+ .sf-vote-count {
568
+ font-size: 0.875rem;
569
+ font-weight: 700;
570
+ color: var(--sf-text);
571
+ }
572
+
573
+ /* Item content */
574
+ .sf-item-content {
575
+ flex: 1;
576
+ min-width: 0;
577
+ }
578
+
579
+ .sf-item-header {
580
+ display: flex;
581
+ align-items: center;
582
+ gap: 0.5rem;
583
+ flex-wrap: wrap;
584
+ }
585
+
586
+ .sf-item-category {
587
+ font-size: 0.875rem;
588
+ }
589
+
590
+ .sf-item-title {
591
+ font-size: 0.9375rem;
592
+ font-weight: 600;
593
+ margin: 0;
594
+ flex: 1;
595
+ }
596
+
597
+ .sf-item-status {
598
+ font-size: 0.75rem;
599
+ padding: 0.125rem 0.5rem;
600
+ border-radius: 999px;
601
+ font-weight: 500;
602
+ }
603
+
604
+ .sf-item-status[data-status='new'],
605
+ .sf-item-status[data-status='reviewed'] {
606
+ background: color-mix(in srgb, var(--sf-success) 15%, transparent);
607
+ color: var(--sf-success);
608
+ }
609
+
610
+ .sf-item-status[data-status='resolved'] {
611
+ background: color-mix(in srgb, var(--sf-text-tertiary) 15%, transparent);
612
+ color: var(--sf-text-tertiary);
613
+ }
614
+
615
+ .sf-item-message {
616
+ font-size: 0.875rem;
617
+ color: var(--sf-text-secondary);
618
+ margin: 0.5rem 0 0;
619
+ line-height: 1.5;
620
+ }
621
+
622
+ .sf-item-footer {
623
+ display: flex;
624
+ align-items: center;
625
+ gap: 0.75rem;
626
+ margin-top: 0.5rem;
627
+ font-size: 0.8125rem;
628
+ color: var(--sf-text-tertiary);
629
+ }
630
+
631
+ .sf-item-responses {
632
+ background: none;
633
+ border: none;
634
+ color: var(--sf-text-tertiary);
635
+ cursor: pointer;
636
+ font-size: 0.8125rem;
637
+ display: flex;
638
+ align-items: center;
639
+ gap: 0.25rem;
640
+ padding: 0;
641
+ }
642
+
643
+ .sf-item-responses:hover {
644
+ color: var(--sf-text);
645
+ }
646
+
647
+ .sf-item-badge {
648
+ font-size: 0.75rem;
649
+ color: var(--sf-primary);
650
+ }
651
+
652
+ /* Responses */
653
+ .sf-responses {
654
+ margin-top: 0.75rem;
655
+ padding-top: 0.75rem;
656
+ border-top: 1px solid var(--sf-border);
657
+ display: flex;
658
+ flex-direction: column;
659
+ gap: 0.5rem;
660
+ }
661
+
662
+ .sf-response {
663
+ padding: 0.5rem 0.75rem;
664
+ background: var(--sf-bg);
665
+ border-radius: var(--sf-radius);
666
+ }
667
+
668
+ .sf-response-header {
669
+ display: flex;
670
+ align-items: center;
671
+ justify-content: space-between;
672
+ margin-bottom: 0.25rem;
673
+ }
674
+
675
+ .sf-response-author {
676
+ font-size: 0.8125rem;
677
+ font-weight: 600;
678
+ color: var(--sf-primary);
679
+ }
680
+
681
+ .sf-response-time {
682
+ font-size: 0.75rem;
683
+ color: var(--sf-text-tertiary);
684
+ }
685
+
686
+ .sf-response-message {
687
+ font-size: 0.875rem;
688
+ color: var(--sf-text-secondary);
689
+ margin: 0;
690
+ line-height: 1.5;
691
+ }
692
+
693
+ .sf-responses-loading,
694
+ .sf-responses-empty {
695
+ font-size: 0.8125rem;
696
+ color: var(--sf-text-tertiary);
697
+ margin: 0;
698
+ }
699
+
700
+ /* Empty state */
701
+ .sf-empty {
702
+ text-align: center;
703
+ padding: 3rem 1rem;
704
+ color: var(--sf-text-tertiary);
705
+ }
706
+
707
+ .sf-empty i {
708
+ font-size: 2rem;
709
+ margin-bottom: 0.5rem;
710
+ }
711
+
712
+ .sf-empty p {
713
+ margin: 0.25rem 0;
714
+ }
715
+
716
+ .sf-empty-sub {
717
+ font-size: 0.875rem;
718
+ }
719
+
720
+ /* Form elements */
721
+ .sf-select {
722
+ width: 100%;
723
+ padding: 0.375rem 0.5rem;
724
+ background: var(--sf-bg-secondary);
725
+ border: 1px solid var(--sf-border);
726
+ border-radius: 0.375rem;
727
+ color: var(--sf-text);
728
+ font-size: 0.8125rem;
729
+ cursor: pointer;
730
+ }
731
+
732
+ .sf-submit-btn {
733
+ display: inline-flex;
734
+ align-items: center;
735
+ gap: 0.375rem;
736
+ padding: 0.5rem 1rem;
737
+ background: var(--sf-primary);
738
+ color: var(--sf-primary-text);
739
+ border: none;
740
+ border-radius: var(--sf-radius);
741
+ font-size: 0.875rem;
742
+ font-weight: 600;
743
+ cursor: pointer;
744
+ transition: opacity 0.15s;
745
+ white-space: nowrap;
746
+ }
747
+
748
+ .sf-submit-btn:hover {
749
+ opacity: 0.9;
750
+ }
751
+
752
+ .sf-submit-btn:disabled {
753
+ opacity: 0.5;
754
+ cursor: not-allowed;
755
+ }
756
+
757
+ .sf-submit-form {
758
+ padding: 1rem;
759
+ margin-bottom: 1rem;
760
+ background: var(--sf-bg-secondary);
761
+ border: 1px solid var(--sf-border);
762
+ border-radius: var(--sf-radius);
763
+ display: flex;
764
+ flex-direction: column;
765
+ gap: 0.5rem;
766
+ }
767
+
768
+ .sf-input,
769
+ .sf-textarea {
770
+ width: 100%;
771
+ padding: 0.5rem 0.75rem;
772
+ background: var(--sf-bg);
773
+ border: 1px solid var(--sf-border);
774
+ border-radius: 0.375rem;
775
+ color: var(--sf-text);
776
+ font-size: 0.875rem;
777
+ font-family: inherit;
778
+ resize: vertical;
779
+ box-sizing: border-box;
780
+ }
781
+
782
+ .sf-input:focus,
783
+ .sf-textarea:focus {
784
+ outline: none;
785
+ border-color: var(--sf-primary);
786
+ }
787
+
788
+ .sf-input-sm {
789
+ flex: 1;
790
+ }
791
+
792
+ .sf-submit-row {
793
+ display: flex;
794
+ gap: 0.5rem;
795
+ align-items: center;
796
+ }
797
+
798
+ .sf-submit-row .sf-select {
799
+ width: auto;
800
+ min-width: 120px;
801
+ }
802
+
803
+ .sf-success {
804
+ color: var(--sf-success);
805
+ text-align: center;
806
+ padding: 0.5rem;
807
+ font-weight: 600;
808
+ }
809
+
810
+ /* Responsive */
811
+ @media (max-width: 768px) {
812
+ .sf-board-layout {
813
+ flex-direction: column;
814
+ }
815
+
816
+ .sf-board-sidebar {
817
+ width: 100%;
818
+ flex-direction: row;
819
+ flex-wrap: wrap;
820
+ gap: 0.25rem;
821
+ }
822
+
823
+ .sf-sidebar-divider {
824
+ display: none;
825
+ }
826
+
827
+ .sf-sidebar-section {
828
+ display: flex;
829
+ align-items: center;
830
+ gap: 0.5rem;
831
+ }
832
+
833
+ .sf-sidebar-label {
834
+ margin: 0;
835
+ white-space: nowrap;
836
+ }
837
+
838
+ .sf-submit-row {
839
+ flex-wrap: wrap;
840
+ }
841
+ }
842
+ </style>
@@ -0,0 +1,19 @@
1
+ interface Props {
2
+ apiUrl: string;
3
+ apiKey: string;
4
+ /** Category labels and icons — order determines sidebar order */
5
+ categoryConfig?: Record<string, {
6
+ label?: string;
7
+ icon?: string;
8
+ description?: string;
9
+ }>;
10
+ /** Title shown at the top */
11
+ title?: string;
12
+ /** Show the submit form inline */
13
+ showSubmit?: boolean;
14
+ /** Available categories for the submit form */
15
+ submitCategories?: string[];
16
+ }
17
+ declare const FeedbackBoard: import("svelte").Component<Props, {}, "">;
18
+ type FeedbackBoard = ReturnType<typeof FeedbackBoard>;
19
+ export default FeedbackBoard;
package/dist/index.d.ts CHANGED
@@ -7,12 +7,13 @@
7
7
  * Usage:
8
8
  * import { StatusPage, FeedbackButton, createStatusClient } from '@alliance-droid/status-feedback-system';
9
9
  */
10
- export type { Project, System, Incident, IncidentUpdate, Feedback, StatusHistoryEntry, SystemStatus, IncidentStatus, IncidentSeverity, FeedbackStatus, ApiResponse, } from './types.js';
10
+ export type { Project, System, Incident, IncidentUpdate, Feedback, FeedbackResponse, FeedbackVisibility, StatusHistoryEntry, SystemStatus, IncidentStatus, IncidentSeverity, FeedbackStatus, ApiResponse, } from './types.js';
11
11
  export { createStatusClient } from './api.js';
12
- export type { StatusClient, StatusClientConfig, StatusData, IncidentData, UptimeData } from './api.js';
12
+ export type { StatusClient, StatusClientConfig, StatusData, IncidentData, UptimeData, BoardData, BoardItem, BoardCategory } from './api.js';
13
13
  export { default as StatusPage } from './components/StatusPage.svelte';
14
14
  export { default as StatusBanner } from './components/StatusBanner.svelte';
15
15
  export { default as FeedbackForm } from './components/FeedbackForm.svelte';
16
+ export { default as FeedbackBoard } from './components/FeedbackBoard.svelte';
16
17
  export { default as FeedbackButton } from './components/FeedbackButton.svelte';
17
18
  export { default as StatusIndicator } from './components/shared/StatusIndicator.svelte';
18
19
  export { default as UptimeBar } from './components/shared/UptimeBar.svelte';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { createStatusClient } from './api.js';
13
13
  export { default as StatusPage } from './components/StatusPage.svelte';
14
14
  export { default as StatusBanner } from './components/StatusBanner.svelte';
15
15
  export { default as FeedbackForm } from './components/FeedbackForm.svelte';
16
+ export { default as FeedbackBoard } from './components/FeedbackBoard.svelte';
16
17
  export { default as FeedbackButton } from './components/FeedbackButton.svelte';
17
18
  // ── Shared Components ──
18
19
  export { default as StatusIndicator } from './components/shared/StatusIndicator.svelte';
package/dist/types.d.ts CHANGED
@@ -42,9 +42,18 @@ export interface IncidentUpdate {
42
42
  message: string;
43
43
  createdAt: number;
44
44
  }
45
+ export type FeedbackVisibility = 'public' | 'private';
46
+ export interface FeedbackResponse {
47
+ id: string;
48
+ feedbackId: string;
49
+ message: string;
50
+ author: string | null;
51
+ createdAt: number;
52
+ }
45
53
  export interface Feedback {
46
54
  id: string;
47
55
  projectId: string;
56
+ title: string | null;
48
57
  category: string | null;
49
58
  message: string;
50
59
  email: string | null;
@@ -52,6 +61,10 @@ export interface Feedback {
52
61
  pageUrl: string | null;
53
62
  metadata: Record<string, unknown> | null;
54
63
  status: FeedbackStatus;
64
+ visibility: FeedbackVisibility;
65
+ votes: number;
66
+ adminResponse: string | null;
67
+ responses?: FeedbackResponse[];
55
68
  createdAt: number;
56
69
  }
57
70
  export interface StatusHistoryEntry {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alliance-droid/status-feedback-system",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Drop-in system status pages and user feedback forms for SvelteKit apps",
6
6
  "svelte": "./dist/index.js",