Restructuring and navigator
All checks were successful
Build Docker image / build (push) Successful in 2m0s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 27s

This commit is contained in:
Roman Krček
2025-07-18 13:59:28 +02:00
parent 1a8ce546d4
commit 8e41c6d78f
8 changed files with 572 additions and 508 deletions

View File

@@ -2,6 +2,7 @@
import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores'; import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores';
import { getSheetNames, getSheetData } from '$lib/google'; import { getSheetNames, getSheetData } from '$lib/google';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte';
// Type definitions for better TypeScript support // Type definitions for better TypeScript support
interface ColumnMappingType { interface ColumnMappingType {
@@ -444,8 +445,6 @@
} catch (err) { } catch (err) {
console.error('Failed to save column mapping to localStorage:', err); console.error('Failed to save column mapping to localStorage:', err);
} }
currentStep.set(4); // Move to next step
} }
async function handleShowEditor() { async function handleShowEditor() {
@@ -754,20 +753,12 @@
{/if} {/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <Navigator
<button canProceed={mappingComplete}
onclick={() => currentStep.set(2)} {currentStep}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300" textBack="Back to Sheet Selection"
> textForwardDisabled="Select a column mapping"
← Back to Sheet Selection textForwardEnabled="Continue"
</button> onForward={handleContinue}
/>
<button
onclick={handleContinue}
disabled={!mappingComplete}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{mappingComplete ? 'Continue →' : 'Select a column mapping'}
</button>
</div>
</div> </div>

View File

@@ -1,506 +1,510 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { columnMapping, filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores'; import { columnMapping, filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl } from '$lib/google'; import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl } from '$lib/google';
import PhotoCard from '../PhotoCard.svelte'; import Navigator from './subcomponents/Navigator.svelte';
import * as tf from '@tensorflow/tfjs'; import PhotoCard from './subcomponents/PhotoCard.svelte';
import * as blazeface from '@tensorflow-models/blazeface'; import * as tf from '@tensorflow/tfjs';
import * as blazeface from '@tensorflow-models/blazeface';
let photos = $state<PhotoInfo[]>([]); let photos = $state<PhotoInfo[]>([]);
let isProcessing = $state(false); let isProcessing = $state(false);
let processedCount = $state(0); let processedCount = $state(0);
let totalCount = $state(0); let totalCount = $state(0);
let detector: blazeface.BlazeFaceModel | undefined; let detector: blazeface.BlazeFaceModel | undefined;
let detectorPromise: Promise<void> | undefined; let detectorPromise: Promise<void> | undefined;
interface PhotoInfo { interface PhotoInfo {
name: string; name: string;
url: string; url: string;
status: 'loading' | 'success' | 'error'; status: 'loading' | 'success' | 'error';
objectUrl?: string; objectUrl?: string;
retryCount: number; retryCount: number;
cropData?: { x: number; y: number; width: number; height: number }; cropData?: { x: number; y: number; width: number; height: number };
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual'; faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
} }
function initializeDetector() { function initializeDetector() {
if (!detectorPromise) { if (!detectorPromise) {
detectorPromise = (async () => { detectorPromise = (async () => {
console.log('Initializing face detector...'); console.log('Initializing face detector...');
await tf.setBackend('webgl'); await tf.setBackend('webgl');
await tf.ready(); await tf.ready();
detector = await blazeface.load(); detector = await blazeface.load();
console.log('BlazeFace model loaded'); console.log('BlazeFace model loaded');
})(); })();
} }
return detectorPromise; return detectorPromise;
} }
async function processPhotosInParallel() { async function processPhotosInParallel() {
if (isProcessing) return; if (isProcessing) return;
console.log('Starting processPhotos in parallel...'); console.log('Starting processPhotos in parallel...');
isProcessing = true; isProcessing = true;
processedCount = 0; processedCount = 0;
const validRows = $filteredSheetData.filter((row) => row._isValid); const validRows = $filteredSheetData.filter((row) => row._isValid);
const photoUrls = new Set<string>(); const photoUrls = new Set<string>();
const photoMap = new Map<string, any[]>(); const photoMap = new Map<string, any[]>();
validRows.forEach((row: any) => { validRows.forEach((row: any) => {
const photoUrl = row.pictureUrl; const photoUrl = row.pictureUrl;
if (photoUrl && photoUrl.trim()) { if (photoUrl && photoUrl.trim()) {
const trimmedUrl = photoUrl.trim(); const trimmedUrl = photoUrl.trim();
photoUrls.add(trimmedUrl); photoUrls.add(trimmedUrl);
if (!photoMap.has(trimmedUrl)) { if (!photoMap.has(trimmedUrl)) {
photoMap.set(trimmedUrl, []); photoMap.set(trimmedUrl, []);
} }
photoMap.get(trimmedUrl)!.push(row); photoMap.get(trimmedUrl)!.push(row);
} }
}); });
totalCount = photoUrls.size; totalCount = photoUrls.size;
console.log(`Found ${totalCount} unique photo URLs`); console.log(`Found ${totalCount} unique photo URLs`);
photos = Array.from(photoUrls).map((url) => ({ photos = Array.from(photoUrls).map((url) => ({
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname,
url, url,
status: 'loading' as const, status: 'loading' as const,
retryCount: 0, retryCount: 0,
faceDetectionStatus: 'pending' as const faceDetectionStatus: 'pending' as const
})); }));
const concurrencyLimit = 5; const concurrencyLimit = 5;
const promises = []; const promises = [];
for (let i = 0; i < photos.length; i++) { for (let i = 0; i < photos.length; i++) {
const promise = (async () => { const promise = (async () => {
await loadPhoto(i); await loadPhoto(i);
processedCount++; processedCount++;
})(); })();
promises.push(promise); promises.push(promise);
if (promises.length >= concurrencyLimit) { if (promises.length >= concurrencyLimit) {
await Promise.all(promises); await Promise.all(promises);
promises.length = 0; promises.length = 0;
} }
} }
await Promise.all(promises); await Promise.all(promises);
isProcessing = false; isProcessing = false;
console.log('All photos processed.'); console.log('All photos processed.');
} }
// Initialize detector and process photos // Initialize detector and process photos
onMount(() => { onMount(() => {
console.log('StepGallery mounted'); console.log('StepGallery mounted');
initializeDetector(); // Start loading model initializeDetector(); // Start loading model
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) { if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
console.log('Processing photos for gallery step'); console.log('Processing photos for gallery step');
processPhotosInParallel(); processPhotosInParallel();
} else { } else {
console.log('No data to process:', { console.log('No data to process:', {
dataLength: $filteredSheetData.length, dataLength: $filteredSheetData.length,
pictureUrlMapping: $columnMapping.pictureUrl pictureUrlMapping: $columnMapping.pictureUrl
}); });
} }
}); });
async function loadPhoto(index: number, isRetry = false) { async function loadPhoto(index: number, isRetry = false) {
const photo = photos[index]; const photo = photos[index];
if (!isRetry) { if (!isRetry) {
photo.status = 'loading'; photo.status = 'loading';
// No need to reassign photos array with $state reactivity // No need to reassign photos array with $state reactivity
} }
try { try {
let blob: Blob; let blob: Blob;
if (isGoogleDriveUrl(photo.url)) { if (isGoogleDriveUrl(photo.url)) {
// Download from Google Drive // Download from Google Drive
console.log(`Downloading from Google Drive: ${photo.name}`); console.log(`Downloading from Google Drive: ${photo.name}`);
blob = await downloadDriveImage(photo.url); blob = await downloadDriveImage(photo.url);
} else { } else {
// For direct URLs, convert to blob // For direct URLs, convert to blob
const response = await fetch(photo.url); const response = await fetch(photo.url);
blob = await response.blob(); blob = await response.blob();
} }
// Check for HEIC/HEIF format. If so, start conversion but don't block. // Check for HEIC/HEIF format. If so, start conversion but don't block.
if ( if (
blob.type === 'image/heic' || blob.type === 'image/heic' ||
blob.type === 'image/heif' || blob.type === 'image/heif' ||
photo.url.toLowerCase().endsWith('.heic') photo.url.toLowerCase().endsWith('.heic')
) { ) {
console.log(`HEIC detected for ${photo.name}. Starting conversion in background.`); console.log(`HEIC detected for ${photo.name}. Starting conversion in background.`);
photo.status = 'loading'; // Visually indicate something is happening photo.status = 'loading'; // Visually indicate something is happening
// Don't await this, let it run in the background // Don't await this, let it run in the background
convertHeicPhoto(index, blob); convertHeicPhoto(index, blob);
return; // End loadPhoto here for HEIC, conversion will handle the rest return; // End loadPhoto here for HEIC, conversion will handle the rest
} }
// For non-HEIC images, proceed as normal // For non-HEIC images, proceed as normal
await processLoadedBlob(index, blob); await processLoadedBlob(index, blob);
} catch (error) {
console.error(`Failed to load photo for ${photo.name}:`, error);
photo.status = 'error';
}
}
} catch (error) { async function convertHeicPhoto(index: number, blob: Blob) {
console.error(`Failed to load photo for ${photo.name}:`, error); const photo = photos[index];
photo.status = 'error'; try {
} console.log(`Converting HEIC with heic-convert for ${photo.name}...`);
}
async function convertHeicPhoto(index: number, blob: Blob) { // Dynamically import the browser-specific version of the library
const photo = photos[index]; const { default: convert } = await import('heic-convert/browser');
try {
console.log(`Converting HEIC with heic-convert for ${photo.name}...`);
// Dynamically import the browser-specific version of the library const inputBuffer = await blob.arrayBuffer();
const { default: convert } = await import('heic-convert/browser'); const outputBuffer = await convert({
buffer: new Uint8Array(inputBuffer), // heic-convert expects a Uint8Array
format: 'JPEG',
quality: 0.9
});
const inputBuffer = await blob.arrayBuffer(); const convertedBlob = new Blob([outputBuffer], { type: 'image/jpeg' });
const outputBuffer = await convert({
buffer: new Uint8Array(inputBuffer), // heic-convert expects a Uint8Array
format: 'JPEG',
quality: 0.9
});
const convertedBlob = new Blob([outputBuffer], { type: 'image/jpeg' }); console.log(`Successfully converted HEIC for ${photo.name}`);
console.log(`Successfully converted HEIC for ${photo.name}`); // Now that it's converted, process it like any other image
await processLoadedBlob(index, convertedBlob);
} catch (e) {
console.error(`Failed to convert HEIC image for ${photo.name}:`, e);
photo.status = 'error';
}
}
// Now that it's converted, process it like any other image async function processLoadedBlob(index: number, blob: Blob) {
await processLoadedBlob(index, convertedBlob); const photo = photos[index];
try {
const objectUrl = createImageObjectUrl(blob);
} catch (e) { // Test if image loads properly
console.error(`Failed to convert HEIC image for ${photo.name}:`, e); await new Promise<void>((resolve, reject) => {
photo.status = 'error'; const img = new Image();
} img.onload = () => resolve();
} img.onerror = (error) => {
console.error(`Failed to load image for ${photo.name}:`, error);
reject(new Error('Failed to load image'));
};
img.src = objectUrl;
});
async function processLoadedBlob(index: number, blob: Blob) { photo.objectUrl = objectUrl;
const photo = photos[index]; photo.status = 'success';
try { console.log(`Photo loaded successfully: ${photo.name}`);
const objectUrl = createImageObjectUrl(blob);
// Test if image loads properly // Save to pictures store
await new Promise<void>((resolve, reject) => { pictures.update((pics) => ({
const img = new Image(); ...pics,
img.onload = () => resolve(); [photo.url]: {
img.onerror = (error) => { id: photo.url,
console.error(`Failed to load image for ${photo.name}:`, error); blob: blob,
reject(new Error('Failed to load image')); url: objectUrl,
}; downloaded: true,
img.src = objectUrl; faceDetected: false,
}); faceCount: 0
}
}));
photo.objectUrl = objectUrl; // Automatically run face detection to generate crop
photo.status = 'success'; await detectFaceForPhoto(index);
console.log(`Photo loaded successfully: ${photo.name}`); } catch (error) {
console.error(`Failed to process blob for ${photo.name}:`, error);
photo.status = 'error';
}
}
// Save to pictures store async function detectFaceForPhoto(index: number) {
pictures.update(pics => ({ try {
...pics, await initializeDetector(); // Ensure detector is loaded
[photo.url]: { if (!detector) {
id: photo.url, photos[index].faceDetectionStatus = 'failed';
blob: blob, console.error('Face detector not available.');
url: objectUrl, return;
downloaded: true, }
faceDetected: false,
faceCount: 0
}
}));
// Automatically run face detection to generate crop photos[index].faceDetectionStatus = 'processing';
await detectFaceForPhoto(index); const img = new Image();
} catch (error) { img.crossOrigin = 'anonymous';
console.error(`Failed to process blob for ${photo.name}:`, error); img.src = photos[index].objectUrl!;
photo.status = 'error'; await new Promise((r, e) => {
} img.onload = r;
} img.onerror = e;
});
const predictions = await detector.estimateFaces(img, false);
async function detectFaceForPhoto(index: number) { if (predictions.length > 0) {
try { const getProbability = (p: number | tf.Tensor) =>
await initializeDetector(); // Ensure detector is loaded typeof p === 'number' ? p : p.dataSync()[0];
if (!detector) {
photos[index].faceDetectionStatus = 'failed';
console.error('Face detector not available.');
return;
}
photos[index].faceDetectionStatus = 'processing'; const face = predictions.sort(
const img = new Image(); (a, b) => getProbability(b.probability!) - getProbability(a.probability!)
img.crossOrigin = 'anonymous'; )[0];
img.src = photos[index].objectUrl!; // Coordinates in displayed image space
await new Promise((r, e) => { img.onload = r; img.onerror = e; }); let [x1, y1] = face.topLeft as [number, number];
const predictions = await detector.estimateFaces(img, false); let [x2, y2] = face.bottomRight as [number, number];
// Scale to natural image size
const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height;
const faceWidth = (x2 - x1) * scaleX;
const faceHeight = (y2 - y1) * scaleY;
const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX;
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
// Load crop config from env
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0');
const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0');
const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0');
const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5');
// Compute crop size and center
let cropWidth = faceWidth * cropScale;
let cropHeight = cropWidth / cropRatio;
if (predictions.length > 0) { // If crop is larger than image, scale it down while maintaining aspect ratio
const getProbability = (p: number | tf.Tensor) => (typeof p === 'number' ? p : p.dataSync()[0]); if (cropWidth > img.naturalWidth || cropHeight > img.naturalHeight) {
const widthRatio = img.naturalWidth / cropWidth;
const heightRatio = img.naturalHeight / cropHeight;
const scale = Math.min(widthRatio, heightRatio);
cropWidth *= scale;
cropHeight *= scale;
}
const face = predictions.sort((a,b) => getProbability(b.probability!) - getProbability(a.probability!))[0]; let centerX = faceCenterX + cropWidth * offsetX;
// Coordinates in displayed image space let centerY = faceCenterY + cropHeight * offsetY;
let [x1,y1] = face.topLeft as [number, number];
let [x2,y2] = face.bottomRight as [number, number];
// Scale to natural image size
const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height;
const faceWidth = (x2 - x1) * scaleX;
const faceHeight = (y2 - y1) * scaleY;
const faceCenterX = (x1 + (x2 - x1)/2) * scaleX;
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
// Load crop config from env
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0');
const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0');
const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0');
const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5');
// Compute crop size and center
let cropWidth = faceWidth * cropScale;
let cropHeight = cropWidth / cropRatio;
// If crop is larger than image, scale it down while maintaining aspect ratio // Clamp center to ensure crop fits
if (cropWidth > img.naturalWidth || cropHeight > img.naturalHeight) { centerX = Math.max(cropWidth / 2, Math.min(centerX, img.naturalWidth - cropWidth / 2));
const widthRatio = img.naturalWidth / cropWidth; centerY = Math.max(cropHeight / 2, Math.min(centerY, img.naturalHeight - cropHeight / 2));
const heightRatio = img.naturalHeight / cropHeight;
const scale = Math.min(widthRatio, heightRatio);
cropWidth *= scale;
cropHeight *= scale;
}
let centerX = faceCenterX + cropWidth * offsetX; const cropX = centerX - cropWidth / 2;
let centerY = faceCenterY + cropHeight * offsetY; const cropY = centerY - cropHeight / 2;
// Clamp center to ensure crop fits const crop = {
centerX = Math.max(cropWidth/2, Math.min(centerX, img.naturalWidth - cropWidth/2)); x: Math.round(Math.max(0, cropX)),
centerY = Math.max(cropHeight/2, Math.min(centerY, img.naturalHeight - cropHeight/2)); y: Math.round(Math.max(0, cropY)),
width: Math.round(cropWidth),
height: Math.round(cropHeight)
};
photos[index].cropData = crop;
photos[index].faceDetectionStatus = 'completed';
const cropX = centerX - cropWidth/2; // Save crop data to store
const cropY = centerY - cropHeight/2; cropRects.update((crops) => ({
...crops,
[photos[index].url]: crop
}));
const crop = { // Update pictures store with face detection info
x: Math.round(Math.max(0, cropX)), pictures.update((pics) => ({
y: Math.round(Math.max(0, cropY)), ...pics,
width: Math.round(cropWidth), [photos[index].url]: {
height: Math.round(cropHeight) ...pics[photos[index].url],
}; faceDetected: true,
photos[index].cropData = crop; faceCount: predictions.length
photos[index].faceDetectionStatus = 'completed'; }
}));
} else {
photos[index].faceDetectionStatus = 'failed';
}
} catch (error) {
console.error(`Face detection failed for ${photos[index].name}:`, error);
photos[index].faceDetectionStatus = 'failed';
}
// No need to reassign photos array with $state reactivity
}
// Save crop data to store async function retryPhoto(index: number) {
cropRects.update(crops => ({ const photo = photos[index];
...crops,
[photos[index].url]: crop
}));
// Update pictures store with face detection info if (photo.retryCount >= 3) {
pictures.update(pics => ({ return; // Max retries reached
...pics, }
[photos[index].url]: {
...pics[photos[index].url],
faceDetected: true,
faceCount: predictions.length
}
}));
} else {
photos[index].faceDetectionStatus = 'failed';
}
} catch (error) {
console.error(`Face detection failed for ${photos[index].name}:`, error);
photos[index].faceDetectionStatus = 'failed';
}
// No need to reassign photos array with $state reactivity
}
async function retryPhoto(index: number) { photo.retryCount++;
const photo = photos[index]; await loadPhoto(index, true);
}
if (photo.retryCount >= 3) { function handleCropUpdate(
return; // Max retries reached index: number,
} detail: { cropData: { x: number; y: number; width: number; height: number } }
) {
photos[index].cropData = detail.cropData;
photos[index].faceDetectionStatus = 'manual';
photo.retryCount++; // Save updated crop data to store
await loadPhoto(index, true); cropRects.update((crops) => ({
} ...crops,
[photos[index].url]: detail.cropData
}));
function handleCropUpdate(index: number, detail: { cropData: { x: number; y: number; width: number; height: number } }) { // No need to reassign photos array with $state reactivity
photos[index].cropData = detail.cropData; }
photos[index].faceDetectionStatus = 'manual';
// Save updated crop data to store // Cleanup object URLs when component is destroyed
cropRects.update(crops => ({ function cleanupObjectUrls() {
...crops, photos.forEach((photo) => {
[photos[index].url]: detail.cropData if (photo.objectUrl && photo.objectUrl.startsWith('blob:')) {
})); URL.revokeObjectURL(photo.objectUrl);
}
});
}
// No need to reassign photos array with $state reactivity const canProceed = $derived(() => {
} const hasPhotos = photos.length > 0;
const allLoaded = photos.every((photo) => photo.status === 'success');
const allCropped = photos.every((photo) => photo.cropData);
const canProceed = $derived(() => { return hasPhotos && allLoaded && allCropped;
const hasPhotos = photos.length > 0; });
const allLoaded = photos.every(photo => photo.status === 'success');
const allCropped = photos.every(photo => photo.cropData);
return hasPhotos && allLoaded && allCropped; // Cleanup on unmount using $effect
}); $effect(() => {
return () => {
// Cleanup object URLs when component is destroyed cleanupObjectUrls();
function cleanupObjectUrls() { };
photos.forEach(photo => { });
if (photo.objectUrl && photo.objectUrl.startsWith('blob:')) {
URL.revokeObjectURL(photo.objectUrl);
}
});
}
// Cleanup on unmount using $effect
$effect(() => {
return () => {
cleanupObjectUrls();
};
});
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-6xl mx-auto"> <div class="mb-6">
<div class="mb-6"> <h2 class="mb-2 text-xl font-semibold text-gray-900">Review & Crop Photos</h2>
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Review & Crop Photos
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="mb-4 text-sm text-gray-700">
Photos are automatically cropped using face detection. Click the pen icon to manually adjust the crop area. Photos are automatically cropped using face detection. Click the pen icon to manually adjust
</p> the crop area.
</div> </p>
</div>
<!-- Processing Status --> <!-- Processing Status -->
{#if isProcessing} {#if isProcessing}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6"> <div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div> <div
<span class="text-sm text-blue-800"> class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
Processing photos... ></div>
</span> <span class="text-sm text-blue-800"> Processing photos... </span>
</div> </div>
<span class="text-sm text-blue-600"> <span class="text-sm text-blue-600">
{processedCount} / {totalCount} {processedCount} / {totalCount}
</span> </span>
</div> </div>
{#if totalCount > 0} {#if totalCount > 0}
<div class="mt-3 w-full bg-blue-200 rounded-full h-2"> <div class="mt-3 h-2 w-full rounded-full bg-blue-200">
<div <div
class="bg-blue-600 h-2 rounded-full transition-all duration-300" class="h-2 rounded-full bg-blue-600 transition-all duration-300"
style="width: {(processedCount / totalCount) * 100}%" style="width: {(processedCount / totalCount) * 100}%"
></div> ></div>
</div> </div>
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Summary Stats --> <!-- Summary Stats -->
{#if !isProcessing && photos.length > 0} {#if !isProcessing && photos.length > 0}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6"> <div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Processing Summary</h3> <h3 class="mb-3 text-sm font-medium text-gray-700">Processing Summary</h3>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm md:grid-cols-5">
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-gray-900">{photos.length}</div> <div class="text-2xl font-bold text-gray-900">{photos.length}</div>
<div class="text-gray-600">Total Photos</div> <div class="text-gray-600">Total Photos</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-green-600"> <div class="text-2xl font-bold text-green-600">
{photos.filter(p => p.status === 'success').length} {photos.filter((p) => p.status === 'success').length}
</div> </div>
<div class="text-gray-600">Loaded</div> <div class="text-gray-600">Loaded</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-blue-600"> <div class="text-2xl font-bold text-blue-600">
{photos.filter(p => p.faceDetectionStatus === 'completed').length} {photos.filter((p) => p.faceDetectionStatus === 'completed').length}
</div> </div>
<div class="text-gray-600">Auto-cropped</div> <div class="text-gray-600">Auto-cropped</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-purple-600"> <div class="text-2xl font-bold text-purple-600">
{photos.filter(p => p.cropData).length} {photos.filter((p) => p.cropData).length}
</div> </div>
<div class="text-gray-600">Ready</div> <div class="text-gray-600">Ready</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-red-600"> <div class="text-2xl font-bold text-red-600">
{photos.filter(p => p.status === 'error').length} {photos.filter((p) => p.status === 'error').length}
</div> </div>
<div class="text-gray-600">Failed</div> <div class="text-gray-600">Failed</div>
</div> </div>
</div> </div>
{#if photos.filter(p => p.status === 'error').length > 0} {#if photos.filter((p) => p.status === 'error').length > 0}
<div class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded"> <div class="mt-4 rounded border border-yellow-200 bg-yellow-50 p-3">
<p class="text-sm text-yellow-800"> <p class="text-sm text-yellow-800">
<strong>Note:</strong> Cards will only be generated for photos that load successfully. <strong>Note:</strong> Cards will only be generated for photos that load successfully.
</p> </p>
</div> </div>
{/if} {/if}
{#if !canProceed() && photos.filter(p => p.status === 'success').length > 0} {#if !canProceed() && photos.filter((p) => p.status === 'success').length > 0}
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded"> <div class="mt-4 rounded border border-blue-200 bg-blue-50 p-3">
<p class="text-sm text-blue-800"> <p class="text-sm text-blue-800">
<strong>Tip:</strong> All photos need to be cropped before proceeding. Face detection runs automatically. <strong>Tip:</strong> All photos need to be cropped before proceeding. Face detection runs
</p> automatically.
</div> </p>
{/if} </div>
</div> {/if}
{/if} </div>
{/if}
<!-- Photo Grid --> <!-- Photo Grid -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6"> <div class="mb-6 overflow-hidden rounded-lg bg-white">
{#if photos.length === 0 && !isProcessing} {#if photos.length === 0 && !isProcessing}
<div class="text-center py-12"> <div class="py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 002 2z"/> class="mx-auto h-12 w-12 text-gray-400"
</svg> fill="none"
<h3 class="mt-2 text-sm font-medium text-gray-900">No photos found</h3> viewBox="0 0 24 24"
<p class="mt-1 text-sm text-gray-500"> stroke="currentColor"
Go back to check your column mapping and selected rows. >
</p> <path
</div> stroke-linecap="round"
{:else} stroke-linejoin="round"
<div class="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6"> stroke-width="2"
{#each photos as photo, index} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 002 2z"
<PhotoCard />
{photo} </svg>
onCropUpdated={(e) => handleCropUpdate(index, e)} <h3 class="mt-2 text-sm font-medium text-gray-900">No photos found</h3>
onRetry={() => retryPhoto(index)} <p class="mt-1 text-sm text-gray-500">
/> Go back to check your column mapping and selected rows.
{/each} </p>
</div> </div>
{/if} {:else}
</div> <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3">
{#each photos as photo, index}
<PhotoCard
{photo}
onCropUpdated={(e) => handleCropUpdate(index, e)}
onRetry={() => retryPhoto(index)}
/>
{/each}
</div>
{/if}
</div>
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <Navigator
<button canProceed={canProceed()}
onclick={() => currentStep.set(4)} {currentStep}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300" textBack="Back to Row Filter"
> textForwardDisabled="Waiting from photos"
← Back to Row Filter textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
</button> />
<button
onclick={() => currentStep.set(6)}
disabled={!canProceed()}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{canProceed()
? `Generate ${photos.filter(p => p.status === 'success' && p.cropData).length} Cards `
: 'Waiting for photos to load and crop'}
</button>
</div>
</div>
</div> </div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
let { canProceed, photos, currentStep } = $props<{
canProceed: () => boolean;
photos: any[];
currentStep: any;
}>();
function handleBack() {
currentStep.set(4);
}
function handleNext() {
currentStep.set(6);
}
</script>
<div class="flex justify-between">
<button
onclick={handleBack}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
← Back to Row Filter
</button>
<button
onclick={handleNext}
disabled={!canProceed()}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{canProceed()
? `Generate ${photos.filter(p => p.status === 'success' && p.cropData).length} Cards `
: 'Waiting for photos to load and crop'}
</button>
</div>

View File

@@ -7,7 +7,7 @@
currentStep, currentStep,
sheetData sheetData
} from '$lib/stores'; } from '$lib/stores';
import type { RowData } from '$lib/stores'; import Navigator from './subcomponents/Navigator.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSheetNames, getSheetData } from '$lib/google'; import { getSheetNames, getSheetData } from '$lib/google';
@@ -213,9 +213,6 @@
// Store the filtered data // Store the filtered data
filteredSheetData.set(selectedData); filteredSheetData.set(selectedData);
// Move to next step
currentStep.set(5);
} }
$: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => { $: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => {
@@ -496,22 +493,13 @@
</div> </div>
{/if} {/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <Navigator
<button canProceed={canProceed}
onclick={() => currentStep.set(3)} currentStep={currentStep}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300" textBack="Back to Colum Selection"
> textForwardDisabled="Select rows to continue"
← Back to Colum Selection textForwardEnabled={`Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} →`}
</button> onForward={handleContinue}
<button />
onclick={handleContinue}
disabled={!canProceed}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{canProceed
? `Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} `
: 'Select rows to continue'}
</button>
</div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { availableSheets, selectedSheet, currentStep } from '$lib/stores'; import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
import { searchSheets } from '$lib/google'; import { searchSheets } from '$lib/google';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte';
let searchQuery = $state(''); let searchQuery = $state('');
let isLoading = $state(false); let isLoading = $state(false);
@@ -66,11 +67,6 @@
} }
let canProceed = $derived($selectedSheet !== null); let canProceed = $derived($selectedSheet !== null);
function handleContinue() {
if (!canProceed) return;
currentStep.set(3); // Move to the column mapping step
}
</script> </script>
<div class="p-6"> <div class="p-6">
@@ -262,20 +258,11 @@
{/if} {/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <Navigator
<button {canProceed}
onclick={() => currentStep.set(1)} {currentStep}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300" textBack="Back to Auth"
> textForwardDisabled="Select a sheet"
← Back to Auth textForwardEnabled="Continue"
</button> />
<button
onclick={handleContinue}
disabled={!canProceed}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{canProceed ? 'Continue →' : 'Select a sheet to continue'}
</button>
</div>
</div> </div>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { on } from 'svelte/events';
let {
canProceed,
currentStep,
textBack,
textForwardDisabled,
textForwardEnabled,
onBack,
onForward
} = $props<{
canProceed: boolean;
currentStep: any;
textBack: string;
textForwardDisabled: string;
textForwardEnabled: string;
onBack?: () => void | null;
onForward?: () => void | null;
}>();
async function handleBack() {
if (onBack) {
// Allow custom back logic if provided
await onBack();
}
currentStep.set($currentStep - 1);
}
async function handleForward() {
if (onForward) {
// Allow custom forward logic if provided
await onForward();
}
currentStep.set($currentStep + 1);
}
</script>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-between">
<button
onclick={handleBack}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300 sm:w-auto"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{textBack}</span>
</button>
<button
onclick={handleForward}
disabled={!canProceed}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400 sm:w-auto"
>
<span>{canProceed ? textForwardEnabled : textForwardDisabled}</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>