@alliance-droid/status-feedback-system 1.1.3 → 1.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/components/board/FeedbackDetail.svelte +87 -0
- package/dist/components/board/FeedbackDetail.svelte.d.ts +9 -0
- package/dist/components/board/FeedbackItem.svelte +98 -0
- package/dist/components/board/FeedbackItem.svelte.d.ts +10 -0
- package/dist/components/board/FeedbackList.svelte +38 -0
- package/dist/components/board/FeedbackList.svelte.d.ts +10 -0
- package/dist/components/board/FeedbackSidebar.svelte +60 -0
- package/dist/components/board/FeedbackSidebar.svelte.d.ts +7 -0
- package/dist/components/board/FeedbackSubmitForm.svelte +100 -0
- package/dist/components/board/FeedbackSubmitForm.svelte.d.ts +9 -0
- package/dist/components/board/index.d.ts +8 -0
- package/dist/components/board/index.js +7 -0
- package/dist/components/board/state.svelte.d.ts +49 -0
- package/dist/components/board/state.svelte.js +119 -0
- package/dist/components/board/utils.d.ts +4 -0
- package/dist/components/board/utils.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { Badge, Spinner } from '@alliance-droid/svelte-component-library';
|
|
4
|
+
import type { BoardItem } from '../../api.js';
|
|
5
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
6
|
+
import { timeAgo, getStatusBadgeVariant } from './utils.js';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
board: FeedbackBoardState;
|
|
10
|
+
item: BoardItem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { board, item }: Props = $props();
|
|
14
|
+
|
|
15
|
+
let responses = $state<{ message: string; author: string | null; createdAt: number }[]>([]);
|
|
16
|
+
let loadingResponses = $state(true);
|
|
17
|
+
|
|
18
|
+
onMount(async () => {
|
|
19
|
+
responses = await board.getResponses(item.id);
|
|
20
|
+
loadingResponses = false;
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<div class="space-y-4">
|
|
25
|
+
<!-- Header -->
|
|
26
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
27
|
+
{#if item.category}
|
|
28
|
+
<i class="{board.getCategoryIcon(item.category)} text-foreground-secondary"></i>
|
|
29
|
+
{/if}
|
|
30
|
+
<Badge variant={getStatusBadgeVariant(item.status)} size="sm">
|
|
31
|
+
{item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
|
32
|
+
</Badge>
|
|
33
|
+
<span class="text-xs text-foreground-tertiary">{timeAgo(item.createdAt)}</span>
|
|
34
|
+
{#if item.responseCount > 0}
|
|
35
|
+
<span class="text-xs text-foreground-tertiary flex items-center gap-1">
|
|
36
|
+
<i class="fa-solid fa-comment"></i> {item.responseCount}
|
|
37
|
+
</span>
|
|
38
|
+
{/if}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- Title + Message -->
|
|
42
|
+
{#if item.title}
|
|
43
|
+
<h3 class="text-lg font-semibold text-foreground">{item.title}</h3>
|
|
44
|
+
{/if}
|
|
45
|
+
<p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{item.message}</p>
|
|
46
|
+
|
|
47
|
+
<!-- Votes -->
|
|
48
|
+
<div class="flex items-center gap-2 text-sm text-foreground-tertiary">
|
|
49
|
+
<i class="fa-solid fa-arrow-up"></i>
|
|
50
|
+
<span class="font-semibold">{item.votes}</span>
|
|
51
|
+
<span>votes</span>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<!-- Admin response -->
|
|
55
|
+
{#if item.adminResponse}
|
|
56
|
+
<div class="rounded-lg bg-primary/5 border border-primary/20 p-3">
|
|
57
|
+
<p class="text-xs font-semibold text-primary mb-1">
|
|
58
|
+
<i class="fa-solid fa-shield mr-1"></i> Official Response
|
|
59
|
+
</p>
|
|
60
|
+
<p class="text-sm text-foreground-secondary">{item.adminResponse}</p>
|
|
61
|
+
</div>
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
<!-- Responses -->
|
|
65
|
+
{#if loadingResponses}
|
|
66
|
+
<div class="text-foreground-tertiary text-sm">
|
|
67
|
+
<Spinner /> Loading responses...
|
|
68
|
+
</div>
|
|
69
|
+
{:else if responses.length > 0}
|
|
70
|
+
<div class="space-y-3">
|
|
71
|
+
<h4 class="text-sm font-semibold text-foreground">
|
|
72
|
+
<i class="fa-solid fa-comments mr-1"></i> Responses
|
|
73
|
+
</h4>
|
|
74
|
+
{#each responses as resp}
|
|
75
|
+
<div class="rounded-lg bg-surface-secondary p-3">
|
|
76
|
+
<div class="flex items-center justify-between mb-1">
|
|
77
|
+
<span class="text-xs font-semibold text-primary">
|
|
78
|
+
<i class="fa-solid fa-shield mr-1"></i>{resp.author || 'Team'}
|
|
79
|
+
</span>
|
|
80
|
+
<span class="text-[0.6875rem] text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
|
|
81
|
+
</div>
|
|
82
|
+
<p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{resp.message}</p>
|
|
83
|
+
</div>
|
|
84
|
+
{/each}
|
|
85
|
+
</div>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { BoardItem } from '../../api.js';
|
|
2
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
board: FeedbackBoardState;
|
|
5
|
+
item: BoardItem;
|
|
6
|
+
}
|
|
7
|
+
declare const FeedbackDetail: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type FeedbackDetail = ReturnType<typeof FeedbackDetail>;
|
|
9
|
+
export default FeedbackDetail;
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<!-- svelte-ignore a11y_no_static_element_interactions — clickable variant uses onclick for item selection, keyboard handled manually -->
|
|
19
|
+
<div
|
|
20
|
+
class="rounded-xl border border-border bg-surface transition-colors {isClickable ? 'cursor-pointer hover:bg-surface-hover' : ''}"
|
|
21
|
+
onclick={() => onclick?.(item)}
|
|
22
|
+
onkeydown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onclick?.(item); } }}
|
|
23
|
+
>
|
|
24
|
+
<div class="flex gap-3 p-4">
|
|
25
|
+
<!-- Vote column -->
|
|
26
|
+
<div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]">
|
|
27
|
+
<!-- svelte-ignore a11y_click_events_have_key_events — IconButton handles keyboard -->
|
|
28
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
29
|
+
<div onclick={(e) => e.stopPropagation()}>
|
|
30
|
+
<IconButton
|
|
31
|
+
size="sm"
|
|
32
|
+
variant={board.votedItems[item.id] === 'up' ? 'primary' : 'neutral'}
|
|
33
|
+
onclick={() => board.vote(item.id, 'up')}
|
|
34
|
+
label="Upvote"
|
|
35
|
+
>
|
|
36
|
+
<i class="fa-solid fa-chevron-up text-xs"></i>
|
|
37
|
+
</IconButton>
|
|
38
|
+
</div>
|
|
39
|
+
<span class="text-sm font-bold tabular-nums">{item.votes}</span>
|
|
40
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
41
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
42
|
+
<div onclick={(e) => e.stopPropagation()}>
|
|
43
|
+
<IconButton
|
|
44
|
+
size="sm"
|
|
45
|
+
variant={board.votedItems[item.id] === 'down' ? 'primary' : 'neutral'}
|
|
46
|
+
onclick={() => board.vote(item.id, 'down')}
|
|
47
|
+
label="Downvote"
|
|
48
|
+
>
|
|
49
|
+
<i class="fa-solid fa-chevron-down text-xs"></i>
|
|
50
|
+
</IconButton>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<!-- Content -->
|
|
55
|
+
<div class="flex-1 min-w-0">
|
|
56
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
57
|
+
{#if item.category}
|
|
58
|
+
<i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-secondary"></i>
|
|
59
|
+
{/if}
|
|
60
|
+
<h3 class="text-[0.9375rem] font-semibold flex-1 leading-snug">
|
|
61
|
+
{item.title || item.message.slice(0, 80)}
|
|
62
|
+
</h3>
|
|
63
|
+
<Badge variant={getStatusBadgeVariant(item.status)} size="sm">
|
|
64
|
+
{item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
|
65
|
+
</Badge>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{#if item.title}
|
|
69
|
+
<p class="text-sm text-foreground-secondary mt-1.5 leading-relaxed line-clamp-2">{item.message}</p>
|
|
70
|
+
{/if}
|
|
71
|
+
|
|
72
|
+
<div class="flex items-center gap-3 mt-2 text-[0.8125rem] text-foreground-tertiary">
|
|
73
|
+
<span>{timeAgo(item.createdAt)}</span>
|
|
74
|
+
{#if item.responseCount > 0}
|
|
75
|
+
<span class="flex items-center gap-1">
|
|
76
|
+
<i class="fa-solid fa-comment"></i>
|
|
77
|
+
{item.responseCount}
|
|
78
|
+
</span>
|
|
79
|
+
{/if}
|
|
80
|
+
{#if item.adminResponse}
|
|
81
|
+
<span class="text-xs text-primary">
|
|
82
|
+
<i class="fa-solid fa-shield"></i> Official response
|
|
83
|
+
</span>
|
|
84
|
+
{/if}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<style>
|
|
91
|
+
.line-clamp-2 {
|
|
92
|
+
display: -webkit-box;
|
|
93
|
+
-webkit-line-clamp: 2;
|
|
94
|
+
line-clamp: 2;
|
|
95
|
+
-webkit-box-orient: vertical;
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BoardItem } from '../../api.js';
|
|
2
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
board: FeedbackBoardState;
|
|
5
|
+
item: BoardItem;
|
|
6
|
+
onclick?: (item: BoardItem) => void;
|
|
7
|
+
}
|
|
8
|
+
declare const FeedbackItem: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type FeedbackItem = ReturnType<typeof FeedbackItem>;
|
|
10
|
+
export default FeedbackItem;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { 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
|
+
</div>
|
|
26
|
+
{:else if board.items.length === 0}
|
|
27
|
+
<div class="text-center py-12 text-foreground-tertiary">
|
|
28
|
+
<i class="fa-solid fa-inbox text-3xl"></i>
|
|
29
|
+
<p class="mt-2">No feedback yet</p>
|
|
30
|
+
<p class="text-sm">Be the first to submit!</p>
|
|
31
|
+
</div>
|
|
32
|
+
{:else}
|
|
33
|
+
<div class="flex flex-col gap-2">
|
|
34
|
+
{#each board.items as item (item.id)}
|
|
35
|
+
<FeedbackItem {board} {item} onclick={onSelect ? () => onSelect(item) : undefined} />
|
|
36
|
+
{/each}
|
|
37
|
+
</div>
|
|
38
|
+
{/if}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BoardItem } from '../../api.js';
|
|
2
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
board: FeedbackBoardState;
|
|
5
|
+
/** Called when an item is clicked. If not provided, items are not clickable. */
|
|
6
|
+
onSelect?: (item: BoardItem) => void;
|
|
7
|
+
}
|
|
8
|
+
declare const FeedbackList: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type FeedbackList = ReturnType<typeof FeedbackList>;
|
|
10
|
+
export default FeedbackList;
|
|
@@ -0,0 +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">
|
|
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"></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}
|
|
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)}></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>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
board: FeedbackBoardState;
|
|
4
|
+
}
|
|
5
|
+
declare const FeedbackSidebar: import("svelte").Component<Props, {}, "">;
|
|
6
|
+
type FeedbackSidebar = ReturnType<typeof FeedbackSidebar>;
|
|
7
|
+
export default FeedbackSidebar;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Card, Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
|
|
3
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
board: FeedbackBoardState;
|
|
7
|
+
/** Available category options for the dropdown */
|
|
8
|
+
categories?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { board, categories = [] }: Props = $props();
|
|
12
|
+
|
|
13
|
+
let title = $state('');
|
|
14
|
+
let message = $state('');
|
|
15
|
+
let category = $state('');
|
|
16
|
+
let email = $state('');
|
|
17
|
+
let submitting = $state(false);
|
|
18
|
+
let success = $state(false);
|
|
19
|
+
let error = $state('');
|
|
20
|
+
|
|
21
|
+
let categoryOptions = $derived(
|
|
22
|
+
categories.map((c) => ({ value: c, label: board.getCategoryLabel(c) }))
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
async function handleSubmit() {
|
|
26
|
+
if (!message.trim()) return;
|
|
27
|
+
submitting = true;
|
|
28
|
+
error = '';
|
|
29
|
+
try {
|
|
30
|
+
await board.submitFeedback({
|
|
31
|
+
title: title.trim() || undefined,
|
|
32
|
+
message: message.trim(),
|
|
33
|
+
category: category || undefined,
|
|
34
|
+
email: email.trim() || undefined,
|
|
35
|
+
});
|
|
36
|
+
success = true;
|
|
37
|
+
title = '';
|
|
38
|
+
message = '';
|
|
39
|
+
category = '';
|
|
40
|
+
email = '';
|
|
41
|
+
setTimeout(() => { success = false; }, 2000);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
error = err instanceof Error ? err.message : 'Failed to submit';
|
|
44
|
+
} finally {
|
|
45
|
+
submitting = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<Card variant="outlined" padding="md">
|
|
51
|
+
{#if success}
|
|
52
|
+
<div class="text-center py-2 text-green-400 font-semibold">
|
|
53
|
+
<i class="fa-solid fa-check-circle mr-1"></i> Submitted!
|
|
54
|
+
</div>
|
|
55
|
+
{:else}
|
|
56
|
+
<div class="space-y-3">
|
|
57
|
+
<Input
|
|
58
|
+
bind:value={title}
|
|
59
|
+
placeholder="Title (optional)"
|
|
60
|
+
/>
|
|
61
|
+
<Textarea
|
|
62
|
+
value={message}
|
|
63
|
+
oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
64
|
+
placeholder="Describe your feedback..."
|
|
65
|
+
rows={3}
|
|
66
|
+
/>
|
|
67
|
+
{#if error}
|
|
68
|
+
<p class="text-sm text-red-400">
|
|
69
|
+
<i class="fa-solid fa-exclamation-circle mr-1"></i>
|
|
70
|
+
{error}
|
|
71
|
+
</p>
|
|
72
|
+
{/if}
|
|
73
|
+
<div class="flex gap-2 items-center flex-wrap">
|
|
74
|
+
{#if categoryOptions.length > 0}
|
|
75
|
+
<div class="min-w-[140px]">
|
|
76
|
+
<Select
|
|
77
|
+
options={[{ value: '', label: 'Category...' }, ...categoryOptions]}
|
|
78
|
+
bind:value={category}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
{/if}
|
|
82
|
+
<div class="flex-1">
|
|
83
|
+
<Input
|
|
84
|
+
type="email"
|
|
85
|
+
bind:value={email}
|
|
86
|
+
placeholder="Email (optional)"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
<Button
|
|
90
|
+
variant="primary"
|
|
91
|
+
disabled={submitting || !message.trim()}
|
|
92
|
+
loading={submitting}
|
|
93
|
+
onclick={handleSubmit}
|
|
94
|
+
>
|
|
95
|
+
Submit
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
{/if}
|
|
100
|
+
</Card>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
board: FeedbackBoardState;
|
|
4
|
+
/** Available category options for the dropdown */
|
|
5
|
+
categories?: string[];
|
|
6
|
+
}
|
|
7
|
+
declare const FeedbackSubmitForm: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type FeedbackSubmitForm = ReturnType<typeof FeedbackSubmitForm>;
|
|
9
|
+
export default FeedbackSubmitForm;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createFeedbackBoard } from './state.svelte.js';
|
|
2
|
+
export type { FeedbackBoardState, FeedbackBoardOptions, CategoryConfig } from './state.svelte.js';
|
|
3
|
+
export { default as FeedbackSidebar } from './FeedbackSidebar.svelte';
|
|
4
|
+
export { default as FeedbackList } from './FeedbackList.svelte';
|
|
5
|
+
export { default as FeedbackItem } from './FeedbackItem.svelte';
|
|
6
|
+
export { default as FeedbackDetail } from './FeedbackDetail.svelte';
|
|
7
|
+
export { default as FeedbackSubmitForm } from './FeedbackSubmitForm.svelte';
|
|
8
|
+
export { timeAgo, getStatusBadgeVariant } from './utils.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createFeedbackBoard } from './state.svelte.js';
|
|
2
|
+
export { default as FeedbackSidebar } from './FeedbackSidebar.svelte';
|
|
3
|
+
export { default as FeedbackList } from './FeedbackList.svelte';
|
|
4
|
+
export { default as FeedbackItem } from './FeedbackItem.svelte';
|
|
5
|
+
export { default as FeedbackDetail } from './FeedbackDetail.svelte';
|
|
6
|
+
export { default as FeedbackSubmitForm } from './FeedbackSubmitForm.svelte';
|
|
7
|
+
export { timeAgo, getStatusBadgeVariant } from './utils.js';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive state manager for FeedbackBoard composable components.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const board = createFeedbackBoard({ apiUrl, apiKey });
|
|
6
|
+
* <FeedbackSidebar {board} />
|
|
7
|
+
* <FeedbackList {board} />
|
|
8
|
+
*/
|
|
9
|
+
import { type BoardItem, type BoardCategory, type StatusClient } from '../../api.js';
|
|
10
|
+
export interface CategoryConfig {
|
|
11
|
+
label?: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface FeedbackBoardOptions {
|
|
16
|
+
apiUrl: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
categoryConfig?: Record<string, CategoryConfig>;
|
|
19
|
+
}
|
|
20
|
+
export interface FeedbackBoardState {
|
|
21
|
+
readonly client: StatusClient;
|
|
22
|
+
readonly categoryConfig: Record<string, CategoryConfig>;
|
|
23
|
+
readonly items: BoardItem[];
|
|
24
|
+
readonly categories: BoardCategory[];
|
|
25
|
+
readonly total: number;
|
|
26
|
+
readonly loading: boolean;
|
|
27
|
+
readonly error: string;
|
|
28
|
+
activeCategory: string | null;
|
|
29
|
+
statusFilter: string;
|
|
30
|
+
sortBy: string;
|
|
31
|
+
readonly votedItems: Record<string, 'up' | 'down'>;
|
|
32
|
+
reload(): Promise<void>;
|
|
33
|
+
vote(itemId: string, direction: 'up' | 'down'): Promise<void>;
|
|
34
|
+
getResponses(itemId: string): Promise<{
|
|
35
|
+
message: string;
|
|
36
|
+
author: string | null;
|
|
37
|
+
createdAt: number;
|
|
38
|
+
}[]>;
|
|
39
|
+
submitFeedback(data: {
|
|
40
|
+
title?: string;
|
|
41
|
+
message: string;
|
|
42
|
+
category?: string;
|
|
43
|
+
email?: string;
|
|
44
|
+
}): Promise<void>;
|
|
45
|
+
getCategoryIcon(name: string): string;
|
|
46
|
+
getCategoryLabel(name: string): string;
|
|
47
|
+
getCategoryDescription(name: string): string;
|
|
48
|
+
}
|
|
49
|
+
export declare function createFeedbackBoard(options: FeedbackBoardOptions): FeedbackBoardState;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive state manager for FeedbackBoard composable components.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const board = createFeedbackBoard({ apiUrl, apiKey });
|
|
6
|
+
* <FeedbackSidebar {board} />
|
|
7
|
+
* <FeedbackList {board} />
|
|
8
|
+
*/
|
|
9
|
+
import { createStatusClient } from '../../api.js';
|
|
10
|
+
export function createFeedbackBoard(options) {
|
|
11
|
+
const { apiUrl, apiKey, categoryConfig = {} } = options;
|
|
12
|
+
const client = createStatusClient({ apiUrl, apiKey });
|
|
13
|
+
// Reactive state
|
|
14
|
+
let items = $state([]);
|
|
15
|
+
let categories = $state([]);
|
|
16
|
+
let total = $state(0);
|
|
17
|
+
let loading = $state(true);
|
|
18
|
+
let error = $state('');
|
|
19
|
+
let activeCategory = $state(null);
|
|
20
|
+
let statusFilter = $state('open');
|
|
21
|
+
let sortBy = $state('votes');
|
|
22
|
+
let votedItems = $state({});
|
|
23
|
+
// Response cache
|
|
24
|
+
const responseCache = {};
|
|
25
|
+
async function load() {
|
|
26
|
+
loading = true;
|
|
27
|
+
error = '';
|
|
28
|
+
try {
|
|
29
|
+
const data = await client.getBoard({
|
|
30
|
+
category: activeCategory || undefined,
|
|
31
|
+
status: statusFilter,
|
|
32
|
+
sort: sortBy,
|
|
33
|
+
});
|
|
34
|
+
items = data.items;
|
|
35
|
+
total = data.total;
|
|
36
|
+
categories = data.categories;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
error = e instanceof Error ? e.message : 'Failed to load';
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
loading = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Auto-reload when filters change
|
|
46
|
+
$effect(() => {
|
|
47
|
+
void activeCategory;
|
|
48
|
+
void statusFilter;
|
|
49
|
+
void sortBy;
|
|
50
|
+
load();
|
|
51
|
+
});
|
|
52
|
+
async function vote(itemId, direction) {
|
|
53
|
+
const prev = votedItems[itemId];
|
|
54
|
+
try {
|
|
55
|
+
const result = await client.vote(itemId, direction);
|
|
56
|
+
const item = items.find((i) => i.id === itemId);
|
|
57
|
+
if (item)
|
|
58
|
+
item.votes = result.votes;
|
|
59
|
+
if (prev === direction) {
|
|
60
|
+
delete votedItems[itemId];
|
|
61
|
+
votedItems = { ...votedItems };
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
votedItems = { ...votedItems, [itemId]: direction };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Vote failures are silent — optimistic UI
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function getResponses(itemId) {
|
|
72
|
+
if (responseCache[itemId])
|
|
73
|
+
return responseCache[itemId];
|
|
74
|
+
try {
|
|
75
|
+
const data = await client.getResponses(itemId);
|
|
76
|
+
responseCache[itemId] = data.responses;
|
|
77
|
+
return data.responses;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function submitFeedback(data) {
|
|
84
|
+
await client.submitFeedback({
|
|
85
|
+
...data,
|
|
86
|
+
pageUrl: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
87
|
+
});
|
|
88
|
+
await load();
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
get client() { return client; },
|
|
92
|
+
get categoryConfig() { return categoryConfig; },
|
|
93
|
+
get items() { return items; },
|
|
94
|
+
get categories() { return categories; },
|
|
95
|
+
get total() { return total; },
|
|
96
|
+
get loading() { return loading; },
|
|
97
|
+
get error() { return error; },
|
|
98
|
+
get activeCategory() { return activeCategory; },
|
|
99
|
+
set activeCategory(v) { activeCategory = v; },
|
|
100
|
+
get statusFilter() { return statusFilter; },
|
|
101
|
+
set statusFilter(v) { statusFilter = v; },
|
|
102
|
+
get sortBy() { return sortBy; },
|
|
103
|
+
set sortBy(v) { sortBy = v; },
|
|
104
|
+
get votedItems() { return votedItems; },
|
|
105
|
+
reload: load,
|
|
106
|
+
vote,
|
|
107
|
+
getResponses,
|
|
108
|
+
submitFeedback,
|
|
109
|
+
getCategoryIcon(name) {
|
|
110
|
+
return categoryConfig[name]?.icon || 'fa-solid fa-circle';
|
|
111
|
+
},
|
|
112
|
+
getCategoryLabel(name) {
|
|
113
|
+
return categoryConfig[name]?.label || name;
|
|
114
|
+
},
|
|
115
|
+
getCategoryDescription(name) {
|
|
116
|
+
return categoryConfig[name]?.description || '';
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Relative time formatting for feedback timestamps */
|
|
2
|
+
export function timeAgo(timestamp) {
|
|
3
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
4
|
+
if (seconds < 60)
|
|
5
|
+
return 'just now';
|
|
6
|
+
if (seconds < 3600)
|
|
7
|
+
return `${Math.floor(seconds / 60)}m ago`;
|
|
8
|
+
if (seconds < 86400)
|
|
9
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
10
|
+
const days = Math.floor(seconds / 86400);
|
|
11
|
+
if (days < 30)
|
|
12
|
+
return `${days}d ago`;
|
|
13
|
+
return new Date(timestamp).toLocaleDateString();
|
|
14
|
+
}
|
|
15
|
+
/** Map feedback status to badge variant */
|
|
16
|
+
export function getStatusBadgeVariant(status) {
|
|
17
|
+
if (status === 'new' || status === 'reviewed')
|
|
18
|
+
return 'success';
|
|
19
|
+
if (status === 'resolved')
|
|
20
|
+
return 'neutral';
|
|
21
|
+
return 'info';
|
|
22
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { default as FeedbackForm } from './components/FeedbackForm.svelte';
|
|
|
16
16
|
export { default as DevFeedback } from './components/DevFeedback.svelte';
|
|
17
17
|
export { default as FeedbackBoard } from './components/FeedbackBoard.svelte';
|
|
18
18
|
export { default as FeedbackButton } from './components/FeedbackButton.svelte';
|
|
19
|
+
export { createFeedbackBoard, FeedbackSidebar, FeedbackList, FeedbackItem, FeedbackDetail, FeedbackSubmitForm, } from './components/board/index.js';
|
|
20
|
+
export type { FeedbackBoardState, FeedbackBoardOptions, CategoryConfig } from './components/board/index.js';
|
|
19
21
|
export { default as StatusIndicator } from './components/shared/StatusIndicator.svelte';
|
|
20
22
|
export { default as UptimeBar } from './components/shared/UptimeBar.svelte';
|
|
21
23
|
export { default as IncidentTimeline } from './components/shared/IncidentTimeline.svelte';
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,8 @@ export { default as FeedbackForm } from './components/FeedbackForm.svelte';
|
|
|
16
16
|
export { default as DevFeedback } from './components/DevFeedback.svelte';
|
|
17
17
|
export { default as FeedbackBoard } from './components/FeedbackBoard.svelte';
|
|
18
18
|
export { default as FeedbackButton } from './components/FeedbackButton.svelte';
|
|
19
|
+
// ── Composable Board Components ──
|
|
20
|
+
export { createFeedbackBoard, FeedbackSidebar, FeedbackList, FeedbackItem, FeedbackDetail, FeedbackSubmitForm, } from './components/board/index.js';
|
|
19
21
|
// ── Shared Components ──
|
|
20
22
|
export { default as StatusIndicator } from './components/shared/StatusIndicator.svelte';
|
|
21
23
|
export { default as UptimeBar } from './components/shared/UptimeBar.svelte';
|
package/package.json
CHANGED