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>
963 lines
30 KiB
Svelte
963 lines
30 KiB
Svelte
<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 & more — 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}
|
||
— 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>
|