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