Split admin dashboard, implement user and booking search
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user