Split admin dashboard, implement user and booking search

This commit is contained in:
2026-01-17 18:55:58 +00:00
parent 1e8995f577
commit 3a77e582e3
17 changed files with 4315 additions and 3266 deletions
@@ -0,0 +1,392 @@
<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';
// =============== 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 (square, center-cropped). */
function createThumbnail(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const thumbSize = 250;
const { 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 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'));
// 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
);
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);
});
}
const knownTags = [
'portfolio',
'gel',
'acrylic',
'french',
'ombre',
'summer',
'wedding',
'holiday',
'pink',
'red',
'style:french',
'style:minimal',
'colour:red',
'colour:pink',
'season:summer'
];
let tags = $state<string[]>([]);
let input = $state('');
const suggestions = $derived.by(() => {
const q = input.trim().toLowerCase();
if (!q) return [];
return knownTags
.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 = '';
}
}
function isSemantic(tag: string) {
return tag.includes(':');
}
function addTag(raw: string) {
raw.split(',').forEach((p) => {
const t = p.trim().toLowerCase();
if (t && !tags.includes(t)) tags = [...tags, t];
});
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addTag(input);
input = '';
return;
}
if (e.key === 'Backspace' && !input && tags.length) {
tags = tags.slice(0, -1);
}
}
function selectSuggestion(tag: string) {
addTag(tag);
input = '';
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
}
/** Core upload function now processes the images before sending. */
async function uploadOneOrMany() {
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); // this will be the "original"
fd.append('file', thumbBlob, thumbName); // the thumbnail
/* -------- 5. Mock the API call --------------------------------- */
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
if (file.name.toLowerCase().includes('fail')) {
uploadResults.push({
name: file.name,
error: 'Mocked API error'
});
} else {
// In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
uploadResults.push({
name: file.name,
url: `/images/${baseName}` // pretend this is the returned 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: ${result.url}`}
</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-[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"
>
{#each tags as tag (tag)}
<span
class="flex items-center gap-1 rounded-full px-2 py-0.5 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
{isSemantic(tag)
? 'text-indigo-700 hover:text-indigo-900'
: 'text-emerald-700 hover:text-emerald-900'}"
onmousedown={(e) => {
e.preventDefault();
removeTag(tag);
}}
aria-label={`Remove ${tag}`}
>
×
</button>
</span>
{/each}
<input
class="min-w-[120px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
bind:value={input}
onkeydown={handleKey}
oninput={handleTagInput}
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="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
onmousedown={(e) => {
e.preventDefault();
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>