@alliance-droid/status-feedback-system 2.1.0 → 2.3.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/CHANGELOG.md +14 -0
- package/dist/api.d.ts +1 -0
- package/dist/components/DevFeedback.svelte +39 -9
- package/dist/components/board/FeedbackDetail.svelte +135 -9
- package/dist/components/board/state.svelte.d.ts +1 -1
- package/dist/components/board/state.svelte.js +5 -2
- package/dist/components/shared/PasteImageInput.svelte +169 -0
- package/dist/components/shared/PasteImageInput.svelte.d.ts +14 -0
- package/dist/components/shared/index.d.ts +1 -0
- package/dist/components/shared/index.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +1 -0
- package/package.json +65 -65
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `<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.
|
|
8
|
+
- `FeedbackResponse.imageUrl` field for persisted image rendering.
|
|
9
|
+
|
|
10
|
+
## 2.2.0
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `<DevFeedback>` *Report Bug* form: paste a screenshot from clipboard (Cmd/Ctrl+V) into the description textarea. Pasted images attach to the resulting ADO work item. When both a pasted image and a server-side capture are available, the pasted image wins.
|
|
15
|
+
- `<PasteImageInput>` shared component exported from the package root for consumers that want to build their own paste-to-attach inputs.
|
|
16
|
+
|
|
3
17
|
## 2.0.0
|
|
4
18
|
|
|
5
19
|
### Breaking Changes
|
package/dist/api.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, onDestroy } from 'svelte';
|
|
3
3
|
import { createStatusClient, type StatusClient } from '../api.js';
|
|
4
|
+
import PasteImageInput from './shared/PasteImageInput.svelte';
|
|
4
5
|
import {
|
|
5
6
|
Card,
|
|
6
7
|
Button,
|
|
@@ -142,6 +143,10 @@
|
|
|
142
143
|
let formType = $state<'bug' | 'feature'>('bug');
|
|
143
144
|
let formTitle = $state('');
|
|
144
145
|
let formMessage = $state('');
|
|
146
|
+
let pastedScreenshot = $state<string | null>(null);
|
|
147
|
+
let pastedFilename = $state('');
|
|
148
|
+
let messageTextareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
149
|
+
let messageTextareaEl: HTMLTextAreaElement | undefined = $state();
|
|
145
150
|
|
|
146
151
|
|
|
147
152
|
let submitting = $state(false);
|
|
@@ -246,6 +251,8 @@
|
|
|
246
251
|
menuOpen = false;
|
|
247
252
|
formOpen = false;
|
|
248
253
|
renderContext = null;
|
|
254
|
+
pastedScreenshot = null;
|
|
255
|
+
pastedFilename = '';
|
|
249
256
|
}
|
|
250
257
|
}
|
|
251
258
|
|
|
@@ -277,10 +284,18 @@
|
|
|
277
284
|
}
|
|
278
285
|
}
|
|
279
286
|
|
|
287
|
+
$effect(() => {
|
|
288
|
+
if (messageTextareaWrapperEl) {
|
|
289
|
+
messageTextareaEl = messageTextareaWrapperEl.querySelector('textarea') ?? undefined;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
280
293
|
function openForm(type: 'bug' | 'feature') {
|
|
281
294
|
formType = type;
|
|
282
295
|
formTitle = '';
|
|
283
296
|
formMessage = '';
|
|
297
|
+
pastedScreenshot = null;
|
|
298
|
+
pastedFilename = '';
|
|
284
299
|
submitResult = null;
|
|
285
300
|
menuOpen = false;
|
|
286
301
|
formOpen = true;
|
|
@@ -318,6 +333,11 @@
|
|
|
318
333
|
metadata.consoleErrors = [...consoleErrors];
|
|
319
334
|
}
|
|
320
335
|
|
|
336
|
+
if (pastedScreenshot) {
|
|
337
|
+
metadata.pastedScreenshot = pastedScreenshot;
|
|
338
|
+
metadata.pastedScreenshotFilename = pastedFilename;
|
|
339
|
+
}
|
|
340
|
+
|
|
321
341
|
if (renderContext) {
|
|
322
342
|
metadata.renderToken = renderContext.renderToken;
|
|
323
343
|
metadata.issuer = renderContext.issuer;
|
|
@@ -346,6 +366,8 @@
|
|
|
346
366
|
formOpen = false;
|
|
347
367
|
submitResult = null;
|
|
348
368
|
renderContext = null;
|
|
369
|
+
pastedScreenshot = null;
|
|
370
|
+
pastedFilename = '';
|
|
349
371
|
}, 2000);
|
|
350
372
|
} catch {
|
|
351
373
|
submitResult = 'error';
|
|
@@ -434,13 +456,21 @@
|
|
|
434
456
|
label="Title"
|
|
435
457
|
/>
|
|
436
458
|
|
|
437
|
-
<
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
459
|
+
<div bind:this={messageTextareaWrapperEl}>
|
|
460
|
+
<Textarea
|
|
461
|
+
value={formMessage} oninput={(e) => (formMessage = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
462
|
+
placeholder={formType === 'bug'
|
|
463
|
+
? 'What happened? What did you expect?'
|
|
464
|
+
: 'Describe the feature you\'d like...'}
|
|
465
|
+
label={formType === 'bug' ? 'Description' : 'Details'}
|
|
466
|
+
rows={4}
|
|
467
|
+
/>
|
|
468
|
+
</div>
|
|
469
|
+
<PasteImageInput
|
|
470
|
+
textareaEl={messageTextareaEl}
|
|
471
|
+
bind:image={pastedScreenshot}
|
|
472
|
+
bind:filename={pastedFilename}
|
|
473
|
+
previewClass="mt-2"
|
|
444
474
|
/>
|
|
445
475
|
|
|
446
476
|
<!-- Auto-captured context (bugs only) -->
|
|
@@ -475,7 +505,7 @@
|
|
|
475
505
|
<div class="flex items-center gap-2 text-sm text-foreground-tertiary">
|
|
476
506
|
<Spinner /> Preparing screenshot...
|
|
477
507
|
</div>
|
|
478
|
-
{:else if renderTokenFailed}
|
|
508
|
+
{:else if renderTokenFailed && !pastedScreenshot}
|
|
479
509
|
<div class="rounded-md border border-border bg-surface p-3">
|
|
480
510
|
<div class="flex items-center gap-2 text-sm text-foreground-secondary mb-1">
|
|
481
511
|
<i class="fa-solid fa-image-slash" aria-hidden="true"></i>
|
|
@@ -489,7 +519,7 @@
|
|
|
489
519
|
Retry
|
|
490
520
|
</button>
|
|
491
521
|
</div>
|
|
492
|
-
{:else if renderContext}
|
|
522
|
+
{:else if renderContext && !pastedScreenshot}
|
|
493
523
|
<div class="rounded-md border border-border bg-surface-secondary p-2 flex items-center gap-2 text-sm text-foreground-secondary">
|
|
494
524
|
<i class="fa-solid fa-camera" aria-hidden="true"></i>
|
|
495
525
|
<span>Screenshot will be captured server-side on submit</span>
|
|
@@ -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>
|
|
@@ -47,7 +47,7 @@ export interface FeedbackBoardState {
|
|
|
47
47
|
message: string;
|
|
48
48
|
category?: string;
|
|
49
49
|
}): Promise<void>;
|
|
50
|
-
submitResponse(itemId: string, message: string): Promise<void>;
|
|
50
|
+
submitResponse(itemId: string, message: string, pastedImage?: string | null): Promise<void>;
|
|
51
51
|
getCategoryIcon(name: string): string;
|
|
52
52
|
getCategoryLabel(name: string): string;
|
|
53
53
|
getCategoryDescription(name: string): string;
|
|
@@ -135,10 +135,13 @@ export function createFeedbackBoard(options) {
|
|
|
135
135
|
});
|
|
136
136
|
await load();
|
|
137
137
|
}
|
|
138
|
-
async function submitResponse(itemId, message) {
|
|
138
|
+
async function submitResponse(itemId, message, pastedImage) {
|
|
139
139
|
if (!resolvedEmail)
|
|
140
140
|
throw new Error('Authentication required to submit a response');
|
|
141
|
-
|
|
141
|
+
const payload = { message, author: resolvedEmail };
|
|
142
|
+
if (pastedImage)
|
|
143
|
+
payload.pastedImage = pastedImage;
|
|
144
|
+
await client.submitResponse(itemId, payload);
|
|
142
145
|
// Invalidate cache so next getResponses fetches fresh data
|
|
143
146
|
delete responseCache[itemId];
|
|
144
147
|
// Update response count on the item
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
textareaEl: HTMLTextAreaElement | undefined;
|
|
6
|
+
image?: string | null;
|
|
7
|
+
filename?: string;
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
allowedMimeTypes?: string[];
|
|
10
|
+
onimage?: (value: string | null) => void;
|
|
11
|
+
previewClass?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
textareaEl,
|
|
16
|
+
image = $bindable(null),
|
|
17
|
+
filename = $bindable(''),
|
|
18
|
+
maxBytes = 5 * 1024 * 1024,
|
|
19
|
+
allowedMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
|
20
|
+
onimage,
|
|
21
|
+
previewClass = '',
|
|
22
|
+
}: Props = $props();
|
|
23
|
+
|
|
24
|
+
let errorMessage = $state('');
|
|
25
|
+
let errorTimer: ReturnType<typeof setTimeout> | null = null;
|
|
26
|
+
let attachedTextarea: HTMLTextAreaElement | null = null;
|
|
27
|
+
|
|
28
|
+
function setError(msg: string) {
|
|
29
|
+
errorMessage = msg;
|
|
30
|
+
if (errorTimer) clearTimeout(errorTimer);
|
|
31
|
+
errorTimer = setTimeout(() => { errorMessage = ''; }, 4000);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function handlePaste(e: ClipboardEvent) {
|
|
35
|
+
const items = Array.from(e.clipboardData?.items ?? []);
|
|
36
|
+
const imageItem = items.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
|
|
37
|
+
if (!imageItem) return;
|
|
38
|
+
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
const file = imageItem.getAsFile();
|
|
41
|
+
if (!file) return;
|
|
42
|
+
|
|
43
|
+
if (!allowedMimeTypes.includes(file.type)) {
|
|
44
|
+
setError('Unsupported image type \u2014 PNG, JPEG, GIF, or WebP only');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (file.size > maxBytes) {
|
|
48
|
+
const limitMb = Math.round(maxBytes / (1024 * 1024));
|
|
49
|
+
setError(`Image too large \u2014 max ${limitMb} MB`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const dataUrl = await readFileAsDataUrl(file);
|
|
54
|
+
image = dataUrl;
|
|
55
|
+
filename = generateFilename(file.type);
|
|
56
|
+
errorMessage = '';
|
|
57
|
+
onimage?.(dataUrl);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readFileAsDataUrl(file: File): Promise<string> {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const reader = new FileReader();
|
|
63
|
+
reader.onload = () => resolve(reader.result as string);
|
|
64
|
+
reader.onerror = () => reject(reader.error);
|
|
65
|
+
reader.readAsDataURL(file);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function generateFilename(mime: string): string {
|
|
70
|
+
const ext = mime.split('/')[1] || 'png';
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const stamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
73
|
+
return `pasted-image-${stamp}.${ext}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function removeImage() {
|
|
77
|
+
image = null;
|
|
78
|
+
filename = '';
|
|
79
|
+
onimage?.(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatSize(dataUrl: string): string {
|
|
83
|
+
const commaIdx = dataUrl.indexOf(',');
|
|
84
|
+
if (commaIdx === -1) return '';
|
|
85
|
+
const bytes = Math.round((dataUrl.length - commaIdx - 1) * 0.75);
|
|
86
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
87
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
88
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
$effect(() => {
|
|
92
|
+
if (attachedTextarea === textareaEl) return;
|
|
93
|
+
if (attachedTextarea) attachedTextarea.removeEventListener('paste', handlePaste);
|
|
94
|
+
attachedTextarea = textareaEl ?? null;
|
|
95
|
+
if (attachedTextarea) attachedTextarea.addEventListener('paste', handlePaste);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
onDestroy(() => {
|
|
99
|
+
if (attachedTextarea) attachedTextarea.removeEventListener('paste', handlePaste);
|
|
100
|
+
if (errorTimer) clearTimeout(errorTimer);
|
|
101
|
+
});
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
{#if image}
|
|
105
|
+
<div class="sf-paste-preview {previewClass}">
|
|
106
|
+
<img src={image} alt={filename} class="sf-paste-thumb" />
|
|
107
|
+
<div class="sf-paste-meta">
|
|
108
|
+
<span class="sf-paste-name">{filename}</span>
|
|
109
|
+
<span class="sf-paste-size">{formatSize(image)}</span>
|
|
110
|
+
</div>
|
|
111
|
+
<button type="button" class="sf-paste-remove" onclick={removeImage} aria-label="Remove image">
|
|
112
|
+
×
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
{/if}
|
|
116
|
+
|
|
117
|
+
{#if errorMessage}
|
|
118
|
+
<p class="sf-paste-error">{errorMessage}</p>
|
|
119
|
+
{/if}
|
|
120
|
+
|
|
121
|
+
<style>
|
|
122
|
+
.sf-paste-preview {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: 0.75rem;
|
|
126
|
+
padding: 0.5rem;
|
|
127
|
+
border: 1px solid var(--color-border, #2a2f3a);
|
|
128
|
+
border-radius: 0.5rem;
|
|
129
|
+
background: var(--color-surface-secondary, rgba(255, 255, 255, 0.04));
|
|
130
|
+
}
|
|
131
|
+
.sf-paste-thumb {
|
|
132
|
+
width: 120px;
|
|
133
|
+
height: 80px;
|
|
134
|
+
object-fit: cover;
|
|
135
|
+
border-radius: 0.25rem;
|
|
136
|
+
flex-shrink: 0;
|
|
137
|
+
}
|
|
138
|
+
.sf-paste-meta {
|
|
139
|
+
flex: 1;
|
|
140
|
+
display: flex;
|
|
141
|
+
flex-direction: column;
|
|
142
|
+
min-width: 0;
|
|
143
|
+
}
|
|
144
|
+
.sf-paste-name {
|
|
145
|
+
font-size: 0.8125rem;
|
|
146
|
+
color: var(--color-foreground, #e5e7eb);
|
|
147
|
+
white-space: nowrap;
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
text-overflow: ellipsis;
|
|
150
|
+
}
|
|
151
|
+
.sf-paste-size {
|
|
152
|
+
font-size: 0.75rem;
|
|
153
|
+
color: var(--color-foreground-tertiary, #9ca3af);
|
|
154
|
+
}
|
|
155
|
+
.sf-paste-remove {
|
|
156
|
+
background: transparent;
|
|
157
|
+
border: none;
|
|
158
|
+
color: var(--color-foreground-tertiary, #9ca3af);
|
|
159
|
+
font-size: 1.25rem;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
padding: 0.25rem 0.5rem;
|
|
162
|
+
}
|
|
163
|
+
.sf-paste-remove:hover { color: var(--color-danger, #ef4444); }
|
|
164
|
+
.sf-paste-error {
|
|
165
|
+
margin-top: 0.5rem;
|
|
166
|
+
font-size: 0.8125rem;
|
|
167
|
+
color: var(--color-danger, #ef4444);
|
|
168
|
+
}
|
|
169
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
textareaEl: HTMLTextAreaElement | undefined;
|
|
3
|
+
image?: string | null;
|
|
4
|
+
filename?: string;
|
|
5
|
+
maxBytes?: number;
|
|
6
|
+
allowedMimeTypes?: string[];
|
|
7
|
+
onimage?: (value: string | null) => void;
|
|
8
|
+
previewClass?: string;
|
|
9
|
+
}
|
|
10
|
+
declare const PasteImageInput: import("svelte").Component<Props, {
|
|
11
|
+
removeImage: () => void;
|
|
12
|
+
}, "image" | "filename">;
|
|
13
|
+
type PasteImageInput = ReturnType<typeof PasteImageInput>;
|
|
14
|
+
export default PasteImageInput;
|
|
@@ -2,3 +2,4 @@ export { default as StatusIndicator } from './StatusIndicator.svelte';
|
|
|
2
2
|
export { default as UptimeBar } from './UptimeBar.svelte';
|
|
3
3
|
export { default as IncidentTimeline } from './IncidentTimeline.svelte';
|
|
4
4
|
export { default as SystemCard } from './SystemCard.svelte';
|
|
5
|
+
export { default as PasteImageInput } from './PasteImageInput.svelte';
|
|
@@ -2,3 +2,4 @@ export { default as StatusIndicator } from './StatusIndicator.svelte';
|
|
|
2
2
|
export { default as UptimeBar } from './UptimeBar.svelte';
|
|
3
3
|
export { default as IncidentTimeline } from './IncidentTimeline.svelte';
|
|
4
4
|
export { default as SystemCard } from './SystemCard.svelte';
|
|
5
|
+
export { default as PasteImageInput } from './PasteImageInput.svelte';
|
package/dist/index.d.ts
CHANGED
|
@@ -23,3 +23,4 @@ export { default as StatusIndicator } from './components/shared/StatusIndicator.
|
|
|
23
23
|
export { default as UptimeBar } from './components/shared/UptimeBar.svelte';
|
|
24
24
|
export { default as IncidentTimeline } from './components/shared/IncidentTimeline.svelte';
|
|
25
25
|
export { default as SystemCard } from './components/shared/SystemCard.svelte';
|
|
26
|
+
export { default as PasteImageInput } from './components/shared/PasteImageInput.svelte';
|
package/dist/index.js
CHANGED
|
@@ -25,3 +25,4 @@ export { default as StatusIndicator } from './components/shared/StatusIndicator.
|
|
|
25
25
|
export { default as UptimeBar } from './components/shared/UptimeBar.svelte';
|
|
26
26
|
export { default as IncidentTimeline } from './components/shared/IncidentTimeline.svelte';
|
|
27
27
|
export { default as SystemCard } from './components/shared/SystemCard.svelte';
|
|
28
|
+
export { default as PasteImageInput } from './components/shared/PasteImageInput.svelte';
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
}
|
|
2
|
+
"name": "@alliance-droid/status-feedback-system",
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Drop-in system status pages and user feedback forms for SvelteKit apps",
|
|
6
|
+
"svelte": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"svelte": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"!dist/**/*.test.*",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "svelte-package -o dist && publint",
|
|
21
|
+
"lint": "eslint src",
|
|
22
|
+
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@alliance-droid/svelte-component-library": ">=1.0.0",
|
|
29
|
+
"svelte": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@alliance-droid/svelte-component-library": "^1.0.5",
|
|
33
|
+
"@eslint/js": "^10.0.1",
|
|
34
|
+
"@sveltejs/package": "^2.3.0",
|
|
35
|
+
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
|
36
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
37
|
+
"@testing-library/svelte": "^5.3.1",
|
|
38
|
+
"eslint": "^10.4.0",
|
|
39
|
+
"eslint-plugin-svelte": "^3.17.1",
|
|
40
|
+
"globals": "^17.6.0",
|
|
41
|
+
"jsdom": "^29.0.0",
|
|
42
|
+
"publint": "^0.3.0",
|
|
43
|
+
"svelte": "^5.48.0",
|
|
44
|
+
"svelte-check": "^4.3.0",
|
|
45
|
+
"typescript": "^5.9.0",
|
|
46
|
+
"typescript-eslint": "^8.59.4",
|
|
47
|
+
"vite": "^7.3.0",
|
|
48
|
+
"vitest": "^4.0.0"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"svelte",
|
|
52
|
+
"sveltekit",
|
|
53
|
+
"status-page",
|
|
54
|
+
"feedback",
|
|
55
|
+
"uptime"
|
|
56
|
+
],
|
|
57
|
+
"author": "Alliance Technical Group",
|
|
58
|
+
"license": "UNLICENSED",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "restricted"
|
|
61
|
+
},
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "git+https://alliance.ghe.com/alliance/status-feedback-system.git"
|
|
65
|
+
}
|
|
66
|
+
}
|