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">
import { Button } from '$lib/components/ui/button';
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 { authStore } from '$lib/stores/auth.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -8,15 +9,43 @@
// =============== 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 }[]>([]);
function handleFilesDropped(files: File[]) {
uploadFiles = files;
generatePreviews(files);
}
/** Helper: turn any File into a JPEG-encoded Blob. */
function toJpegBlob(file: File): Promise<Blob> {
/** 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);
});
}
/** Convert image to AVIF at 0.55 quality for full-size */
function toAvifBlob(file: File): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
@@ -29,10 +58,12 @@
canvas.toBlob(
(blob) => {
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',
0.92
'image/avif',
0.72
);
};
img.onerror = () => reject(new Error('Image load failed'));
@@ -71,10 +102,12 @@
canvas.toBlob(
(blob) => {
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',
0.92
'image/avif',
0.72
);
};
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> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const targetShortSide = 250;
const thumbSize = 250;
let { width, height } = img;
const targetSize = 250;
let srcWidth = img.width;
let srcHeight = img.height;
let sourceCanvas: HTMLCanvasElement | null = null;
const shortSide = Math.min(width, height);
if (shortSide > targetShortSide) {
const scale = targetShortSide / shortSide;
width = Math.round(width * scale);
height = Math.round(height * scale);
// Multi-pass downsampling with tunable scale factor
// Lower SCALE_FACTOR (0.5) = more passes = softer/blurrier
// Higher SCALE_FACTOR (0.75+) = fewer passes = sharper but risking pixelation
// Sweet spot: 0.65-0.70
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);
}
const canvas = document.createElement('canvas');
canvas.width = thumbSize;
canvas.height = thumbSize;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
sourceCanvas = nextCanvas;
srcWidth = newWidth;
srcHeight = newHeight;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 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');
const destX = (thumbSize - width) / 2;
const destY = (thumbSize - height) / 2;
ctx.drawImage(img, 0, 0, img.width, img.height, destX, destY, width, height);
if (!finalCtx) return reject(new Error('2D context not available'));
finalCtx.imageSmoothingEnabled = true;
finalCtx.imageSmoothingQuality = 'high';
canvas.toBlob(
// Center crop from the final source (either image or downsampled canvas)
if (sourceCanvas === null) {
// 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
);
}
finalCanvas.toBlob(
(blob) => {
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',
0.92
'image/webp',
0.8
);
};
img.onerror = () => reject(new Error('Image load failed'));
@@ -163,7 +255,7 @@
return availableTags
.map((t) => t.toLowerCase())
.filter((t) => t.startsWith(q) && !tags.includes(t))
.slice(0, 6);
.slice(0, 4);
});
function handleTagInput(e: Event) {
@@ -307,26 +399,38 @@
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. Turn whatever the user gave us into JPEG ------- */
const jpegBlob = await toJpegBlob(file);
/* -------- 1. Convert to base image format (AVIF) ------- */
uploadStatus[fileKey] = 'Converting to AVIF...';
const avifBlob = await toAvifBlob(file);
/* -------- 2. Create the two processed versions ------------- */
const resizedBlob = await resizeShortSide(jpegBlob);
const thumbBlob = await createThumbnail(jpegBlob);
/* -------- 2. Create the two processed versions IN PARALLEL ------- */
uploadStatus[fileKey] = 'Compressing...';
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 -------------------------------- */
const ts = startTs - i; // 1 ms decrement per file
const baseName = `${ts}.jpg`;
const thumbName = `${ts}_thumb.jpg`;
const baseName = `${ts}.avif`;
const thumbName = `${ts}_thumb.webp`;
/* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName);
@@ -336,7 +440,15 @@
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 ------------------------------------- */
uploadStatus[fileKey] = 'Uploading...';
const response = await fetch('/api/portfolio/images', {
method: 'POST',
headers: {
@@ -351,18 +463,21 @@
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);
@@ -370,97 +485,250 @@
uploading = false;
uploadFiles = [];
filePreviews = [];
}
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Image Upload</Card.Title>
<Card.Description>Upload images for the portfolio or other uses.</Card.Description>
<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>
<div class="hidden rounded-lg p-2 md:block">
<!-- 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>Supported: JPEG, PNG, WebP, and other image formats</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"
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" />
<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>
</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>
<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>
<div class="mt-4">
<div class="text-sm text-gray-600">Selected files ({uploadFiles.length})</div>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
{#each uploadFiles as f (f.name)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>{f.name}{Math.round(f.size / 1024)}KB</div>
<button
class="text-red-500"
onclick={() => (uploadFiles = uploadFiles.filter((x) => x !== f))}
<!-- 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"
>
Remove
{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>
{/if}
<!-- Upload Results Section -->
{#if uploadResults.length > 0}
<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">
<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 p-2 text-xs {result.error
? 'bg-red-100 text-red-800'
: 'bg-emerald-100 text-emerald-800'}"
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}
>
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: `}<a
<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="underline hover:text-emerald-600">{result.url}</a
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>
{/if}
</div>
{/if}
<div class="space-y-1">
<label for="tag-input" class="text-sm font-medium text-gray-700">Tags</label>
<!-- 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-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"
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="flex items-center gap-1 rounded-full px-2 py-1 text-xs
{isSemantic(tag) ? 'bg-indigo-100 text-indigo-800' : 'bg-emerald-100 text-emerald-800'}"
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"
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}`}
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>
@@ -475,7 +743,7 @@
autocorrect="off"
autocapitalize="off"
spellcheck="false"
class="min-w-[100px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
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}
@@ -486,7 +754,7 @@
</div>
{#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)}
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
<div
@@ -504,35 +772,73 @@
</div>
{/if}
</div>
<p class="text-xs text-gray-500">
Add searchable tags here such as <code>`scooby doo`</code> or filterable categories like
<code>`style:french`</code>
or
<code>`colour:green`</code>
</p>
</div>
<div class="flex items-center justify-end gap-2">
<!-- Action Buttons -->
<div class="border-t pt-6">
<div class="flex justify-end">
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}>
{uploading ? `Uploading (${uploadProgress}%)` : 'Upload Selected Files'}
{#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>
</Card.Content>
</Card.Root>
</div>
</div></Card.Content
>
</Card.Root>
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
<!-- Confirmation Dialog -->
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Upload with {tags.length} tag{tags.length === 1 ? '' : 's'}?</AlertDialog.Title>
<AlertDialog.Title>Confirm Upload</AlertDialog.Title>
<AlertDialog.Description>
You have {tags.length} tag{tags.length === 1 ? '' : 's'} selected:
<span class="font-medium">{tags.join(', ')}</span>.
Ready to upload {uploadFiles.length} file{uploadFiles.length === 1 ? '' : 's'}?
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}
<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</AlertDialog.Action>
<AlertDialog.Action onclick={uploadOneOrMany}>Upload Now</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</AlertDialog.Root>
</div>
+35 -3
View File
@@ -275,6 +275,20 @@
function clearFilters() {
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 = '';
selectedTags = [];
searchQuery = '';
@@ -284,13 +298,10 @@
const url = new URL(page.url);
url.searchParams.delete('tag');
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
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
fetchImages(false);
fetchFilters();
}
let sentinelRef: HTMLDivElement | undefined = $state(undefined);
@@ -614,6 +625,27 @@
<Button onclick={applySearch}>
{loading ? 'Searching...' : 'Search'}
</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>