Files
Crussell/frontend/src/lib/components/admin/ImageUpload.svelte
T
popertotsandSisyphus 8f8bf83b64 feat(frontend): add custom services management UI components
Add CustomServicesManagement component for CRUD operations on custom services. Update BookingCreateModal, WalkInCreateModal, and BookingsCard to support custom service selection. Minor improvements to ServicesManagement and ImageUpload.

Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-15 16:58:28 +01:00

963 lines
30 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
// =============== Image Upload ===============
let uploading = $state(false);
let uploadFiles = $state<File[]>([]);
let filePreviews = $state<{ file: File; preview: string }[]>([]);
let uploadProgress = $state(0);
let uploadStatus = $state<Record<string, string>>({});
let uploadResults = $state<{ name: string; url?: string; error?: string }[]>([]);
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return (bytes / 1024).toFixed(1) + ' KB';
}
function isFileTooBig(file: File): boolean {
return file.size > MAX_FILE_SIZE;
}
let hasOversizedFiles = $derived(uploadFiles.some(isFileTooBig));
function isHeicFile(file: File): boolean {
const name = file.name.toLowerCase();
return name.endsWith('.heic') || name.endsWith('.heif');
}
/**
* Check if the browser can natively decode a HEIC file.
* Safari (macOS/iOS) and Chrome-on-Android have native HEIC support,
* which preserves HDR metadata and color profiles better than any conversion.
*/
function supportsNativeHeicDecode(file: File): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => { URL.revokeObjectURL(url); resolve(true); };
img.onerror = () => { URL.revokeObjectURL(url); resolve(false); };
img.src = url;
});
}
async function convertHeicToPng(file: File): Promise<File> {
const heic2any = (await import('heic2any')).default;
const result = await heic2any({ blob: file, toType: 'image/png' });
const blob = Array.isArray(result) ? result[0] : result;
const pngName = file.name.replace(/\.(heic|heif)$/i, '.png');
return new File([blob], pngName, { type: 'image/png' });
}
async function handleFilesDropped(files: File[]) {
const converted = await Promise.all(
files.map(async (file) => {
if (!isHeicFile(file)) return file;
// Try native browser HEIC decode first (Safari, Chrome w/ HEVC)
// This preserves HDR metadata & color profiles at full quality
try {
const nativeOk = await supportsNativeHeicDecode(file);
if (nativeOk) return file;
} catch {
// Fall through to heic2any
}
// Fallback: convert to PNG via heic2any (libheif WASM)
// PNG is lossless, libheif handles ICC profiles correctly,
// and the downstream canvas pipeline is 8-bit anyway.
try {
return await convertHeicToPng(file);
} catch (e) {
console.error('HEIC conversion failed for', file.name, e);
return file;
}
})
);
uploadFiles = converted;
generatePreviews(converted);
}
/** Generate scaled previews for each file */
function generatePreviews(files: File[]) {
filePreviews = [];
files.forEach((file) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = 150 / Math.max(img.width, img.height);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const preview = canvas.toDataURL('image/jpeg', 0.8);
filePreviews = [...filePreviews, { file, preview }];
}
};
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
});
}
/** Load a File or Blob into an HTMLImageElement. */
function loadImage(src: File | Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(src);
});
}
/** Encode ImageData to AVIF via Web Worker. */
function encodeAvif(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('$lib/workers/avif-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/avif' }));
};
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.postMessage({ imageData, quality });
});
}
/** Encode ImageData to WebP via Web Worker. */
function encodeWebp(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('$lib/workers/webp-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/webp' }));
};
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.postMessage({ imageData, quality });
});
}
/** Encode ImageData to JPEG via Web Worker. */
function encodeJpeg(imageData: ImageData, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('$lib/workers/jpeg-encoder.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
worker.terminate();
if (e.data.error) return reject(new Error(e.data.error));
resolve(new Blob([e.data.encoded], { type: 'image/jpeg' }));
};
worker.onerror = (err) => { worker.terminate(); reject(err); };
worker.postMessage({ imageData, quality });
});
}
/** Encode ImageData to JPEG XL via Web Worker. Returns null if unavailable. */
function encodeJxl(imageData: ImageData, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
const worker = new Worker(
new URL('$lib/workers/jxl-encoder.ts', import.meta.url),
{ type: 'module' }
);
const timeout = setTimeout(() => { worker.terminate(); resolve(null); }, 10000);
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
clearTimeout(timeout);
worker.terminate();
if (e.data.error) return resolve(null);
resolve(new Blob([e.data.encoded], { type: 'image/jxl' }));
};
worker.onerror = () => { clearTimeout(timeout); worker.terminate(); resolve(null); };
worker.postMessage({ imageData, quality });
});
}
/** Create a 250×250 center-cropped thumbnail canvas using multi-pass downsampling. */
function createThumbnailCanvas(img: HTMLImageElement): HTMLCanvasElement {
const targetSize = 250;
let srcWidth = img.width;
let srcHeight = img.height;
let sourceCanvas: HTMLCanvasElement | null = null;
const SCALE_FACTOR = 0.2;
const STOP_THRESHOLD = targetSize * 1.1;
while (Math.min(srcWidth, srcHeight) > STOP_THRESHOLD) {
const scale = Math.max(SCALE_FACTOR, targetSize / Math.min(srcWidth, srcHeight));
const newWidth = Math.round(srcWidth * scale);
const newHeight = Math.round(srcHeight * scale);
const nextCanvas = document.createElement('canvas');
nextCanvas.width = newWidth;
nextCanvas.height = newHeight;
const nextCtx = nextCanvas.getContext('2d')!;
nextCtx.imageSmoothingEnabled = true;
nextCtx.imageSmoothingQuality = 'high';
if (sourceCanvas === null) {
nextCtx.drawImage(img, 0, 0, newWidth, newHeight);
} else {
nextCtx.drawImage(sourceCanvas, 0, 0, newWidth, newHeight);
}
sourceCanvas = nextCanvas;
srcWidth = newWidth;
srcHeight = newHeight;
}
// Final crop to exact 250×250 square from center
const finalCanvas = document.createElement('canvas');
finalCanvas.width = targetSize;
finalCanvas.height = targetSize;
const finalCtx = finalCanvas.getContext('2d')!;
finalCtx.imageSmoothingEnabled = true;
finalCtx.imageSmoothingQuality = 'high';
if (sourceCanvas === null) {
const offsetX = (img.width - targetSize) / 2;
const offsetY = (img.height - targetSize) / 2;
finalCtx.drawImage(
img,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
} else {
const offsetX = (srcWidth - targetSize) / 2;
const offsetY = (srcHeight - targetSize) / 2;
finalCtx.drawImage(
sourceCanvas,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
}
return finalCanvas;
}
/** Resize to max 1500px on the short side, only scale down, never up. Returns canvas. */
function createFullCanvas(img: HTMLImageElement): HTMLCanvasElement {
let { width, height } = img;
const maxShortSide = 1500;
const shortSide = Math.min(width, height);
if (shortSide > maxShortSide) {
if (width < height) {
const scale = maxShortSide / width;
width = maxShortSide;
height = Math.round(height * scale);
} else {
const scale = maxShortSide / height;
height = maxShortSide;
width = Math.round(width * scale);
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, width, height);
return canvas;
}
/** Generate all thumbnail variants (AVIF, WebP, JPEG) from a single file. */
async function generateThumbnailVariants(file: File): Promise<{
avif: Blob;
webp: Blob;
jpg: Blob;
}> {
const img = await loadImage(file);
const canvas = createThumbnailCanvas(img);
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const [avif, webp, jpg] = await Promise.all([
encodeAvif(imageData, 80),
encodeWebp(imageData, 80),
encodeJpeg(imageData, 85)
]);
return { avif, webp, jpg };
}
/** Generate all full-size variants (AVIF, WebP, JPEG, optional JXL) from a single file. */
async function generateFullVariants(file: File): Promise<{
avif: Blob;
webp: Blob;
jpg: Blob;
jxl: Blob | null;
}> {
const img = await loadImage(file);
const canvas = createFullCanvas(img);
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const [avif, webp, jpg, jxl] = await Promise.all([
encodeAvif(imageData, 72),
encodeWebp(imageData, 80),
encodeJpeg(imageData, 85),
encodeJxl(imageData, 75)
]);
return { avif, webp, jpg, jxl };
}
let availableTags = $state<string[]>([]);
let loadingTags = $state(true);
let isMobile = $state(false);
async function fetchTags() {
try {
const response = await fetch('/api/portfolio/tags');
if (response.ok) {
const data = await response.json();
availableTags = data.map((t: { name: string }) => t.name);
}
} catch (e) {
console.error('Failed to fetch tags:', e);
} finally {
loadingTags = false;
}
}
// Check for mobile on mount
if (typeof window !== 'undefined') {
isMobile = window.matchMedia('(pointer: coarse)').matches;
}
fetchTags();
let tags = $state<string[]>([]);
let input = $state('');
let selectedSuggestionIndex = $state(-1);
let inputRef = $state<HTMLInputElement | undefined>(undefined);
let showConfirmUploadAlert = $state(false);
const suggestions = $derived.by(() => {
const q = input.trim().toLowerCase();
if (!q) return [];
return availableTags
.map((t) => t.toLowerCase())
.filter((t) => t.startsWith(q) && !tags.includes(t))
.slice(0, 4);
});
function handleTagInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
if (value.includes(',')) {
addTag(value);
input = '';
}
selectedSuggestionIndex = -1;
}
function handlePaste(e: ClipboardEvent) {
const value = e.clipboardData?.getData('text') || '';
if (value.includes(',')) {
e.preventDefault();
addTag(value);
input = '';
}
}
function isSemantic(tag: string) {
return tag.includes(':');
}
function addTag(raw: string) {
raw.split(',').forEach((p) => {
let t = p.trim().toLowerCase();
if (!t) return;
t = t.replace(/^colour:/, 'color:');
if (!tags.includes(t)) tags = [...tags, t];
});
}
function handleKey(e: KeyboardEvent) {
// Prevent tab from moving focus away from this input
if (e.key === 'Tab') {
e.preventDefault();
// Allow arrow key navigation when tab is pressed
if (e.shiftKey) {
// Shift+Tab - go to previous suggestion (wrap to end)
if (suggestions.length > 0) {
if (selectedSuggestionIndex <= 0) {
selectedSuggestionIndex = suggestions.length - 1;
} else {
selectedSuggestionIndex = selectedSuggestionIndex - 1;
}
}
} else {
// Tab - go to next suggestion (wrap to start)
if (suggestions.length > 0) {
if (selectedSuggestionIndex >= suggestions.length - 1) {
selectedSuggestionIndex = 0;
} else {
selectedSuggestionIndex = selectedSuggestionIndex + 1;
}
}
}
return;
}
// Handle mobile keyboard action keys
const isActionKey = ['Enter', 'Done', 'Go'].includes(e.key);
if (isActionKey) {
e.preventDefault();
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (suggestions.length > 0) {
// Wrap to start if at end
if (selectedSuggestionIndex >= suggestions.length - 1) {
selectedSuggestionIndex = 0;
} else {
selectedSuggestionIndex = Math.min(selectedSuggestionIndex + 1, suggestions.length - 1);
}
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (suggestions.length > 0) {
// Wrap to end if at start
if (selectedSuggestionIndex <= 0) {
selectedSuggestionIndex = suggestions.length - 1;
} else {
selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, -1);
}
}
return;
}
if (e.key === 'Enter' || e.key === 'Done' || e.key === 'Go') {
// If there's a highlighted suggestion, select it
if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
selectSuggestion(suggestions[selectedSuggestionIndex]);
return;
}
// If input has text, add it as tag
if (input.trim()) {
addTag(input);
input = '';
return;
}
// If input is empty but we have tags, show confirmation to upload
if (tags.length > 0) {
showConfirmUploadAlert = true;
return;
}
return;
}
if (e.key === 'Backspace' && !input && tags.length) {
tags = tags.slice(0, -1);
}
}
function selectSuggestion(tag: string) {
addTag(tag);
input = '';
selectedSuggestionIndex = -1;
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
}
/** Core upload function multi-format: generates AVIF+WebP+JPEG+JXL variants. */
async function uploadOneOrMany() {
// Add any pending input as tag before uploading
if (input.trim()) {
addTag(input);
input = '';
}
if (!uploadFiles.length) return;
uploading = true;
uploadResults = [];
uploadStatus = {};
uploadProgress = 0;
const startTs = Date.now(); // timestamp of button click
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
const fileKey = file.name;
const fd = new FormData();
try {
/* -------- 1. Generate all full-size variants (AVIF, WebP, JPEG, JXL) ------- */
uploadStatus[fileKey] = 'Generating full-size variants...';
const fullVariants = await generateFullVariants(file);
/* -------- 2. Generate all thumbnail variants (AVIF, WebP, JPEG) ------- */
uploadStatus[fileKey] = 'Generating thumbnail variants...';
const thumbVariants = await generateThumbnailVariants(file);
/* -------- 3. Generate filenames -------------------------------- */
const ts = startTs - i; // 1 ms decrement per file
/* -------- 4. Attach all variants to FormData -------------------------------- */
fd.append('file_full_avif', fullVariants.avif, `${ts}_full.avif`);
fd.append('file_full_webp', fullVariants.webp, `${ts}_full.webp`);
fd.append('file_full_jpg', fullVariants.jpg, `${ts}_full.jpg`);
if (fullVariants.jxl) {
fd.append('file_full_jxl', fullVariants.jxl, `${ts}_full.jxl`);
}
fd.append('file_thumb_avif', thumbVariants.avif, `${ts}_thumb.avif`);
fd.append('file_thumb_webp', thumbVariants.webp, `${ts}_thumb.webp`);
fd.append('file_thumb_jpg', thumbVariants.jpg, `${ts}_thumb.jpg`);
if (tags.length > 0) {
fd.append('tags', tags.join(','));
}
/* -------- 5. Call the API ------------------------------------- */
uploadStatus[fileKey] = 'Uploading...';
const response = await fetch('/api/portfolio/images', {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
},
body: fd
});
if (!response.ok) {
const errText = await response.text();
uploadResults.push({
name: file.name,
error: errText || `Server error: ${response.status}`
});
uploadStatus[fileKey] = 'Failed';
} else {
const result = await response.json();
uploadResults.push({
name: file.name,
url: result.url
});
uploadStatus[fileKey] = 'Complete';
}
} catch (err: unknown) {
uploadResults.push({
name: file.name,
error: err instanceof Error ? err.message : 'Unknown error'
});
uploadStatus[fileKey] = 'Error';
}
uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100);
}
uploading = false;
uploadFiles = [];
filePreviews = [];
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="mb-6">
<h2 class="text-2xl font-bold">Upload Images</h2>
<p class="mt-1 text-sm text-gray-600">
Add portfolio images with tags and optional descriptions
</p>
</div>
<!-- Main Card -->
<Card.Root>
<Card.Header>
<div class="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<div>
<Card.Title>Drop or Select Files</Card.Title>
<Card.Description>HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-6">
<!-- File Drop Zone -->
<FileDropZone onfiles={handleFilesDropped} accept="image/*" multiple>
<div
class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 px-4 py-8 text-center transition-colors hover:border-primary hover:bg-primary/5 sm:py-12"
>
<svg
class="mb-3 h-8 w-8 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p class="text-sm font-medium text-gray-700">Drop images here or click to browse</p>
<p class="mt-1 text-xs text-gray-500">HEIC, AVIF, WebP, PNG, JPEG, GIF &amp; more &mdash; max 20MB per file</p>
</div>
</FileDropZone>
<!-- Selected Files Section -->
{#if uploadFiles.length > 0}
<div class="border-t pt-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="font-semibold text-gray-900">Ready to Upload</h3>
<p class="text-sm text-gray-600">
{uploadFiles.length}
{uploadFiles.length === 1 ? 'file' : 'files'} selected
</p>
</div>
<span
class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700"
>
{uploadFiles.length} pending
</span>
</div>
<!-- File List -->
<div class="space-y-2">
{#each uploadFiles as f (f.name)}
{@const preview = filePreviews.find((p) => p.file === f)?.preview}
{@const oversized = isFileTooBig(f)}
<div
class="flex items-center justify-between rounded-lg border bg-white p-3 sm:p-4"
class:border-red-300={oversized}
class:border-gray-200={!oversized}
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex-shrink-0">
{#if preview}
<img src={preview} alt={f.name} class="h-12 w-12 rounded object-cover" />
{:else}
<div class="flex h-12 w-12 items-center justify-center rounded bg-blue-100">
<svg
class="h-6 w-6 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium" class:text-gray-900={!oversized} class:text-red-900={oversized}>
{f.name}
</p>
<div class="flex items-center gap-2">
<p class="text-xs" class:text-gray-500={!oversized} class:text-red-600={oversized}>
{formatFileSize(f.size)}
{#if oversized}
&mdash; exceeds 20MB limit
{/if}
</p>
{#if uploading && uploadStatus[f.name]}
<span
class="inline-flex items-center gap-1.5 text-xs font-medium text-blue-600"
>
<svg
class="h-3 w-3 animate-spin"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" opacity="0.3" />
<path d="M12 2A10 10 0 0 1 22 12" />
</svg>
{uploadStatus[f.name]}
</span>
{/if}
</div>
</div>
</div>
<button
type="button"
onclick={() => (uploadFiles = uploadFiles.filter((x) => x !== f))}
class="ml-2 flex-shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:outline-none"
aria-label="Remove {f.name}"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
</div>
{/if}
<!-- Upload Results Section -->
{#if uploadResults.length > 0}
<div class="border-t pt-6">
<h3 class="mb-4 font-semibold text-gray-900">Upload Results</h3>
<div class="space-y-2">
{#each uploadResults as result (result.url || result.name)}
<div
class="rounded-lg border p-3 sm:p-4"
class:bg-red-50={result.error}
class:border-red-200={result.error}
class:bg-green-50={!result.error}
class:border-green-200={!result.error}
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 pt-0.5">
{#if result.error}
<svg
class="h-5 w-5 text-red-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{:else}
<svg
class="h-5 w-5 text-green-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
{/if}
</div>
<div class="min-w-0 flex-1">
<p
class="truncate text-sm font-medium {result.error
? 'text-red-900'
: 'text-green-900'}"
>
{result.name}
</p>
{#if result.error}
<p class="mt-1 text-xs text-red-700">{result.error}</p>
{:else}
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
class="mt-1 inline-block text-xs text-green-700 underline hover:text-green-900"
>
View uploaded image →
</a>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Tags Section -->
<div class="border-t pt-6">
<label for="tag-input" class="block text-sm font-semibold text-gray-900">Add Tags</label>
<p class="mt-1 text-xs text-gray-600">
Searchable tags help organize images. Use <code class="rounded bg-gray-100 px-1 py-0.5"
>category:value</code
> format.
</p>
<div class="relative">
<div class="flex gap-2">
<div
class="flex min-h-[44px] w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm transition-colors focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/10 focus-within:outline-none"
>
{#each tags as tag (tag)}
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors"
class:bg-indigo-100={isSemantic(tag)}
class:text-indigo-800={isSemantic(tag)}
class:bg-emerald-100={!isSemantic(tag)}
class:text-emerald-800={!isSemantic(tag)}
>
{tag}
<button
type="button"
onclick={() => removeTag(tag)}
class="inline-flex h-4 w-4 items-center justify-center rounded-full leading-none transition-colors hover:opacity-80 focus:ring-2 focus:ring-offset-2 focus:outline-none"
class:text-indigo-700={isSemantic(tag)}
class:focus:ring-indigo-500={isSemantic(tag)}
class:text-emerald-700={!isSemantic(tag)}
class:focus:ring-emerald-500={!isSemantic(tag)}
aria-label="Remove {tag}"
>
×
</button>
</span>
{/each}
<input
type="search"
maxlength={256}
enterkeyhint="done"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
class="min-w-[100px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-gray-500"
bind:this={inputRef}
bind:value={input}
onkeydown={handleKey}
oninput={handleTagInput}
onpaste={handlePaste}
placeholder={tags.length ? '' : 'Add tags…'}
/>
</div>
{#if input.length && suggestions.length}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md"
>
{#each suggestions as s, i (s)}
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
<div
class="cursor-pointer touch-manipulation px-3 py-3 text-sm {isHighlighted
? 'bg-primary/10 font-medium text-primary'
: 'hover:bg-gray-100'}"
onclick={() => selectSuggestion(s)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectSuggestion(s)}
role="button"
tabindex="0"
>
{s}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Action Buttons -->
<div class="border-t pt-6">
<div class="flex justify-end">
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading || hasOversizedFiles}>
{#if uploading}
<svg
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" opacity="0.3" />
<path d="M12 2A10 10 0 0 1 22 12" />
</svg>
Uploading ({uploadProgress}%)
{:else}
<svg
class="mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Upload {uploadFiles.length > 1 ? uploadFiles.length + ' Files' : 'File'}
{/if}
</Button>
</div>
</div>
</div></Card.Content
>
</Card.Root>
<!-- Confirmation Dialog -->
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
<AlertDialog.Content class="z-60">
<AlertDialog.Header>
<AlertDialog.Title>Confirm Upload</AlertDialog.Title>
<AlertDialog.Description>
You're about to upload {uploadFiles.length}
{uploadFiles.length === 1 ? 'file' : 'files'} with {tags.length}
{tags.length === 1 ? 'tag' : 'tags'}.
{#if tags.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
{#each tags as tag (tag)}
<span
class="inline-block rounded-full bg-gray-200 px-2.5 py-1 text-xs text-gray-800"
>
{tag}
</span>
{/each}
</div>
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={uploadOneOrMany}>Upload Now</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>