@alliance-droid/status-feedback-system 3.1.0 → 4.0.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/README.md +69 -24
- package/dist/api.d.ts +35 -0
- package/dist/api.js +12 -0
- package/dist/components/DevFeedback.svelte +2 -1
- package/dist/components/board/FeedbackDetail.svelte +205 -171
- package/dist/components/board/FeedbackItem.svelte +50 -46
- package/dist/components/board/FeedbackSidebar.svelte +58 -35
- package/dist/components/board/FeedbackSidebar.svelte.d.ts +1 -1
- package/dist/components/board/FeedbackSubmitForm.svelte +239 -123
- package/dist/components/board/FeedbackSubmitForm.svelte.d.ts +0 -2
- package/dist/components/board/StatusBadgeMenu.svelte +264 -0
- package/dist/components/board/StatusBadgeMenu.svelte.d.ts +11 -0
- package/dist/components/board/state.svelte.d.ts +21 -3
- package/dist/components/board/state.svelte.js +49 -4
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.js +14 -1
- package/package.json +1 -1
- package/dist/components/FeedbackBoard.svelte +0 -110
- package/dist/components/FeedbackBoard.svelte.d.ts +0 -18
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { IconButton } from '@alliance-droid/svelte-component-library';
|
|
3
3
|
import type { BoardItem } from '../../api.js';
|
|
4
4
|
import type { FeedbackBoardState } from './state.svelte.js';
|
|
5
|
-
import { timeAgo
|
|
5
|
+
import { timeAgo } from './utils.js';
|
|
6
|
+
import StatusBadgeMenu from './StatusBadgeMenu.svelte';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
board: FeedbackBoardState;
|
|
@@ -13,26 +14,20 @@
|
|
|
13
14
|
let { board, item, onclick }: Props = $props();
|
|
14
15
|
|
|
15
16
|
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
17
|
</script>
|
|
23
18
|
|
|
24
19
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
25
20
|
<article
|
|
26
|
-
class="rounded-xl border border-border bg-surface transition-
|
|
21
|
+
class="rounded-xl border border-border bg-surface p-4 transition-all {isClickable ? 'cursor-pointer hover:border-border-strong hover:bg-surface-hover' : ''}"
|
|
27
22
|
role={isClickable ? 'button' : undefined}
|
|
28
23
|
tabindex={isClickable ? 0 : undefined}
|
|
29
24
|
aria-label={isClickable ? (item.title || item.message.slice(0, 80)) : undefined}
|
|
30
25
|
onclick={() => onclick?.(item)}
|
|
31
26
|
onkeydown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onclick?.(item); } }}
|
|
32
27
|
>
|
|
33
|
-
<div class="flex gap-
|
|
28
|
+
<div class="flex gap-4">
|
|
34
29
|
<!-- Vote column -->
|
|
35
|
-
<div class="flex flex-col items-center gap-0.5
|
|
30
|
+
<div class="flex flex-col items-center gap-1 pt-0.5" role="group" aria-label="Vote on this feedback">
|
|
36
31
|
<div role="presentation" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
|
37
32
|
<IconButton
|
|
38
33
|
size="sm"
|
|
@@ -43,7 +38,7 @@
|
|
|
43
38
|
<i class="fa-solid fa-chevron-up text-xs"></i>
|
|
44
39
|
</IconButton>
|
|
45
40
|
</div>
|
|
46
|
-
<span class="text-sm font-
|
|
41
|
+
<span class="text-sm font-semibold tabular-nums text-foreground" aria-label="{item.votes} votes">{item.votes}</span>
|
|
47
42
|
{#if board.voteErrors[item.id]}
|
|
48
43
|
<span class="text-[0.625rem] text-red-400" role="alert">
|
|
49
44
|
<i class="fa-solid fa-exclamation-circle" aria-hidden="true"></i>
|
|
@@ -62,41 +57,43 @@
|
|
|
62
57
|
</div>
|
|
63
58
|
|
|
64
59
|
<!-- Content -->
|
|
65
|
-
<div class="
|
|
66
|
-
<div class="flex items-
|
|
67
|
-
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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>
|
|
60
|
+
<div class="min-w-0 flex-1">
|
|
61
|
+
<div class="flex items-start gap-3">
|
|
62
|
+
<div class="min-w-0 flex-1">
|
|
63
|
+
<div class="flex items-center gap-2">
|
|
64
|
+
{#if item.category}
|
|
65
|
+
<i class="{board.getCategoryIcon(item.category)} flex-shrink-0 text-sm text-foreground-tertiary" aria-hidden="true"></i>
|
|
66
|
+
{/if}
|
|
67
|
+
<h3 class="line-clamp-1 text-[0.9375rem] font-semibold leading-snug text-foreground">
|
|
68
|
+
{item.title || item.message}
|
|
69
|
+
</h3>
|
|
70
|
+
</div>
|
|
71
|
+
{#if item.title}
|
|
72
|
+
<p class="mt-1 line-clamp-2 text-sm leading-relaxed text-foreground-secondary">{item.message}</p>
|
|
96
73
|
{/if}
|
|
97
74
|
</div>
|
|
75
|
+
<StatusBadgeMenu {board} {item} />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Meta -->
|
|
79
|
+
<div class="mt-3 flex items-center gap-3 text-xs text-foreground-tertiary">
|
|
80
|
+
{#if item.email}
|
|
81
|
+
<span class="inline-flex min-w-0 flex-1 items-center gap-1.5">
|
|
82
|
+
<i class="fa-solid fa-circle-user flex-shrink-0" aria-hidden="true"></i>
|
|
83
|
+
<span class="truncate">{item.email}</span>
|
|
84
|
+
</span>
|
|
85
|
+
{/if}
|
|
86
|
+
<span class="flex-shrink-0 whitespace-nowrap">{timeAgo(item.createdAt)}</span>
|
|
87
|
+
{#if item.adminResponse}
|
|
88
|
+
<span class="inline-flex flex-shrink-0 items-center gap-1 whitespace-nowrap text-primary">
|
|
89
|
+
<i class="fa-solid fa-shield" aria-hidden="true"></i> Official response
|
|
90
|
+
</span>
|
|
91
|
+
{/if}
|
|
98
92
|
{#if item.responseCount > 0}
|
|
99
|
-
<span
|
|
93
|
+
<span
|
|
94
|
+
class="ml-auto inline-flex flex-shrink-0 items-center gap-1"
|
|
95
|
+
aria-label="{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}"
|
|
96
|
+
>
|
|
100
97
|
<i class="fa-solid fa-comment" aria-hidden="true"></i>
|
|
101
98
|
{item.responseCount}
|
|
102
99
|
</span>
|
|
@@ -107,11 +104,18 @@
|
|
|
107
104
|
</article>
|
|
108
105
|
|
|
109
106
|
<style>
|
|
107
|
+
.line-clamp-1,
|
|
110
108
|
.line-clamp-2 {
|
|
111
109
|
display: -webkit-box;
|
|
112
|
-
-webkit-line-clamp: 2;
|
|
113
|
-
line-clamp: 2;
|
|
114
110
|
-webkit-box-orient: vertical;
|
|
115
111
|
overflow: hidden;
|
|
116
112
|
}
|
|
113
|
+
.line-clamp-1 {
|
|
114
|
+
-webkit-line-clamp: 1;
|
|
115
|
+
line-clamp: 1;
|
|
116
|
+
}
|
|
117
|
+
.line-clamp-2 {
|
|
118
|
+
-webkit-line-clamp: 2;
|
|
119
|
+
line-clamp: 2;
|
|
120
|
+
}
|
|
117
121
|
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Divider, Select } from
|
|
3
|
-
import type { FeedbackBoardState } from
|
|
2
|
+
import { Divider, Select } from "@alliance-droid/svelte-component-library";
|
|
3
|
+
import type { FeedbackBoardState } from "./state.svelte.js";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
board: FeedbackBoardState;
|
|
@@ -9,52 +9,75 @@
|
|
|
9
9
|
let { board }: Props = $props();
|
|
10
10
|
|
|
11
11
|
const statusOptions = [
|
|
12
|
-
{ value:
|
|
13
|
-
{ value:
|
|
14
|
-
{ value:
|
|
12
|
+
{ value: "open", label: "Open" },
|
|
13
|
+
{ value: "resolved", label: "Done" },
|
|
14
|
+
{ value: "all", label: "All" },
|
|
15
15
|
];
|
|
16
16
|
|
|
17
17
|
const sortOptions = [
|
|
18
|
-
{ value:
|
|
19
|
-
{ value:
|
|
18
|
+
{ value: "votes", label: "Most Votes" },
|
|
19
|
+
{ value: "newest", label: "Newest" },
|
|
20
20
|
];
|
|
21
21
|
</script>
|
|
22
22
|
|
|
23
23
|
<aside class="flex flex-col gap-1" aria-label="Filter feedback">
|
|
24
|
-
<
|
|
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)}
|
|
24
|
+
<div class="mx-2 pt-2">
|
|
35
25
|
<button
|
|
36
26
|
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
|
|
37
|
-
|
|
38
|
-
|
|
27
|
+
{board.activeCategory === null
|
|
28
|
+
? 'bg-surface-secondary text-foreground font-semibold'
|
|
29
|
+
: 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
|
|
30
|
+
onclick={() => (board.activeCategory = null)}
|
|
39
31
|
>
|
|
40
|
-
<i class=
|
|
41
|
-
<span>
|
|
42
|
-
<span
|
|
32
|
+
<i class="fa-solid fa-layer-group" aria-hidden="true"></i>
|
|
33
|
+
<span>All</span>
|
|
34
|
+
<span
|
|
35
|
+
class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs"
|
|
36
|
+
>{board.total}</span
|
|
37
|
+
>
|
|
43
38
|
</button>
|
|
44
|
-
|
|
39
|
+
|
|
40
|
+
{#each board.categories as cat (cat.name)}
|
|
41
|
+
<button
|
|
42
|
+
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
|
|
43
|
+
{board.activeCategory === cat.name
|
|
44
|
+
? 'bg-surface-secondary text-foreground font-semibold'
|
|
45
|
+
: 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
|
|
46
|
+
onclick={() =>
|
|
47
|
+
(board.activeCategory =
|
|
48
|
+
board.activeCategory === cat.name ? null : cat.name)}
|
|
49
|
+
>
|
|
50
|
+
<i class={board.getCategoryIcon(cat.name)} aria-hidden="true"
|
|
51
|
+
></i>
|
|
52
|
+
<span>{board.getCategoryLabel(cat.name)}</span>
|
|
53
|
+
<span
|
|
54
|
+
class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs"
|
|
55
|
+
>{cat.count}</span
|
|
56
|
+
>
|
|
57
|
+
</button>
|
|
58
|
+
{/each}
|
|
59
|
+
</div>
|
|
45
60
|
|
|
46
61
|
<Divider class="my-2" />
|
|
47
62
|
|
|
48
|
-
<div class="
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
<div class="space-y-3">
|
|
64
|
+
<div class="mx-4">
|
|
65
|
+
<Select
|
|
66
|
+
label="Status"
|
|
67
|
+
class="rounded-lg border-0 bg-surface-hover"
|
|
68
|
+
options={statusOptions}
|
|
69
|
+
bind:value={board.statusFilter}
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
<Divider class="my-2" />
|
|
73
|
+
<div class="mx-4">
|
|
74
|
+
<Select
|
|
75
|
+
label="Sort By"
|
|
76
|
+
class="rounded-lg border-0 bg-surface-hover"
|
|
77
|
+
options={sortOptions}
|
|
78
|
+
bind:value={board.sortBy}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
59
81
|
</div>
|
|
60
82
|
</aside>
|
|
83
|
+
|
|
@@ -1,123 +1,239 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
interface Props {
|
|
7
|
-
board: FeedbackBoardState;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
let
|
|
13
|
-
|
|
14
|
-
let
|
|
15
|
-
let
|
|
16
|
-
let
|
|
17
|
-
let
|
|
18
|
-
let
|
|
19
|
-
let
|
|
20
|
-
let
|
|
21
|
-
let
|
|
22
|
-
let textareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
23
|
-
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
|
|
3
|
+
import { FEEDBACK_CATEGORIES } from '../../types.js';
|
|
4
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
board: FeedbackBoardState;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { board }: Props = $props();
|
|
11
|
+
|
|
12
|
+
let title = $state('');
|
|
13
|
+
let message = $state('');
|
|
14
|
+
let category = $state('');
|
|
15
|
+
let submitting = $state(false);
|
|
16
|
+
let success = $state(false);
|
|
17
|
+
let error = $state('');
|
|
18
|
+
let pastedImage = $state<string | null>(null);
|
|
19
|
+
let pastedFilename = $state('');
|
|
20
|
+
let fileInputEl: HTMLInputElement | undefined = $state();
|
|
21
|
+
let dragActive = $state(false);
|
|
22
|
+
let textareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
23
|
+
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
|
24
|
+
|
|
25
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
26
|
+
const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
27
|
+
|
|
28
|
+
$effect(() => {
|
|
29
|
+
if (textareaWrapperEl) {
|
|
30
|
+
textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
$effect(() => {
|
|
35
|
+
if (!textareaEl) return;
|
|
36
|
+
const handler = (e: ClipboardEvent) => handlePaste(e);
|
|
37
|
+
textareaEl.addEventListener('paste', handler);
|
|
38
|
+
return () => textareaEl?.removeEventListener('paste', handler);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let isAuthenticated = $derived(!!board.userEmail);
|
|
42
|
+
|
|
43
|
+
// Labels are the canonical system category names — not the consumer's
|
|
44
|
+
// `categoryConfig` (which only customizes the sidebar).
|
|
45
|
+
let categoryOptions = FEEDBACK_CATEGORIES.map((c) => ({ value: c, label: c }));
|
|
46
|
+
|
|
47
|
+
/** Validate and load an image file (shared by paste, file-pick, and drop). */
|
|
48
|
+
function processFile(file: File) {
|
|
49
|
+
if (!ALLOWED_MIME.includes(file.type)) {
|
|
50
|
+
error = 'Unsupported image type — PNG, JPEG, GIF, or WebP only';
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (file.size > MAX_BYTES) {
|
|
54
|
+
error = 'Image too large — max 5 MB';
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const reader = new FileReader();
|
|
58
|
+
reader.onload = () => {
|
|
59
|
+
pastedImage = reader.result as string;
|
|
60
|
+
const ext = file.type.split('/')[1] || 'png';
|
|
61
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
62
|
+
pastedFilename = file.name || `screenshot-${stamp}.${ext}`;
|
|
63
|
+
error = '';
|
|
64
|
+
};
|
|
65
|
+
reader.readAsDataURL(file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handlePaste(e: ClipboardEvent) {
|
|
69
|
+
const clipItems = Array.from(e.clipboardData?.items ?? []);
|
|
70
|
+
const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
|
|
71
|
+
if (!imageItem) return;
|
|
72
|
+
const file = imageItem.getAsFile();
|
|
73
|
+
if (!file) return;
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
processFile(file);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handleFileSelect(e: Event) {
|
|
79
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
80
|
+
const file = input.files?.[0];
|
|
81
|
+
if (file) processFile(file);
|
|
82
|
+
input.value = '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleDrop(e: DragEvent) {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
dragActive = false;
|
|
88
|
+
const file = e.dataTransfer?.files?.[0];
|
|
89
|
+
if (file) processFile(file);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatSize(dataUrl: string): string {
|
|
93
|
+
const comma = dataUrl.indexOf(',');
|
|
94
|
+
if (comma === -1) return '';
|
|
95
|
+
const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
|
|
96
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
97
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
98
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleSubmit() {
|
|
102
|
+
if (!message.trim() || !category || !isAuthenticated) return;
|
|
103
|
+
submitting = true;
|
|
104
|
+
error = '';
|
|
105
|
+
try {
|
|
106
|
+
await board.submitFeedback({
|
|
107
|
+
title: title.trim() || undefined,
|
|
108
|
+
message: message.trim(),
|
|
109
|
+
category,
|
|
110
|
+
pastedImage,
|
|
111
|
+
});
|
|
112
|
+
success = true;
|
|
113
|
+
title = '';
|
|
114
|
+
message = '';
|
|
115
|
+
category = '';
|
|
116
|
+
pastedImage = null;
|
|
117
|
+
pastedFilename = '';
|
|
118
|
+
setTimeout(() => { success = false; }, 2000);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
error = err instanceof Error ? err.message : 'Failed to submit';
|
|
121
|
+
} finally {
|
|
122
|
+
submitting = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<div>
|
|
128
|
+
{#if !isAuthenticated}
|
|
129
|
+
<div class="py-3 text-center text-foreground-tertiary">
|
|
130
|
+
<i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
|
|
131
|
+
<span class="text-sm">Sign in to submit feedback</span>
|
|
132
|
+
</div>
|
|
133
|
+
{:else if success}
|
|
134
|
+
<div class="py-2 text-center font-semibold text-green-400">
|
|
135
|
+
<i class="fa-solid fa-check-circle mr-1"></i> Submitted!
|
|
136
|
+
</div>
|
|
137
|
+
{:else}
|
|
138
|
+
<div class="space-y-2">
|
|
139
|
+
<!-- Composer card: title + description + attachment + toolbar -->
|
|
140
|
+
<div
|
|
141
|
+
class="overflow-hidden rounded-xl border bg-background shadow-sm transition-all {dragActive
|
|
142
|
+
? 'border-primary ring-2 ring-primary/30'
|
|
143
|
+
: 'border-border focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20'}"
|
|
144
|
+
role="group"
|
|
145
|
+
ondragover={(e) => { e.preventDefault(); dragActive = true; }}
|
|
146
|
+
ondragleave={() => (dragActive = false)}
|
|
147
|
+
ondrop={handleDrop}
|
|
148
|
+
>
|
|
149
|
+
<div class="border-b border-border">
|
|
150
|
+
<Input
|
|
151
|
+
class="rounded-none border-0 bg-transparent px-4 py-3 text-sm font-medium hover:border-transparent focus:border-transparent"
|
|
152
|
+
bind:value={title}
|
|
153
|
+
placeholder="Title (optional)"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div bind:this={textareaWrapperEl}>
|
|
158
|
+
<Textarea
|
|
159
|
+
class="resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm hover:border-transparent focus:border-transparent"
|
|
160
|
+
value={message}
|
|
161
|
+
oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
162
|
+
placeholder="Describe your feedback…"
|
|
163
|
+
rows={4}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{#if pastedImage}
|
|
168
|
+
<div class="flex items-center gap-3 border-t border-border px-3 py-2">
|
|
169
|
+
<img
|
|
170
|
+
src={pastedImage}
|
|
171
|
+
alt={pastedFilename}
|
|
172
|
+
class="h-11 w-11 flex-shrink-0 rounded border border-border object-cover"
|
|
173
|
+
/>
|
|
174
|
+
<div class="min-w-0 flex-1">
|
|
175
|
+
<p class="truncate text-xs font-medium text-foreground">{pastedFilename}</p>
|
|
176
|
+
<p class="text-[11px] text-foreground-tertiary">{formatSize(pastedImage)}</p>
|
|
177
|
+
</div>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class="flex-shrink-0 rounded p-1.5 text-foreground-tertiary transition-colors hover:bg-surface-hover hover:text-foreground"
|
|
181
|
+
onclick={() => { pastedImage = null; pastedFilename = ''; }}
|
|
182
|
+
aria-label="Remove image"
|
|
183
|
+
>
|
|
184
|
+
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
{/if}
|
|
188
|
+
|
|
189
|
+
<!-- Toolbar -->
|
|
190
|
+
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-2.5">
|
|
191
|
+
<div class="flex items-center gap-2">
|
|
192
|
+
<div class="min-w-[150px]">
|
|
193
|
+
<Select
|
|
194
|
+
class="border-0 bg-transparent"
|
|
195
|
+
size="sm"
|
|
196
|
+
placeholder="Category…"
|
|
197
|
+
options={categoryOptions}
|
|
198
|
+
required
|
|
199
|
+
bind:value={category}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-secondary transition-colors hover:border-border-strong hover:text-foreground"
|
|
205
|
+
onclick={() => fileInputEl?.click()}
|
|
206
|
+
>
|
|
207
|
+
<i class="fa-solid fa-image" aria-hidden="true"></i>
|
|
208
|
+
Attach screenshot
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
<Button
|
|
212
|
+
variant="primary"
|
|
213
|
+
size="sm"
|
|
214
|
+
disabled={submitting || !message.trim() || !category}
|
|
215
|
+
loading={submitting}
|
|
216
|
+
onclick={handleSubmit}
|
|
217
|
+
>
|
|
218
|
+
Submit
|
|
219
|
+
</Button>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<input
|
|
223
|
+
bind:this={fileInputEl}
|
|
224
|
+
type="file"
|
|
225
|
+
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
226
|
+
class="hidden"
|
|
227
|
+
onchange={handleFileSelect}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{#if error}
|
|
232
|
+
<p class="text-sm text-red-400">
|
|
233
|
+
<i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
|
|
234
|
+
{error}
|
|
235
|
+
</p>
|
|
236
|
+
{/if}
|
|
237
|
+
</div>
|
|
238
|
+
{/if}
|
|
239
|
+
</div>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { FeedbackBoardState } from './state.svelte.js';
|
|
2
2
|
interface Props {
|
|
3
3
|
board: FeedbackBoardState;
|
|
4
|
-
/** Available category options for the dropdown */
|
|
5
|
-
categories?: string[];
|
|
6
4
|
}
|
|
7
5
|
declare const FeedbackSubmitForm: import("svelte").Component<Props, {}, "">;
|
|
8
6
|
type FeedbackSubmitForm = ReturnType<typeof FeedbackSubmitForm>;
|