Improve the the cropping process, UI and UX

This commit is contained in:
Roman Krček
2025-07-18 09:11:17 +02:00
parent c77c96c1c7
commit 9bbd02dd67
8 changed files with 2146 additions and 1881 deletions

View File

@@ -7,8 +7,10 @@
- Pass fucntions as props instead od dispatching events - Pass fucntions as props instead od dispatching events
- Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax - Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax
- when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state. - when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state.
- USe $props instead of export let!
- Use styling from ".github/styling.md" for any UI components. - Use styling from ".github/styling.md" for any UI components.
- Refer to the ".github/core-instructions.md" for the overall structure of the application. - Refer to the ".github/core-instructions.md" for the overall structure of the application.
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task. - Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
- Remain consistent in styling and code structure. - Remain consistent in styling and code structure.
- Avoid unncessary iterations. If problems is mostly solved, stop. - Avoid unncessary iterations. If problems is mostly solved, stop.
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.

View File

@@ -1,90 +1,185 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte';
import PhotoCrop from './PhotoCrop.svelte'; import PhotoCrop from './PhotoCrop.svelte';
export let imageUrl: string; let { photo, onCropUpdated, onRetry } = $props<{
export let personName: string; photo: {
export let isProcessing = false; name: string;
export let cropData: { x: number; y: number; width: number; height: number } | null = null; url: string;
status: 'loading' | 'success' | 'error';
const dispatch = createEventDispatcher<{ objectUrl?: string;
cropUpdated: { x: number; y: number; width: number; height: number }; retryCount: number;
cropData?: { x: number; y: number; width: number; height: number };
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
};
onCropUpdated: (detail: any) => void;
onRetry: () => void;
}>(); }>();
let showCropEditor = false; let showCropper = $state(false);
let currentCrop = cropData; let imageDimensions = $state<{ w: number; h: number } | null>(null);
let imageContainer = $state<HTMLDivElement | undefined>();
let photoElement: HTMLImageElement; const cropBoxStyle = $derived(() => {
if (!photo.cropData || !imageDimensions || !imageContainer) {
function openCropEditor() { return 'display: none;';
showCropEditor = true;
} }
function handleCropSave(e: CustomEvent<{ x: number; y: number; width: number; height: number }>) { const { w: naturalW, h: naturalH } = imageDimensions;
currentCrop = e.detail; const { x, y, width, height } = photo.cropData;
showCropEditor = false; const { clientWidth: containerW, clientHeight: containerH } = imageContainer;
dispatch('cropUpdated', currentCrop!);
const containerAspect = containerW / containerH;
const naturalAspect = naturalW / naturalH;
let imgW, imgH;
if (naturalAspect > containerAspect) {
// Image is wider than container, so it's letterboxed top/bottom
imgW = containerW;
imgH = containerW / naturalAspect;
} else {
// Image is taller than container, so it's letterboxed left/right
imgH = containerH;
imgW = containerH * naturalAspect;
} }
function handleCropCancel() { const offsetX = (containerW - imgW) / 2;
showCropEditor = false; const offsetY = (containerH - imgH) / 2;
const scaleX = imgW / naturalW;
const scaleY = imgH / naturalH;
const left = x * scaleX + offsetX;
const top = y * scaleY + offsetY;
const boxWidth = width * scaleX;
const boxHeight = height * scaleY;
return `
position: absolute;
left: ${left}px;
top: ${top}px;
width: ${boxWidth}px;
height: ${boxHeight}px;
border: 2px solid #3b82f6; /* blue-500 */
box-shadow: 0 0 0 9999px rgba(229, 231, 235, 0.75); /* gray-200 with opacity */
transition: all 0.3s;
`;
});
function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement;
imageDimensions = { w: img.naturalWidth, h: img.naturalHeight };
} }
$: if (cropData) currentCrop = cropData; function handleCropUpdated(detail: any) {
onCropUpdated(detail);
showCropper = false;
}
</script> </script>
<div class="relative group"> {#if photo.status === 'loading'}
<div class="relative overflow-hidden rounded-lg border-2 border-gray-200"> <div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<img <div class="h-48 bg-gray-100 flex items-center justify-center">
bind:this={photoElement} <div class="flex flex-col items-center">
src={imageUrl}
alt={personName}
class="w-full h-full object-cover"
/>
{#if currentCrop}
<!-- Show crop preview overlay with proper masking -->
<div class="absolute inset-0 pointer-events-none">
<div class="relative w-full h-full">
<!-- Create mask using box-shadow to darken only non-crop areas -->
<div <div
class="absolute border-2 border-blue-500 border-dashed" class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"
style="left: {(currentCrop.x / photoElement?.naturalWidth) * 100}%;
top: {(currentCrop.y / photoElement?.naturalHeight) * 100}%;
width: {(currentCrop.width / photoElement?.naturalWidth) * 100}%;
height: {(currentCrop.height / photoElement?.naturalHeight) * 100}%;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.3);"
></div> ></div>
<span class="text-xs text-gray-600">Loading...</span>
</div> </div>
</div> </div>
{/if} <div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<!-- Edit crop button --> <span class="text-xs text-blue-600">Processing photo...</span>
<button </div>
on:click={openCropEditor} </div>
class="absolute top-2 right-2 bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full p-2 shadow-lg transition-all duration-200 opacity-0 group-hover:opacity-100" {:else if photo.status === 'success' && photo.objectUrl}
title="Edit crop area" <div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative">
<div
class="h-48 bg-gray-100 flex items-center justify-center relative overflow-hidden"
bind:this={imageContainer}
> >
<svg class="w-4 h-4 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <img
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/> src={photo.objectUrl}
alt={`Photo of ${photo.name}`}
class="max-w-full max-h-full object-contain"
onload={handleImageLoad}
/>
{#if photo.cropData}
<div style={cropBoxStyle()}></div>
{/if}
</div>
<div class="p-3 flex items-center justify-between">
<div>
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
{#if photo.faceDetectionStatus === 'completed'}
<span class="text-xs text-green-600">Face detected</span>
{:else if photo.faceDetectionStatus === 'failed'}
<span class="text-xs text-orange-600">Face not found</span>
{:else if photo.faceDetectionStatus === 'processing'}
<span class="text-xs text-blue-600">Detecting face...</span>
{:else if photo.faceDetectionStatus === 'manual'}
<span class="text-xs text-purple-600">Manual crop</span>
{:else if photo.faceDetectionStatus === 'pending'}
<span class="text-xs text-gray-500">Queued...</span>
{/if}
</div>
<button
onclick={() => (showCropper = true)}
class="p-1 text-gray-500 hover:text-blue-600"
title="Edit Crop"
aria-label="Edit Crop"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L16.732 3.732z"
/>
</svg> </svg>
</button> </button>
</div> </div>
<div class="mt-2"> {#if showCropper}
<p class="text-sm font-medium text-gray-900 truncate">{personName}</p>
{#if isProcessing}
<p class="text-xs text-gray-500">Processing...</p>
{/if}
</div>
</div>
{#if showCropEditor}
<PhotoCrop <PhotoCrop
{imageUrl} imageUrl={photo.objectUrl}
{personName} personName={photo.name}
initialCrop={currentCrop} initialCropData={photo.cropData}
on:save={handleCropSave} onClose={() => (showCropper = false)}
on:cancel={handleCropCancel} onCropUpdated={handleCropUpdated}
/> />
{/if} {/if}
</div>
{:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center">
<div class="flex flex-col items-center text-center p-4">
<svg
class="w-12 h-12 text-red-400 mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs text-red-600 mb-2">Failed to load</span>
<button
class="text-xs text-blue-600 hover:text-blue-800 underline"
onclick={onRetry}
disabled={photo.retryCount >= 3}
>
{photo.retryCount >= 3 ? 'Max retries' : 'Retry'}
</button>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-red-600">Failed to load</span>
</div>
</div>
{/if}

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'; import { onMount } from 'svelte';
export let imageUrl: string; let { imageUrl, personName, initialCropData, onCropUpdated, onClose } = $props<{
export let personName: string; imageUrl: string;
export let initialCrop: { x: number; y: number; width: number; height: number } | null = null; personName: string;
initialCropData?: { x: number; y: number; width: number; height: number };
const dispatch = createEventDispatcher<{ onCropUpdated: (detail: {
save: { x: number; y: number; width: number; height: number }; cropData: { x: number; y: number; width: number; height: number };
cancel: void; }) => void;
onClose: () => void;
}>(); }>();
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
@@ -63,15 +64,15 @@
canvas.height = canvasHeight; canvas.height = canvasHeight;
// Initialize crop rectangle // Initialize crop rectangle
if (initialCrop) { if (initialCropData) {
// Scale initial crop to canvas dimensions // Scale initial crop to canvas dimensions
const scaleX = canvasWidth / image.width; const scaleX = canvasWidth / image.width;
const scaleY = canvasHeight / image.height; const scaleY = canvasHeight / image.height;
crop = { crop = {
x: initialCrop.x * scaleX, x: initialCropData.x * scaleX,
y: initialCrop.y * scaleY, y: initialCropData.y * scaleY,
width: initialCrop.width * scaleX, width: initialCropData.width * scaleX,
height: initialCrop.height * scaleY height: initialCropData.height * scaleY
}; };
} else { } else {
// Default crop: centered with correct aspect ratio // Default crop: centered with correct aspect ratio
@@ -283,72 +284,86 @@
} }
function handleSave() { function handleSave() {
// Convert canvas coordinates back to image coordinates // Scale crop rectangle back to original image dimensions
const scaleX = image.width / canvasWidth; const scaleX = image.width / canvasWidth;
const scaleY = image.height / canvasHeight; const scaleY = image.height / canvasHeight;
const imageCrop = { const finalCrop = {
x: Math.round(crop.x * scaleX), x: Math.round(crop.x * scaleX),
y: Math.round(crop.y * scaleY), y: Math.round(crop.y * scaleY),
width: Math.round(crop.width * scaleX), width: Math.round(crop.width * scaleX),
height: Math.round(crop.height * scaleY) height: Math.round(crop.height * scaleY)
}; };
dispatch('save', imageCrop); onCropUpdated({ cropData: finalCrop });
onClose();
} }
function handleCancel() { function handleCancel() {
dispatch('cancel'); onClose();
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
} }
</script> </script>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" on:click={handleCancel}> <div
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4" on:click|stopPropagation> class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={handleOverlayClick}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
>
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4" role="document">
<div class="p-6"> <div class="p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900"> <h3 id="dialog-title" class="text-lg font-semibold text-gray-800">
Crop Photo - {personName} Crop Photo: {personName}
</h3> </h3>
<button onclick={onClose} class="text-gray-400 hover:text-gray-600" aria-label="Close">
<button
on:click={handleCancel}
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
<div class="flex flex-col items-center space-y-4"> <div class="mb-4 p-2 rounded-md text-center">
<div class="border border-gray-300 rounded-lg overflow-hidden">
<canvas <canvas
bind:this={canvas} bind:this={canvas}
on:mousedown={handleMouseDown} onmousedown={handleMouseDown}
on:mousemove={handleMouseMove} onmousemove={handleMouseMove}
on:mouseup={handleMouseUp} onmouseup={handleMouseUp}
on:mouseleave={handleMouseUp} onmouseleave={handleMouseUp}
class="block" class="mx-auto cursor-move"
style="max-width: 100%; height: auto;"
></canvas> ></canvas>
</div> </div>
<p class="text-sm text-gray-600 text-center max-w-lg"> <div class="flex justify-end space-x-3">
Drag the crop area to move it, or drag the corner handles to resize.
The selected area will be used for the member card.
<br>
<span class="font-medium">Aspect Ratio: {cropRatio.toFixed(1)}:1 {cropRatio === 1.0 ? '(Square)' : cropRatio === 1.5 ? '(3:2)' : ''}</span>
</p>
<div class="flex space-x-3">
<button <button
on:click={handleCancel} onclick={handleCancel}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
> >
Cancel Cancel
</button> </button>
<button <button
on:click={handleSave} onclick={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700" class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
> >
Save Crop Save Crop
@@ -357,4 +372,3 @@
</div> </div>
</div> </div>
</div> </div>
</div>

View File

@@ -48,14 +48,14 @@
<div class="flex space-x-3 justify-center"> <div class="flex space-x-3 justify-center">
<button <button
on:click={proceed} onclick={proceed}
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700"
> >
Continue → Continue →
</button> </button>
<button <button
on:click={handleSignOut} onclick={handleSignOut}
class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium" class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium"
> >
Sign Out Sign Out
@@ -65,7 +65,7 @@
{:else} {:else}
<!-- Unauthenticated state --> <!-- Unauthenticated state -->
<button <button
on:click={handleSignIn} onclick={handleSignIn}
disabled={!$isGoogleApiReady} disabled={!$isGoogleApiReady}
class="w-full bg-blue-600 text-white px-4 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed" class="w-full bg-blue-600 text-white px-4 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
> >

View File

@@ -67,8 +67,10 @@
const recentSheets = JSON.parse(recentSheetsData); const recentSheets = JSON.parse(recentSheetsData);
if (recentSheets && recentSheets.length > 0) { if (recentSheets && recentSheets.length > 0) {
// Find a sheet that matches the current spreadsheet // Find a sheet that matches the current spreadsheet
const savedSheet = recentSheets.find((sheet: SheetInfoType) => const savedSheet = recentSheets.find(
(sheet.id === $selectedSheet.spreadsheetId || sheet.spreadsheetId === $selectedSheet.spreadsheetId) (sheet: SheetInfoType) =>
sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId
); );
if (savedSheet) { if (savedSheet) {
@@ -122,7 +124,12 @@
} }
try { try {
console.log('Loading sheet data quietly for spreadsheet:', $selectedSheet.spreadsheetId, 'sheet:', sheetName); console.log(
'Loading sheet data quietly for spreadsheet:',
$selectedSheet.spreadsheetId,
'sheet:',
sheetName
);
// Make sure we verify the sheet exists before trying to load it // Make sure we verify the sheet exists before trying to load it
if (availableSheets.length === 0) { if (availableSheets.length === 0) {
@@ -212,7 +219,12 @@
return; return;
} }
console.log('Loading sheet data for spreadsheet:', $selectedSheet.spreadsheetId, 'sheet:', sheetName); console.log(
'Loading sheet data for spreadsheet:',
$selectedSheet.spreadsheetId,
'sheet:',
sheetName
);
isLoadingData = true; isLoadingData = true;
error = ''; error = '';
@@ -281,11 +293,13 @@
for (let colIndex = 0; colIndex < maxColumns; colIndex++) { for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
// Check if this column is empty (all preview rows are empty for this column) // Check if this column is empty (all preview rows are empty for this column)
const isEmpty = previewData.every(row => !row[colIndex] || String(row[colIndex]).trim() === ''); const isEmpty = previewData.every(
(row) => !row[colIndex] || String(row[colIndex]).trim() === ''
);
// Also check if this column isn't already mapped to another field // Also check if this column isn't already mapped to another field
const isAlreadyMapped = Object.entries(mappedIndices).some(([field, index]) => const isAlreadyMapped = Object.entries(mappedIndices).some(
field !== 'alreadyPrinted' && index === colIndex ([field, index]) => field !== 'alreadyPrinted' && index === colIndex
); );
if (isEmpty && !isAlreadyMapped) { if (isEmpty && !isAlreadyMapped) {
@@ -311,8 +325,10 @@
if (existingData) { if (existingData) {
const recentSheets = JSON.parse(existingData); const recentSheets = JSON.parse(existingData);
const savedSheet = recentSheets.find((sheet: SheetInfoType) => const savedSheet = recentSheets.find(
(sheet.id === $selectedSheet.spreadsheetId || sheet.spreadsheetId === $selectedSheet.spreadsheetId) && (sheet: SheetInfoType) =>
(sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName) (sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
); );
@@ -356,7 +372,7 @@
pictureUrl: mappedIndices.pictureUrl pictureUrl: mappedIndices.pictureUrl
}; };
mappingComplete = Object.values(requiredIndices).every(index => index !== -1); mappingComplete = Object.values(requiredIndices).every((index) => index !== -1);
console.log('Mapping complete:', mappingComplete); console.log('Mapping complete:', mappingComplete);
// Update the column mapping store // Update the column mapping store
@@ -380,8 +396,10 @@
let recentSheets = existingData ? JSON.parse(existingData) : []; let recentSheets = existingData ? JSON.parse(existingData) : [];
// Find the current sheet in recent sheets and update its column mapping // Find the current sheet in recent sheets and update its column mapping
const sheetIndex = recentSheets.findIndex((sheet: SheetInfoType) => const sheetIndex = recentSheets.findIndex(
(sheet.id === $selectedSheet.spreadsheetId || sheet.spreadsheetId === $selectedSheet.spreadsheetId) && (sheet: SheetInfoType) =>
(sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName) (sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
); );
@@ -400,8 +418,10 @@
recentSheets[sheetIndex].lastUsed = new Date().toISOString(); recentSheets[sheetIndex].lastUsed = new Date().toISOString();
// Ensure we have consistent property names // Ensure we have consistent property names
recentSheets[sheetIndex].spreadsheetId = recentSheets[sheetIndex].spreadsheetId || recentSheets[sheetIndex].id; recentSheets[sheetIndex].spreadsheetId =
recentSheets[sheetIndex].sheetMapping = recentSheets[sheetIndex].sheetMapping || recentSheets[sheetIndex].sheetName; recentSheets[sheetIndex].spreadsheetId || recentSheets[sheetIndex].id;
recentSheets[sheetIndex].sheetMapping =
recentSheets[sheetIndex].sheetMapping || recentSheets[sheetIndex].sheetName;
} else { } else {
// Add new entry // Add new entry
const newEntry = { const newEntry = {
@@ -458,41 +478,50 @@
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-3xl mx-auto">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2"> <h2 class="mb-2 text-xl font-semibold text-gray-900">Select Sheet and Map Columns</h2>
Select Sheet and Map Columns
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="mb-4 text-sm text-gray-700">
First, select which sheet contains your member data, then map the columns to the required fields. First, select which sheet contains your member data, then map the columns to the required
fields.
</p> </p>
</div> </div>
{#if hasSavedMapping && !showMappingEditor} {#if hasSavedMapping && !showMappingEditor}
<!-- Simplified view when we have saved mapping --> <!-- Simplified view when we have saved mapping -->
<div class="bg-green-50 border border-green-200 rounded-lg p-6 mb-6"> <div class="mb-6 rounded-lg border border-green-200 bg-green-50 p-6">
<div class="text-center"> <div class="text-center">
<svg class="mx-auto h-16 w-16 text-green-600 mb-4" fill="currentColor" viewBox="0 0 20 20"> <svg class="mx-auto mb-4 h-16 w-16 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg> </svg>
<h3 class="text-xl font-semibold text-green-800 mb-3">Configuration Complete</h3> <h3 class="mb-3 text-xl font-semibold text-green-800">Configuration Complete</h3>
<p class="text-green-700 mb-2"> <p class="mb-2 text-green-700">
<span class="font-medium">Spreadsheet:</span> {savedSheetInfo?.name} <span class="font-medium">Spreadsheet:</span>
{savedSheetInfo?.name}
</p> </p>
<p class="text-green-700 mb-2"> <p class="mb-2 text-green-700">
<span class="font-medium">Sheet:</span> {selectedSheetName} <span class="font-medium">Sheet:</span>
{selectedSheetName}
</p> </p>
<p class="text-green-700 mb-6"> <p class="mb-6 text-green-700">
Column mapping loaded from your previous session.<br> Column mapping loaded from your previous session.<br />
Everything is ready to proceed to the next step. Everything is ready to proceed to the next step.
</p> </p>
<button <button
onclick={handleShowEditor} onclick={handleShowEditor}
class="inline-flex items-center px-4 py-2 text-sm text-green-700 hover:text-green-900 font-medium border border-green-300 rounded-lg hover:bg-green-100 transition-colors" class="inline-flex items-center rounded-lg border border-green-300 px-4 py-2 text-sm font-medium text-green-700 transition-colors hover:bg-green-100 hover:text-green-900"
> >
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="mr-2 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.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg> </svg>
Make changes if needed Make changes if needed
</button> </button>
@@ -500,18 +529,18 @@
</div> </div>
{:else} {:else}
<!-- Sheet Selection --> <!-- Sheet Selection -->
<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"> <h3 class="mb-3 text-sm font-medium text-gray-700">Step 1: Select Sheet</h3>
Step 1: Select Sheet
</h3>
{#if isLoadingSheets} {#if isLoadingSheets}
<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
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-gray-600">Loading sheets...</span> <span class="text-sm text-gray-600">Loading sheets...</span>
</div> </div>
{:else if error} {:else if error}
<div class="bg-red-50 border border-red-300 rounded-lg p-3 mb-3"> <div class="mb-3 rounded-lg border border-red-300 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p> <p class="text-sm text-red-800">{error}</p>
<button <button
class="mt-2 text-sm text-blue-600 hover:text-blue-800" class="mt-2 text-sm text-blue-600 hover:text-blue-800"
@@ -521,9 +550,19 @@
</button> </button>
</div> </div>
{:else if availableSheets.length === 0} {:else if availableSheets.length === 0}
<div class="text-center py-6 bg-white rounded border border-gray-200"> <div class="rounded border border-gray-200 bg-white py-6 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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No sheets found</h3> <h3 class="mt-2 text-sm font-medium text-gray-900">No sheets found</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
@@ -537,26 +576,30 @@
</p> </p>
<div> <div>
<p class="block text-sm font-medium text-gray-700 mb-3"> <p class="mb-3 block text-sm font-medium text-gray-700">Choose sheet:</p>
Choose sheet:
</p>
<div class="space-y-2"> <div class="space-y-2">
{#each availableSheets as sheetName} {#each availableSheets as sheetName}
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
class="border rounded-lg p-3 cursor-pointer transition-colors hover:bg-gray-50 class="cursor-pointer rounded-lg border p-3 transition-colors hover:bg-gray-50
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" {selectedSheetName === sheetName
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSheetSelect(sheetName)} onclick={() => handleSheetSelect(sheetName)}
onkeydown={(e) => e.key === 'Enter' && handleSheetSelect(sheetName)} onkeydown={(e) => e.key === 'Enter' && handleSheetSelect(sheetName)}
> >
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-4 h-4 rounded-full border-2 flex items-center justify-center <div
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-500' : 'border-gray-300'}"> class="flex h-4 w-4 items-center justify-center rounded-full border-2
{selectedSheetName === sheetName
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'}"
>
{#if selectedSheetName === sheetName} {#if selectedSheetName === sheetName}
<div class="w-2 h-2 rounded-full bg-white"></div> <div class="h-2 w-2 rounded-full bg-white"></div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -567,8 +610,12 @@
{#if selectedSheetName === sheetName} {#if selectedSheetName === sheetName}
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@@ -582,23 +629,23 @@
</div> </div>
<!-- Column Mapping Section --> <!-- Column Mapping Section -->
<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"> <h3 class="mb-3 text-sm font-medium text-gray-700">Step 2: Map Columns</h3>
Step 2: Map Columns
</h3>
{#if isLoadingData} {#if isLoadingData}
<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
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-gray-600">Loading sheet data...</span> <span class="text-sm text-gray-600">Loading sheet data...</span>
</div> </div>
{:else if sheetHeaders.length === 0} {:else if sheetHeaders.length === 0}
<div class="text-center py-8 text-gray-500"> <div class="py-8 text-center text-gray-500">
<p class="text-sm">Select a sheet above to map columns</p> <p class="text-sm">Select a sheet above to map columns</p>
</div> </div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-gray-600 mb-4"> <p class="mb-4 text-sm text-gray-600">
Map the columns from your sheet to the required fields: Map the columns from your sheet to the required fields:
</p> </p>
@@ -619,8 +666,12 @@
<select <select
id={`field-${field.key}`} id={`field-${field.key}`}
bind:value={mappedIndices[field.key]} bind:value={mappedIndices[field.key]}
onchange={() => handleColumnMapping(field.key as keyof ColumnMappingType, mappedIndices[field.key])} onchange={() =>
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" handleColumnMapping(
field.key as keyof ColumnMappingType,
mappedIndices[field.key]
)}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
> >
<option value={-1}>-- Select column --</option> <option value={-1}>-- Select column --</option>
{#each Array.from({ length: Math.max(sheetHeaders.length, 26) }, (_, i) => i) as index} {#each Array.from({ length: Math.max(sheetHeaders.length, 26) }, (_, i) => i) as index}
@@ -640,30 +691,40 @@
<!-- Data preview --> <!-- Data preview -->
{#if previewData.length > 0} {#if previewData.length > 0}
<div class="mt-6"> <div class="mt-6">
<h4 class="text-sm font-medium text-gray-700 mb-3">Data Preview:</h4> <h4 class="mb-3 text-sm font-medium text-gray-700">Data Preview:</h4>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg"> <table
class="min-w-full divide-y divide-gray-200 rounded-lg border border-gray-200"
>
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
{#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, previewData[0]?.length || 0), 26) }, (_, i) => i) as index} {#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, previewData[0]?.length || 0), 26) }, (_, i) => i) as index}
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <th
{Object.values(mappedIndices).includes(index) ? 'bg-blue-100' : ''}"> class="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase
{Object.values(mappedIndices).includes(index)
? 'bg-blue-100'
: ''}"
>
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`} {sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
{#if Object.values(mappedIndices).includes(index)} {#if Object.values(mappedIndices).includes(index)}
<div class="text-blue-600 text-xs mt-1"> <div class="mt-1 text-xs text-blue-600">
{requiredFields.find(f => mappedIndices[f.key] === index)?.label} {requiredFields.find((f) => mappedIndices[f.key] === index)?.label}
</div> </div>
{/if} {/if}
</th> </th>
{/each} {/each}
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200 bg-white">
{#each previewData as row} {#each previewData as row}
<tr> <tr>
{#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, row.length), 26) }, (_, i) => i) as index} {#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, row.length), 26) }, (_, i) => i) as index}
<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate <td
{Object.values(mappedIndices).includes(index) ? 'bg-blue-50' : ''}"> class="max-w-xs truncate px-3 py-2 text-sm text-gray-500
{Object.values(mappedIndices).includes(index)
? 'bg-blue-50'
: ''}"
>
{row[index] || ''} {row[index] || ''}
</td> </td>
{/each} {/each}
@@ -677,16 +738,14 @@
<!-- Mapping status --> <!-- Mapping status -->
{#if mappingComplete} {#if mappingComplete}
<div class="bg-green-50 border border-green-200 rounded p-3"> <div class="rounded border border-green-200 bg-green-50 p-3">
<p class="text-sm text-green-800"> <p class="text-sm text-green-800">
✓ All required fields are mapped! You can continue to the next step. ✓ All required fields are mapped! You can continue to the next step.
</p> </p>
</div> </div>
{:else} {:else}
<div class="bg-yellow-50 border border-yellow-200 rounded p-3"> <div class="rounded border border-yellow-200 bg-yellow-50 p-3">
<p class="text-sm text-yellow-800"> <p class="text-sm text-yellow-800">Please map all required fields to continue.</p>
Please map all required fields to continue.
</p>
</div> </div>
{/if} {/if}
</div> </div>
@@ -694,12 +753,11 @@
</div> </div>
{/if} {/if}
<!-- Navigation -->
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
onclick={() => currentStep.set(2)} onclick={() => currentStep.set(2)}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300" class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
> >
← Back to Sheet Selection ← Back to Sheet Selection
</button> </button>
@@ -707,12 +765,9 @@
<button <button
onclick={handleContinue} onclick={handleContinue}
disabled={!mappingComplete} disabled={!mappingComplete}
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" 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 {mappingComplete ? 'Continue →' : 'Select a column mapping'}
? 'Continue →'
: 'Select a column mapping'}
</button> </button>
</div> </div>
</div> </div>
</div>

View File

@@ -10,7 +10,8 @@
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; let detector: blazeface.BlazeFaceModel | undefined;
let detectorPromise: Promise<void> | undefined;
interface PhotoInfo { interface PhotoInfo {
name: string; name: string;
@@ -19,72 +20,93 @@
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'; faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
} }
// Initialize detector and process photos function initializeDetector() {
onMount(async () => { if (!detectorPromise) {
console.log('StepGallery mounted, initializing face detector...'); detectorPromise = (async () => {
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');
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) { })();
console.log('Processing photos for gallery step'); }
processPhotos(); return detectorPromise;
} else {
console.log('No data to process:', { dataLength: $filteredSheetData.length, pictureUrlMapping: $columnMapping.pictureUrl });
} }
});
async function processPhotos() { async function processPhotosInParallel() {
if (isProcessing) return; if (isProcessing) return;
console.log('Starting processPhotos...'); console.log('Starting processPhotos in parallel...');
isProcessing = true; isProcessing = true;
processedCount = 0; processedCount = 0;
// Get valid and included rows from filteredSheetData const validRows = $filteredSheetData.filter((row) => row._isValid);
const validRows = $filteredSheetData.filter(row => row._isValid);
console.log(`Found ${validRows.length} valid rows`);
// Get unique photos to process
const photoUrls = new Set<string>(); const photoUrls = new Set<string>();
const photoMap = new Map<string, any[]>(); // url -> row data 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()) {
photoUrls.add(photoUrl.trim()); const trimmedUrl = photoUrl.trim();
if (!photoMap.has(photoUrl.trim())) { photoUrls.add(trimmedUrl);
photoMap.set(photoUrl.trim(), []); if (!photoMap.has(trimmedUrl)) {
photoMap.set(trimmedUrl, []);
} }
photoMap.get(photoUrl.trim())!.push(row); photoMap.get(trimmedUrl)!.push(row);
} }
}); });
console.log(`Found ${photoUrls.size} unique photo URLs`);
totalCount = photoUrls.size; totalCount = photoUrls.size;
console.log(`Found ${totalCount} unique photo URLs`);
// Initialize photos array 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, // Use first person's name for display
url, url,
status: 'loading' as const, status: 'loading' as const,
retryCount: 0, retryCount: 0,
faceDetectionStatus: 'pending' as const faceDetectionStatus: 'pending' as const
})); }));
// Process each photo const concurrencyLimit = 5;
const promises = [];
for (let i = 0; i < photos.length; i++) { for (let i = 0; i < photos.length; i++) {
const promise = (async () => {
await loadPhoto(i); await loadPhoto(i);
await detectFaceForPhoto(i);
processedCount++; processedCount++;
})();
promises.push(promise);
if (promises.length >= concurrencyLimit) {
await Promise.all(promises);
promises.length = 0;
} }
}
await Promise.all(promises);
isProcessing = false; isProcessing = false;
console.log('All photos processed.');
} }
// Initialize detector and process photos
onMount(() => {
console.log('StepGallery mounted');
initializeDetector(); // Start loading model
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
console.log('Processing photos for gallery step');
processPhotosInParallel();
} else {
console.log('No data to process:', {
dataLength: $filteredSheetData.length,
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];
@@ -165,17 +187,27 @@
async function detectFaceForPhoto(index: number) { async function detectFaceForPhoto(index: number) {
try { try {
await initializeDetector(); // Ensure detector is loaded
if (!detector) {
photos[index].faceDetectionStatus = 'failed';
console.error('Face detector not available.');
return;
}
photos[index].faceDetectionStatus = 'processing'; photos[index].faceDetectionStatus = 'processing';
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
img.src = photos[index].objectUrl!; img.src = photos[index].objectUrl!;
await new Promise((r, e) => { img.onload = r; img.onerror = e; }); await new Promise((r, e) => { img.onload = r; img.onerror = e; });
const predictions = await detector.estimateFaces(img, false); const predictions = await detector.estimateFaces(img, false);
if (predictions.length > 0) { if (predictions.length > 0) {
const face = predictions.sort((a,b) => (b.probability?.[0]||0) - (a.probability?.[0]||0))[0]; const getProbability = (p: number | tf.Tensor) => (typeof p === 'number' ? p : p.dataSync()[0]);
const face = predictions.sort((a,b) => getProbability(b.probability!) - getProbability(a.probability!))[0];
// Coordinates in displayed image space // Coordinates in displayed image space
let [x1,y1] = face.topLeft; let [x1,y1] = face.topLeft as [number, number];
let [x2,y2] = face.bottomRight; let [x2,y2] = face.bottomRight as [number, number];
// Scale to natural image size // Scale to natural image size
const scaleX = img.naturalWidth / img.width; const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height; const scaleY = img.naturalHeight / img.height;
@@ -225,7 +257,8 @@
} else { } else {
photos[index].faceDetectionStatus = 'failed'; photos[index].faceDetectionStatus = 'failed';
} }
} catch { } catch (error) {
console.error(`Face detection failed for ${photos[index].name}:`, error);
photos[index].faceDetectionStatus = 'failed'; photos[index].faceDetectionStatus = 'failed';
} }
// No need to reassign photos array with $state reactivity // No need to reassign photos array with $state reactivity
@@ -242,13 +275,14 @@
await loadPhoto(index, true); await loadPhoto(index, true);
} }
function handleCropUpdate(index: number, cropData: { x: number; y: number; width: number; height: number }) { function handleCropUpdate(index: number, detail: { cropData: { x: number; y: number; width: number; height: number } }) {
photos[index].cropData = cropData; photos[index].cropData = detail.cropData;
photos[index].faceDetectionStatus = 'manual';
// Save updated crop data to store // Save updated crop data to store
cropRects.update(crops => ({ cropRects.update(crops => ({
...crops, ...crops,
[photos[index].url]: cropData [photos[index].url]: detail.cropData
})); }));
// No need to reassign photos array with $state reactivity // No need to reassign photos array with $state reactivity
@@ -376,7 +410,7 @@
{/if} {/if}
<!-- Photo Grid --> <!-- Photo Grid -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6"> <div class="bg-white overflow-hidden mb-6">
{#if photos.length === 0 && !isProcessing} {#if photos.length === 0 && !isProcessing}
<div class="text-center py-12"> <div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -388,56 +422,15 @@
</p> </p>
</div> </div>
{:else} {:else}
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each photos as photo, index} {#each photos as photo, index}
{#if photo.status === 'loading'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="aspect-square bg-gray-100 flex items-center justify-center">
<div class="flex flex-col items-center">
<div class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"></div>
<span class="text-xs text-gray-600">Loading...</span>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-blue-600">Processing photo...</span>
</div>
</div>
{:else if photo.status === 'success' && photo.objectUrl}
<PhotoCard <PhotoCard
imageUrl={photo.objectUrl} {photo}
personName={photo.name} onCropUpdated={(e) => handleCropUpdate(index, e)}
isProcessing={photo.faceDetectionStatus === 'processing'} onRetry={() => retryPhoto(index)}
cropData={photo.cropData}
on:cropUpdated={(e) => handleCropUpdate(index, e.detail)}
/> />
{:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="aspect-square bg-gray-100 flex items-center justify-center">
<div class="flex flex-col items-center text-center p-4">
<svg class="w-12 h-12 text-red-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-xs text-red-600 mb-2">Failed to load</span>
<button
class="text-xs text-blue-600 hover:text-blue-800 underline"
onclick={() => retryPhoto(index)}
disabled={photo.retryCount >= 3}
>
{photo.retryCount >= 3 ? 'Max retries' : 'Retry'}
</button>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-red-600">Failed to load</span>
</div>
</div>
{/if}
{/each} {/each}
</div> </div>
</div>
{/if} {/if}
</div> </div>

View File

@@ -1,5 +1,12 @@
<script lang="ts"> <script lang="ts">
import { selectedSheet, columnMapping, rawSheetData, filteredSheetData, currentStep, sheetData } from '$lib/stores'; import {
selectedSheet,
columnMapping,
rawSheetData,
filteredSheetData,
currentStep,
sheetData
} from '$lib/stores';
import type { RowData } from '$lib/stores'; import type { RowData } from '$lib/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSheetNames, getSheetData } from '$lib/google'; import { getSheetNames, getSheetData } from '$lib/google';
@@ -17,8 +24,8 @@
$: { $: {
// Filter data based on search term // Filter data based on search term
if (searchTerm.trim()) { if (searchTerm.trim()) {
filteredData = processedData.filter(row => filteredData = processedData.filter((row) =>
Object.values(row).some(value => Object.values(row).some((value) =>
String(value).toLowerCase().includes(searchTerm.toLowerCase()) String(value).toLowerCase().includes(searchTerm.toLowerCase())
) )
); );
@@ -50,8 +57,7 @@
// Fetch raw sheet data from Google Sheets if not already loaded // Fetch raw sheet data from Google Sheets if not already loaded
async function fetchRawSheetData() { async function fetchRawSheetData() {
if (!$rawSheetData || $rawSheetData.length === 0) { console.log("Fetching raw sheet data...");
if (!$selectedSheet) return;
const sheetNames = await getSheetNames($selectedSheet.spreadsheetId); const sheetNames = await getSheetNames($selectedSheet.spreadsheetId);
if (sheetNames.length === 0) return; if (sheetNames.length === 0) return;
const sheetName = sheetNames[0]; const sheetName = sheetNames[0];
@@ -59,19 +65,15 @@
const data = await getSheetData($selectedSheet.spreadsheetId, range); const data = await getSheetData($selectedSheet.spreadsheetId, range);
rawSheetData.set(data); rawSheetData.set(data);
} }
}
async function processSheetData() { async function processSheetData() {
isLoading = true; isLoading = true;
try { try {
await fetchRawSheetData();
if (!$rawSheetData || $rawSheetData.length === 0 || !$columnMapping) {
return;
}
// Get headers from the mapping // Get headers from the mapping
headers = Object.keys($columnMapping); headers = Object.keys($columnMapping);
await fetchRawSheetData();
// Process the data starting from row 2 (skip header row) // Process the data starting from row 2 (skip header row)
processedData = $rawSheetData.slice(1).map((row, index) => { processedData = $rawSheetData.slice(1).map((row, index) => {
const processedRow: any = { const processedRow: any = {
@@ -94,8 +96,8 @@
// Check if all required fields have values (excluding alreadyPrinted) // Check if all required fields have values (excluding alreadyPrinted)
const requiredFields = ['name', 'surname', 'nationality', 'birthday', 'pictureUrl']; const requiredFields = ['name', 'surname', 'nationality', 'birthday', 'pictureUrl'];
const hasAllRequiredFields = requiredFields.every(field => const hasAllRequiredFields = requiredFields.every(
processedRow[field] && String(processedRow[field]).trim() !== '' (field) => processedRow[field] && String(processedRow[field]).trim() !== ''
); );
if (!hasAllRequiredFields) { if (!hasAllRequiredFields) {
@@ -108,7 +110,7 @@
// Initially select rows based on validity and "Already Printed" status // Initially select rows based on validity and "Already Printed" status
selectedRows = new Set( selectedRows = new Set(
processedData processedData
.filter(row => { .filter((row) => {
if (!row._isValid) return false; if (!row._isValid) return false;
// Check "Already Printed" column value // Check "Already Printed" column value
@@ -122,7 +124,7 @@
// If empty or falsy, select the row // If empty or falsy, select the row
return true; return true;
}) })
.map(row => row._rowIndex) .map((row) => row._rowIndex)
); );
updateSelectAllState(); updateSelectAllState();
@@ -144,14 +146,14 @@
function toggleSelectAll() { function toggleSelectAll() {
if (selectAll) { if (selectAll) {
// Deselect all visible valid rows that aren't already printed // Deselect all visible valid rows that aren't already printed
filteredData.forEach(row => { filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) { if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.delete(row._rowIndex); selectedRows.delete(row._rowIndex);
} }
}); });
} else { } else {
// Select all visible valid rows that aren't already printed // Select all visible valid rows that aren't already printed
filteredData.forEach(row => { filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) { if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.add(row._rowIndex); selectedRows.add(row._rowIndex);
} }
@@ -162,10 +164,16 @@
} }
function updateSelectAllState() { function updateSelectAllState() {
const visibleValidUnprintedRows = filteredData.filter(row => row._isValid && !isRowAlreadyPrinted(row)); const visibleValidUnprintedRows = filteredData.filter(
const selectedVisibleValidUnprintedRows = visibleValidUnprintedRows.filter(row => selectedRows.has(row._rowIndex)); (row) => row._isValid && !isRowAlreadyPrinted(row)
);
const selectedVisibleValidUnprintedRows = visibleValidUnprintedRows.filter((row) =>
selectedRows.has(row._rowIndex)
);
selectAll = visibleValidUnprintedRows.length > 0 && selectedVisibleValidUnprintedRows.length === visibleValidUnprintedRows.length; selectAll =
visibleValidUnprintedRows.length > 0 &&
selectedVisibleValidUnprintedRows.length === visibleValidUnprintedRows.length;
} }
function handleSort(column: string) { function handleSort(column: string) {
@@ -178,7 +186,7 @@
} }
function getFieldLabel(field: string): string { function getFieldLabel(field: string): string {
const labels = { const labels: { [key: string]: string } = {
name: 'First Name', name: 'First Name',
surname: 'Last Name', surname: 'Last Name',
nationality: 'Nationality', nationality: 'Nationality',
@@ -199,8 +207,8 @@
function handleContinue() { function handleContinue() {
// Filter the data to only include selected rows // Filter the data to only include selected rows
const selectedData = processedData.filter(row => const selectedData = processedData.filter(
selectedRows.has(row._rowIndex) && row._isValid (row) => selectedRows.has(row._rowIndex) && row._isValid
); );
// Store the filtered data // Store the filtered data
@@ -210,8 +218,8 @@
currentStep.set(5); currentStep.set(5);
} }
$: selectedValidCount = Array.from(selectedRows).filter(rowIndex => { $: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => {
const row = processedData.find(r => r._rowIndex === rowIndex); const row = processedData.find((r) => r._rowIndex === rowIndex);
return row && row._isValid; return row && row._isValid;
}).length; }).length;
// Allow proceeding only if at least one valid row is selected // Allow proceeding only if at least one valid row is selected
@@ -219,24 +227,21 @@
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-4xl mx-auto">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2"> <h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
Filter and Select Rows
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="mb-4 text-sm text-gray-700">
Review your data and select which rows you want to include in the card generation. Review your data and select which rows you want to include in the card generation. Only rows
Only rows with all required fields will be available for selection. with all required fields will be available for selection.
</p> </p>
</div> </div>
<!-- Search and Filter Controls --> <!-- Search and Filter Controls -->
<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">
<div class="flex flex-col sm:flex-row gap-4"> <div class="flex flex-col gap-4 sm:flex-row">
<!-- Search --> <!-- Search -->
<div class="flex-grow"> <div class="flex-grow">
<label for="search" class="block text-sm font-medium text-gray-700 mb-2"> <label for="search" class="mb-2 block text-sm font-medium text-gray-700">
Search rows Search rows
</label> </label>
<input <input
@@ -244,19 +249,17 @@
type="text" type="text"
bind:value={searchTerm} bind:value={searchTerm}
placeholder="Search in any field..." placeholder="Search in any field..."
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/> />
</div> </div>
<!-- Sort --> <!-- Sort -->
<div class="sm:w-48"> <div class="sm:w-48">
<label for="sort" class="block text-sm font-medium text-gray-700 mb-2"> <label for="sort" class="mb-2 block text-sm font-medium text-gray-700"> Sort by </label>
Sort by
</label>
<select <select
id="sort" id="sort"
bind:value={sortColumn} bind:value={sortColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
> >
<option value="">No sorting</option> <option value="">No sorting</option>
{#each headers as header} {#each headers as header}
@@ -267,20 +270,34 @@
</div> </div>
<!-- Stats --> <!-- Stats -->
<div class="mt-4 flex items-center flex-wrap gap-4 text-sm text-gray-600"> <div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
<span>Total rows: {processedData.length}</span> <span>Total rows: {processedData.length}</span>
<span>Valid rows: {processedData.filter(row => row._isValid).length}</span> <span>Valid rows: {processedData.filter((row) => row._isValid).length}</span>
<span class="text-orange-600">Printed: {processedData.filter(row => isRowAlreadyPrinted(row)).length}</span> <span class="text-orange-600"
>Printed: {processedData.filter((row) => isRowAlreadyPrinted(row)).length}</span
>
<span>Filtered rows: {filteredData.length}</span> <span>Filtered rows: {filteredData.length}</span>
<span class="font-medium text-blue-600">Selected: {selectedValidCount}</span> <span class="font-medium text-blue-600">Selected: {selectedValidCount}</span>
<button <button
onclick={processSheetData} onclick={processSheetData}
disabled={isLoading} disabled={isLoading}
class="ml-auto inline-flex items-center px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-wait" class="ml-auto inline-flex items-center rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-wait disabled:opacity-50"
> >
{#if isLoading} {#if isLoading}
<svg class="h-4 w-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg> </svg>
Refreshing... Refreshing...
@@ -292,19 +309,21 @@
</div> </div>
<!-- Data Table --> <!-- Data Table -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6 relative"> <div class="relative mb-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
{#if isLoading} {#if filteredData.length === 0 && !isLoading}
<div class="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 z-10"> <div class="py-12 text-center">
<svg class="h-10 w-10 text-blue-600 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> class="mx-auto h-12 w-12 text-gray-400"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> fill="none"
</svg> viewBox="0 0 24 24"
</div> stroke="currentColor"
{/if} >
{#if filteredData.length === 0} <path
<div class="text-center py-12"> stroke-linecap="round"
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> stroke-linejoin="round"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3> <h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
@@ -322,24 +341,33 @@
type="checkbox" type="checkbox"
bind:checked={selectAll} bind:checked={selectAll}
onchange={toggleSelectAll} onchange={toggleSelectAll}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
disabled={isLoading}
/> />
</th> </th>
<!-- Column Headers --> <!-- Column Headers -->
{#each headers.filter(h => h !== 'alreadyPrinted') as header} {#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<th <th
class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" class="cursor-pointer px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:bg-gray-100"
onclick={() => handleSort(header)} onclick={() => !isLoading && handleSort(header)}
> >
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span>{getFieldLabel(header)}</span> <span>{getFieldLabel(header)}</span>
{#if sortColumn === header} {#if sortColumn === header}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
{#if sortDirection === 'asc'} {#if sortDirection === 'asc'}
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
{:else} {:else}
<path fill-rule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
{/if} {/if}
</svg> </svg>
{/if} {/if}
@@ -348,14 +376,51 @@
{/each} {/each}
<!-- Status Column --> <!-- Status Column -->
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th
class="px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Status Status
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<!-- Loading skeleton rows -->
{#each Array(5) as _, index}
<tr class="hover:bg-gray-50">
<!-- Selection Checkbox Skeleton -->
<td class="px-3 py-4">
<div class="h-4 w-4 animate-pulse rounded bg-gray-200"></div>
</td>
<!-- Data Columns Skeletons -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<td class="px-3 py-4">
<div
class="h-4 animate-pulse rounded bg-gray-200"
style="width: {Math.random() * 40 + 60}%"
></div>
</td>
{/each}
<!-- Status Column Skeleton -->
<td class="px-3 py-4">
<div class="flex flex-col space-y-1">
<div class="h-6 w-16 animate-pulse rounded-full bg-gray-200"></div>
</div>
</td>
</tr>
{/each}
{:else}
<!-- Actual data rows -->
{#each filteredData as row} {#each filteredData as row}
<tr class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(row) ? 'bg-orange-50' : ''}"> <tr
class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(
row
)
? 'bg-orange-50'
: ''}"
>
<!-- Selection Checkbox --> <!-- Selection Checkbox -->
<td class="px-3 py-4"> <td class="px-3 py-4">
{#if row._isValid} {#if row._isValid}
@@ -363,16 +428,16 @@
type="checkbox" type="checkbox"
checked={selectedRows.has(row._rowIndex)} checked={selectedRows.has(row._rowIndex)}
onchange={() => toggleRowSelection(row._rowIndex)} onchange={() => toggleRowSelection(row._rowIndex)}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/> />
{:else} {:else}
<div class="w-4 h-4 bg-gray-200 rounded"></div> <div class="h-4 w-4 rounded bg-gray-200"></div>
{/if} {/if}
</td> </td>
<!-- Data Columns --> <!-- Data Columns -->
{#each headers.filter(h => h !== 'alreadyPrinted') as header} {#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<td class="px-3 py-4 text-sm text-gray-900 max-w-xs truncate"> <td class="max-w-xs truncate px-3 py-4 text-sm text-gray-900">
{row[header] || ''} {row[header] || ''}
</td> </td>
{/each} {/each}
@@ -381,17 +446,23 @@
<td class="px-3 py-4 text-sm"> <td class="px-3 py-4 text-sm">
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
{#if row._isValid} {#if row._isValid}
<span class="inline-flex px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full"> <span
class="inline-flex rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800"
>
Valid Valid
</span> </span>
{:else} {:else}
<span class="inline-flex px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full"> <span
class="inline-flex rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800"
>
Missing data Missing data
</span> </span>
{/if} {/if}
{#if isRowAlreadyPrinted(row)} {#if isRowAlreadyPrinted(row)}
<span class="inline-flex px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded-full"> <span
class="inline-flex rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800"
>
Already Printed Already Printed
</span> </span>
{/if} {/if}
@@ -399,6 +470,7 @@
</td> </td>
</tr> </tr>
{/each} {/each}
{/if}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -407,13 +479,18 @@
<!-- Selection Summary --> <!-- Selection Summary -->
{#if selectedValidCount > 0} {#if selectedValidCount > 0}
<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"> <div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg class="mr-2 h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg> </svg>
<span class="text-sm text-blue-800"> <span class="text-sm text-blue-800">
<strong>{selectedValidCount}</strong> {selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation <strong>{selectedValidCount}</strong>
{selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation
</span> </span>
</div> </div>
</div> </div>
@@ -423,14 +500,14 @@
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
onclick={() => currentStep.set(3)} onclick={() => currentStep.set(3)}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300" class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
> >
← Back to Colum Selection ← Back to Colum Selection
</button> </button>
<button <button
onclick={handleContinue} onclick={handleContinue}
disabled={!canProceed} 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" 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 {canProceed
? `Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} ` ? `Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} `
@@ -438,4 +515,3 @@
</button> </button>
</div> </div>
</div> </div>
</div>

View File

@@ -25,7 +25,7 @@
try { try {
searchResults = await searchSheets(searchQuery); searchResults = await searchSheets(searchQuery);
availableSheets.set( availableSheets.set(
searchResults.map(sheet => ({ searchResults.map((sheet) => ({
spreadsheetId: sheet.spreadsheetId || sheet.id, spreadsheetId: sheet.spreadsheetId || sheet.id,
name: sheet.name, name: sheet.name,
url: sheet.webViewLink url: sheet.webViewLink
@@ -74,20 +74,17 @@
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-2xl mx-auto">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2"> <h2 class="mb-2 text-xl font-semibold text-gray-900">Select Google Sheet</h2>
Select Google Sheet
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="mb-4 text-sm text-gray-700">
Search for and select the Google Sheet containing your member data. Search for and select the Google Sheet containing your member data.
</p> </p>
</div> </div>
<!-- Search input --> <!-- Search input -->
<div class="mb-6"> <div class="mb-6">
<label for="sheet-search" class="block text-sm font-medium text-gray-700 mb-2"> <label for="sheet-search" class="mb-2 block text-sm font-medium text-gray-700">
Search sheets Search sheets
</label> </label>
@@ -97,17 +94,21 @@
type="text" type="text"
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Type sheet name..." placeholder="Type sheet name..."
class="flex-grow px-4 py-2 border border-gray-300 rounded-l-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent" class="flex-grow rounded-l-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-600"
onkeydown={e => { if (e.key === 'Enter') handleSearch(); }} onkeydown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
/> />
<button <button
onclick={handleSearch} onclick={handleSearch}
disabled={isLoading || !searchQuery.trim()} disabled={isLoading || !searchQuery.trim()}
class="px-4 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed" class="rounded-r-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
> >
{#if isLoading} {#if isLoading}
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div> <div
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
{:else} {:else}
Search Search
{/if} {/if}
@@ -116,7 +117,7 @@
</div> </div>
{#if error} {#if error}
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6"> <div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p> <p class="text-sm text-red-800">{error}</p>
</div> </div>
{/if} {/if}
@@ -124,7 +125,7 @@
<!-- Results --> <!-- Results -->
{#if hasSearched} {#if hasSearched}
<div class="mb-6"> <div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3"> <h3 class="mb-3 text-sm font-medium text-gray-700">
{searchResults.length {searchResults.length
? `Found ${searchResults.length} matching sheets` ? `Found ${searchResults.length} matching sheets`
: 'No matching sheets found'} : 'No matching sheets found'}
@@ -134,26 +135,35 @@
<div class="space-y-3"> <div class="space-y-3">
{#each searchResults as sheet} {#each searchResults as sheet}
<div <div
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
(sheet.spreadsheetId || sheet.id)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)} onclick={() => handleSelectSheet(sheet)}
tabindex="0" tabindex="0"
role="button" role="button"
onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); }} onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
}}
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-gray-900">{sheet.name}</p> <p class="font-medium text-gray-900">{sheet.name}</p>
<p class="text-xs text-gray-500 mt-1">ID: {sheet.id}</p> <p class="mt-1 text-xs text-gray-500">ID: {sheet.id}</p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
{#if sheet.iconLink} {#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" /> <img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
{/if} {/if}
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)} {#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg> </svg>
{/if} {/if}
</div> </div>
@@ -162,9 +172,19 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="text-center py-8 bg-gray-50 rounded-lg border border-gray-200"> <div class="rounded-lg border border-gray-200 bg-gray-50 py-8 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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<p class="mt-2 text-sm text-gray-500">Try a different search term</p> <p class="mt-2 text-sm text-gray-500">Try a different search term</p>
</div> </div>
@@ -174,33 +194,40 @@
<!-- If we have recent sheets and haven't searched yet, show them --> <!-- If we have recent sheets and haven't searched yet, show them -->
{#if recentSheets.length > 0 && !hasSearched} {#if recentSheets.length > 0 && !hasSearched}
<div class="mb-6"> <div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3"> <h3 class="mb-3 text-sm font-medium text-gray-700">Recent sheets</h3>
Recent sheets
</h3>
<div class="space-y-3"> <div class="space-y-3">
{#each recentSheets as sheet} {#each recentSheets as sheet}
<div <div
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
(sheet.spreadsheetId || sheet.id)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)} onclick={() => handleSelectSheet(sheet)}
tabindex="0" tabindex="0"
role="button" role="button"
onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); }} onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
}}
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-gray-900">{sheet.name}</p> <p class="font-medium text-gray-900">{sheet.name}</p>
<p class="text-xs text-gray-500 mt-1">Recently used</p> <p class="mt-1 text-xs text-gray-500">Recently used</p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
{#if sheet.iconLink} {#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" /> <img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
{/if} {/if}
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)} {#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg> </svg>
{/if} {/if}
</div> </div>
@@ -209,21 +236,27 @@
{/each} {/each}
</div> </div>
<div class="border-t border-gray-200 mt-4 pt-4"> <div class="mt-4 border-t border-gray-200 pt-4">
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">Or search for a different sheet above</p>
Or search for a different sheet above
</p>
</div> </div>
</div> </div>
{:else} {:else}
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200 mb-6"> <div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 py-12 text-center">
<svg class="mx-auto h-16 w-16 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> class="mx-auto h-16 w-16 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg> </svg>
<h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3> <h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">Enter a name or keyword to find your Google Sheets</p>
Enter a name or keyword to find your Google Sheets
</p>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -232,7 +265,7 @@
<div class="flex justify-between"> <div class="flex justify-between">
<button <button
onclick={() => currentStep.set(1)} onclick={() => currentStep.set(1)}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300" class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
> >
← Back to Auth ← Back to Auth
</button> </button>
@@ -240,12 +273,9 @@
<button <button
onclick={handleContinue} onclick={handleContinue}
disabled={!canProceed} 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" 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 {canProceed ? 'Continue →' : 'Select a sheet to continue'}
? 'Continue →'
: 'Select a sheet to continue'}
</button> </button>
</div> </div>
</div> </div>
</div>