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
This commit is contained in:
2026-02-20 00:32:09 +00:00
parent dfd552b02f
commit 9259de9393
15 changed files with 1560 additions and 290 deletions
@@ -4,7 +4,6 @@
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
interface Props {
open: boolean;
@@ -253,7 +253,6 @@
users = (data.users || []).filter(
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
);
console.log(users);
}
} catch (err) {
console.error('Failed to fetch users', err);
@@ -2,6 +2,8 @@
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);
@@ -80,18 +82,21 @@
});
}
/** Create a 250×250 thumbnail (square, center-cropped). */
/** 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;
const { width, height } = img;
let { width, height } = img;
// Scale up *or* down so that the image covers 250×250
const scale = Math.max(thumbSize / width, thumbSize / height);
const scaledW = Math.round(width * scale);
const scaledH = Math.round(height * scale);
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;
@@ -99,18 +104,12 @@
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
// Draw the scaled image, then crop the center 250×250
ctx.drawImage(
img,
(scaledW - thumbSize) / -2, // offset to center
(scaledH - thumbSize) / -2,
scaledW,
scaledH,
0,
0,
thumbSize,
thumbSize
);
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) => {
@@ -126,32 +125,42 @@
});
}
const knownTags = [
'portfolio',
'gel',
'acrylic',
'french',
'ombre',
'summer',
'wedding',
'holiday',
'pink',
'red',
'style:french',
'style:minimal',
'colour:red',
'colour:pink',
'season:summer'
];
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 knownTags
return availableTags
.map((t) => t.toLowerCase())
.filter((t) => t.startsWith(q) && !tags.includes(t))
.slice(0, 6);
@@ -164,6 +173,16 @@
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) {
@@ -172,16 +191,93 @@
function addTag(raw: string) {
raw.split(',').forEach((p) => {
const t = p.trim().toLowerCase();
if (t && !tags.includes(t)) tags = [...tags, t];
let t = p.trim().toLowerCase();
if (!t) return;
t = t.replace(/^colour:/, 'color:');
if (!tags.includes(t)) tags = [...tags, t];
});
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
// Prevent tab from moving focus away from this input
if (e.key === 'Tab') {
e.preventDefault();
addTag(input);
input = '';
// 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;
}
@@ -193,6 +289,7 @@
function selectSuggestion(tag: string) {
addTag(tag);
input = '';
selectedSuggestionIndex = -1;
}
function removeTag(tag: string) {
@@ -201,6 +298,12 @@
/** 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 = [];
@@ -226,21 +329,33 @@
const thumbName = `${ts}_thumb.jpg`;
/* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName); // this will be the "original"
fd.append('file', thumbBlob, thumbName); // the thumbnail
fd.append('file', resizedBlob, baseName);
fd.append('thumbnail', thumbBlob, thumbName);
/* -------- 5. Mock the API call --------------------------------- */
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
if (file.name.toLowerCase().includes('fail')) {
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: 'Mocked API error'
error: errText || `Server error: ${response.status}`
});
} else {
// In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
const result = await response.json();
uploadResults.push({
name: file.name,
url: `/images/${baseName}` // pretend this is the returned URL
url: result.url
});
}
} catch (err: unknown) {
@@ -312,7 +427,12 @@
? 'bg-red-100 text-red-800'
: 'bg-emerald-100 text-emerald-800'}"
>
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: ${result.url}`}
{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>
@@ -324,25 +444,22 @@
<div class="relative">
<div
class="flex min-h-[38px] w-full flex-wrap gap-2 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-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-0.5 text-xs
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-1 leading-none
class="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full leading-none
{isSemantic(tag)
? 'text-indigo-700 hover:text-indigo-900'
: 'text-emerald-700 hover:text-emerald-900'}"
onmousedown={(e) => {
e.preventDefault();
removeTag(tag);
}}
? 'text-indigo-700 hover:bg-indigo-200'
: 'text-emerald-700 hover:bg-emerald-200'}"
onclick={() => removeTag(tag)}
aria-label={`Remove ${tag}`}
>
×
@@ -351,23 +468,31 @@
{/each}
<input
class="min-w-[120px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
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">
{#each suggestions as s (s)}
<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-2 text-sm hover:bg-gray-100"
onmousedown={(e) => {
e.preventDefault();
selectSuggestion(s);
}}
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>
@@ -390,3 +515,20 @@
</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>