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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user