fix: improve portfolio and image upload UX

- Fix tag suggestion dropdown positioning to appear below input (not overlay)
- Limit tag suggestions to 4 for cleaner UI
- Add separate X clear button for search tags (distinct from filter clear)
- Ensure img URL parameter is cleared when closing image modal
This commit is contained in:
2026-03-07 23:28:16 +00:00
parent c7c10bc177
commit a7c6e90978
2 changed files with 522 additions and 184 deletions
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte'; import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -8,15 +9,43 @@
// =============== Image Upload =============== // =============== Image Upload ===============
let uploading = $state(false); let uploading = $state(false);
let uploadFiles = $state<File[]>([]); let uploadFiles = $state<File[]>([]);
let filePreviews = $state<{ file: File; preview: string }[]>([]);
let uploadProgress = $state(0); let uploadProgress = $state(0);
let uploadStatus = $state<Record<string, string>>({});
let uploadResults = $state<{ name: string; url?: string; error?: string }[]>([]); let uploadResults = $state<{ name: string; url?: string; error?: string }[]>([]);
function handleFilesDropped(files: File[]) { function handleFilesDropped(files: File[]) {
uploadFiles = files; uploadFiles = files;
generatePreviews(files);
} }
/** Helper: turn any File into a JPEG-encoded Blob. */ /** Generate scaled previews for each file */
function toJpegBlob(file: File): Promise<Blob> { 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);
});
}
/** Convert image to AVIF at 0.55 quality for full-size */
function toAvifBlob(file: File): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
@@ -29,10 +58,12 @@
canvas.toBlob( canvas.toBlob(
(blob) => { (blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed')); if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob); // Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/avif' });
resolve(typedBlob);
}, },
'image/jpeg', 'image/avif',
0.92 0.72
); );
}; };
img.onerror = () => reject(new Error('Image load failed')); img.onerror = () => reject(new Error('Image load failed'));
@@ -71,10 +102,12 @@
canvas.toBlob( canvas.toBlob(
(blob) => { (blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed')); if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob); // Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/avif' });
resolve(typedBlob);
}, },
'image/jpeg', 'image/avif',
0.92 0.72
); );
}; };
img.onerror = () => reject(new Error('Image load failed')); img.onerror = () => reject(new Error('Image load failed'));
@@ -82,42 +115,101 @@
}); });
} }
/** Create a 250×250 thumbnail. First scale down so short side is 250px, then center-crop to 250×250 square. */ /** Create a 250×250 thumbnail using multi-pass downsampling for better quality. */
function createThumbnail(blob: Blob): Promise<Blob> { function createThumbnail(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
const targetShortSide = 250; const targetSize = 250;
const thumbSize = 250; let srcWidth = img.width;
let { width, height } = img; let srcHeight = img.height;
let sourceCanvas: HTMLCanvasElement | null = null;
const shortSide = Math.min(width, height); // Multi-pass downsampling with tunable scale factor
if (shortSide > targetShortSide) { // Lower SCALE_FACTOR (0.5) = more passes = softer/blurrier
const scale = targetShortSide / shortSide; // Higher SCALE_FACTOR (0.75+) = fewer passes = sharper but risking pixelation
width = Math.round(width * scale); // Sweet spot: 0.65-0.70
height = Math.round(height * scale); const SCALE_FACTOR = 0.2;
const STOP_THRESHOLD = targetSize * 1.1; // Stop when close enough
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');
if (!nextCtx) return reject(new Error('2D context not available'));
nextCtx.imageSmoothingEnabled = true;
nextCtx.imageSmoothingQuality = 'high';
// Draw from source (either original image or previous canvas)
if (sourceCanvas === null) {
nextCtx.drawImage(img, 0, 0, newWidth, newHeight);
} else {
nextCtx.drawImage(sourceCanvas, 0, 0, newWidth, newHeight);
}
sourceCanvas = nextCanvas;
srcWidth = newWidth;
srcHeight = newHeight;
} }
const canvas = document.createElement('canvas'); // Final crop to exact 250×250 square from center
canvas.width = thumbSize; const finalCanvas = document.createElement('canvas');
canvas.height = thumbSize; finalCanvas.width = targetSize;
const ctx = canvas.getContext('2d'); finalCanvas.height = targetSize;
if (!ctx) return reject(new Error('2D context not available')); const finalCtx = finalCanvas.getContext('2d');
ctx.imageSmoothingEnabled = true; if (!finalCtx) return reject(new Error('2D context not available'));
ctx.imageSmoothingQuality = 'high'; finalCtx.imageSmoothingEnabled = true;
finalCtx.imageSmoothingQuality = 'high';
const destX = (thumbSize - width) / 2; // Center crop from the final source (either image or downsampled canvas)
const destY = (thumbSize - height) / 2; if (sourceCanvas === null) {
ctx.drawImage(img, 0, 0, img.width, img.height, destX, destY, width, height); // No downsampling was needed, crop directly from image
const offsetX = (img.width - targetSize) / 2;
const offsetY = (img.height - targetSize) / 2;
finalCtx.drawImage(
img,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
} else {
// Crop from the downsampled canvas
const offsetX = (srcWidth - targetSize) / 2;
const offsetY = (srcHeight - targetSize) / 2;
finalCtx.drawImage(
sourceCanvas,
offsetX,
offsetY,
targetSize,
targetSize,
0,
0,
targetSize,
targetSize
);
}
canvas.toBlob( finalCanvas.toBlob(
(blob) => { (blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed')); if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob); // Ensure blob has correct MIME type
const typedBlob = new Blob([blob], { type: 'image/webp' });
resolve(typedBlob);
}, },
'image/jpeg', 'image/webp',
0.92 0.8
); );
}; };
img.onerror = () => reject(new Error('Image load failed')); img.onerror = () => reject(new Error('Image load failed'));
@@ -163,7 +255,7 @@
return availableTags return availableTags
.map((t) => t.toLowerCase()) .map((t) => t.toLowerCase())
.filter((t) => t.startsWith(q) && !tags.includes(t)) .filter((t) => t.startsWith(q) && !tags.includes(t))
.slice(0, 6); .slice(0, 4);
}); });
function handleTagInput(e: Event) { function handleTagInput(e: Event) {
@@ -175,7 +267,7 @@
} }
selectedSuggestionIndex = -1; selectedSuggestionIndex = -1;
} }
function handlePaste(e: ClipboardEvent) { function handlePaste(e: ClipboardEvent) {
const value = e.clipboardData?.getData('text') || ''; const value = e.clipboardData?.getData('text') || '';
if (value.includes(',')) { if (value.includes(',')) {
@@ -224,10 +316,10 @@
} }
return; return;
} }
// Handle mobile keyboard action keys // Handle mobile keyboard action keys
const isActionKey = ['Enter', 'Done', 'Go'].includes(e.key); const isActionKey = ['Enter', 'Done', 'Go'].includes(e.key);
if (isActionKey) { if (isActionKey) {
e.preventDefault(); e.preventDefault();
} }
@@ -264,20 +356,20 @@
selectSuggestion(suggestions[selectedSuggestionIndex]); selectSuggestion(suggestions[selectedSuggestionIndex]);
return; return;
} }
// If input has text, add it as tag // If input has text, add it as tag
if (input.trim()) { if (input.trim()) {
addTag(input); addTag(input);
input = ''; input = '';
return; return;
} }
// If input is empty but we have tags, show confirmation to upload // If input is empty but we have tags, show confirmation to upload
if (tags.length > 0) { if (tags.length > 0) {
showConfirmUploadAlert = true; showConfirmUploadAlert = true;
return; return;
} }
return; return;
} }
@@ -303,30 +395,42 @@
addTag(input); addTag(input);
input = ''; input = '';
} }
if (!uploadFiles.length) return; if (!uploadFiles.length) return;
uploading = true; uploading = true;
uploadResults = []; uploadResults = [];
uploadStatus = {};
uploadProgress = 0; uploadProgress = 0;
const startTs = Date.now(); // timestamp of button click const startTs = Date.now(); // timestamp of button click
for (let i = 0; i < uploadFiles.length; i++) { for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i]; const file = uploadFiles[i];
const fileKey = file.name;
const fd = new FormData(); const fd = new FormData();
try { try {
/* -------- 1. Turn whatever the user gave us into JPEG ------- */ /* -------- 1. Convert to base image format (AVIF) ------- */
const jpegBlob = await toJpegBlob(file); uploadStatus[fileKey] = 'Converting to AVIF...';
const avifBlob = await toAvifBlob(file);
/* -------- 2. Create the two processed versions ------------- */ /* -------- 2. Create the two processed versions IN PARALLEL ------- */
const resizedBlob = await resizeShortSide(jpegBlob); uploadStatus[fileKey] = 'Compressing...';
const thumbBlob = await createThumbnail(jpegBlob); const [resizedBlob, thumbBlob] = await Promise.all([
resizeShortSide(avifBlob).then((blob) => {
uploadStatus[fileKey] = 'Full size ready';
return blob;
}),
createThumbnail(avifBlob).then((blob) => {
uploadStatus[fileKey] = 'Thumbnail ready';
return blob;
})
]);
/* -------- 3. Generate filenames -------------------------------- */ /* -------- 3. Generate filenames -------------------------------- */
const ts = startTs - i; // 1 ms decrement per file const ts = startTs - i; // 1 ms decrement per file
const baseName = `${ts}.jpg`; const baseName = `${ts}.avif`;
const thumbName = `${ts}_thumb.jpg`; const thumbName = `${ts}_thumb.webp`;
/* -------- 4. Attach to FormData -------------------------------- */ /* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName); fd.append('file', resizedBlob, baseName);
@@ -336,7 +440,15 @@
fd.append('tags', tags.join(',')); fd.append('tags', tags.join(','));
} }
// Debug: log what we're sending
console.log(`Uploading ${fileKey}:`, {
baseName,
thumbName,
thumbMimeType: thumbBlob.type,
fullMimeType: resizedBlob.type
});
/* -------- 5. Call the API ------------------------------------- */ /* -------- 5. Call the API ------------------------------------- */
uploadStatus[fileKey] = 'Uploading...';
const response = await fetch('/api/portfolio/images', { const response = await fetch('/api/portfolio/images', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -351,18 +463,21 @@
name: file.name, name: file.name,
error: errText || `Server error: ${response.status}` error: errText || `Server error: ${response.status}`
}); });
uploadStatus[fileKey] = 'Failed';
} else { } else {
const result = await response.json(); const result = await response.json();
uploadResults.push({ uploadResults.push({
name: file.name, name: file.name,
url: result.url url: result.url
}); });
uploadStatus[fileKey] = 'Complete';
} }
} catch (err: unknown) { } catch (err: unknown) {
uploadResults.push({ uploadResults.push({
name: file.name, name: file.name,
error: err instanceof Error ? err.message : 'Unknown error' error: err instanceof Error ? err.message : 'Unknown error'
}); });
uploadStatus[fileKey] = 'Error';
} }
uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100); uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100);
@@ -370,123 +485,276 @@
uploading = false; uploading = false;
uploadFiles = []; uploadFiles = [];
filePreviews = [];
} }
</script> </script>
<Card.Root> <div class="space-y-6">
<Card.Header> <!-- Header -->
<div class="flex items-center justify-between"> <div class="mb-6">
<div> <h2 class="text-2xl font-bold">Upload Images</h2>
<Card.Title>Image Upload</Card.Title> <p class="mt-1 text-sm text-gray-600">
<Card.Description>Upload images for the portfolio or other uses.</Card.Description> Add portfolio images with tags and optional descriptions
</div> </p>
<div class="hidden rounded-lg p-2 md:block"> </div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<FileDropZone onfiles={handleFilesDropped} accept="image/*" multiple>
<div class="p-6 text-center">
<p class="text-sm text-gray-500">Drop files here, or click to open the file picker</p>
</div>
</FileDropZone>
<div class="mt-4"> <!-- Main Card -->
<div class="text-sm text-gray-600">Selected files ({uploadFiles.length})</div> <Card.Root>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto"> <Card.Header>
{#each uploadFiles as f (f.name)} <div class="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<div class="flex items-center justify-between rounded bg-gray-50 p-2"> <div>
<div>{f.name}{Math.round(f.size / 1024)}KB</div> <Card.Title>Drop or Select Files</Card.Title>
<button <Card.Description>Supported: JPEG, PNG, WebP, and other image formats</Card.Description>
class="text-red-500" </div>
onclick={() => (uploadFiles = uploadFiles.filter((x) => x !== f))}
>
Remove
</button>
</div>
{/each}
</div> </div>
{#if uploadResults.length > 0} </Card.Header>
<div class="mt-4 text-sm text-gray-600">Upload Results</div>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto"> <Card.Content class="space-y-6">
{#each uploadResults as result (result.url || result.name)} <!-- File Drop Zone -->
<div <FileDropZone onfiles={handleFilesDropped} accept="image/*" multiple>
class="rounded p-2 text-xs {result.error <div
? 'bg-red-100 text-red-800' 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"
: 'bg-emerald-100 text-emerald-800'}" >
> <svg
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: `}<a class="mb-3 h-8 w-8 text-gray-400"
href={result.url} xmlns="http://www.w3.org/2000/svg"
target="_blank" viewBox="0 0 24 24"
rel="noopener noreferrer" fill="none"
class="underline hover:text-emerald-600">{result.url}</a 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">Supports AVIF, PNG, JPG, WebP and other formats</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> </div>
{/each} <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}
<div
class="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-3 sm:p-4"
>
<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 text-gray-900">{f.name}</p>
<div class="flex items-center gap-2">
<p class="text-xs text-gray-500">{(f.size / 1024).toFixed(1)} KB</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> </div>
{/if} {/if}
</div>
<div class="space-y-1"> <!-- Upload Results Section -->
<label for="tag-input" class="text-sm font-medium text-gray-700">Tags</label> {#if uploadResults.length > 0}
<div class="border-t pt-6">
<div class="relative"> <h3 class="mb-4 font-semibold text-gray-900">Upload Results</h3>
<div <div class="space-y-2">
class="flex min-h-[44px] w-full flex-wrap gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none" {#each uploadResults as result (result.url || result.name)}
> <div
{#each tags as tag (tag)} class="rounded-lg border p-3 sm:p-4"
<span class:bg-red-50={result.error}
class="flex items-center gap-1 rounded-full px-2 py-1 text-xs class:border-red-200={result.error}
{isSemantic(tag) ? 'bg-indigo-100 text-indigo-800' : 'bg-emerald-100 text-emerald-800'}" class:bg-green-50={!result.error}
> class:border-green-200={!result.error}
{tag}
<button
type="button"
class="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full leading-none
{isSemantic(tag)
? 'text-indigo-700 hover:bg-indigo-200'
: 'text-emerald-700 hover:bg-emerald-200'}"
onclick={() => removeTag(tag)}
aria-label={`Remove ${tag}`}
> >
× <div class="flex items-start gap-3">
</button> <div class="flex-shrink-0 pt-0.5">
</span> {#if result.error}
{/each} <svg
class="h-5 w-5 text-red-600"
<input xmlns="http://www.w3.org/2000/svg"
type="search" viewBox="0 0 24 24"
maxlength={256} fill="none"
enterkeyhint="done" stroke="currentColor"
autocomplete="off" stroke-width="2"
autocorrect="off" >
autocapitalize="off" <circle cx="12" cy="12" r="10" />
spellcheck="false" <line x1="12" y1="8" x2="12" y2="12" />
class="min-w-[100px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground" <line x1="12" y1="16" x2="12.01" y2="16" />
bind:this={inputRef} </svg>
bind:value={input} {:else}
onkeydown={handleKey} <svg
oninput={handleTagInput} class="h-5 w-5 text-green-600"
onpaste={handlePaste} xmlns="http://www.w3.org/2000/svg"
placeholder={tags.length ? '' : 'Add tags…'} 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> </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} {#if input.length && suggestions.length}
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md"> <div class="absolute left-0 right-0 top-full z-10 mt-1 rounded-md border bg-white shadow-md">
{#each suggestions as s, i (s)} {#each suggestions as s, i (s)}
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex} {@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
<div <div
@@ -503,36 +771,74 @@
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
</div>
<p class="text-xs text-gray-500"> <!-- Action Buttons -->
Add searchable tags here such as <code>`scooby doo`</code> or filterable categories like <div class="border-t pt-6">
<code>`style:french`</code> <div class="flex justify-end">
or <Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}>
<code>`colour:green`</code> {#if uploading}
</p> <svg
</div> class="mr-2 h-4 w-4 animate-spin"
<div class="flex items-center justify-end gap-2"> xmlns="http://www.w3.org/2000/svg"
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}> viewBox="0 0 24 24"
{uploading ? `Uploading (${uploadProgress}%)` : 'Upload Selected Files'} fill="none"
</Button> stroke="currentColor"
</div> stroke-width="2"
</Card.Content> >
</Card.Root> <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>
<AlertDialog.Root bind:open={showConfirmUploadAlert}> <!-- Confirmation Dialog -->
<AlertDialog.Content class="z-[60]"> <AlertDialog.Root bind:open={showConfirmUploadAlert}>
<AlertDialog.Header> <AlertDialog.Content class="z-[60]">
<AlertDialog.Title>Upload with {tags.length} tag{tags.length === 1 ? '' : 's'}?</AlertDialog.Title> <AlertDialog.Header>
<AlertDialog.Description> <AlertDialog.Title>Confirm Upload</AlertDialog.Title>
You have {tags.length} tag{tags.length === 1 ? '' : 's'} selected: <AlertDialog.Description>
<span class="font-medium">{tags.join(', ')}</span>. You're about to upload {uploadFiles.length}
Ready to upload {uploadFiles.length} file{uploadFiles.length === 1 ? '' : 's'}? {uploadFiles.length === 1 ? 'file' : 'files'} with {tags.length}
</AlertDialog.Description> {tags.length === 1 ? 'tag' : 'tags'}.
</AlertDialog.Header> {#if tags.length > 0}
<AlertDialog.Footer> <div class="mt-3 flex flex-wrap gap-2">
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel> {#each tags as tag}
<AlertDialog.Action onclick={uploadOneOrMany}>Upload</AlertDialog.Action> <span
</AlertDialog.Footer> class="inline-block rounded-full bg-gray-200 px-2.5 py-1 text-xs text-gray-800"
</AlertDialog.Content> >
</AlertDialog.Root> {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>
+35 -3
View File
@@ -275,6 +275,20 @@
function clearFilters() { function clearFilters() {
selectedFilters = {}; selectedFilters = {};
offset = 0;
// Build URL with reactive page state
const url = new URL(page.url);
const keysToDelete = Array.from(url.searchParams.keys()).filter((k) => k.startsWith('filter['));
keysToDelete.forEach((k) => url.searchParams.delete(k));
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
fetchImages(false);
fetchFilters();
}
function clearSearch() {
selectedTag = ''; selectedTag = '';
selectedTags = []; selectedTags = [];
searchQuery = ''; searchQuery = '';
@@ -284,13 +298,10 @@
const url = new URL(page.url); const url = new URL(page.url);
url.searchParams.delete('tag'); url.searchParams.delete('tag');
url.searchParams.delete('tags'); url.searchParams.delete('tags');
const keysToDelete = Array.from(url.searchParams.keys()).filter((k) => k.startsWith('filter['));
keysToDelete.forEach((k) => url.searchParams.delete(k));
// eslint-disable-next-line svelte/no-navigation-without-resolve // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.pathname + url.search, { replaceState: true, noScroll: true }); goto(url.pathname + url.search, { replaceState: true, noScroll: true });
fetchImages(false); fetchImages(false);
fetchFilters();
} }
let sentinelRef: HTMLDivElement | undefined = $state(undefined); let sentinelRef: HTMLDivElement | undefined = $state(undefined);
@@ -614,6 +625,27 @@
<Button onclick={applySearch}> <Button onclick={applySearch}>
{loading ? 'Searching...' : 'Search'} {loading ? 'Searching...' : 'Search'}
</Button> </Button>
{#if selectedTag || selectedTags.length > 0}
<button
class="flex shrink-0 items-center justify-center"
onclick={clearSearch}
aria-label="Clear search tags"
>
<div
class="flex h-6 w-6 items-center justify-center rounded-full bg-red-100 text-red-600 hover:bg-red-200"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</button>
{/if}
</div> </div>
</div> </div>
</div> </div>