@alliance-droid/status-feedback-system 2.1.0 → 2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0
4
+
5
+ ### Added
6
+
7
+ - `<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.
8
+ - `<PasteImageInput>` shared component exported from the package root for consumers that want to build their own paste-to-attach inputs.
9
+
3
10
  ## 2.0.0
4
11
 
5
12
  ### Breaking Changes
@@ -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
- <Textarea
438
- value={formMessage} oninput={(e) => (formMessage = (e.currentTarget as HTMLTextAreaElement).value)}
439
- placeholder={formType === 'bug'
440
- ? 'What happened? What did you expect?'
441
- : 'Describe the feature you\'d like...'}
442
- label={formType === 'bug' ? 'Description' : 'Details'}
443
- rows={4}
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>
@@ -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
+ &times;
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/package.json CHANGED
@@ -1,66 +1,66 @@
1
1
  {
2
- "name": "@alliance-droid/status-feedback-system",
3
- "version": "2.1.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
- }
2
+ "name": "@alliance-droid/status-feedback-system",
3
+ "version": "2.2.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
+ }