@alliance-droid/status-feedback-system 3.0.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.
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
- import { Divider, Select } from '@alliance-droid/svelte-component-library';
3
- import type { FeedbackBoardState } from './state.svelte.js';
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: 'open', label: 'Open' },
13
- { value: 'resolved', label: 'Resolved' },
14
- { value: 'all', label: 'All' },
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: 'votes', label: 'Most Votes' },
19
- { value: 'newest', label: 'Newest' },
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
- <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)}
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
- {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)}
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={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>
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
- {/each}
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="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
- />
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,4 +1,4 @@
1
- import type { FeedbackBoardState } from './state.svelte.js';
1
+ import type { FeedbackBoardState } from "./state.svelte.js";
2
2
  interface Props {
3
3
  board: FeedbackBoardState;
4
4
  }
@@ -1,123 +1,239 @@
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
- import PasteImageInput from '../shared/PasteImageInput.svelte';
5
-
6
- interface Props {
7
- board: FeedbackBoardState;
8
- /** Available category options for the dropdown */
9
- categories?: string[];
10
- }
11
-
12
- let { board, categories = [] }: Props = $props();
13
-
14
- let title = $state('');
15
- let message = $state('');
16
- let category = $state('');
17
- let submitting = $state(false);
18
- let success = $state(false);
19
- let error = $state('');
20
- let pastedImage = $state<string | null>(null);
21
- let pastedFilename = $state('');
22
- let textareaWrapperEl: HTMLDivElement | undefined = $state();
23
- let textareaEl: HTMLTextAreaElement | undefined = $state();
24
-
25
- $effect(() => {
26
- if (textareaWrapperEl) {
27
- textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
28
- }
29
- });
30
-
31
- let isAuthenticated = $derived(!!board.userEmail);
32
-
33
- let categoryOptions = $derived(
34
- categories.map((c) => ({ value: c, label: board.getCategoryLabel(c) }))
35
- );
36
-
37
- async function handleSubmit() {
38
- if (!message.trim() || !isAuthenticated) return;
39
- submitting = true;
40
- error = '';
41
- try {
42
- await board.submitFeedback({
43
- title: title.trim() || undefined,
44
- message: message.trim(),
45
- category: category || undefined,
46
- pastedImage,
47
- });
48
- success = true;
49
- title = '';
50
- message = '';
51
- category = '';
52
- pastedImage = null;
53
- pastedFilename = '';
54
- setTimeout(() => { success = false; }, 2000);
55
- } catch (err) {
56
- error = err instanceof Error ? err.message : 'Failed to submit';
57
- } finally {
58
- submitting = false;
59
- }
60
- }
61
- </script>
62
-
63
- <Card variant="outlined" padding="md">
64
- {#if !isAuthenticated}
65
- <div class="text-center py-3 text-foreground-tertiary">
66
- <i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
67
- <span class="text-sm">Sign in to submit feedback</span>
68
- </div>
69
- {:else if success}
70
- <div class="text-center py-2 text-green-400 font-semibold">
71
- <i class="fa-solid fa-check-circle mr-1"></i> Submitted!
72
- </div>
73
- {:else}
74
- <div class="space-y-3">
75
- <div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
76
- <i class="fa-solid fa-user" aria-hidden="true"></i>
77
- <span>Submitting as <span class="font-medium text-foreground-secondary">{board.userEmail}</span></span>
78
- </div>
79
- <Input
80
- bind:value={title}
81
- placeholder="Title (optional)"
82
- />
83
- <div bind:this={textareaWrapperEl}>
84
- <Textarea
85
- value={message}
86
- oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
87
- placeholder="Describe your feedback (paste an image to attach it)\u2026"
88
- rows={3}
89
- />
90
- </div>
91
- <PasteImageInput
92
- {textareaEl}
93
- bind:image={pastedImage}
94
- bind:filename={pastedFilename}
95
- />
96
- {#if error}
97
- <p class="text-sm text-red-400">
98
- <i class="fa-solid fa-exclamation-circle mr-1"></i>
99
- {error}
100
- </p>
101
- {/if}
102
- <div class="flex gap-2 items-center flex-wrap">
103
- {#if categoryOptions.length > 0}
104
- <div class="min-w-[140px]">
105
- <Select
106
- options={[{ value: '', label: 'Category...' }, ...categoryOptions]}
107
- bind:value={category}
108
- />
109
- </div>
110
- {/if}
111
- <div class="flex-1"></div>
112
- <Button
113
- variant="primary"
114
- disabled={submitting || !message.trim()}
115
- loading={submitting}
116
- onclick={handleSubmit}
117
- >
118
- Submit
119
- </Button>
120
- </div>
121
- </div>
122
- {/if}
123
- </Card>
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>;