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