@alliance-droid/status-feedback-system 2.2.0 → 2.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 +13 -0
- package/dist/api.d.ts +1 -0
- package/dist/components/board/FeedbackDetail.svelte +135 -9
- package/dist/components/board/FeedbackSubmitForm.svelte +26 -5
- package/dist/components/board/state.svelte.d.ts +2 -1
- package/dist/components/board/state.svelte.js +12 -3
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `<FeedbackSubmitForm>` paste-to-attach was missing from 2.3.0 (commit didn't land on main before merge). Now included.
|
|
8
|
+
|
|
9
|
+
## 2.3.0
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `<FeedbackBoard>` submit form and reply threads: paste a screenshot from clipboard (Cmd/Ctrl+V) into the textarea. Pasted images are sent to the server as `metadata.pastedScreenshot` (submit form) or `pastedImage` (reply). Existing responses with `imageUrl` render inline on reload.
|
|
14
|
+
- `FeedbackResponse.imageUrl` field for persisted image rendering.
|
|
15
|
+
|
|
3
16
|
## 2.2.0
|
|
4
17
|
|
|
5
18
|
### Added
|
package/dist/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { onMount } from 'svelte';
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
3
|
import { Badge, Spinner, Button, Textarea } from '@alliance-droid/svelte-component-library';
|
|
4
4
|
import type { BoardItem } from '../../api.js';
|
|
5
5
|
import type { FeedbackResponse } from '../../types.js';
|
|
@@ -21,6 +21,65 @@
|
|
|
21
21
|
let submittingReply = $state(false);
|
|
22
22
|
let replyError = $state('');
|
|
23
23
|
let replySuccess = $state(false);
|
|
24
|
+
let replyTextareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
25
|
+
let replyTextareaEl: HTMLTextAreaElement | undefined = $state();
|
|
26
|
+
let pastedImage = $state<string | null>(null);
|
|
27
|
+
let pastedFilename = $state('');
|
|
28
|
+
let pasteError = $state('');
|
|
29
|
+
|
|
30
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
31
|
+
const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
32
|
+
|
|
33
|
+
$effect(() => {
|
|
34
|
+
if (replyTextareaWrapperEl) {
|
|
35
|
+
replyTextareaEl = replyTextareaWrapperEl.querySelector('textarea') ?? undefined;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
$effect(() => {
|
|
40
|
+
if (!replyTextareaEl) return;
|
|
41
|
+
const handler = (e: ClipboardEvent) => void handlePaste(e);
|
|
42
|
+
replyTextareaEl.addEventListener('paste', handler);
|
|
43
|
+
return () => replyTextareaEl?.removeEventListener('paste', handler);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function handlePaste(e: ClipboardEvent) {
|
|
47
|
+
const clipItems = Array.from(e.clipboardData?.items ?? []);
|
|
48
|
+
const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
|
|
49
|
+
if (!imageItem) return;
|
|
50
|
+
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
const file = imageItem.getAsFile();
|
|
53
|
+
if (!file) return;
|
|
54
|
+
|
|
55
|
+
if (!ALLOWED_MIME.includes(file.type)) {
|
|
56
|
+
pasteError = 'Unsupported image type \u2014 PNG, JPEG, GIF, or WebP only';
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (file.size > MAX_BYTES) {
|
|
60
|
+
pasteError = 'Image too large \u2014 max 5 MB';
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const reader = new FileReader();
|
|
65
|
+
reader.onload = () => {
|
|
66
|
+
pastedImage = reader.result as string;
|
|
67
|
+
const ext = file.type.split('/')[1] || 'png';
|
|
68
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
69
|
+
pastedFilename = `pasted-image-${stamp}.${ext}`;
|
|
70
|
+
pasteError = '';
|
|
71
|
+
};
|
|
72
|
+
reader.readAsDataURL(file);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatSize(dataUrl: string): string {
|
|
76
|
+
const comma = dataUrl.indexOf(',');
|
|
77
|
+
if (comma === -1) return '';
|
|
78
|
+
const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
|
|
79
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
80
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
81
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
82
|
+
}
|
|
24
83
|
|
|
25
84
|
let isAuthenticated = $derived(!!board.userEmail);
|
|
26
85
|
|
|
@@ -34,10 +93,11 @@
|
|
|
34
93
|
submittingReply = true;
|
|
35
94
|
replyError = '';
|
|
36
95
|
try {
|
|
37
|
-
await board.submitResponse(item.id, replyMessage.trim());
|
|
38
|
-
// Refresh responses
|
|
96
|
+
await board.submitResponse(item.id, replyMessage.trim(), pastedImage);
|
|
39
97
|
responses = await board.getResponses(item.id);
|
|
40
98
|
replyMessage = '';
|
|
99
|
+
pastedImage = null;
|
|
100
|
+
pastedFilename = '';
|
|
41
101
|
replySuccess = true;
|
|
42
102
|
setTimeout(() => { replySuccess = false; }, 2000);
|
|
43
103
|
} catch (err) {
|
|
@@ -121,6 +181,9 @@
|
|
|
121
181
|
<span class="text-[0.6875rem] text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
|
|
122
182
|
</div>
|
|
123
183
|
<p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{resp.message}</p>
|
|
184
|
+
{#if resp.imageUrl}
|
|
185
|
+
<img src={resp.imageUrl} alt="Pasted screenshot" class="mt-2 max-w-full rounded border border-border" />
|
|
186
|
+
{/if}
|
|
124
187
|
</div>
|
|
125
188
|
{/each}
|
|
126
189
|
</div>
|
|
@@ -145,12 +208,30 @@
|
|
|
145
208
|
<i class="fa-solid fa-user" aria-hidden="true"></i>
|
|
146
209
|
<span>Replying as <span class="font-medium text-foreground-secondary">{board.userEmail}</span></span>
|
|
147
210
|
</div>
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
211
|
+
<div bind:this={replyTextareaWrapperEl}>
|
|
212
|
+
<Textarea
|
|
213
|
+
value={replyMessage}
|
|
214
|
+
oninput={(e) => (replyMessage = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
215
|
+
placeholder="Share your thoughts (paste an image to attach it)\u2026"
|
|
216
|
+
rows={3}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{#if pastedImage}
|
|
221
|
+
<div class="sf-paste-preview">
|
|
222
|
+
<img src={pastedImage} alt={pastedFilename} class="sf-paste-thumb" />
|
|
223
|
+
<div class="sf-paste-meta">
|
|
224
|
+
<span class="sf-paste-name">{pastedFilename}</span>
|
|
225
|
+
<span class="sf-paste-size">{formatSize(pastedImage)}</span>
|
|
226
|
+
</div>
|
|
227
|
+
<button type="button" class="sf-paste-remove" onclick={() => { pastedImage = null; pastedFilename = ''; }} aria-label="Remove image">×</button>
|
|
228
|
+
</div>
|
|
229
|
+
{/if}
|
|
230
|
+
|
|
231
|
+
{#if pasteError}
|
|
232
|
+
<p class="text-sm" style="color: var(--color-danger, #ef4444);">{pasteError}</p>
|
|
233
|
+
{/if}
|
|
234
|
+
|
|
154
235
|
<div class="flex gap-2 items-center justify-end">
|
|
155
236
|
<Button
|
|
156
237
|
variant="primary"
|
|
@@ -171,3 +252,48 @@
|
|
|
171
252
|
{/if}
|
|
172
253
|
</div>
|
|
173
254
|
</div>
|
|
255
|
+
|
|
256
|
+
<style>
|
|
257
|
+
.sf-paste-preview {
|
|
258
|
+
display: flex;
|
|
259
|
+
align-items: center;
|
|
260
|
+
gap: 0.75rem;
|
|
261
|
+
padding: 0.5rem;
|
|
262
|
+
border: 1px solid var(--color-border, #2a2f3a);
|
|
263
|
+
border-radius: 0.5rem;
|
|
264
|
+
background: var(--color-surface-secondary, rgba(255, 255, 255, 0.04));
|
|
265
|
+
}
|
|
266
|
+
.sf-paste-thumb {
|
|
267
|
+
width: 120px;
|
|
268
|
+
height: 80px;
|
|
269
|
+
object-fit: cover;
|
|
270
|
+
border-radius: 0.25rem;
|
|
271
|
+
flex-shrink: 0;
|
|
272
|
+
}
|
|
273
|
+
.sf-paste-meta {
|
|
274
|
+
flex: 1;
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-direction: column;
|
|
277
|
+
min-width: 0;
|
|
278
|
+
}
|
|
279
|
+
.sf-paste-name {
|
|
280
|
+
font-size: 0.8125rem;
|
|
281
|
+
color: var(--color-foreground, #e5e7eb);
|
|
282
|
+
white-space: nowrap;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
text-overflow: ellipsis;
|
|
285
|
+
}
|
|
286
|
+
.sf-paste-size {
|
|
287
|
+
font-size: 0.75rem;
|
|
288
|
+
color: var(--color-foreground-tertiary, #9ca3af);
|
|
289
|
+
}
|
|
290
|
+
.sf-paste-remove {
|
|
291
|
+
background: transparent;
|
|
292
|
+
border: none;
|
|
293
|
+
color: var(--color-foreground-tertiary, #9ca3af);
|
|
294
|
+
font-size: 1.25rem;
|
|
295
|
+
cursor: pointer;
|
|
296
|
+
padding: 0.25rem 0.5rem;
|
|
297
|
+
}
|
|
298
|
+
.sf-paste-remove:hover { color: var(--color-danger, #ef4444); }
|
|
299
|
+
</style>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Card, Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
|
|
3
3
|
import type { FeedbackBoardState } from './state.svelte.js';
|
|
4
|
+
import PasteImageInput from '../shared/PasteImageInput.svelte';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
board: FeedbackBoardState;
|
|
@@ -16,6 +17,16 @@
|
|
|
16
17
|
let submitting = $state(false);
|
|
17
18
|
let success = $state(false);
|
|
18
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
|
+
});
|
|
19
30
|
|
|
20
31
|
let isAuthenticated = $derived(!!board.userEmail);
|
|
21
32
|
|
|
@@ -32,11 +43,14 @@
|
|
|
32
43
|
title: title.trim() || undefined,
|
|
33
44
|
message: message.trim(),
|
|
34
45
|
category: category || undefined,
|
|
46
|
+
pastedImage,
|
|
35
47
|
});
|
|
36
48
|
success = true;
|
|
37
49
|
title = '';
|
|
38
50
|
message = '';
|
|
39
51
|
category = '';
|
|
52
|
+
pastedImage = null;
|
|
53
|
+
pastedFilename = '';
|
|
40
54
|
setTimeout(() => { success = false; }, 2000);
|
|
41
55
|
} catch (err) {
|
|
42
56
|
error = err instanceof Error ? err.message : 'Failed to submit';
|
|
@@ -66,11 +80,18 @@
|
|
|
66
80
|
bind:value={title}
|
|
67
81
|
placeholder="Title (optional)"
|
|
68
82
|
/>
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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}
|
|
74
95
|
/>
|
|
75
96
|
{#if error}
|
|
76
97
|
<p class="text-sm text-red-400">
|
|
@@ -46,8 +46,9 @@ export interface FeedbackBoardState {
|
|
|
46
46
|
title?: string;
|
|
47
47
|
message: string;
|
|
48
48
|
category?: string;
|
|
49
|
+
pastedImage?: string | null;
|
|
49
50
|
}): Promise<void>;
|
|
50
|
-
submitResponse(itemId: string, message: string): Promise<void>;
|
|
51
|
+
submitResponse(itemId: string, message: string, pastedImage?: string | null): Promise<void>;
|
|
51
52
|
getCategoryIcon(name: string): string;
|
|
52
53
|
getCategoryLabel(name: string): string;
|
|
53
54
|
getCategoryDescription(name: string): string;
|
|
@@ -128,17 +128,26 @@ export function createFeedbackBoard(options) {
|
|
|
128
128
|
async function submitFeedback(data) {
|
|
129
129
|
if (!resolvedEmail)
|
|
130
130
|
throw new Error('Authentication required to submit feedback');
|
|
131
|
+
const metadata = {};
|
|
132
|
+
if (data.pastedImage)
|
|
133
|
+
metadata.pastedScreenshot = data.pastedImage;
|
|
131
134
|
await client.submitFeedback({
|
|
132
|
-
|
|
135
|
+
title: data.title,
|
|
136
|
+
message: data.message,
|
|
137
|
+
category: data.category,
|
|
133
138
|
email: resolvedEmail,
|
|
134
139
|
pageUrl: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
140
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
135
141
|
});
|
|
136
142
|
await load();
|
|
137
143
|
}
|
|
138
|
-
async function submitResponse(itemId, message) {
|
|
144
|
+
async function submitResponse(itemId, message, pastedImage) {
|
|
139
145
|
if (!resolvedEmail)
|
|
140
146
|
throw new Error('Authentication required to submit a response');
|
|
141
|
-
|
|
147
|
+
const payload = { message, author: resolvedEmail };
|
|
148
|
+
if (pastedImage)
|
|
149
|
+
payload.pastedImage = pastedImage;
|
|
150
|
+
await client.submitResponse(itemId, payload);
|
|
142
151
|
// Invalidate cache so next getResponses fetches fresh data
|
|
143
152
|
delete responseCache[itemId];
|
|
144
153
|
// Update response count on the item
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED