- 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
535 lines
15 KiB
Svelte
535 lines
15 KiB
Svelte
<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>
|