@alliance-droid/status-feedback-system 2.3.2 → 3.1.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 +78 -67
- package/LICENSE +11 -11
- package/README.md +275 -273
- package/dist/api.d.ts +3 -1
- package/dist/components/DevFeedback.svelte +589 -585
- package/dist/components/DevFeedback.svelte.d.ts +7 -2
- package/dist/components/FeedbackBoard.svelte +110 -110
- package/dist/components/FeedbackButton.svelte +72 -72
- package/dist/components/FeedbackForm.svelte +155 -155
- package/dist/components/StatusBanner.svelte +105 -105
- package/dist/components/StatusPage.svelte +244 -244
- package/dist/components/board/FeedbackDetail.svelte +299 -299
- package/dist/components/board/FeedbackItem.svelte +117 -117
- package/dist/components/board/FeedbackList.svelte +58 -58
- package/dist/components/board/FeedbackSidebar.svelte +60 -60
- package/dist/components/board/FeedbackSubmitForm.svelte +123 -123
- package/dist/components/board/utils.d.ts +5 -2
- package/dist/components/board/utils.js +20 -6
- package/dist/components/dev/DevContextMenu.svelte +178 -178
- package/dist/components/dev/DevOnboardingToast.svelte +73 -73
- package/dist/components/dev/environment.d.ts +2 -6
- package/dist/components/dev/environment.js +2 -23
- package/dist/components/shared/IncidentTimeline.svelte +55 -55
- package/dist/components/shared/PasteImageInput.svelte +169 -169
- package/dist/components/shared/StatusIndicator.svelte +50 -50
- package/dist/components/shared/SystemCard.svelte +30 -30
- package/dist/components/shared/UptimeBar.svelte +85 -85
- package/dist/types.d.ts +9 -1
- package/package.json +66 -66
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
interface Props {
|
|
3
|
-
onDismiss: () => void;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
let { onDismiss }: Props = $props();
|
|
7
|
-
</script>
|
|
8
|
-
|
|
9
|
-
<div class="fixed bottom-4 right-4 animate-fade-in" style="z-index: 99997;">
|
|
10
|
-
<div class="sf-toast">
|
|
11
|
-
<i class="fa-solid fa-computer-mouse sf-toast-icon"></i>
|
|
12
|
-
<div>
|
|
13
|
-
<p class="sf-toast-title">Ctrl + Right-click anywhere</p>
|
|
14
|
-
<p class="sf-toast-subtitle">to report bugs or request features</p>
|
|
15
|
-
</div>
|
|
16
|
-
<button class="sf-toast-close" onclick={onDismiss} aria-label="Dismiss">
|
|
17
|
-
<i class="fa-solid fa-xmark"></i>
|
|
18
|
-
</button>
|
|
19
|
-
</div>
|
|
20
|
-
</div>
|
|
21
|
-
|
|
22
|
-
<style>
|
|
23
|
-
@keyframes fade-in {
|
|
24
|
-
from { opacity: 0; transform: translateY(8px); }
|
|
25
|
-
to { opacity: 1; transform: translateY(0); }
|
|
26
|
-
}
|
|
27
|
-
.animate-fade-in {
|
|
28
|
-
animation: fade-in 0.3s ease-out;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
.sf-toast {
|
|
32
|
-
background: var(--sf-surface, var(--color-surface, #1e293b));
|
|
33
|
-
border: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
34
|
-
border-radius: 0.5rem;
|
|
35
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
36
|
-
padding: 0.75rem 1rem;
|
|
37
|
-
display: flex;
|
|
38
|
-
align-items: center;
|
|
39
|
-
gap: 0.75rem;
|
|
40
|
-
max-width: 20rem;
|
|
41
|
-
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
.sf-toast-icon {
|
|
45
|
-
color: var(--color-primary, #3b82f6);
|
|
46
|
-
font-size: 1.125rem;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
.sf-toast-title {
|
|
50
|
-
font-size: 0.875rem;
|
|
51
|
-
font-weight: 600;
|
|
52
|
-
margin: 0;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.sf-toast-subtitle {
|
|
56
|
-
font-size: 0.75rem;
|
|
57
|
-
color: var(--sf-foreground-secondary, var(--color-foreground-secondary, #94a3b8));
|
|
58
|
-
margin: 0;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
.sf-toast-close {
|
|
62
|
-
color: var(--sf-foreground-tertiary, var(--color-foreground-tertiary, #64748b));
|
|
63
|
-
background: transparent;
|
|
64
|
-
border: none;
|
|
65
|
-
cursor: pointer;
|
|
66
|
-
margin-left: 0.5rem;
|
|
67
|
-
transition: color 0.15s;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
.sf-toast-close:hover {
|
|
71
|
-
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
72
|
-
}
|
|
73
|
-
</style>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
onDismiss: () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let { onDismiss }: Props = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div class="fixed bottom-4 right-4 animate-fade-in" style="z-index: 99997;">
|
|
10
|
+
<div class="sf-toast">
|
|
11
|
+
<i class="fa-solid fa-computer-mouse sf-toast-icon"></i>
|
|
12
|
+
<div>
|
|
13
|
+
<p class="sf-toast-title">Ctrl + Right-click anywhere</p>
|
|
14
|
+
<p class="sf-toast-subtitle">to report bugs or request features</p>
|
|
15
|
+
</div>
|
|
16
|
+
<button class="sf-toast-close" onclick={onDismiss} aria-label="Dismiss">
|
|
17
|
+
<i class="fa-solid fa-xmark"></i>
|
|
18
|
+
</button>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<style>
|
|
23
|
+
@keyframes fade-in {
|
|
24
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
25
|
+
to { opacity: 1; transform: translateY(0); }
|
|
26
|
+
}
|
|
27
|
+
.animate-fade-in {
|
|
28
|
+
animation: fade-in 0.3s ease-out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.sf-toast {
|
|
32
|
+
background: var(--sf-surface, var(--color-surface, #1e293b));
|
|
33
|
+
border: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
34
|
+
border-radius: 0.5rem;
|
|
35
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
36
|
+
padding: 0.75rem 1rem;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 0.75rem;
|
|
40
|
+
max-width: 20rem;
|
|
41
|
+
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.sf-toast-icon {
|
|
45
|
+
color: var(--color-primary, #3b82f6);
|
|
46
|
+
font-size: 1.125rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.sf-toast-title {
|
|
50
|
+
font-size: 0.875rem;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
margin: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.sf-toast-subtitle {
|
|
56
|
+
font-size: 0.75rem;
|
|
57
|
+
color: var(--sf-foreground-secondary, var(--color-foreground-secondary, #94a3b8));
|
|
58
|
+
margin: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.sf-toast-close {
|
|
62
|
+
color: var(--sf-foreground-tertiary, var(--color-foreground-tertiary, #64748b));
|
|
63
|
+
background: transparent;
|
|
64
|
+
border: none;
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
margin-left: 0.5rem;
|
|
67
|
+
transition: color 0.15s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.sf-toast-close:hover {
|
|
71
|
+
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -6,12 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export declare function getEnvVar(key: string): string | undefined;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*/
|
|
12
|
-
export declare function detectEnvironment(explicitEnabled?: boolean): boolean;
|
|
13
|
-
/**
|
|
14
|
-
* Get a human-readable environment name.
|
|
9
|
+
* Get a human-readable environment name (labelling only — used in the feedback
|
|
10
|
+
* payload/badge, not for gating).
|
|
15
11
|
*/
|
|
16
12
|
export declare function getEnvironmentName(): string;
|
|
17
13
|
/**
|
|
@@ -21,29 +21,8 @@ export function getEnvVar(key) {
|
|
|
21
21
|
return undefined;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*/
|
|
27
|
-
export function detectEnvironment(explicitEnabled) {
|
|
28
|
-
if (explicitEnabled !== undefined)
|
|
29
|
-
return explicitEnabled;
|
|
30
|
-
if (typeof window === 'undefined')
|
|
31
|
-
return false;
|
|
32
|
-
const feedbackEnabled = getEnvVar('PUBLIC_FEEDBACK_ENABLED');
|
|
33
|
-
if (feedbackEnabled === 'true')
|
|
34
|
-
return true;
|
|
35
|
-
if (feedbackEnabled === 'false')
|
|
36
|
-
return false;
|
|
37
|
-
const vercelEnv = getEnvVar('VERCEL_ENV');
|
|
38
|
-
if (vercelEnv === 'production')
|
|
39
|
-
return false;
|
|
40
|
-
if (vercelEnv)
|
|
41
|
-
return true;
|
|
42
|
-
const host = window.location.hostname;
|
|
43
|
-
return host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local');
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Get a human-readable environment name.
|
|
24
|
+
* Get a human-readable environment name (labelling only — used in the feedback
|
|
25
|
+
* payload/badge, not for gating).
|
|
47
26
|
*/
|
|
48
27
|
export function getEnvironmentName() {
|
|
49
28
|
const vercelEnv = getEnvVar('VERCEL_ENV');
|
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { IncidentUpdate, IncidentStatus } from '../../types.js';
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
updates: IncidentUpdate[];
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
let { updates }: Props = $props();
|
|
9
|
-
|
|
10
|
-
const statusConfig: Record<IncidentStatus, { label: string; icon: string; colorClass: string }> = {
|
|
11
|
-
investigating: {
|
|
12
|
-
label: 'Investigating',
|
|
13
|
-
icon: 'fa-magnifying-glass',
|
|
14
|
-
colorClass: 'text-yellow-500',
|
|
15
|
-
},
|
|
16
|
-
identified: {
|
|
17
|
-
label: 'Identified',
|
|
18
|
-
icon: 'fa-circle-exclamation',
|
|
19
|
-
colorClass: 'text-orange-500',
|
|
20
|
-
},
|
|
21
|
-
monitoring: {
|
|
22
|
-
label: 'Monitoring',
|
|
23
|
-
icon: 'fa-eye',
|
|
24
|
-
colorClass: 'text-blue-500',
|
|
25
|
-
},
|
|
26
|
-
resolved: {
|
|
27
|
-
label: 'Resolved',
|
|
28
|
-
icon: 'fa-circle-check',
|
|
29
|
-
colorClass: 'text-green-500',
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
function formatTime(timestamp: number): string {
|
|
34
|
-
return new Date(timestamp).toLocaleString();
|
|
35
|
-
}
|
|
36
|
-
</script>
|
|
37
|
-
|
|
38
|
-
<div class="space-y-4" role="list" aria-label="Incident timeline">
|
|
39
|
-
{#each updates as update (update.id)}
|
|
40
|
-
{@const config = statusConfig[update.status]}
|
|
41
|
-
<div class="flex gap-3" role="listitem">
|
|
42
|
-
<div class="flex flex-col items-center">
|
|
43
|
-
<i class="fa-solid {config.icon} {config.colorClass} text-sm" aria-hidden="true"></i>
|
|
44
|
-
<div class="mt-1 h-full w-px bg-border"></div>
|
|
45
|
-
</div>
|
|
46
|
-
<div class="pb-4">
|
|
47
|
-
<div class="flex items-center gap-2">
|
|
48
|
-
<span class="text-sm font-semibold text-foreground">{config.label}</span>
|
|
49
|
-
<span class="text-xs text-foreground-tertiary">{formatTime(update.createdAt)}</span>
|
|
50
|
-
</div>
|
|
51
|
-
<p class="mt-1 text-sm text-foreground-secondary">{update.message}</p>
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
{/each}
|
|
55
|
-
</div>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { IncidentUpdate, IncidentStatus } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
updates: IncidentUpdate[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let { updates }: Props = $props();
|
|
9
|
+
|
|
10
|
+
const statusConfig: Record<IncidentStatus, { label: string; icon: string; colorClass: string }> = {
|
|
11
|
+
investigating: {
|
|
12
|
+
label: 'Investigating',
|
|
13
|
+
icon: 'fa-magnifying-glass',
|
|
14
|
+
colorClass: 'text-yellow-500',
|
|
15
|
+
},
|
|
16
|
+
identified: {
|
|
17
|
+
label: 'Identified',
|
|
18
|
+
icon: 'fa-circle-exclamation',
|
|
19
|
+
colorClass: 'text-orange-500',
|
|
20
|
+
},
|
|
21
|
+
monitoring: {
|
|
22
|
+
label: 'Monitoring',
|
|
23
|
+
icon: 'fa-eye',
|
|
24
|
+
colorClass: 'text-blue-500',
|
|
25
|
+
},
|
|
26
|
+
resolved: {
|
|
27
|
+
label: 'Resolved',
|
|
28
|
+
icon: 'fa-circle-check',
|
|
29
|
+
colorClass: 'text-green-500',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function formatTime(timestamp: number): string {
|
|
34
|
+
return new Date(timestamp).toLocaleString();
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<div class="space-y-4" role="list" aria-label="Incident timeline">
|
|
39
|
+
{#each updates as update (update.id)}
|
|
40
|
+
{@const config = statusConfig[update.status]}
|
|
41
|
+
<div class="flex gap-3" role="listitem">
|
|
42
|
+
<div class="flex flex-col items-center">
|
|
43
|
+
<i class="fa-solid {config.icon} {config.colorClass} text-sm" aria-hidden="true"></i>
|
|
44
|
+
<div class="mt-1 h-full w-px bg-border"></div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="pb-4">
|
|
47
|
+
<div class="flex items-center gap-2">
|
|
48
|
+
<span class="text-sm font-semibold text-foreground">{config.label}</span>
|
|
49
|
+
<span class="text-xs text-foreground-tertiary">{formatTime(update.createdAt)}</span>
|
|
50
|
+
</div>
|
|
51
|
+
<p class="mt-1 text-sm text-foreground-secondary">{update.message}</p>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
{/each}
|
|
55
|
+
</div>
|
|
@@ -1,169 +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>
|
|
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>
|