Files
Crussell/frontend/src/lib/components/admin/ImageUpload.svelte
T
popertots 9259de9393 Portfolio: add filtering, URL sharing, and improved tag input
- Add category filters with dynamic counts that reduce as filters
  applied
- Add ?filter[category]=value URL params for filterable links
- Add ?img= timestamp param that bypasses filters to show specific image
- Update URL when opening/navigating/closing modal for shareable links
- Backend: add /api/portfolio/filters endpoint with filter logic
- Backend: add timestamp lookup fallback for GetImage endpoint
  Frontend:
- Portfolio page: filter dropdowns, keyboard nav, mobile improvements
- ImageUpload: live tag suggestions from API, arrow/Tab navigation,
  confirmation modal before upload, mobile-optimized touch targets
- Add scrollbar-hide utility and fix filter dropdown overflow
- Move Clear all button, add vertical separator on desktop
2026-02-20 00:32:09 +00:00

535 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
// =============== Image Upload ===============
let uploading = $state(false);
let uploadFiles = $state<File[]>([]);
let uploadProgress = $state(0);
let uploadResults = $state<{ name: string; url?: string; error?: string }[]>([]);
function handleFilesDropped(files: File[]) {
uploadFiles = files;
}
/** Helper: turn any File into a JPEG-encoded Blob. */
function toJpegBlob(file: File): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(file);
});
}
/** Resize to max 1500px on the *short* side, only scale down, never up. */
function resizeShortSide(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
const maxShortSide = 1500;
// Only resize if image is larger than target
const shortSide = Math.min(width, height);
if (shortSide > maxShortSide) {
if (width < height) {
const scale = maxShortSide / width;
width = maxShortSide;
height = Math.round(height * scale);
} else {
const scale = maxShortSide / height;
height = maxShortSide;
width = Math.round(width * scale);
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
});
}
/** Create a 250×250 thumbnail. First scale down so short side is 250px, then center-crop to 250×250 square. */
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 shortSide = Math.min(width, height);
if (shortSide > targetShortSide) {
const scale = targetShortSide / shortSide;
width = Math.round(width * scale);
height = Math.round(height * scale);
}
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'));
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const destX = (thumbSize - width) / 2;
const destY = (thumbSize - height) / 2;
ctx.drawImage(img, 0, 0, img.width, img.height, destX, destY, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
});
}
let availableTags = $state<string[]>([]);
let loadingTags = $state(true);
let isMobile = $state(false);
async function fetchTags() {
try {
const response = await fetch('/api/portfolio/tags');
if (response.ok) {
const data = await response.json();
availableTags = data.map((t: { name: string }) => t.name);
}
} catch (e) {
console.error('Failed to fetch tags:', e);
} finally {
loadingTags = false;
}
}
// Check for mobile on mount
if (typeof window !== 'undefined') {
isMobile = window.matchMedia('(pointer: coarse)').matches;
}
fetchTags();
let tags = $state<string[]>([]);
let input = $state('');
let selectedSuggestionIndex = $state(-1);
let inputRef = $state<HTMLInputElement | undefined>(undefined);
let showConfirmUploadAlert = $state(false);
const suggestions = $derived.by(() => {
const q = input.trim().toLowerCase();
if (!q) return [];
return availableTags
.map((t) => t.toLowerCase())
.filter((t) => t.startsWith(q) && !tags.includes(t))
.slice(0, 6);
});
function handleTagInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
if (value.includes(',')) {
addTag(value);
input = '';
}
selectedSuggestionIndex = -1;
}
function handlePaste(e: ClipboardEvent) {
const value = e.clipboardData?.getData('text') || '';
if (value.includes(',')) {
e.preventDefault();
addTag(value);
input = '';
}
}
function isSemantic(tag: string) {
return tag.includes(':');
}
function addTag(raw: string) {
raw.split(',').forEach((p) => {
let t = p.trim().toLowerCase();
if (!t) return;
t = t.replace(/^colour:/, 'color:');
if (!tags.includes(t)) tags = [...tags, t];
});
}
function handleKey(e: KeyboardEvent) {
// Prevent tab from moving focus away from this input
if (e.key === 'Tab') {
e.preventDefault();
// Allow arrow key navigation when tab is pressed
if (e.shiftKey) {
// Shift+Tab - go to previous suggestion (wrap to end)
if (suggestions.length > 0) {
if (selectedSuggestionIndex <= 0) {
selectedSuggestionIndex = suggestions.length - 1;
} else {
selectedSuggestionIndex = selectedSuggestionIndex - 1;
}
}
} else {
// Tab - go to next suggestion (wrap to start)
if (suggestions.length > 0) {
if (selectedSuggestionIndex >= suggestions.length - 1) {
selectedSuggestionIndex = 0;
} else {
selectedSuggestionIndex = selectedSuggestionIndex + 1;
}
}
}
return;
}
// Handle mobile keyboard action keys
const isActionKey = ['Enter', 'Done', 'Go'].includes(e.key);
if (isActionKey) {
e.preventDefault();
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (suggestions.length > 0) {
// Wrap to start if at end
if (selectedSuggestionIndex >= suggestions.length - 1) {
selectedSuggestionIndex = 0;
} else {
selectedSuggestionIndex = Math.min(selectedSuggestionIndex + 1, suggestions.length - 1);
}
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (suggestions.length > 0) {
// Wrap to end if at start
if (selectedSuggestionIndex <= 0) {
selectedSuggestionIndex = suggestions.length - 1;
} else {
selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, -1);
}
}
return;
}
if (e.key === 'Enter' || e.key === 'Done' || e.key === 'Go') {
// If there's a highlighted suggestion, select it
if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
selectSuggestion(suggestions[selectedSuggestionIndex]);
return;
}
// If input has text, add it as tag
if (input.trim()) {
addTag(input);
input = '';
return;
}
// If input is empty but we have tags, show confirmation to upload
if (tags.length > 0) {
showConfirmUploadAlert = true;
return;
}
return;
}
if (e.key === 'Backspace' && !input && tags.length) {
tags = tags.slice(0, -1);
}
}
function selectSuggestion(tag: string) {
addTag(tag);
input = '';
selectedSuggestionIndex = -1;
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
}
/** Core upload function now processes the images before sending. */
async function uploadOneOrMany() {
// Add any pending input as tag before uploading
if (input.trim()) {
addTag(input);
input = '';
}
if (!uploadFiles.length) return;
uploading = true;
uploadResults = [];
uploadProgress = 0;
const startTs = Date.now(); // timestamp of button click
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
const fd = new FormData();
try {
/* -------- 1. Turn whatever the user gave us into JPEG ------- */
const jpegBlob = await toJpegBlob(file);
/* -------- 2. Create the two processed versions ------------- */
const resizedBlob = await resizeShortSide(jpegBlob);
const thumbBlob = await createThumbnail(jpegBlob);
/* -------- 3. Generate filenames -------------------------------- */
const ts = startTs - i; // 1 ms decrement per file
const baseName = `${ts}.jpg`;
const thumbName = `${ts}_thumb.jpg`;
/* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName);
fd.append('thumbnail', thumbBlob, thumbName);
if (tags.length > 0) {
fd.append('tags', tags.join(','));
}
/* -------- 5. Call the API ------------------------------------- */
const response = await fetch('/api/portfolio/images', {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
},
body: fd
});
if (!response.ok) {
const errText = await response.text();
uploadResults.push({
name: file.name,
error: errText || `Server error: ${response.status}`
});
} else {
const result = await response.json();
uploadResults.push({
name: file.name,
url: result.url
});
}
} catch (err: unknown) {
uploadResults.push({
name: file.name,
error: err instanceof Error ? err.message : 'Unknown error'
});
}
uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100);
}
uploading = false;
uploadFiles = [];
}
</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>
<div class="hidden rounded-lg p-2 md:block">
<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">
<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))}
>
Remove
</button>
</div>
{/each}
</div>
{#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">
{#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'}"
>
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: `}<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-emerald-600">{result.url}</a
>
</div>
{/each}
</div>
{/if}
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700">Tags</label>
<div class="relative">
<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"
>
{#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'}"
>
{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}`}
>
×
</button>
</span>
{/each}
<input
type="search"
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-muted-foreground"
bind:this={inputRef}
bind:value={input}
onkeydown={handleKey}
oninput={handleTagInput}
onpaste={handlePaste}
placeholder={tags.length ? '' : 'Add tags…'}
/>
</div>
{#if input.length && suggestions.length}
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md">
{#each suggestions as s, i (s)}
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
<div
class="cursor-pointer px-3 py-3 text-sm touch-manipulation {isHighlighted
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-gray-100'}"
onclick={() => selectSuggestion(s)}
>
{s}
</div>
{/each}
</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">
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}>
{uploading ? `Uploading (${uploadProgress}%)` : 'Upload Selected Files'}
</Button>
</div>
</Card.Content>
</Card.Root>
<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.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'}?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={uploadOneOrMany}>Upload</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>