@alliance-droid/status-feedback-system 2.3.2 → 3.1.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.
@@ -1,117 +1,117 @@
1
- <script lang="ts">
2
- import { Badge, IconButton } from '@alliance-droid/svelte-component-library';
3
- import type { BoardItem } from '../../api.js';
4
- import type { FeedbackBoardState } from './state.svelte.js';
5
- import { timeAgo, getStatusBadgeVariant } from './utils.js';
6
-
7
- interface Props {
8
- board: FeedbackBoardState;
9
- item: BoardItem;
10
- onclick?: (item: BoardItem) => void;
11
- }
12
-
13
- let { board, item, onclick }: Props = $props();
14
-
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
- });
22
- </script>
23
-
24
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
25
- <article
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}
30
- onclick={() => onclick?.(item)}
31
- onkeydown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onclick?.(item); } }}
32
- >
33
- <div class="flex gap-3 p-4">
34
- <!-- Vote column -->
35
- <div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]" role="group" aria-label="Vote on this feedback">
36
- <div role="presentation" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
37
- <IconButton
38
- size="sm"
39
- variant={board.votedItems[item.id] === 'up' ? 'primary' : 'neutral'}
40
- onclick={() => board.vote(item.id, 'up')}
41
- label={board.votedItems[item.id] === 'up' ? 'Remove upvote' : 'Upvote'}
42
- >
43
- <i class="fa-solid fa-chevron-up text-xs"></i>
44
- </IconButton>
45
- </div>
46
- <span class="text-sm font-bold tabular-nums" aria-label="{item.votes} votes">{item.votes}</span>
47
- {#if board.voteErrors[item.id]}
48
- <span class="text-[0.625rem] text-red-400" role="alert">
49
- <i class="fa-solid fa-exclamation-circle" aria-hidden="true"></i>
50
- </span>
51
- {/if}
52
- <div role="presentation" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
53
- <IconButton
54
- size="sm"
55
- variant={board.votedItems[item.id] === 'down' ? 'primary' : 'neutral'}
56
- onclick={() => board.vote(item.id, 'down')}
57
- label={board.votedItems[item.id] === 'down' ? 'Remove downvote' : 'Downvote'}
58
- >
59
- <i class="fa-solid fa-chevron-down text-xs"></i>
60
- </IconButton>
61
- </div>
62
- </div>
63
-
64
- <!-- Content -->
65
- <div class="flex-1 min-w-0">
66
- <div class="flex items-center gap-2 flex-wrap">
67
- {#if item.category}
68
- <i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-secondary" aria-hidden="true"></i>
69
- {/if}
70
- <h3 class="text-[0.9375rem] font-semibold flex-1 leading-snug">
71
- {item.title || item.message.slice(0, 80)}
72
- </h3>
73
- <Badge variant={getStatusBadgeVariant(item.status)} size="sm">
74
- {item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
75
- </Badge>
76
- </div>
77
-
78
- {#if item.title}
79
- <p class="text-sm text-foreground-secondary mt-1.5 leading-relaxed line-clamp-2">{item.message}</p>
80
- {/if}
81
-
82
- {#if item.email}
83
- <p class="text-[0.8125rem] text-foreground-tertiary mt-1.5 flex items-center gap-1">
84
- <i class="fa-solid fa-envelope" aria-hidden="true"></i>
85
- {item.email}
86
- </p>
87
- {/if}
88
-
89
- <div class="flex items-center justify-between mt-2 text-[0.8125rem] text-foreground-tertiary">
90
- <div class="flex items-center gap-3">
91
- <span>{timeAgo(item.createdAt)}</span>
92
- {#if item.adminResponse}
93
- <span class="text-xs text-primary">
94
- <i class="fa-solid fa-shield" aria-hidden="true"></i> Official response
95
- </span>
96
- {/if}
97
- </div>
98
- {#if item.responseCount > 0}
99
- <span class="flex items-center gap-1" aria-label="{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}">
100
- <i class="fa-solid fa-comment" aria-hidden="true"></i>
101
- {item.responseCount}
102
- </span>
103
- {/if}
104
- </div>
105
- </div>
106
- </div>
107
- </article>
108
-
109
- <style>
110
- .line-clamp-2 {
111
- display: -webkit-box;
112
- -webkit-line-clamp: 2;
113
- line-clamp: 2;
114
- -webkit-box-orient: vertical;
115
- overflow: hidden;
116
- }
117
- </style>
1
+ <script lang="ts">
2
+ import { Badge, IconButton } from '@alliance-droid/svelte-component-library';
3
+ import type { BoardItem } from '../../api.js';
4
+ import type { FeedbackBoardState } from './state.svelte.js';
5
+ import { timeAgo, getStatusBadgeVariant, publicStatusLabel } from './utils.js';
6
+
7
+ interface Props {
8
+ board: FeedbackBoardState;
9
+ item: BoardItem;
10
+ onclick?: (item: BoardItem) => void;
11
+ }
12
+
13
+ let { board, item, onclick }: Props = $props();
14
+
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
+ });
22
+ </script>
23
+
24
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
25
+ <article
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}
30
+ onclick={() => onclick?.(item)}
31
+ onkeydown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onclick?.(item); } }}
32
+ >
33
+ <div class="flex gap-3 p-4">
34
+ <!-- Vote column -->
35
+ <div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]" role="group" aria-label="Vote on this feedback">
36
+ <div role="presentation" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
37
+ <IconButton
38
+ size="sm"
39
+ variant={board.votedItems[item.id] === 'up' ? 'primary' : 'neutral'}
40
+ onclick={() => board.vote(item.id, 'up')}
41
+ label={board.votedItems[item.id] === 'up' ? 'Remove upvote' : 'Upvote'}
42
+ >
43
+ <i class="fa-solid fa-chevron-up text-xs"></i>
44
+ </IconButton>
45
+ </div>
46
+ <span class="text-sm font-bold tabular-nums" aria-label="{item.votes} votes">{item.votes}</span>
47
+ {#if board.voteErrors[item.id]}
48
+ <span class="text-[0.625rem] text-red-400" role="alert">
49
+ <i class="fa-solid fa-exclamation-circle" aria-hidden="true"></i>
50
+ </span>
51
+ {/if}
52
+ <div role="presentation" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
53
+ <IconButton
54
+ size="sm"
55
+ variant={board.votedItems[item.id] === 'down' ? 'primary' : 'neutral'}
56
+ onclick={() => board.vote(item.id, 'down')}
57
+ label={board.votedItems[item.id] === 'down' ? 'Remove downvote' : 'Downvote'}
58
+ >
59
+ <i class="fa-solid fa-chevron-down text-xs"></i>
60
+ </IconButton>
61
+ </div>
62
+ </div>
63
+
64
+ <!-- Content -->
65
+ <div class="flex-1 min-w-0">
66
+ <div class="flex items-center gap-2 flex-wrap">
67
+ {#if item.category}
68
+ <i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-secondary" aria-hidden="true"></i>
69
+ {/if}
70
+ <h3 class="text-[0.9375rem] font-semibold flex-1 leading-snug">
71
+ {item.title || item.message.slice(0, 80)}
72
+ </h3>
73
+ <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
74
+ {publicStatusLabel(item.publicStatus)}
75
+ </Badge>
76
+ </div>
77
+
78
+ {#if item.title}
79
+ <p class="text-sm text-foreground-secondary mt-1.5 leading-relaxed line-clamp-2">{item.message}</p>
80
+ {/if}
81
+
82
+ {#if item.email}
83
+ <p class="text-[0.8125rem] text-foreground-tertiary mt-1.5 flex items-center gap-1">
84
+ <i class="fa-solid fa-envelope" aria-hidden="true"></i>
85
+ {item.email}
86
+ </p>
87
+ {/if}
88
+
89
+ <div class="flex items-center justify-between mt-2 text-[0.8125rem] text-foreground-tertiary">
90
+ <div class="flex items-center gap-3">
91
+ <span>{timeAgo(item.createdAt)}</span>
92
+ {#if item.adminResponse}
93
+ <span class="text-xs text-primary">
94
+ <i class="fa-solid fa-shield" aria-hidden="true"></i> Official response
95
+ </span>
96
+ {/if}
97
+ </div>
98
+ {#if item.responseCount > 0}
99
+ <span class="flex items-center gap-1" aria-label="{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}">
100
+ <i class="fa-solid fa-comment" aria-hidden="true"></i>
101
+ {item.responseCount}
102
+ </span>
103
+ {/if}
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </article>
108
+
109
+ <style>
110
+ .line-clamp-2 {
111
+ display: -webkit-box;
112
+ -webkit-line-clamp: 2;
113
+ line-clamp: 2;
114
+ -webkit-box-orient: vertical;
115
+ overflow: hidden;
116
+ }
117
+ </style>
@@ -1,58 +1,58 @@
1
- <script lang="ts">
2
- import { Button, Spinner } from '@alliance-droid/svelte-component-library';
3
- import type { BoardItem } from '../../api.js';
4
- import type { FeedbackBoardState } from './state.svelte.js';
5
- import FeedbackItem from './FeedbackItem.svelte';
6
-
7
- interface Props {
8
- board: FeedbackBoardState;
9
- /** Called when an item is clicked. If not provided, items are not clickable. */
10
- onSelect?: (item: BoardItem) => void;
11
- }
12
-
13
- let { board, onSelect }: Props = $props();
14
- </script>
15
-
16
- {#if board.loading}
17
- <div class="text-center py-12 text-foreground-tertiary">
18
- <Spinner />
19
- <p class="mt-2">Loading...</p>
20
- </div>
21
- {:else if board.error}
22
- <div class="text-center py-12 text-foreground-tertiary">
23
- <i class="fa-solid fa-exclamation-triangle text-2xl"></i>
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>
31
- </div>
32
- {:else if board.items.length === 0}
33
- <div class="text-center py-12 text-foreground-tertiary">
34
- <i class="fa-solid fa-inbox text-3xl"></i>
35
- <p class="mt-2">No feedback yet</p>
36
- <p class="text-sm">Be the first to submit!</p>
37
- </div>
38
- {:else}
39
- <div class="flex flex-col gap-2">
40
- {#each board.items as item (item.id)}
41
- <FeedbackItem {board} {item} onclick={onSelect ? () => onSelect(item) : undefined} />
42
- {/each}
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}
58
- {/if}
1
+ <script lang="ts">
2
+ import { Button, Spinner } from '@alliance-droid/svelte-component-library';
3
+ import type { BoardItem } from '../../api.js';
4
+ import type { FeedbackBoardState } from './state.svelte.js';
5
+ import FeedbackItem from './FeedbackItem.svelte';
6
+
7
+ interface Props {
8
+ board: FeedbackBoardState;
9
+ /** Called when an item is clicked. If not provided, items are not clickable. */
10
+ onSelect?: (item: BoardItem) => void;
11
+ }
12
+
13
+ let { board, onSelect }: Props = $props();
14
+ </script>
15
+
16
+ {#if board.loading}
17
+ <div class="text-center py-12 text-foreground-tertiary">
18
+ <Spinner />
19
+ <p class="mt-2">Loading...</p>
20
+ </div>
21
+ {:else if board.error}
22
+ <div class="text-center py-12 text-foreground-tertiary">
23
+ <i class="fa-solid fa-exclamation-triangle text-2xl"></i>
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>
31
+ </div>
32
+ {:else if board.items.length === 0}
33
+ <div class="text-center py-12 text-foreground-tertiary">
34
+ <i class="fa-solid fa-inbox text-3xl"></i>
35
+ <p class="mt-2">No feedback yet</p>
36
+ <p class="text-sm">Be the first to submit!</p>
37
+ </div>
38
+ {:else}
39
+ <div class="flex flex-col gap-2">
40
+ {#each board.items as item (item.id)}
41
+ <FeedbackItem {board} {item} onclick={onSelect ? () => onSelect(item) : undefined} />
42
+ {/each}
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}
58
+ {/if}
@@ -1,60 +1,60 @@
1
- <script lang="ts">
2
- import { Divider, Select } from '@alliance-droid/svelte-component-library';
3
- import type { FeedbackBoardState } from './state.svelte.js';
4
-
5
- interface Props {
6
- board: FeedbackBoardState;
7
- }
8
-
9
- let { board }: Props = $props();
10
-
11
- const statusOptions = [
12
- { value: 'open', label: 'Open' },
13
- { value: 'resolved', label: 'Resolved' },
14
- { value: 'all', label: 'All' },
15
- ];
16
-
17
- const sortOptions = [
18
- { value: 'votes', label: 'Most Votes' },
19
- { value: 'newest', label: 'Newest' },
20
- ];
21
- </script>
22
-
23
- <aside class="flex flex-col gap-1" aria-label="Filter feedback">
24
- <button
25
- class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
26
- {board.activeCategory === null ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
27
- onclick={() => (board.activeCategory = null)}
28
- >
29
- <i class="fa-solid fa-layer-group" aria-hidden="true"></i>
30
- <span>All</span>
31
- <span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{board.total}</span>
32
- </button>
33
-
34
- {#each board.categories as cat (cat.name)}
35
- <button
36
- class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
37
- {board.activeCategory === cat.name ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
38
- onclick={() => (board.activeCategory = board.activeCategory === cat.name ? null : cat.name)}
39
- >
40
- <i class={board.getCategoryIcon(cat.name)} aria-hidden="true"></i>
41
- <span>{board.getCategoryLabel(cat.name)}</span>
42
- <span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{cat.count}</span>
43
- </button>
44
- {/each}
45
-
46
- <Divider class="my-2" />
47
-
48
- <div class="px-3 space-y-3">
49
- <Select
50
- label="Status"
51
- options={statusOptions}
52
- bind:value={board.statusFilter}
53
- />
54
- <Select
55
- label="Sort By"
56
- options={sortOptions}
57
- bind:value={board.sortBy}
58
- />
59
- </div>
60
- </aside>
1
+ <script lang="ts">
2
+ import { Divider, Select } from '@alliance-droid/svelte-component-library';
3
+ import type { FeedbackBoardState } from './state.svelte.js';
4
+
5
+ interface Props {
6
+ board: FeedbackBoardState;
7
+ }
8
+
9
+ let { board }: Props = $props();
10
+
11
+ const statusOptions = [
12
+ { value: 'open', label: 'Open' },
13
+ { value: 'resolved', label: 'Done' },
14
+ { value: 'all', label: 'All' },
15
+ ];
16
+
17
+ const sortOptions = [
18
+ { value: 'votes', label: 'Most Votes' },
19
+ { value: 'newest', label: 'Newest' },
20
+ ];
21
+ </script>
22
+
23
+ <aside class="flex flex-col gap-1" aria-label="Filter feedback">
24
+ <button
25
+ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
26
+ {board.activeCategory === null ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
27
+ onclick={() => (board.activeCategory = null)}
28
+ >
29
+ <i class="fa-solid fa-layer-group" aria-hidden="true"></i>
30
+ <span>All</span>
31
+ <span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{board.total}</span>
32
+ </button>
33
+
34
+ {#each board.categories as cat (cat.name)}
35
+ <button
36
+ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
37
+ {board.activeCategory === cat.name ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
38
+ onclick={() => (board.activeCategory = board.activeCategory === cat.name ? null : cat.name)}
39
+ >
40
+ <i class={board.getCategoryIcon(cat.name)} aria-hidden="true"></i>
41
+ <span>{board.getCategoryLabel(cat.name)}</span>
42
+ <span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{cat.count}</span>
43
+ </button>
44
+ {/each}
45
+
46
+ <Divider class="my-2" />
47
+
48
+ <div class="px-3 space-y-3">
49
+ <Select
50
+ label="Status"
51
+ options={statusOptions}
52
+ bind:value={board.statusFilter}
53
+ />
54
+ <Select
55
+ label="Sort By"
56
+ options={sortOptions}
57
+ bind:value={board.sortBy}
58
+ />
59
+ </div>
60
+ </aside>