diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6519c4d..05149cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,8 +7,10 @@ - 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 - 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. - 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. - Remain consistent in styling and code structure. -- Avoid unncessary iterations. If problems is mostly solved, stop. \ No newline at end of file +- Avoid unncessary iterations. If problems is mostly solved, stop. +- Split big components into subcomponents. Always create smaller subcomponents for better context management later. \ No newline at end of file diff --git a/src/lib/components/PhotoCard.svelte b/src/lib/components/PhotoCard.svelte index d3ad581..d84201c 100644 --- a/src/lib/components/PhotoCard.svelte +++ b/src/lib/components/PhotoCard.svelte @@ -1,90 +1,185 @@ -
-
- {personName} - - {#if currentCrop} - -
-
- -
-
-
- {/if} - - - -
- -
-

{personName}

- {#if isProcessing} -

Processing...

- {/if} -
-
+{#if photo.status === 'loading'} +
+
+
+
+ Loading... +
+
+
+

{photo.name}

+ Processing photo... +
+
+{:else if photo.status === 'success' && photo.objectUrl} +
+
+ {`Photo + {#if photo.cropData} +
+ {/if} +
-{#if showCropEditor} - +
+
+

{photo.name}

+ {#if photo.faceDetectionStatus === 'completed'} + Face detected + {:else if photo.faceDetectionStatus === 'failed'} + Face not found + {:else if photo.faceDetectionStatus === 'processing'} + Detecting face... + {:else if photo.faceDetectionStatus === 'manual'} + Manual crop + {:else if photo.faceDetectionStatus === 'pending'} + Queued... + {/if} +
+ +
+ + {#if showCropper} + (showCropper = false)} + onCropUpdated={handleCropUpdated} + /> + {/if} +
+{:else if photo.status === 'error'} +
+
+
+ + + + Failed to load + +
+
+
+

{photo.name}

+ Failed to load +
+
{/if} diff --git a/src/lib/components/PhotoCrop.svelte b/src/lib/components/PhotoCrop.svelte index a47c697..0859bfc 100644 --- a/src/lib/components/PhotoCrop.svelte +++ b/src/lib/components/PhotoCrop.svelte @@ -1,360 +1,374 @@ -
-
-
-
-

- Crop Photo - {personName} -

- - -
- -
-
- -
- -

- Drag the crop area to move it, or drag the corner handles to resize. - The selected area will be used for the member card. -
- Aspect Ratio: {cropRatio.toFixed(1)}:1 {cropRatio === 1.0 ? '(Square)' : cropRatio === 1.5 ? '(3:2)' : ''} -

- -
- - - -
-
-
-
+ diff --git a/src/lib/components/wizard/StepAuth.svelte b/src/lib/components/wizard/StepAuth.svelte index b7647c5..32013dd 100644 --- a/src/lib/components/wizard/StepAuth.svelte +++ b/src/lib/components/wizard/StepAuth.svelte @@ -48,14 +48,14 @@
-
-
- {:else} - -
-

- Step 1: Select Sheet -

- - {#if isLoadingSheets} -
-
- Loading sheets... -
- {:else if error} -
-

{error}

- -
- {:else if availableSheets.length === 0} -
- - - -

No sheets found

-

- This spreadsheet doesn't appear to contain any sheets. -

-
- {:else} -
-

- Spreadsheet: {$selectedSheet?.name} -

- -
-

- Choose sheet: -

- -
- {#each availableSheets as sheetName} -
handleSheetSelect(sheetName)} - onkeydown={(e) => e.key === 'Enter' && handleSheetSelect(sheetName)} - > -
-
-
- {#if selectedSheetName === sheetName} -
- {/if} -
-
- -
-

{sheetName}

-
- - {#if selectedSheetName === sheetName} -
- - - -
- {/if} -
-
- {/each} -
-
-
- {/if} -
+
+

Select Sheet and Map Columns

- -
-

- Step 2: Map Columns -

- - {#if isLoadingData} -
-
- Loading sheet data... -
- {:else if sheetHeaders.length === 0} -
-

Select a sheet above to map columns

-
- {:else} -
-

- Map the columns from your sheet to the required fields: -

- - -
- {#each requiredFields as field} -
-
- -
- -
- -
-
- {/each} -
- - - {#if previewData.length > 0} -
-

Data Preview:

-
- - - - {#each Array.from({length: Math.min(Math.max(sheetHeaders.length, previewData[0]?.length || 0), 26)}, (_, i) => i) as index} - - {/each} - - - - {#each previewData as row} - - {#each Array.from({length: Math.min(Math.max(sheetHeaders.length, row.length), 26)}, (_, i) => i) as index} - - {/each} - - {/each} - -
- {sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`} - {#if Object.values(mappedIndices).includes(index)} -
- {requiredFields.find(f => mappedIndices[f.key] === index)?.label} -
- {/if} -
- {row[index] || ''} -
-
-
- {/if} - - - {#if mappingComplete} -
-

- ✓ All required fields are mapped! You can continue to the next step. -

-
- {:else} -
-

- Please map all required fields to continue. -

-
- {/if} -
- {/if} -
- {/if} +

+ First, select which sheet contains your member data, then map the columns to the required + fields. +

+
- - -
- - - -
- + {#if hasSavedMapping && !showMappingEditor} + +
+
+ + + +

Configuration Complete

+

+ Spreadsheet: + {savedSheetInfo?.name} +

+

+ Sheet: + {selectedSheetName} +

+

+ Column mapping loaded from your previous session.
+ Everything is ready to proceed to the next step. +

+ +
+
+ {:else} + +
+

Step 1: Select Sheet

+ + {#if isLoadingSheets} +
+
+ Loading sheets... +
+ {:else if error} +
+

{error}

+ +
+ {:else if availableSheets.length === 0} +
+ + + +

No sheets found

+

+ This spreadsheet doesn't appear to contain any sheets. +

+
+ {:else} +
+

+ Spreadsheet: {$selectedSheet?.name} +

+ +
+

Choose sheet:

+ +
+ {#each availableSheets as sheetName} +
handleSheetSelect(sheetName)} + onkeydown={(e) => e.key === 'Enter' && handleSheetSelect(sheetName)} + > +
+
+
+ {#if selectedSheetName === sheetName} +
+ {/if} +
+
+ +
+

{sheetName}

+
+ + {#if selectedSheetName === sheetName} +
+ + + +
+ {/if} +
+
+ {/each} +
+
+
+ {/if} +
+ + +
+

Step 2: Map Columns

+ + {#if isLoadingData} +
+
+ Loading sheet data... +
+ {:else if sheetHeaders.length === 0} +
+

Select a sheet above to map columns

+
+ {:else} +
+

+ Map the columns from your sheet to the required fields: +

+ + +
+ {#each requiredFields as field} +
+
+ +
+ +
+ +
+
+ {/each} +
+ + + {#if previewData.length > 0} +
+

Data Preview:

+
+ + + + {#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, previewData[0]?.length || 0), 26) }, (_, i) => i) as index} + + {/each} + + + + {#each previewData as row} + + {#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, row.length), 26) }, (_, i) => i) as index} + + {/each} + + {/each} + +
+ {sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`} + {#if Object.values(mappedIndices).includes(index)} +
+ {requiredFields.find((f) => mappedIndices[f.key] === index)?.label} +
+ {/if} +
+ {row[index] || ''} +
+
+
+ {/if} + + + {#if mappingComplete} +
+

+ ✓ All required fields are mapped! You can continue to the next step. +

+
+ {:else} +
+

Please map all required fields to continue.

+
+ {/if} +
+ {/if} +
+ {/if} + + +
+ + + +
diff --git a/src/lib/components/wizard/StepGallery.svelte b/src/lib/components/wizard/StepGallery.svelte index 487caaf..0dc8506 100644 --- a/src/lib/components/wizard/StepGallery.svelte +++ b/src/lib/components/wizard/StepGallery.svelte @@ -10,7 +10,8 @@ let isProcessing = $state(false); let processedCount = $state(0); let totalCount = $state(0); - let detector: blazeface.BlazeFaceModel; + let detector: blazeface.BlazeFaceModel | undefined; + let detectorPromise: Promise | undefined; interface PhotoInfo { name: string; @@ -19,72 +20,93 @@ objectUrl?: string; retryCount: 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 - onMount(async () => { - console.log('StepGallery mounted, initializing face detector...'); - await tf.setBackend('webgl'); - await tf.ready(); - detector = await blazeface.load(); - console.log('BlazeFace model loaded'); - if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) { - console.log('Processing photos for gallery step'); - processPhotos(); - } else { - console.log('No data to process:', { dataLength: $filteredSheetData.length, pictureUrlMapping: $columnMapping.pictureUrl }); + function initializeDetector() { + if (!detectorPromise) { + detectorPromise = (async () => { + console.log('Initializing face detector...'); + await tf.setBackend('webgl'); + await tf.ready(); + detector = await blazeface.load(); + console.log('BlazeFace model loaded'); + })(); } - }); + return detectorPromise; + } - async function processPhotos() { + async function processPhotosInParallel() { if (isProcessing) return; - console.log('Starting processPhotos...'); + console.log('Starting processPhotos in parallel...'); isProcessing = true; processedCount = 0; - // Get valid and included rows from filteredSheetData - const validRows = $filteredSheetData.filter(row => row._isValid); - console.log(`Found ${validRows.length} valid rows`); - - // Get unique photos to process + const validRows = $filteredSheetData.filter((row) => row._isValid); const photoUrls = new Set(); - const photoMap = new Map(); // url -> row data + const photoMap = new Map(); validRows.forEach((row: any) => { const photoUrl = row.pictureUrl; - if (photoUrl && photoUrl.trim()) { - photoUrls.add(photoUrl.trim()); - if (!photoMap.has(photoUrl.trim())) { - photoMap.set(photoUrl.trim(), []); + const trimmedUrl = photoUrl.trim(); + photoUrls.add(trimmedUrl); + 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; + console.log(`Found ${totalCount} unique photo URLs`); - // Initialize photos array - photos = Array.from(photoUrls).map(url => ({ - name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, // Use first person's name for display + photos = Array.from(photoUrls).map((url) => ({ + name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, url, status: 'loading' as const, retryCount: 0, faceDetectionStatus: 'pending' as const })); - // Process each photo + const concurrencyLimit = 5; + const promises = []; + for (let i = 0; i < photos.length; i++) { - await loadPhoto(i); - await detectFaceForPhoto(i); - processedCount++; + const promise = (async () => { + await loadPhoto(i); + processedCount++; + })(); + promises.push(promise); + + if (promises.length >= concurrencyLimit) { + await Promise.all(promises); + promises.length = 0; + } } + + await Promise.all(promises); + 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) { const photo = photos[index]; @@ -165,17 +187,27 @@ async function detectFaceForPhoto(index: number) { try { + await initializeDetector(); // Ensure detector is loaded + if (!detector) { + photos[index].faceDetectionStatus = 'failed'; + console.error('Face detector not available.'); + return; + } + photos[index].faceDetectionStatus = 'processing'; const img = new Image(); img.crossOrigin = 'anonymous'; img.src = photos[index].objectUrl!; await new Promise((r, e) => { img.onload = r; img.onerror = e; }); const predictions = await detector.estimateFaces(img, false); + 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 - let [x1,y1] = face.topLeft; - let [x2,y2] = face.bottomRight; + 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; @@ -225,7 +257,8 @@ } else { photos[index].faceDetectionStatus = 'failed'; } - } catch { + } 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 @@ -242,13 +275,14 @@ await loadPhoto(index, true); } - function handleCropUpdate(index: number, cropData: { x: number; y: number; width: number; height: number }) { - photos[index].cropData = cropData; + function handleCropUpdate(index: number, detail: { cropData: { x: number; y: number; width: number; height: number } }) { + photos[index].cropData = detail.cropData; + photos[index].faceDetectionStatus = 'manual'; // Save updated crop data to store cropRects.update(crops => ({ ...crops, - [photos[index].url]: cropData + [photos[index].url]: detail.cropData })); // No need to reassign photos array with $state reactivity @@ -376,7 +410,7 @@ {/if} -
+
{#if photos.length === 0 && !isProcessing}
@@ -388,55 +422,14 @@

{:else} -
-
- {#each photos as photo, index} - {#if photo.status === 'loading'} -
-
-
-
- Loading... -
-
-
-

{photo.name}

- Processing photo... -
-
- {:else if photo.status === 'success' && photo.objectUrl} - handleCropUpdate(index, e.detail)} - /> - {:else if photo.status === 'error'} -
-
-
- - - - Failed to load - -
-
-
-

{photo.name}

- Failed to load -
-
- {/if} - {/each} -
+
+ {#each photos as photo, index} + handleCropUpdate(index, e)} + onRetry={() => retryPhoto(index)} + /> + {/each}
{/if}
diff --git a/src/lib/components/wizard/StepRowFilter.svelte b/src/lib/components/wizard/StepRowFilter.svelte index 9bbb0f2..8681423 100644 --- a/src/lib/components/wizard/StepRowFilter.svelte +++ b/src/lib/components/wizard/StepRowFilter.svelte @@ -1,441 +1,517 @@
-
-
-

- Filter and Select Rows -

- -

- Review your data and select which rows you want to include in the card generation. - Only rows with all required fields will be available for selection. -

-
+
+

Filter and Select Rows

- -
-
- -
- - -
+

+ Review your data and select which rows you want to include in the card generation. Only rows + with all required fields will be available for selection. +

+
- -
- - -
-
+ +
+
+ +
+ + +
- -
- Total rows: {processedData.length} - Valid rows: {processedData.filter(row => row._isValid).length} - Printed: {processedData.filter(row => isRowAlreadyPrinted(row)).length} - Filtered rows: {filteredData.length} - Selected: {selectedValidCount} - -
-
+ +
+ + +
+
- -
- {#if isLoading} -
- - - - -
- {/if} - {#if filteredData.length === 0} -
- - - -

No data found

-

- {searchTerm ? 'No rows match your search criteria.' : 'No data available to display.'} -

-
- {:else} -
- - - - - - - - {#each headers.filter(h => h !== 'alreadyPrinted') as header} - - {/each} + +
+ Total rows: {processedData.length} + Valid rows: {processedData.filter((row) => row._isValid).length} + Printed: {processedData.filter((row) => isRowAlreadyPrinted(row)).length} + Filtered rows: {filteredData.length} + Selected: {selectedValidCount} + +
+ - - - - - - {#each filteredData as row} - - - - - - {#each headers.filter(h => h !== 'alreadyPrinted') as header} - - {/each} + +
+ {#if filteredData.length === 0 && !isLoading} +
+ + + +

No data found

+

+ {searchTerm ? 'No rows match your search criteria.' : 'No data available to display.'} +

+
+ {:else} +
+
- - handleSort(header)} - > -
- {getFieldLabel(header)} - {#if sortColumn === header} - - {#if sortDirection === 'asc'} - - {:else} - - {/if} - - {/if} -
-
- Status -
- {#if row._isValid} - toggleRowSelection(row._rowIndex)} - class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" - /> - {:else} -
- {/if} -
- {row[header] || ''} -
+ + + + - - - - {/each} - -
+ + -
- {#if row._isValid} - - Valid - - {:else} - - Missing data - - {/if} - - {#if isRowAlreadyPrinted(row)} - - Already Printed - - {/if} -
-
-
- {/if} -
+ + {#each headers.filter((h) => h !== 'alreadyPrinted') as header} + !isLoading && handleSort(header)} + > +
+ {getFieldLabel(header)} + {#if sortColumn === header} + + {#if sortDirection === 'asc'} + + {:else} + + {/if} + + {/if} +
+ + {/each} - - {#if selectedValidCount > 0} -
-
- - - - - {selectedValidCount} {selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation - -
-
- {/if} + + + Status + + + + + {#if isLoading} + + {#each Array(5) as _, index} + + + +
+ - -
- - -
-
+ + {#each headers.filter((h) => h !== 'alreadyPrinted') as header} + +
+ + {/each} + + + +
+
+
+ + + {/each} + {:else} + + {#each filteredData as row} + + + + {#if row._isValid} + toggleRowSelection(row._rowIndex)} + class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + {:else} +
+ {/if} + + + + {#each headers.filter((h) => h !== 'alreadyPrinted') as header} + + {row[header] || ''} + + {/each} + + + +
+ {#if row._isValid} + + Valid + + {:else} + + Missing data + + {/if} + + {#if isRowAlreadyPrinted(row)} + + Already Printed + + {/if} +
+ + + {/each} + {/if} + + +
+ {/if} +
+ + + {#if selectedValidCount > 0} +
+
+ + + + + {selectedValidCount} + {selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation + +
+
+ {/if} + + +
+ + +
diff --git a/src/lib/components/wizard/StepSheetSearch.svelte b/src/lib/components/wizard/StepSheetSearch.svelte index 576390c..4cb5bb8 100644 --- a/src/lib/components/wizard/StepSheetSearch.svelte +++ b/src/lib/components/wizard/StepSheetSearch.svelte @@ -1,251 +1,281 @@
-
-
-

- Select Google Sheet -

- -

- Search for and select the Google Sheet containing your member data. -

-
+
+

Select Google Sheet

- -
- - -
- { if (e.key === 'Enter') handleSearch(); }} - /> - - -
-
+

+ Search for and select the Google Sheet containing your member data. +

+
- {#if error} -
-

{error}

-
- {/if} + +
+ - - {#if hasSearched} -
-

- {searchResults.length - ? `Found ${searchResults.length} matching sheets` - : 'No matching sheets found'} -

- - {#if searchResults.length} -
- {#each searchResults as sheet} -
handleSelectSheet(sheet)} - tabindex="0" - role="button" - onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); }} - > -
-
-

{sheet.name}

-

ID: {sheet.id}

-
- -
- {#if sheet.iconLink} - Sheet icon - {/if} - - {#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)} - - - - {/if} -
-
-
- {/each} -
- {:else} -
- - - -

Try a different search term

-
- {/if} -
- {:else} - - {#if recentSheets.length > 0 && !hasSearched} -
-

- Recent sheets -

- -
- {#each recentSheets as sheet} -
handleSelectSheet(sheet)} - tabindex="0" - role="button" - onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); }} - > -
-
-

{sheet.name}

-

Recently used

-
- -
- {#if sheet.iconLink} - Sheet icon - {/if} - - {#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)} - - - - {/if} -
-
-
- {/each} -
- -
-

- Or search for a different sheet above -

-
-
- {:else} -
- - - -

Search for your sheet

-

- Enter a name or keyword to find your Google Sheets -

-
- {/if} - {/if} +
+ { + if (e.key === 'Enter') handleSearch(); + }} + /> - -
- - - -
-
+ +
+
+ + {#if error} +
+

{error}

+
+ {/if} + + + {#if hasSearched} +
+

+ {searchResults.length + ? `Found ${searchResults.length} matching sheets` + : 'No matching sheets found'} +

+ + {#if searchResults.length} +
+ {#each searchResults as sheet} +
handleSelectSheet(sheet)} + tabindex="0" + role="button" + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); + }} + > +
+
+

{sheet.name}

+

ID: {sheet.id}

+
+ +
+ {#if sheet.iconLink} + Sheet icon + {/if} + + {#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)} + + + + {/if} +
+
+
+ {/each} +
+ {:else} +
+ + + +

Try a different search term

+
+ {/if} +
+ {:else} + + {#if recentSheets.length > 0 && !hasSearched} +
+

Recent sheets

+ +
+ {#each recentSheets as sheet} +
handleSelectSheet(sheet)} + tabindex="0" + role="button" + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); + }} + > +
+
+

{sheet.name}

+

Recently used

+
+ +
+ {#if sheet.iconLink} + Sheet icon + {/if} + + {#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)} + + + + {/if} +
+
+
+ {/each} +
+ +
+

Or search for a different sheet above

+
+
+ {:else} +
+ + + +

Search for your sheet

+

Enter a name or keyword to find your Google Sheets

+
+ {/if} + {/if} + + +
+ + + +