fix(frontend): Svelte 5 reactivity, each block keys, and path resolution
Add proper keys to #each blocks across 15+ components to fix reordering bugs. Replace new Date() with SvelteDate in reactive contexts. Use $derived for computed values (totalPages). Use resolve() from $app/paths for all internal navigation hrefs. Add ARIA labels and keyboard accessibility to NavBar mobile menu. Remove unused handleRetry from UserPaymentModal. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
email?: string;
|
||||
phone?: string;
|
||||
};
|
||||
services: Array<{
|
||||
services?: Array<{
|
||||
service_id: string;
|
||||
service_name?: string;
|
||||
price?: number;
|
||||
@@ -632,7 +632,7 @@
|
||||
|
||||
<!-- Decline Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showDeclineConfirm}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Decline this booking?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
let selectedTime = $state<string | null>(null);
|
||||
let workingHours = $state<Record<string, DayHours> | null>(null);
|
||||
let availableHours = $state<Record<string, DayAvailability> | null>(null);
|
||||
let loadingWorkingHours = $state(false);
|
||||
let _loadingWorkingHours = $state(false);
|
||||
let loadingAvailableHours = $state(false);
|
||||
let hoursRangeGeneration = $state(0);
|
||||
let hoursMonthGeneration = $state(0);
|
||||
@@ -340,7 +340,7 @@
|
||||
hoursRangeGeneration++;
|
||||
const gen = hoursRangeGeneration;
|
||||
|
||||
loadingWorkingHours = true;
|
||||
_loadingWorkingHours = true;
|
||||
loadingAvailableHours = true;
|
||||
|
||||
try {
|
||||
@@ -388,7 +388,7 @@
|
||||
console.error('Failed to fetch hours', err);
|
||||
toast.error('Failed to load availability');
|
||||
} finally {
|
||||
loadingWorkingHours = false;
|
||||
_loadingWorkingHours = false;
|
||||
loadingAvailableHours = false;
|
||||
}
|
||||
}
|
||||
@@ -410,7 +410,7 @@
|
||||
hoursMonthGeneration++;
|
||||
const gen = hoursMonthGeneration;
|
||||
|
||||
loadingWorkingHours = true;
|
||||
_loadingWorkingHours = true;
|
||||
loadingAvailableHours = true;
|
||||
|
||||
try {
|
||||
@@ -453,7 +453,7 @@
|
||||
console.error('Failed to fetch hours', err);
|
||||
toast.error('Failed to load availability');
|
||||
} finally {
|
||||
loadingWorkingHours = false;
|
||||
_loadingWorkingHours = false;
|
||||
loadingAvailableHours = false;
|
||||
loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey));
|
||||
}
|
||||
@@ -547,7 +547,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const now = new SvelteDate();
|
||||
const diff = reservationExpiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
@@ -862,7 +862,7 @@
|
||||
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
|
||||
{#if loadingUsers}
|
||||
<div class="space-y-2 p-2">
|
||||
{#each Array(3) as _}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -970,7 +970,7 @@
|
||||
<Card.Content class="space-y-4">
|
||||
{#if loadingServices}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#each Array(4) as _}
|
||||
{#each Array(4) as _, i (i)}
|
||||
<Skeleton class="h-28 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
||||
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
|
||||
import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format';
|
||||
import type { Booking } from '$lib/types/booking';
|
||||
import type { Booking, BookingService } from '$lib/types/booking';
|
||||
import type { Payment } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -83,7 +84,7 @@
|
||||
: undefined,
|
||||
|
||||
// Services
|
||||
services: (data.services || []).map((s) => ({
|
||||
services: (data.services || []).map((s: BookingService) => ({
|
||||
booking_id: s.booking_id,
|
||||
service_id: s.service_id,
|
||||
service_name: s.service_name,
|
||||
@@ -95,7 +96,7 @@
|
||||
})),
|
||||
|
||||
// Payments
|
||||
payments: (data.payments || []).map((p) => ({
|
||||
payments: (data.payments || []).map((p: Payment) => ({
|
||||
id: p.id,
|
||||
booking_id: p.booking_id,
|
||||
payment_type: p.payment_type,
|
||||
@@ -349,8 +350,11 @@
|
||||
title="Copy referral code"
|
||||
class="inline-flex items-center justify-center rounded p-1 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(selectedBooking.user!.referral_code!);
|
||||
toast.success('Referral code copied');
|
||||
const code = selectedBooking?.user?.referral_code;
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code);
|
||||
toast.success('Referral code copied');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
@@ -454,7 +458,7 @@
|
||||
{:else if payment.payment_method === 'cash'}Cash
|
||||
{:else if payment.payment_method === 'giftcard'}Gift Card
|
||||
{:else if payment.payment_method === 'discount'}Discount
|
||||
{:else}{payment.payment_method.replace('_', ' ')}
|
||||
{:else}{(payment.payment_method as string).replace('_', ' ')}
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -634,7 +634,7 @@
|
||||
|
||||
<!-- Remove Service Confirmation -->
|
||||
<AlertDialog.Root bind:open={showRemoveConfirm}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Remove Service?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -364,7 +364,7 @@
|
||||
|
||||
<!-- Deny Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showDenyConfirm}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Deny this change request?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -605,7 +605,7 @@
|
||||
|
||||
<!-- Delete Exception Confirmation -->
|
||||
<AlertDialog.Root bind:open={showDeleteExceptionAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete exception group?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
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';
|
||||
@@ -59,179 +58,230 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Convert image to AVIF at 0.55 quality for full-size */
|
||||
function toAvifBlob(file: File): Promise<Blob> {
|
||||
/** Load a File or Blob into an HTMLImageElement. */
|
||||
function loadImage(src: File | Blob): Promise<HTMLImageElement> {
|
||||
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'));
|
||||
// Ensure blob has correct MIME type
|
||||
const typedBlob = new Blob([blob], { type: 'image/avif' });
|
||||
resolve(typedBlob);
|
||||
},
|
||||
'image/avif',
|
||||
0.72
|
||||
);
|
||||
};
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.src = URL.createObjectURL(src);
|
||||
});
|
||||
}
|
||||
|
||||
/** Resize to max 1500px on the *short* side, only scale down, never up. */
|
||||
function resizeShortSide(blob: Blob): Promise<Blob> {
|
||||
/** Encode ImageData to AVIF via Web Worker. */
|
||||
function encodeAvif(imageData: ImageData, quality: number): 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'));
|
||||
// Ensure blob has correct MIME type
|
||||
const typedBlob = new Blob([blob], { type: 'image/avif' });
|
||||
resolve(typedBlob);
|
||||
},
|
||||
'image/avif',
|
||||
0.72
|
||||
);
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/avif-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/avif' }));
|
||||
};
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = URL.createObjectURL(blob);
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a 250×250 thumbnail using multi-pass downsampling for better quality. */
|
||||
function createThumbnail(blob: Blob): Promise<Blob> {
|
||||
/** Encode ImageData to WebP via Web Worker. */
|
||||
function encodeWebp(imageData: ImageData, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const targetSize = 250;
|
||||
let srcWidth = img.width;
|
||||
let srcHeight = img.height;
|
||||
let sourceCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
// Multi-pass downsampling with tunable scale factor
|
||||
// Lower SCALE_FACTOR (0.5) = more passes = softer/blurrier
|
||||
// Higher SCALE_FACTOR (0.75+) = fewer passes = sharper but risking pixelation
|
||||
// Sweet spot: 0.65-0.70
|
||||
const SCALE_FACTOR = 0.2;
|
||||
const STOP_THRESHOLD = targetSize * 1.1; // Stop when close enough
|
||||
|
||||
while (Math.min(srcWidth, srcHeight) > STOP_THRESHOLD) {
|
||||
const scale = Math.max(SCALE_FACTOR, targetSize / Math.min(srcWidth, srcHeight));
|
||||
const newWidth = Math.round(srcWidth * scale);
|
||||
const newHeight = Math.round(srcHeight * scale);
|
||||
|
||||
const nextCanvas = document.createElement('canvas');
|
||||
nextCanvas.width = newWidth;
|
||||
nextCanvas.height = newHeight;
|
||||
const nextCtx = nextCanvas.getContext('2d');
|
||||
|
||||
if (!nextCtx) return reject(new Error('2D context not available'));
|
||||
nextCtx.imageSmoothingEnabled = true;
|
||||
nextCtx.imageSmoothingQuality = 'high';
|
||||
|
||||
// Draw from source (either original image or previous canvas)
|
||||
if (sourceCanvas === null) {
|
||||
nextCtx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
} else {
|
||||
nextCtx.drawImage(sourceCanvas, 0, 0, newWidth, newHeight);
|
||||
}
|
||||
|
||||
sourceCanvas = nextCanvas;
|
||||
srcWidth = newWidth;
|
||||
srcHeight = newHeight;
|
||||
}
|
||||
|
||||
// Final crop to exact 250×250 square from center
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
finalCanvas.width = targetSize;
|
||||
finalCanvas.height = targetSize;
|
||||
const finalCtx = finalCanvas.getContext('2d');
|
||||
|
||||
if (!finalCtx) return reject(new Error('2D context not available'));
|
||||
finalCtx.imageSmoothingEnabled = true;
|
||||
finalCtx.imageSmoothingQuality = 'high';
|
||||
|
||||
// Center crop from the final source (either image or downsampled canvas)
|
||||
if (sourceCanvas === null) {
|
||||
// No downsampling was needed, crop directly from image
|
||||
const offsetX = (img.width - targetSize) / 2;
|
||||
const offsetY = (img.height - targetSize) / 2;
|
||||
finalCtx.drawImage(
|
||||
img,
|
||||
offsetX,
|
||||
offsetY,
|
||||
targetSize,
|
||||
targetSize,
|
||||
0,
|
||||
0,
|
||||
targetSize,
|
||||
targetSize
|
||||
);
|
||||
} else {
|
||||
// Crop from the downsampled canvas
|
||||
const offsetX = (srcWidth - targetSize) / 2;
|
||||
const offsetY = (srcHeight - targetSize) / 2;
|
||||
finalCtx.drawImage(
|
||||
sourceCanvas,
|
||||
offsetX,
|
||||
offsetY,
|
||||
targetSize,
|
||||
targetSize,
|
||||
0,
|
||||
0,
|
||||
targetSize,
|
||||
targetSize
|
||||
);
|
||||
}
|
||||
|
||||
finalCanvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error('Canvas toBlob failed'));
|
||||
// Ensure blob has correct MIME type
|
||||
const typedBlob = new Blob([blob], { type: 'image/webp' });
|
||||
resolve(typedBlob);
|
||||
},
|
||||
'image/webp',
|
||||
0.8
|
||||
);
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/webp-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/webp' }));
|
||||
};
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = URL.createObjectURL(blob);
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
|
||||
/** Encode ImageData to JPEG via Web Worker. */
|
||||
function encodeJpeg(imageData: ImageData, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/jpeg-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
worker.terminate();
|
||||
if (e.data.error) return reject(new Error(e.data.error));
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/jpeg' }));
|
||||
};
|
||||
worker.onerror = (err) => { worker.terminate(); reject(err); };
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
|
||||
/** Encode ImageData to JPEG XL via Web Worker. Returns null if unavailable. */
|
||||
function encodeJxl(imageData: ImageData, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(
|
||||
new URL('$lib/workers/jxl-encoder.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
const timeout = setTimeout(() => { worker.terminate(); resolve(null); }, 10000);
|
||||
worker.onmessage = (e: MessageEvent<{ encoded: ArrayBuffer; format: string; error?: string }>) => {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
if (e.data.error) return resolve(null);
|
||||
resolve(new Blob([e.data.encoded], { type: 'image/jxl' }));
|
||||
};
|
||||
worker.onerror = () => { clearTimeout(timeout); worker.terminate(); resolve(null); };
|
||||
worker.postMessage({ imageData, quality });
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a 250×250 center-cropped thumbnail canvas using multi-pass downsampling. */
|
||||
function createThumbnailCanvas(img: HTMLImageElement): HTMLCanvasElement {
|
||||
const targetSize = 250;
|
||||
let srcWidth = img.width;
|
||||
let srcHeight = img.height;
|
||||
let sourceCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
const SCALE_FACTOR = 0.2;
|
||||
const STOP_THRESHOLD = targetSize * 1.1;
|
||||
|
||||
while (Math.min(srcWidth, srcHeight) > STOP_THRESHOLD) {
|
||||
const scale = Math.max(SCALE_FACTOR, targetSize / Math.min(srcWidth, srcHeight));
|
||||
const newWidth = Math.round(srcWidth * scale);
|
||||
const newHeight = Math.round(srcHeight * scale);
|
||||
|
||||
const nextCanvas = document.createElement('canvas');
|
||||
nextCanvas.width = newWidth;
|
||||
nextCanvas.height = newHeight;
|
||||
const nextCtx = nextCanvas.getContext('2d')!;
|
||||
nextCtx.imageSmoothingEnabled = true;
|
||||
nextCtx.imageSmoothingQuality = 'high';
|
||||
|
||||
if (sourceCanvas === null) {
|
||||
nextCtx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
} else {
|
||||
nextCtx.drawImage(sourceCanvas, 0, 0, newWidth, newHeight);
|
||||
}
|
||||
|
||||
sourceCanvas = nextCanvas;
|
||||
srcWidth = newWidth;
|
||||
srcHeight = newHeight;
|
||||
}
|
||||
|
||||
// Final crop to exact 250×250 square from center
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
finalCanvas.width = targetSize;
|
||||
finalCanvas.height = targetSize;
|
||||
const finalCtx = finalCanvas.getContext('2d')!;
|
||||
finalCtx.imageSmoothingEnabled = true;
|
||||
finalCtx.imageSmoothingQuality = 'high';
|
||||
|
||||
if (sourceCanvas === null) {
|
||||
const offsetX = (img.width - targetSize) / 2;
|
||||
const offsetY = (img.height - targetSize) / 2;
|
||||
finalCtx.drawImage(
|
||||
img,
|
||||
offsetX,
|
||||
offsetY,
|
||||
targetSize,
|
||||
targetSize,
|
||||
0,
|
||||
0,
|
||||
targetSize,
|
||||
targetSize
|
||||
);
|
||||
} else {
|
||||
const offsetX = (srcWidth - targetSize) / 2;
|
||||
const offsetY = (srcHeight - targetSize) / 2;
|
||||
finalCtx.drawImage(
|
||||
sourceCanvas,
|
||||
offsetX,
|
||||
offsetY,
|
||||
targetSize,
|
||||
targetSize,
|
||||
0,
|
||||
0,
|
||||
targetSize,
|
||||
targetSize
|
||||
);
|
||||
}
|
||||
|
||||
return finalCanvas;
|
||||
}
|
||||
|
||||
/** Resize to max 1500px on the short side, only scale down, never up. Returns canvas. */
|
||||
function createFullCanvas(img: HTMLImageElement): HTMLCanvasElement {
|
||||
let { width, height } = img;
|
||||
const maxShortSide = 1500;
|
||||
|
||||
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')!;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** Generate all thumbnail variants (AVIF, WebP, JPEG) from a single file. */
|
||||
async function generateThumbnailVariants(file: File): Promise<{
|
||||
avif: Blob;
|
||||
webp: Blob;
|
||||
jpg: Blob;
|
||||
}> {
|
||||
const img = await loadImage(file);
|
||||
const canvas = createThumbnailCanvas(img);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const [avif, webp, jpg] = await Promise.all([
|
||||
encodeAvif(imageData, 80),
|
||||
encodeWebp(imageData, 80),
|
||||
encodeJpeg(imageData, 85)
|
||||
]);
|
||||
|
||||
return { avif, webp, jpg };
|
||||
}
|
||||
|
||||
/** Generate all full-size variants (AVIF, WebP, JPEG, optional JXL) from a single file. */
|
||||
async function generateFullVariants(file: File): Promise<{
|
||||
avif: Blob;
|
||||
webp: Blob;
|
||||
jpg: Blob;
|
||||
jxl: Blob | null;
|
||||
}> {
|
||||
const img = await loadImage(file);
|
||||
const canvas = createFullCanvas(img);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const [avif, webp, jpg, jxl] = await Promise.all([
|
||||
encodeAvif(imageData, 72),
|
||||
encodeWebp(imageData, 80),
|
||||
encodeJpeg(imageData, 85),
|
||||
encodeJxl(imageData, 75)
|
||||
]);
|
||||
|
||||
return { avif, webp, jpg, jxl };
|
||||
}
|
||||
|
||||
let availableTags = $state<string[]>([]);
|
||||
let loadingTags = $state(true);
|
||||
let isMobile = $state(false);
|
||||
@@ -403,7 +453,7 @@
|
||||
tags = tags.filter((t) => t !== tag);
|
||||
}
|
||||
|
||||
/** Core upload function – now processes the images before sending. */
|
||||
/** Core upload function – multi-format: generates AVIF+WebP+JPEG+JXL variants. */
|
||||
async function uploadOneOrMany() {
|
||||
// Add any pending input as tag before uploading
|
||||
if (input.trim()) {
|
||||
@@ -425,44 +475,32 @@
|
||||
const fd = new FormData();
|
||||
|
||||
try {
|
||||
/* -------- 1. Convert to base image format (AVIF) ------- */
|
||||
uploadStatus[fileKey] = 'Converting to AVIF...';
|
||||
const avifBlob = await toAvifBlob(file);
|
||||
/* -------- 1. Generate all full-size variants (AVIF, WebP, JPEG, JXL) ------- */
|
||||
uploadStatus[fileKey] = 'Generating full-size variants...';
|
||||
const fullVariants = await generateFullVariants(file);
|
||||
|
||||
/* -------- 2. Create the two processed versions IN PARALLEL ------- */
|
||||
uploadStatus[fileKey] = 'Compressing...';
|
||||
const [resizedBlob, thumbBlob] = await Promise.all([
|
||||
resizeShortSide(avifBlob).then((blob) => {
|
||||
uploadStatus[fileKey] = 'Full size ready';
|
||||
return blob;
|
||||
}),
|
||||
createThumbnail(avifBlob).then((blob) => {
|
||||
uploadStatus[fileKey] = 'Thumbnail ready';
|
||||
return blob;
|
||||
})
|
||||
]);
|
||||
/* -------- 2. Generate all thumbnail variants (AVIF, WebP, JPEG) ------- */
|
||||
uploadStatus[fileKey] = 'Generating thumbnail variants...';
|
||||
const thumbVariants = await generateThumbnailVariants(file);
|
||||
|
||||
/* -------- 3. Generate filenames -------------------------------- */
|
||||
const ts = startTs - i; // 1 ms decrement per file
|
||||
const baseName = `${ts}.avif`;
|
||||
const thumbName = `${ts}_thumb.webp`;
|
||||
|
||||
/* -------- 4. Attach to FormData -------------------------------- */
|
||||
fd.append('file', resizedBlob, baseName);
|
||||
fd.append('thumbnail', thumbBlob, thumbName);
|
||||
/* -------- 4. Attach all variants to FormData -------------------------------- */
|
||||
fd.append('file_full_avif', fullVariants.avif, `${ts}_full.avif`);
|
||||
fd.append('file_full_webp', fullVariants.webp, `${ts}_full.webp`);
|
||||
fd.append('file_full_jpg', fullVariants.jpg, `${ts}_full.jpg`);
|
||||
if (fullVariants.jxl) {
|
||||
fd.append('file_full_jxl', fullVariants.jxl, `${ts}_full.jxl`);
|
||||
}
|
||||
fd.append('file_thumb_avif', thumbVariants.avif, `${ts}_thumb.avif`);
|
||||
fd.append('file_thumb_webp', thumbVariants.webp, `${ts}_thumb.webp`);
|
||||
fd.append('file_thumb_jpg', thumbVariants.jpg, `${ts}_thumb.jpg`);
|
||||
|
||||
if (tags.length > 0) {
|
||||
fd.append('tags', tags.join(','));
|
||||
}
|
||||
|
||||
/* Debug: log what we're sending
|
||||
console.log(`Uploading ${fileKey}:`, {
|
||||
baseName,
|
||||
thumbName,
|
||||
thumbMimeType: thumbBlob.type,
|
||||
fullMimeType: resizedBlob.type
|
||||
});
|
||||
*/
|
||||
/* -------- 5. Call the API ------------------------------------- */
|
||||
uploadStatus[fileKey] = 'Uploading...';
|
||||
const response = await fetch('/api/portfolio/images', {
|
||||
@@ -843,7 +881,7 @@
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Confirm Upload</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<label class="text-sm font-medium">Applicable Services</label>
|
||||
<div class="text-sm font-medium">Applicable Services</div>
|
||||
<p class="mb-2 text-xs text-gray-500">Select which services require this patch test</p>
|
||||
<div class="max-h-48 space-y-2 overflow-y-auto rounded-md border bg-gray-50 p-2">
|
||||
{#if availableServices.length === 0}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
let workingHoursCache: Record<string, Record<string, any>> = {};
|
||||
let availableHoursCache: Record<string, Record<string, any>> = {};
|
||||
let loadingMonths: Record<string, boolean> = {};
|
||||
let loadingMonthKeys: Set<string> = new Set();
|
||||
let loadingMonthKeys = new SvelteSet<string>();
|
||||
let initialLoadDone = $state(false);
|
||||
let rescheduleAutoSelectDone = $state(false);
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
|
||||
if (loadingMonths[monthKey]) return;
|
||||
if (loadingMonthKeys.has(monthKey)) return;
|
||||
loadingMonthKeys = new Set(loadingMonthKeys).add(monthKey);
|
||||
loadingMonthKeys.add(monthKey);
|
||||
loadingMonths[monthKey] = true;
|
||||
|
||||
hoursMonthGeneration++;
|
||||
@@ -265,7 +265,7 @@
|
||||
toast.error('Failed to load availability');
|
||||
} finally {
|
||||
delete loadingMonths[monthKey];
|
||||
loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey));
|
||||
loadingMonthKeys.delete(monthKey);
|
||||
loadingHours = false;
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@
|
||||
workingHoursCache = {};
|
||||
availableHoursCache = {};
|
||||
loadingMonths = {};
|
||||
loadingMonthKeys = new Set();
|
||||
loadingMonthKeys = new SvelteSet<string>();
|
||||
rescheduleAutoSelectDone = false;
|
||||
placeholder = new CalendarDate(
|
||||
new SvelteDate().getFullYear(),
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
let creatingService = $state(false);
|
||||
let serviceErrors = $state<Record<string, string>>({});
|
||||
|
||||
function validatePrice(price: any): string {
|
||||
function validatePrice(price: number | string): string {
|
||||
if (price === null || price === undefined || price === '') {
|
||||
return 'Price is required';
|
||||
}
|
||||
const numPrice = parseFloat(price);
|
||||
const numPrice = parseFloat(typeof price === 'number' ? price.toString() : price);
|
||||
if (isNaN(numPrice)) {
|
||||
return 'Price must be a valid number';
|
||||
}
|
||||
@@ -62,7 +62,7 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateDuration(value: any, field: string): string {
|
||||
function validateDuration(value: number | string, field: string): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 'Field cannot be empty';
|
||||
}
|
||||
|
||||
@@ -735,7 +735,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs text-gray-600">Time</label>
|
||||
<div class="text-xs text-gray-600">Time</div>
|
||||
{#if hoursLoading}
|
||||
<Skeleton class="h-9 w-full" />
|
||||
{:else if !selectedWorkingHours}
|
||||
@@ -912,7 +912,7 @@
|
||||
</Modal.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showDeleteAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const now = new SvelteDate();
|
||||
const diff = reservationExpiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const now = new SvelteDate();
|
||||
const diff = reservationExpiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
@@ -545,9 +545,9 @@
|
||||
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
|
||||
{#if loadingUsers}
|
||||
<div class="space-y-2 p-2">
|
||||
{#each Array(3) as _}
|
||||
<Skeleton class="h-10 w-full" />
|
||||
{/each}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if users.length === 0}
|
||||
<div class="flex items-center justify-center p-8 text-sm text-gray-500">
|
||||
@@ -653,7 +653,7 @@
|
||||
<Card.Content class="space-y-4">
|
||||
{#if loadingServices}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#each Array(4) as _}
|
||||
{#each Array(4) as _, i (i)}
|
||||
<Skeleton class="h-28 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -534,7 +534,7 @@
|
||||
|
||||
<!-- Save Default Hours Confirmation -->
|
||||
<AlertDialog.Root bind:open={showSaveDefaultHoursAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Content class="z-60">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Save default hours?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
Reference in New Issue
Block a user