@alliance-droid/status-feedback-system 1.3.0 → 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 +159 -322
- 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,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, untrack } from 'svelte';
|
|
3
3
|
import type { System, Incident, StatusHistoryEntry, 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
|
import SystemCard from './shared/SystemCard.svelte';
|
|
7
8
|
import IncidentTimeline from './shared/IncidentTimeline.svelte';
|
|
@@ -20,6 +21,8 @@
|
|
|
20
21
|
/** Map of systemId → StatusHistoryEntry[] for uptime bars (pass-through mode) */
|
|
21
22
|
statusHistory?: Record<string, StatusHistoryEntry[]>;
|
|
22
23
|
showUptime?: boolean;
|
|
24
|
+
/** Auto-refresh interval in seconds. 0 = disabled. Default: 0 */
|
|
25
|
+
refreshInterval?: number;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
let {
|
|
@@ -30,6 +33,7 @@
|
|
|
30
33
|
incidents: incidentsProp,
|
|
31
34
|
statusHistory: statusHistoryProp,
|
|
32
35
|
showUptime = true,
|
|
36
|
+
refreshInterval = 0,
|
|
33
37
|
}: Props = $props();
|
|
34
38
|
|
|
35
39
|
// ── State ──
|
|
@@ -41,12 +45,26 @@
|
|
|
41
45
|
|
|
42
46
|
// ── Self-fetching mode ──
|
|
43
47
|
const usesApi = $derived(!!apiUrl && !!apiKey && !systemsProp);
|
|
48
|
+
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
let cachedClient: StatusClient | null = null;
|
|
51
|
+
let cachedApiUrl = '';
|
|
52
|
+
let cachedApiKey = '';
|
|
53
|
+
|
|
54
|
+
function getClient(): StatusClient {
|
|
55
|
+
if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== apiKey) {
|
|
56
|
+
cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: apiKey! });
|
|
57
|
+
cachedApiUrl = apiUrl!;
|
|
58
|
+
cachedApiKey = apiKey!;
|
|
59
|
+
}
|
|
60
|
+
return cachedClient;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchData() {
|
|
46
64
|
if (!usesApi) return;
|
|
47
65
|
|
|
48
66
|
try {
|
|
49
|
-
const client =
|
|
67
|
+
const client = getClient();
|
|
50
68
|
|
|
51
69
|
const [statusData, incidentData, uptimeData] = await Promise.all([
|
|
52
70
|
client.getStatus(),
|
|
@@ -57,11 +75,24 @@
|
|
|
57
75
|
systems = statusData.systems;
|
|
58
76
|
incidents = incidentData.incidents;
|
|
59
77
|
statusHistory = uptimeData.history;
|
|
78
|
+
error = '';
|
|
60
79
|
} catch (err) {
|
|
61
80
|
error = err instanceof Error ? err.message : 'Failed to load status data';
|
|
62
81
|
} finally {
|
|
63
82
|
loading = false;
|
|
64
83
|
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onMount(() => {
|
|
87
|
+
fetchData();
|
|
88
|
+
|
|
89
|
+
if (refreshInterval > 0) {
|
|
90
|
+
refreshTimer = setInterval(fetchData, refreshInterval * 1000);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return () => {
|
|
94
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
95
|
+
};
|
|
65
96
|
});
|
|
66
97
|
|
|
67
98
|
// ── Sync props when in pass-through mode ──
|
|
@@ -77,13 +108,7 @@
|
|
|
77
108
|
|
|
78
109
|
// ── Derived state ──
|
|
79
110
|
|
|
80
|
-
let overallStatus = $derived
|
|
81
|
-
if (systems.length === 0) return 'operational';
|
|
82
|
-
if (systems.some((s) => s.status === 'outage')) return 'outage';
|
|
83
|
-
if (systems.some((s) => s.status === 'degraded')) return 'degraded';
|
|
84
|
-
if (systems.some((s) => s.status === 'maintenance')) return 'maintenance';
|
|
85
|
-
return 'operational';
|
|
86
|
-
});
|
|
111
|
+
let overallStatus = $derived(deriveOverallStatus(systems));
|
|
87
112
|
|
|
88
113
|
const overallMessages: Record<SystemStatus, string> = {
|
|
89
114
|
operational: 'All Systems Operational',
|
|
@@ -128,6 +153,14 @@
|
|
|
128
153
|
<div class="rounded-lg border border-red-500/30 bg-red-500/10 p-6 text-center">
|
|
129
154
|
<i class="fa-solid fa-circle-exclamation mb-2 text-2xl text-red-500"></i>
|
|
130
155
|
<p class="text-sm text-red-400">{error}</p>
|
|
156
|
+
{#if usesApi}
|
|
157
|
+
<button
|
|
158
|
+
onclick={() => { loading = true; fetchData(); }}
|
|
159
|
+
class="mt-3 rounded-md bg-surface-hover px-4 py-2 text-sm text-foreground-secondary transition-colors hover:bg-border"
|
|
160
|
+
>
|
|
161
|
+
<i class="fa-solid fa-rotate-right mr-1"></i> Retry
|
|
162
|
+
</button>
|
|
163
|
+
{/if}
|
|
131
164
|
</div>
|
|
132
165
|
{:else}
|
|
133
166
|
<!-- Overall Status Banner -->
|
|
@@ -13,6 +13,8 @@ interface Props {
|
|
|
13
13
|
/** Map of systemId → StatusHistoryEntry[] for uptime bars (pass-through mode) */
|
|
14
14
|
statusHistory?: Record<string, StatusHistoryEntry[]>;
|
|
15
15
|
showUptime?: boolean;
|
|
16
|
+
/** Auto-refresh interval in seconds. 0 = disabled. Default: 0 */
|
|
17
|
+
refreshInterval?: number;
|
|
16
18
|
}
|
|
17
19
|
declare const StatusPage: import("svelte").Component<Props, {}, "">;
|
|
18
20
|
type StatusPage = ReturnType<typeof StatusPage>;
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<!-- Header -->
|
|
26
26
|
<div class="flex items-center gap-2 flex-wrap">
|
|
27
27
|
{#if item.category}
|
|
28
|
-
<i class="{board.getCategoryIcon(item.category)} text-foreground-secondary"></i>
|
|
28
|
+
<i class="{board.getCategoryIcon(item.category)} text-foreground-secondary" aria-hidden="true"></i>
|
|
29
29
|
{/if}
|
|
30
30
|
<Badge variant={getStatusBadgeVariant(item.status)} size="sm">
|
|
31
31
|
{item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
<span class="text-xs text-foreground-tertiary">{timeAgo(item.createdAt)}</span>
|
|
34
34
|
{#if item.responseCount > 0}
|
|
35
35
|
<span class="text-xs text-foreground-tertiary flex items-center gap-1">
|
|
36
|
-
<i class="fa-solid fa-comment"></i> {item.responseCount}
|
|
36
|
+
<i class="fa-solid fa-comment" aria-hidden="true"></i> {item.responseCount}
|
|
37
37
|
</span>
|
|
38
38
|
{/if}
|
|
39
39
|
</div>
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
<p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{item.message}</p>
|
|
46
46
|
|
|
47
47
|
<!-- Votes -->
|
|
48
|
-
<div class="flex items-center gap-2 text-sm text-foreground-tertiary">
|
|
49
|
-
<i class="fa-solid fa-arrow-up"></i>
|
|
48
|
+
<div class="flex items-center gap-2 text-sm text-foreground-tertiary" aria-label="{item.votes} votes">
|
|
49
|
+
<i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
|
|
50
50
|
<span class="font-semibold">{item.votes}</span>
|
|
51
51
|
<span>votes</span>
|
|
52
52
|
</div>
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
{#if item.adminResponse}
|
|
56
56
|
<div class="rounded-lg bg-primary/5 border border-primary/20 p-3">
|
|
57
57
|
<p class="text-xs font-semibold text-primary mb-1">
|
|
58
|
-
<i class="fa-solid fa-shield mr-1"></i> Official Response
|
|
58
|
+
<i class="fa-solid fa-shield mr-1" aria-hidden="true"></i> Official Response
|
|
59
59
|
</p>
|
|
60
60
|
<p class="text-sm text-foreground-secondary">{item.adminResponse}</p>
|
|
61
61
|
</div>
|
|
@@ -69,13 +69,13 @@
|
|
|
69
69
|
{:else if responses.length > 0}
|
|
70
70
|
<div class="space-y-3">
|
|
71
71
|
<h4 class="text-sm font-semibold text-foreground">
|
|
72
|
-
<i class="fa-solid fa-comments mr-1"></i> Responses
|
|
72
|
+
<i class="fa-solid fa-comments mr-1" aria-hidden="true"></i> Responses
|
|
73
73
|
</h4>
|
|
74
74
|
{#each responses as resp}
|
|
75
75
|
<div class="rounded-lg bg-surface-secondary p-3">
|
|
76
76
|
<div class="flex items-center justify-between mb-1">
|
|
77
77
|
<span class="text-xs font-semibold text-primary">
|
|
78
|
-
<i class="fa-solid fa-shield mr-1"></i>{resp.author || 'Team'}
|
|
78
|
+
<i class="fa-solid fa-shield mr-1" aria-hidden="true"></i>{resp.author || 'Team'}
|
|
79
79
|
</span>
|
|
80
80
|
<span class="text-[0.6875rem] text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
|
|
81
81
|
</div>
|
|
@@ -13,17 +13,26 @@
|
|
|
13
13
|
let { board, item, onclick }: Props = $props();
|
|
14
14
|
|
|
15
15
|
let isClickable = $derived(!!onclick);
|
|
16
|
+
let voteLabel = $derived.by(() => {
|
|
17
|
+
const current = board.votedItems[item.id];
|
|
18
|
+
if (current === 'up') return 'Remove upvote';
|
|
19
|
+
if (current === 'down') return 'Remove downvote';
|
|
20
|
+
return undefined;
|
|
21
|
+
});
|
|
16
22
|
</script>
|
|
17
23
|
|
|
18
|
-
<!-- svelte-ignore
|
|
19
|
-
<
|
|
24
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
25
|
+
<article
|
|
20
26
|
class="rounded-xl border border-border bg-surface transition-colors {isClickable ? 'cursor-pointer hover:bg-surface-hover' : ''}"
|
|
27
|
+
role={isClickable ? 'button' : undefined}
|
|
28
|
+
tabindex={isClickable ? 0 : undefined}
|
|
29
|
+
aria-label={isClickable ? (item.title || item.message.slice(0, 80)) : undefined}
|
|
21
30
|
onclick={() => onclick?.(item)}
|
|
22
31
|
onkeydown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onclick?.(item); } }}
|
|
23
32
|
>
|
|
24
33
|
<div class="flex gap-3 p-4">
|
|
25
34
|
<!-- Vote column -->
|
|
26
|
-
<div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]">
|
|
35
|
+
<div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]" role="group" aria-label="Vote on this feedback">
|
|
27
36
|
<!-- svelte-ignore a11y_click_events_have_key_events — IconButton handles keyboard -->
|
|
28
37
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
29
38
|
<div onclick={(e) => e.stopPropagation()}>
|
|
@@ -31,12 +40,17 @@
|
|
|
31
40
|
size="sm"
|
|
32
41
|
variant={board.votedItems[item.id] === 'up' ? 'primary' : 'neutral'}
|
|
33
42
|
onclick={() => board.vote(item.id, 'up')}
|
|
34
|
-
label=
|
|
43
|
+
label={board.votedItems[item.id] === 'up' ? 'Remove upvote' : 'Upvote'}
|
|
35
44
|
>
|
|
36
45
|
<i class="fa-solid fa-chevron-up text-xs"></i>
|
|
37
46
|
</IconButton>
|
|
38
47
|
</div>
|
|
39
|
-
<span class="text-sm font-bold tabular-nums">{item.votes}</span>
|
|
48
|
+
<span class="text-sm font-bold tabular-nums" aria-label="{item.votes} votes">{item.votes}</span>
|
|
49
|
+
{#if board.voteErrors[item.id]}
|
|
50
|
+
<span class="text-[0.625rem] text-red-400" role="alert">
|
|
51
|
+
<i class="fa-solid fa-exclamation-circle" aria-hidden="true"></i>
|
|
52
|
+
</span>
|
|
53
|
+
{/if}
|
|
40
54
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
41
55
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
42
56
|
<div onclick={(e) => e.stopPropagation()}>
|
|
@@ -44,7 +58,7 @@
|
|
|
44
58
|
size="sm"
|
|
45
59
|
variant={board.votedItems[item.id] === 'down' ? 'primary' : 'neutral'}
|
|
46
60
|
onclick={() => board.vote(item.id, 'down')}
|
|
47
|
-
label=
|
|
61
|
+
label={board.votedItems[item.id] === 'down' ? 'Remove downvote' : 'Downvote'}
|
|
48
62
|
>
|
|
49
63
|
<i class="fa-solid fa-chevron-down text-xs"></i>
|
|
50
64
|
</IconButton>
|
|
@@ -55,7 +69,7 @@
|
|
|
55
69
|
<div class="flex-1 min-w-0">
|
|
56
70
|
<div class="flex items-center gap-2 flex-wrap">
|
|
57
71
|
{#if item.category}
|
|
58
|
-
<i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-secondary"></i>
|
|
72
|
+
<i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-secondary" aria-hidden="true"></i>
|
|
59
73
|
{/if}
|
|
60
74
|
<h3 class="text-[0.9375rem] font-semibold flex-1 leading-snug">
|
|
61
75
|
{item.title || item.message.slice(0, 80)}
|
|
@@ -72,20 +86,20 @@
|
|
|
72
86
|
<div class="flex items-center gap-3 mt-2 text-[0.8125rem] text-foreground-tertiary">
|
|
73
87
|
<span>{timeAgo(item.createdAt)}</span>
|
|
74
88
|
{#if item.responseCount > 0}
|
|
75
|
-
<span class="flex items-center gap-1">
|
|
76
|
-
<i class="fa-solid fa-comment"></i>
|
|
77
|
-
{item.responseCount}
|
|
89
|
+
<span class="flex items-center gap-1" aria-label="{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}">
|
|
90
|
+
<i class="fa-solid fa-comment" aria-hidden="true"></i>
|
|
91
|
+
{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}
|
|
78
92
|
</span>
|
|
79
93
|
{/if}
|
|
80
94
|
{#if item.adminResponse}
|
|
81
95
|
<span class="text-xs text-primary">
|
|
82
|
-
<i class="fa-solid fa-shield"></i> Official response
|
|
96
|
+
<i class="fa-solid fa-shield" aria-hidden="true"></i> Official response
|
|
83
97
|
</span>
|
|
84
98
|
{/if}
|
|
85
99
|
</div>
|
|
86
100
|
</div>
|
|
87
101
|
</div>
|
|
88
|
-
</
|
|
102
|
+
</article>
|
|
89
103
|
|
|
90
104
|
<style>
|
|
91
105
|
.line-clamp-2 {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Spinner } from '@alliance-droid/svelte-component-library';
|
|
2
|
+
import { Button, Spinner } from '@alliance-droid/svelte-component-library';
|
|
3
3
|
import type { BoardItem } from '../../api.js';
|
|
4
4
|
import type { FeedbackBoardState } from './state.svelte.js';
|
|
5
5
|
import FeedbackItem from './FeedbackItem.svelte';
|
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
<div class="text-center py-12 text-foreground-tertiary">
|
|
23
23
|
<i class="fa-solid fa-exclamation-triangle text-2xl"></i>
|
|
24
24
|
<p class="mt-2">{board.error}</p>
|
|
25
|
+
<button
|
|
26
|
+
onclick={() => board.reload()}
|
|
27
|
+
class="mt-3 rounded-md bg-surface-hover px-4 py-2 text-sm text-foreground-secondary transition-colors hover:bg-border"
|
|
28
|
+
>
|
|
29
|
+
<i class="fa-solid fa-rotate-right mr-1"></i> Retry
|
|
30
|
+
</button>
|
|
25
31
|
</div>
|
|
26
32
|
{:else if board.items.length === 0}
|
|
27
33
|
<div class="text-center py-12 text-foreground-tertiary">
|
|
@@ -35,4 +41,18 @@
|
|
|
35
41
|
<FeedbackItem {board} {item} onclick={onSelect ? () => onSelect(item) : undefined} />
|
|
36
42
|
{/each}
|
|
37
43
|
</div>
|
|
44
|
+
|
|
45
|
+
{#if board.hasMore}
|
|
46
|
+
<div class="mt-4 text-center">
|
|
47
|
+
<Button
|
|
48
|
+
variant="neutral"
|
|
49
|
+
style="outline"
|
|
50
|
+
disabled={board.loadingMore}
|
|
51
|
+
loading={board.loadingMore}
|
|
52
|
+
onclick={() => board.loadMore()}
|
|
53
|
+
>
|
|
54
|
+
Load More ({board.items.length} of {board.total})
|
|
55
|
+
</Button>
|
|
56
|
+
</div>
|
|
57
|
+
{/if}
|
|
38
58
|
{/if}
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
];
|
|
21
21
|
</script>
|
|
22
22
|
|
|
23
|
-
<aside class="flex flex-col gap-1">
|
|
23
|
+
<aside class="flex flex-col gap-1" aria-label="Filter feedback">
|
|
24
24
|
<button
|
|
25
25
|
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
|
|
26
26
|
{board.activeCategory === null ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
|
|
27
27
|
onclick={() => (board.activeCategory = null)}
|
|
28
28
|
>
|
|
29
|
-
<i class="fa-solid fa-layer-group"></i>
|
|
29
|
+
<i class="fa-solid fa-layer-group" aria-hidden="true"></i>
|
|
30
30
|
<span>All</span>
|
|
31
31
|
<span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{board.total}</span>
|
|
32
32
|
</button>
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
{board.activeCategory === cat.name ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
|
|
38
38
|
onclick={() => (board.activeCategory = board.activeCategory === cat.name ? null : cat.name)}
|
|
39
39
|
>
|
|
40
|
-
<i class={board.getCategoryIcon(cat.name)}></i>
|
|
40
|
+
<i class={board.getCategoryIcon(cat.name)} aria-hidden="true"></i>
|
|
41
41
|
<span>{board.getCategoryLabel(cat.name)}</span>
|
|
42
42
|
<span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{cat.count}</span>
|
|
43
43
|
</button>
|
|
@@ -16,6 +16,8 @@ export interface FeedbackBoardOptions {
|
|
|
16
16
|
apiUrl: string;
|
|
17
17
|
apiKey: string;
|
|
18
18
|
categoryConfig?: Record<string, CategoryConfig>;
|
|
19
|
+
/** Items per page. Default: 50 */
|
|
20
|
+
pageSize?: number;
|
|
19
21
|
}
|
|
20
22
|
export interface FeedbackBoardState {
|
|
21
23
|
readonly client: StatusClient;
|
|
@@ -24,12 +26,16 @@ export interface FeedbackBoardState {
|
|
|
24
26
|
readonly categories: BoardCategory[];
|
|
25
27
|
readonly total: number;
|
|
26
28
|
readonly loading: boolean;
|
|
29
|
+
readonly loadingMore: boolean;
|
|
27
30
|
readonly error: string;
|
|
31
|
+
readonly hasMore: boolean;
|
|
28
32
|
activeCategory: string | null;
|
|
29
33
|
statusFilter: string;
|
|
30
34
|
sortBy: string;
|
|
31
35
|
readonly votedItems: Record<string, 'up' | 'down'>;
|
|
36
|
+
readonly voteErrors: Record<string, string>;
|
|
32
37
|
reload(): Promise<void>;
|
|
38
|
+
loadMore(): Promise<void>;
|
|
33
39
|
vote(itemId: string, direction: 'up' | 'down'): Promise<void>;
|
|
34
40
|
getResponses(itemId: string): Promise<{
|
|
35
41
|
message: string;
|
|
@@ -8,18 +8,20 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { createStatusClient } from '../../api.js';
|
|
10
10
|
export function createFeedbackBoard(options) {
|
|
11
|
-
const { apiUrl, apiKey, categoryConfig = {} } = options;
|
|
11
|
+
const { apiUrl, apiKey, categoryConfig = {}, pageSize = 50 } = options;
|
|
12
12
|
const client = createStatusClient({ apiUrl, apiKey });
|
|
13
13
|
// Reactive state
|
|
14
14
|
let items = $state([]);
|
|
15
15
|
let categories = $state([]);
|
|
16
16
|
let total = $state(0);
|
|
17
17
|
let loading = $state(true);
|
|
18
|
+
let loadingMore = $state(false);
|
|
18
19
|
let error = $state('');
|
|
19
20
|
let activeCategory = $state(null);
|
|
20
21
|
let statusFilter = $state('open');
|
|
21
22
|
let sortBy = $state('votes');
|
|
22
23
|
let votedItems = $state({});
|
|
24
|
+
let voteErrors = $state({});
|
|
23
25
|
// Response cache
|
|
24
26
|
const responseCache = {};
|
|
25
27
|
async function load() {
|
|
@@ -30,6 +32,8 @@ export function createFeedbackBoard(options) {
|
|
|
30
32
|
category: activeCategory || undefined,
|
|
31
33
|
status: statusFilter,
|
|
32
34
|
sort: sortBy,
|
|
35
|
+
limit: pageSize,
|
|
36
|
+
offset: 0,
|
|
33
37
|
});
|
|
34
38
|
items = data.items;
|
|
35
39
|
total = data.total;
|
|
@@ -42,6 +46,28 @@ export function createFeedbackBoard(options) {
|
|
|
42
46
|
loading = false;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
49
|
+
async function loadMore() {
|
|
50
|
+
if (loadingMore || items.length >= total)
|
|
51
|
+
return;
|
|
52
|
+
loadingMore = true;
|
|
53
|
+
try {
|
|
54
|
+
const data = await client.getBoard({
|
|
55
|
+
category: activeCategory || undefined,
|
|
56
|
+
status: statusFilter,
|
|
57
|
+
sort: sortBy,
|
|
58
|
+
limit: pageSize,
|
|
59
|
+
offset: items.length,
|
|
60
|
+
});
|
|
61
|
+
items = [...items, ...data.items];
|
|
62
|
+
total = data.total;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Silently fail — user can retry
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
loadingMore = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
45
71
|
// Auto-reload when filters change
|
|
46
72
|
$effect(() => {
|
|
47
73
|
void activeCategory;
|
|
@@ -51,6 +77,11 @@ export function createFeedbackBoard(options) {
|
|
|
51
77
|
});
|
|
52
78
|
async function vote(itemId, direction) {
|
|
53
79
|
const prev = votedItems[itemId];
|
|
80
|
+
// Clear any prior error for this item
|
|
81
|
+
if (voteErrors[itemId]) {
|
|
82
|
+
const { [itemId]: _, ...rest } = voteErrors;
|
|
83
|
+
voteErrors = rest;
|
|
84
|
+
}
|
|
54
85
|
try {
|
|
55
86
|
const result = await client.vote(itemId, direction);
|
|
56
87
|
const item = items.find((i) => i.id === itemId);
|
|
@@ -65,7 +96,12 @@ export function createFeedbackBoard(options) {
|
|
|
65
96
|
}
|
|
66
97
|
}
|
|
67
98
|
catch {
|
|
68
|
-
|
|
99
|
+
voteErrors = { ...voteErrors, [itemId]: 'Vote failed' };
|
|
100
|
+
// Auto-dismiss after 3 seconds
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
const { [itemId]: _, ...rest } = voteErrors;
|
|
103
|
+
voteErrors = rest;
|
|
104
|
+
}, 3000);
|
|
69
105
|
}
|
|
70
106
|
}
|
|
71
107
|
async function getResponses(itemId) {
|
|
@@ -94,7 +130,9 @@ export function createFeedbackBoard(options) {
|
|
|
94
130
|
get categories() { return categories; },
|
|
95
131
|
get total() { return total; },
|
|
96
132
|
get loading() { return loading; },
|
|
133
|
+
get loadingMore() { return loadingMore; },
|
|
97
134
|
get error() { return error; },
|
|
135
|
+
get hasMore() { return items.length < total; },
|
|
98
136
|
get activeCategory() { return activeCategory; },
|
|
99
137
|
set activeCategory(v) { activeCategory = v; },
|
|
100
138
|
get statusFilter() { return statusFilter; },
|
|
@@ -102,7 +140,9 @@ export function createFeedbackBoard(options) {
|
|
|
102
140
|
get sortBy() { return sortBy; },
|
|
103
141
|
set sortBy(v) { sortBy = v; },
|
|
104
142
|
get votedItems() { return votedItems; },
|
|
143
|
+
get voteErrors() { return voteErrors; },
|
|
105
144
|
reload: load,
|
|
145
|
+
loadMore,
|
|
106
146
|
vote,
|
|
107
147
|
getResponses,
|
|
108
148
|
submitFeedback,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
boardUrl?: string;
|
|
6
|
+
onReportBug: () => void;
|
|
7
|
+
onRequestFeature: () => void;
|
|
8
|
+
onViewBoard?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { x, y, boardUrl, onReportBug, onRequestFeature, onViewBoard }: Props = $props();
|
|
12
|
+
|
|
13
|
+
let adjustedX = $derived(Math.min(x, (typeof window !== 'undefined' ? window.innerWidth : 800) - 200));
|
|
14
|
+
let adjustedY = $derived(Math.min(y, (typeof window !== 'undefined' ? window.innerHeight : 600) - 160));
|
|
15
|
+
|
|
16
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
17
|
+
const menu = (e.currentTarget as HTMLElement);
|
|
18
|
+
const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
|
19
|
+
const currentIndex = Array.from(items).indexOf(e.target as HTMLElement);
|
|
20
|
+
|
|
21
|
+
if (e.key === 'ArrowDown') {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
24
|
+
items[next]?.focus();
|
|
25
|
+
} else if (e.key === 'ArrowUp') {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
28
|
+
items[prev]?.focus();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<!-- svelte-ignore a11y_no_static_element_interactions — context menu positioned at cursor -->
|
|
34
|
+
<div
|
|
35
|
+
class="fixed z-[99999]"
|
|
36
|
+
style="left: {adjustedX}px; top: {adjustedY}px;"
|
|
37
|
+
onmousedown={(e) => e.stopPropagation()}
|
|
38
|
+
>
|
|
39
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
40
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
41
|
+
<div class="sf-ctx-menu" role="menu" tabindex="-1" aria-label="Feedback actions" onkeydown={handleKeydown}>
|
|
42
|
+
<button class="sf-ctx-item" role="menuitem" onclick={onReportBug}>
|
|
43
|
+
<i class="fa-solid fa-bug text-red-400" aria-hidden="true"></i>
|
|
44
|
+
Report Bug
|
|
45
|
+
</button>
|
|
46
|
+
<button class="sf-ctx-item" role="menuitem" onclick={onRequestFeature}>
|
|
47
|
+
<i class="fa-solid fa-lightbulb text-amber-400" aria-hidden="true"></i>
|
|
48
|
+
Request Feature
|
|
49
|
+
</button>
|
|
50
|
+
{#if boardUrl && onViewBoard}
|
|
51
|
+
<div class="sf-ctx-divider" role="separator">
|
|
52
|
+
<button class="sf-ctx-item sf-ctx-item-secondary" role="menuitem" onclick={onViewBoard}>
|
|
53
|
+
<i class="fa-solid fa-clipboard-list" aria-hidden="true"></i>
|
|
54
|
+
View Open Issues
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
{/if}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<style>
|
|
62
|
+
.sf-ctx-menu {
|
|
63
|
+
background: var(--sf-surface, var(--color-surface, #1e293b));
|
|
64
|
+
border: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
65
|
+
border-radius: 0.5rem;
|
|
66
|
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
|
|
67
|
+
overflow: hidden;
|
|
68
|
+
min-width: 180px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.sf-ctx-item {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 0.625rem;
|
|
75
|
+
width: 100%;
|
|
76
|
+
padding: 0.625rem 0.75rem;
|
|
77
|
+
font-size: 0.875rem;
|
|
78
|
+
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
79
|
+
background: transparent;
|
|
80
|
+
border: none;
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
text-align: left;
|
|
83
|
+
transition: background 0.15s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.sf-ctx-item:hover,
|
|
87
|
+
.sf-ctx-item:focus-visible {
|
|
88
|
+
background: var(--sf-surface-hover, var(--color-surface-hover, #334155));
|
|
89
|
+
outline: none;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.sf-ctx-item-secondary {
|
|
93
|
+
color: var(--sf-foreground-secondary, var(--color-foreground-secondary, #94a3b8));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.sf-ctx-divider {
|
|
97
|
+
border-top: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
98
|
+
}
|
|
99
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
boardUrl?: string;
|
|
5
|
+
onReportBug: () => void;
|
|
6
|
+
onRequestFeature: () => void;
|
|
7
|
+
onViewBoard?: () => void;
|
|
8
|
+
}
|
|
9
|
+
declare const DevContextMenu: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type DevContextMenu = ReturnType<typeof DevContextMenu>;
|
|
11
|
+
export default DevContextMenu;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
onDismiss: () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let { onDismiss }: Props = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div class="fixed bottom-4 right-4 z-[99997] animate-fade-in">
|
|
10
|
+
<div class="sf-toast">
|
|
11
|
+
<i class="fa-solid fa-computer-mouse sf-toast-icon"></i>
|
|
12
|
+
<div>
|
|
13
|
+
<p class="sf-toast-title">Ctrl + Right-click anywhere</p>
|
|
14
|
+
<p class="sf-toast-subtitle">to report bugs or request features</p>
|
|
15
|
+
</div>
|
|
16
|
+
<button class="sf-toast-close" onclick={onDismiss} aria-label="Dismiss">
|
|
17
|
+
<i class="fa-solid fa-xmark"></i>
|
|
18
|
+
</button>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<style>
|
|
23
|
+
@keyframes fade-in {
|
|
24
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
25
|
+
to { opacity: 1; transform: translateY(0); }
|
|
26
|
+
}
|
|
27
|
+
.animate-fade-in {
|
|
28
|
+
animation: fade-in 0.3s ease-out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.sf-toast {
|
|
32
|
+
background: var(--sf-surface, var(--color-surface, #1e293b));
|
|
33
|
+
border: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
34
|
+
border-radius: 0.5rem;
|
|
35
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
36
|
+
padding: 0.75rem 1rem;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 0.75rem;
|
|
40
|
+
max-width: 20rem;
|
|
41
|
+
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.sf-toast-icon {
|
|
45
|
+
color: var(--color-primary, #3b82f6);
|
|
46
|
+
font-size: 1.125rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.sf-toast-title {
|
|
50
|
+
font-size: 0.875rem;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
margin: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.sf-toast-subtitle {
|
|
56
|
+
font-size: 0.75rem;
|
|
57
|
+
color: var(--sf-foreground-secondary, var(--color-foreground-secondary, #94a3b8));
|
|
58
|
+
margin: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.sf-toast-close {
|
|
62
|
+
color: var(--sf-foreground-tertiary, var(--color-foreground-tertiary, #64748b));
|
|
63
|
+
background: transparent;
|
|
64
|
+
border: none;
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
margin-left: 0.5rem;
|
|
67
|
+
transition: color 0.15s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.sf-toast-close:hover {
|
|
71
|
+
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment detection utilities for DevFeedback.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Read an environment variable from common SvelteKit/Vite injection patterns.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getEnvVar(key: string): string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Detect whether DevFeedback should be enabled.
|
|
10
|
+
* Priority: explicit prop > env var > Vercel env > localhost.
|
|
11
|
+
*/
|
|
12
|
+
export declare function detectEnvironment(explicitEnabled?: boolean): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Get a human-readable environment name.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getEnvironmentName(): string;
|
|
17
|
+
/**
|
|
18
|
+
* Get a CSS selector path for a DOM element (for bug context).
|
|
19
|
+
*/
|
|
20
|
+
export declare function getElementSelector(el: HTMLElement | null): string;
|
|
21
|
+
/**
|
|
22
|
+
* Get a truncated outer HTML snippet for a DOM element (for bug context).
|
|
23
|
+
*/
|
|
24
|
+
export declare function getElementHtml(el: HTMLElement | null): string;
|