Compare commits

...

10 Commits

Author SHA1 Message Date
Roman Krček
ceececfd99 Fixed cropping regression 2025-07-18 09:33:21 +02:00
Roman Krček
2e228126be Heic support 2025-07-18 09:17:46 +02:00
Roman Krček
9bbd02dd67 Improve the the cropping process, UI and UX 2025-07-18 09:11:17 +02:00
Roman Krček
c77c96c1c7 Working POC created 2025-07-17 21:50:14 +02:00
Roman Krček
2072e57585 PDF text generation working so far 2025-07-17 21:40:48 +02:00
Roman Krček
c695664784 Crop works nicely 2025-07-17 21:12:26 +02:00
Roman Krček
4f119dc121 Fixed other two components 2025-07-17 21:00:47 +02:00
Roman Krček
ffa427d42c Fixed first two steps 2025-07-17 20:41:09 +02:00
Roman Krček
735e13731c Cropping mostly done 2025-07-17 18:08:26 +02:00
Roman Krček
3ea48272b2 Step RowFiltering done 2025-07-17 16:34:02 +02:00
18 changed files with 3508 additions and 712 deletions

View File

@@ -1,2 +1,15 @@
# Your Google Cloud OAuth 2.0 Client ID # Your Google Cloud OAuth 2.0 Client ID
VITE_GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE" VITE_GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE"
# Face Detection Crop Configuration
# Crop aspect ratio (width:height) - e.g., 1.0 for square, 1.5 for 3:2 ratio
VITE_CROP_RATIO=1.0
# Face offset from center (as percentage of crop dimensions)
# Positive values move the face toward bottom-right, negative toward top-left
VITE_FACE_OFFSET_X=0.0
VITE_FACE_OFFSET_Y=-0.1
# Crop scale multiplier based on face width
# 1.0 = crop width equals face width, 2.0 = crop is 2x face width
VITE_CROP_SCALE=2.5

View File

@@ -2,7 +2,15 @@
- You are a helpful AI assistant that helps developers write code. - You are a helpful AI assistant that helps developers write code.
- This code is written in Svelte 5 - This code is written in Svelte 5
- It's important to only use modern Svelte 5 syntax, runes, and features. - It's important to only use modern Svelte 5 syntax, runes, and features.
- Do not use $:, do not use eventDispatching as they are both deprecated
- Use $effect, $state, $derived, eg. let { value } = $state(initialValue);
- 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. - 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.
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.

139
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "esn-card-generator", "name": "esn-card-generator",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@tensorflow-models/blazeface": "^0.1.0",
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0", "@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
@@ -15,6 +16,8 @@
"@types/gapi.client.sheets": "^4.0.20201031", "@types/gapi.client.sheets": "^4.0.20201031",
"@types/google.accounts": "^0.0.17", "@types/google.accounts": "^0.0.17",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"fontkit": "^2.0.4",
"heic2any": "^0.0.4",
"idb": "^8.0.3", "idb": "^8.0.3",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"
@@ -1072,6 +1075,21 @@
"vite": "^6.3.0 || ^7.0.0" "vite": "^6.3.0 || ^7.0.0"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@swc/helpers/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
@@ -1349,6 +1367,16 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@tensorflow-models/blazeface": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@tensorflow-models/blazeface/-/blazeface-0.1.0.tgz",
"integrity": "sha512-Qc5Wii8/OE5beC7XfehkhF9SEFLaPbVKnxxalV0T9JXsUynXqvLommc9Eko7b8zXKy4SJ1BtVlcX2cmCzQrn9A==",
"license": "Apache-2.0",
"peerDependencies": {
"@tensorflow/tfjs-converter": "^4.10.0",
"@tensorflow/tfjs-core": "^4.10.0"
}
},
"node_modules/@tensorflow/tfjs": { "node_modules/@tensorflow/tfjs": {
"version": "4.22.0", "version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-4.22.0.tgz", "resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-4.22.0.tgz",
@@ -1651,6 +1679,35 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1717,6 +1774,15 @@
"wrap-ansi": "^7.0.0" "wrap-ansi": "^7.0.0"
} }
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1839,6 +1905,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1993,6 +2065,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.4.6", "version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
@@ -2008,6 +2086,23 @@
} }
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
@@ -2161,6 +2256,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/idb": { "node_modules/idb": {
"version": "8.0.3", "version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
@@ -2864,6 +2965,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.45.1", "version": "4.45.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz",
@@ -3136,6 +3243,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -3195,6 +3308,32 @@
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",

View File

@@ -29,6 +29,7 @@
"vite-plugin-devtools-json": "^0.2.0" "vite-plugin-devtools-json": "^0.2.0"
}, },
"dependencies": { "dependencies": {
"@tensorflow-models/blazeface": "^0.1.0",
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0", "@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
@@ -36,6 +37,8 @@
"@types/gapi.client.sheets": "^4.0.20201031", "@types/gapi.client.sheets": "^4.0.20201031",
"@types/google.accounts": "^0.0.17", "@types/google.accounts": "^0.0.17",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"fontkit": "^2.0.4",
"heic2any": "^0.0.4",
"idb": "^8.0.3", "idb": "^8.0.3",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"

View File

@@ -1,6 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<title>ESN Card Generator</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@@ -0,0 +1,185 @@
<script lang="ts">
import PhotoCrop from './PhotoCrop.svelte';
let { photo, onCropUpdated, onRetry } = $props<{
photo: {
name: string;
url: string;
status: 'loading' | 'success' | 'error';
objectUrl?: string;
retryCount: number;
cropData?: { x: number; y: number; width: number; height: number };
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
};
onCropUpdated: (detail: any) => void;
onRetry: () => void;
}>();
let showCropper = $state(false);
let imageDimensions = $state<{ w: number; h: number } | null>(null);
let imageContainer = $state<HTMLDivElement | undefined>();
const cropBoxStyle = $derived(() => {
if (!photo.cropData || !imageDimensions || !imageContainer) {
return 'display: none;';
}
const { w: naturalW, h: naturalH } = imageDimensions;
const { x, y, width, height } = photo.cropData;
const { clientWidth: containerW, clientHeight: containerH } = imageContainer;
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;
}
const offsetX = (containerW - imgW) / 2;
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 };
}
function handleCropUpdated(detail: any) {
onCropUpdated(detail);
showCropper = false;
}
</script>
{#if photo.status === 'loading'}
<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">
<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}
<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}
>
<img
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>
</button>
</div>
{#if showCropper}
<PhotoCrop
imageUrl={photo.objectUrl}
personName={photo.name}
initialCropData={photo.cropData}
onClose={() => (showCropper = false)}
onCropUpdated={handleCropUpdated}
/>
{/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

@@ -0,0 +1,374 @@
<script lang="ts">
import { onMount } from 'svelte';
let { imageUrl, personName, initialCropData, onCropUpdated, onClose } = $props<{
imageUrl: string;
personName: string;
initialCropData?: { x: number; y: number; width: number; height: number };
onCropUpdated: (detail: {
cropData: { x: number; y: number; width: number; height: number };
}) => void;
onClose: () => void;
}>();
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let image: HTMLImageElement;
let isImageLoaded = false;
// Crop rectangle state
let crop = {
x: 0,
y: 0,
width: 200,
height: 200
};
// Interaction state
let isDragging = false;
let isResizing = false;
let dragStart = { x: 0, y: 0 };
let resizeHandle = '';
// Canvas dimensions
let canvasWidth = 600;
let canvasHeight = 400;
// Get crop ratio from environment
const cropRatio = parseFloat(import.meta.env.VITE_CROP_RATIO || '1.0');
onMount(() => {
ctx = canvas.getContext('2d')!;
loadImage();
});
async function loadImage() {
image = new Image();
image.onload = () => {
isImageLoaded = true;
// Calculate canvas size to fit image while maintaining aspect ratio
const maxWidth = 600;
const maxHeight = 400;
const imageAspect = image.width / image.height;
if (imageAspect > maxWidth / maxHeight) {
canvasWidth = maxWidth;
canvasHeight = maxWidth / imageAspect;
} else {
canvasHeight = maxHeight;
canvasWidth = maxHeight * imageAspect;
}
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Initialize crop rectangle
if (initialCropData) {
// Scale initial crop to canvas dimensions
const scaleX = canvasWidth / image.width;
const scaleY = canvasHeight / image.height;
crop = {
x: initialCropData.x * scaleX,
y: initialCropData.y * scaleY,
width: initialCropData.width * scaleX,
height: initialCropData.height * scaleY
};
} else {
// Default crop: centered with correct aspect ratio
const maxSize = Math.min(canvasWidth, canvasHeight) * 0.6;
const cropWidth = maxSize;
const cropHeight = cropWidth / cropRatio;
// If height exceeds canvas, scale down proportionally
if (cropHeight > canvasHeight * 0.8) {
const scale = (canvasHeight * 0.8) / cropHeight;
crop = {
x: (canvasWidth - (cropWidth * scale)) / 2,
y: (canvasHeight - (cropHeight * scale)) / 2,
width: cropWidth * scale,
height: cropHeight * scale
};
} else {
crop = {
x: (canvasWidth - cropWidth) / 2,
y: (canvasHeight - cropHeight) / 2,
width: cropWidth,
height: cropHeight
};
}
}
drawCanvas();
};
image.src = imageUrl;
}
function drawCanvas() {
if (!ctx || !isImageLoaded) return;
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Draw image
ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
// Draw overlay (darken non-crop area)
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(crop.x, crop.y, crop.width, crop.height);
ctx.globalCompositeOperation = 'source-over';
// Draw crop rectangle border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height);
// Draw resize handles
const handleSize = 12; // Increased from 8 for easier grabbing
ctx.fillStyle = '#3b82f6';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
// Corner handles with white borders for better visibility
const handles = [
{ x: crop.x - handleSize/2, y: crop.y - handleSize/2, cursor: 'nw-resize' },
{ x: crop.x + crop.width - handleSize/2, y: crop.y - handleSize/2, cursor: 'ne-resize' },
{ x: crop.x - handleSize/2, y: crop.y + crop.height - handleSize/2, cursor: 'sw-resize' },
{ x: crop.x + crop.width - handleSize/2, y: crop.y + crop.height - handleSize/2, cursor: 'se-resize' },
];
handles.forEach(handle => {
ctx.fillRect(handle.x, handle.y, handleSize, handleSize);
ctx.strokeRect(handle.x, handle.y, handleSize, handleSize);
});
}
function getMousePos(e: MouseEvent) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function isInCropArea(x: number, y: number) {
return x >= crop.x && x <= crop.x + crop.width &&
y >= crop.y && y <= crop.y + crop.height;
}
function getResizeHandle(x: number, y: number) {
const handleSize = 12; // Match the drawing size
const tolerance = handleSize;
if (Math.abs(x - crop.x) <= tolerance && Math.abs(y - crop.y) <= tolerance) return 'nw';
if (Math.abs(x - (crop.x + crop.width)) <= tolerance && Math.abs(y - crop.y) <= tolerance) return 'ne';
if (Math.abs(x - crop.x) <= tolerance && Math.abs(y - (crop.y + crop.height)) <= tolerance) return 'sw';
if (Math.abs(x - (crop.x + crop.width)) <= tolerance && Math.abs(y - (crop.y + crop.height)) <= tolerance) return 'se';
return '';
}
function handleMouseDown(e: MouseEvent) {
const pos = getMousePos(e);
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
isResizing = true;
resizeHandle = handle;
dragStart = pos;
} else if (isInCropArea(pos.x, pos.y)) {
isDragging = true;
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
}
}
function handleMouseMove(e: MouseEvent) {
const pos = getMousePos(e);
if (isResizing) {
const dx = pos.x - dragStart.x;
const dy = pos.y - dragStart.y;
const newCrop = { ...crop };
// Use primary axis movement for more predictable resizing
switch (resizeHandle) {
case 'nw':
// Use the dominant movement direction
const primaryDelta = Math.abs(dx) > Math.abs(dy) ? dx : dy * cropRatio;
const newWidth = Math.max(20, crop.width - primaryDelta);
const newHeight = newWidth / cropRatio;
newCrop.x = Math.max(0, crop.x + crop.width - newWidth);
newCrop.y = Math.max(0, crop.y + crop.height - newHeight);
newCrop.width = newWidth;
newCrop.height = newHeight;
break;
case 'ne':
// For NE, primarily follow horizontal movement
const newWidthNE = Math.max(20, crop.width + dx);
const newHeightNE = newWidthNE / cropRatio;
newCrop.width = newWidthNE;
newCrop.height = newHeightNE;
newCrop.y = Math.max(0, crop.y + crop.height - newHeightNE);
break;
case 'sw':
// For SW, primarily follow horizontal movement
const newWidthSW = Math.max(20, crop.width - dx);
const newHeightSW = newWidthSW / cropRatio;
newCrop.x = Math.max(0, crop.x + crop.width - newWidthSW);
newCrop.width = newWidthSW;
newCrop.height = newHeightSW;
break;
case 'se':
// For SE, primarily follow horizontal movement
const newWidthSE = Math.max(20, crop.width + dx);
const newHeightSE = newWidthSE / cropRatio;
newCrop.width = newWidthSE;
newCrop.height = newHeightSE;
break;
}
// Ensure crop stays within canvas bounds
if (newCrop.x + newCrop.width > canvasWidth) {
newCrop.width = canvasWidth - newCrop.x;
newCrop.height = newCrop.width / cropRatio;
}
if (newCrop.y + newCrop.height > canvasHeight) {
newCrop.height = canvasHeight - newCrop.y;
newCrop.width = newCrop.height * cropRatio;
}
// Adjust position if crop extends beyond bounds after resizing
if (newCrop.x + newCrop.width > canvasWidth) {
newCrop.x = canvasWidth - newCrop.width;
}
if (newCrop.y + newCrop.height > canvasHeight) {
newCrop.y = canvasHeight - newCrop.height;
}
crop = newCrop;
drawCanvas();
} else if (isDragging) {
crop.x = Math.max(0, Math.min(canvasWidth - crop.width, pos.x - dragStart.x));
crop.y = Math.max(0, Math.min(canvasHeight - crop.height, pos.y - dragStart.y));
drawCanvas();
} else {
// Update cursor based on hover state
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
canvas.style.cursor = handle + '-resize';
} else if (isInCropArea(pos.x, pos.y)) {
canvas.style.cursor = 'move';
} else {
canvas.style.cursor = 'default';
}
}
}
function handleMouseUp() {
isDragging = false;
isResizing = false;
resizeHandle = '';
canvas.style.cursor = 'default';
}
function handleSave() {
// Scale crop rectangle back to original image dimensions
const scaleX = image.width / canvasWidth;
const scaleY = image.height / canvasHeight;
const finalCrop = {
x: Math.round(crop.x * scaleX),
y: Math.round(crop.y * scaleY),
width: Math.round(crop.width * scaleX),
height: Math.round(crop.height * scaleY)
};
onCropUpdated({ cropData: finalCrop });
onClose();
}
function handleCancel() {
onClose();
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
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="flex items-center justify-between mb-4">
<h3 id="dialog-title" class="text-lg font-semibold text-gray-800">
Crop Photo: {personName}
</h3>
<button onclick={onClose} class="text-gray-400 hover:text-gray-600" aria-label="Close">
<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"
/>
</svg>
</button>
</div>
<div class="mb-4 p-2 rounded-md text-center">
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
class="mx-auto cursor-move"
style="max-width: 100%; height: auto;"
></canvas>
</div>
<div class="flex justify-end space-x-3">
<button
onclick={handleCancel}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
Cancel
</button>
<button
onclick={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
>
Save Crop
</button>
</div>
</div>
</div>
</div>

View File

@@ -3,25 +3,27 @@
import StepAuth from './wizard/StepAuth.svelte'; import StepAuth from './wizard/StepAuth.svelte';
import StepSheetSearch from './wizard/StepSheetSearch.svelte'; import StepSheetSearch from './wizard/StepSheetSearch.svelte';
import StepColumnMap from './wizard/StepColumnMap.svelte'; import StepColumnMap from './wizard/StepColumnMap.svelte';
// Additional steps to be added as they are implemented import StepRowFilter from './wizard/StepRowFilter.svelte';
import StepGallery from './wizard/StepGallery.svelte';
import StepGenerate from './wizard/StepGenerate.svelte';
const steps = [ const steps = [
StepAuth, StepAuth,
StepSheetSearch, StepSheetSearch,
StepColumnMap StepColumnMap,
StepRowFilter,
StepGallery,
StepGenerate
]; ];
const stepTitles = [ const stepTitles = [
'Authenticate', 'Authenticate',
'Select Sheet', 'Select Sheet',
'Map Columns' 'Map Columns',
'Filter Rows',
'Review Photos',
'Generate PDFs'
]; ];
function goToPreviousStep() {
if ($currentStep > 1) {
currentStep.update(n => n - 1);
}
}
</script> </script>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
@@ -50,16 +52,5 @@
<div class="bg-white rounded-lg shadow-sm"> <div class="bg-white rounded-lg shadow-sm">
<svelte:component this={steps[$currentStep - 1]} /> <svelte:component this={steps[$currentStep - 1]} />
</div> </div>
<!-- Navigation -->
<div class="flex justify-between mt-6">
<button
on:click={goToPreviousStep}
disabled={$currentStep <= 1}
class="px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
← Previous
</button>
</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

@@ -3,46 +3,181 @@
import { getSheetNames, getSheetData } from '$lib/google'; import { getSheetNames, getSheetData } from '$lib/google';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let isLoadingSheets = false; // Type definitions for better TypeScript support
let isLoadingData = false; interface ColumnMappingType {
let availableSheets: string[] = []; name: number;
let selectedSheetName = ''; surname: number;
let error = ''; nationality: number;
let sheetHeaders: string[] = []; birthday: number;
let previewData: string[][] = []; pictureUrl: number;
let mappingComplete = false; alreadyPrinted: number;
[key: string]: number; // Index signature to allow string indexing
}
interface SheetInfoType {
id?: string;
spreadsheetId?: string;
name: string;
sheetName?: string;
sheetMapping?: string;
columnMapping?: ColumnMappingType;
lastUsed?: string;
}
let isLoadingSheets = $state(false);
let isLoadingData = $state(false);
let availableSheets = $state<string[]>([]);
let selectedSheetName = $state('');
let error = $state('');
let sheetHeaders = $state<string[]>([]);
let previewData = $state<string[][]>([]);
let mappingComplete = $state(false);
let hasSavedMapping = $state(false);
let showMappingEditor = $state(false);
let savedSheetInfo = $state<SheetInfoType | null>(null);
let mappedIndices = $state<ColumnMappingType>({
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1
});
const requiredFields = [ const requiredFields = [
{ key: 'name', label: 'First Name', required: true }, { key: 'name', label: 'First Name', required: true },
{ key: 'surname', label: 'Last Name', required: true }, { key: 'surname', label: 'Last Name', required: true },
{ key: 'nationality', label: 'Nationality', required: true }, { key: 'nationality', label: 'Nationality', required: true },
{ key: 'birthday', label: 'Birthday', required: true }, { key: 'birthday', label: 'Birthday', required: true },
{ key: 'pictureUrl', label: 'Photo URL', required: true } { key: 'pictureUrl', label: 'Photo URL', required: true },
{ key: 'alreadyPrinted', label: 'Already Printed', required: false }
]; ];
let mappedIndices = {
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1
};
// Load available sheets when component mounts // Load available sheets when component mounts
onMount(async () => { onMount(async () => {
if ($selectedSheet) { if ($selectedSheet) {
console.log('Selected sheet on mount:', $selectedSheet);
// Check if we already have saved mapping data
const recentSheetsData = localStorage.getItem('esn-recent-sheets');
if (recentSheetsData) {
try {
const recentSheets = JSON.parse(recentSheetsData);
if (recentSheets && recentSheets.length > 0) {
// Find a sheet that matches the current spreadsheet
const savedSheet = recentSheets.find(
(sheet: SheetInfoType) =>
sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId
);
if (savedSheet) {
console.log('Found saved sheet configuration:', savedSheet);
// We have a saved sheet for this spreadsheet
selectedSheetName = savedSheet.sheetName || savedSheet.sheetMapping || '';
savedSheetInfo = savedSheet;
if (savedSheet.columnMapping) {
// Set the mapped indices from saved data
mappedIndices = {
name: savedSheet.columnMapping.name ?? -1,
surname: savedSheet.columnMapping.surname ?? -1,
nationality: savedSheet.columnMapping.nationality ?? -1,
birthday: savedSheet.columnMapping.birthday ?? -1,
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1
};
hasSavedMapping = true;
updateMappingStatus();
columnMapping.set(mappedIndices);
// Don't load sheet data immediately for better performance
// We'll load it when needed (when editing or continuing)
return; // Skip loading available sheets since we're using saved data
}
}
}
} catch (err) {
console.error('Error parsing saved sheets data:', err);
}
}
// If no saved data was found or it couldn't be used, load sheets as usual
await loadAvailableSheets(); await loadAvailableSheets();
} else {
console.error('No spreadsheet selected on mount');
} }
}); });
async function loadAvailableSheets() { // Load sheet data quietly (for previously saved sheets)
if (!$selectedSheet) return; async function loadSheetDataQuietly(sheetName: string) {
if (!$selectedSheet || !sheetName) {
console.error('Cannot load sheet data: missing selectedSheet or sheetName', {
selectedSheet: $selectedSheet,
sheetName: sheetName
});
return;
}
try {
console.log(
'Loading sheet data quietly for spreadsheet:',
$selectedSheet.spreadsheetId,
'sheet:',
sheetName
);
// Make sure we verify the sheet exists before trying to load it
if (availableSheets.length === 0) {
// We need to load available sheets first
await loadAvailableSheets();
// If after loading sheets, we still don't have the sheet, show the editor
if (!availableSheets.includes(sheetName)) {
console.warn(`Sheet "${sheetName}" not found in spreadsheet, showing editor`);
showMappingEditor = true;
return;
}
}
// Fetch first 10 rows for headers only
const range = `${sheetName}!A1:Z10`;
const data = await getSheetData($selectedSheet.spreadsheetId, range);
if (data && data.length > 0) {
console.log('Loaded sheet data with', data.length, 'rows');
sheetHeaders = data[0];
previewData = data.slice(1, Math.min(4, data.length)); // Get up to 3 rows for preview
// Don't set the rawSheetData here as that will be loaded in the next step
} else {
console.warn(`No data returned for sheet "${sheetName}", showing editor`);
showMappingEditor = true;
}
} catch (err) {
console.error('Error loading sheet data quietly:', err, 'for sheet:', sheetName);
// If there's an error, show the full editor so the user can select a sheet
showMappingEditor = true;
}
}
async function loadAvailableSheets() {
if (!$selectedSheet) {
console.error('Cannot load available sheets: no sheet selected');
return;
}
console.log('Loading available sheets for spreadsheet:', $selectedSheet.spreadsheetId);
isLoadingSheets = true; isLoadingSheets = true;
error = ''; error = '';
try { try {
availableSheets = await getSheetNames($selectedSheet.id); const sheetNames = await getSheetNames($selectedSheet.spreadsheetId);
console.log('Loaded sheet names:', sheetNames);
availableSheets = sheetNames;
// Don't auto-select any sheet - let user choose // Don't auto-select any sheet - let user choose
} catch (err) { } catch (err) {
console.error('Error loading sheet names:', err); console.error('Error loading sheet names:', err);
@@ -53,7 +188,9 @@
} }
function handleSheetSelect(sheetName: string) { function handleSheetSelect(sheetName: string) {
console.log('Sheet selected:', sheetName);
selectedSheetName = sheetName; selectedSheetName = sheetName;
// Clear any previous data when selecting a new sheet // Clear any previous data when selecting a new sheet
rawSheetData.set([]); rawSheetData.set([]);
sheetHeaders = []; sheetHeaders = [];
@@ -63,9 +200,12 @@
surname: -1, surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1 pictureUrl: -1,
alreadyPrinted: -1
}; };
mappingComplete = false; mappingComplete = false;
hasSavedMapping = false;
showMappingEditor = true;
// Load sheet data // Load sheet data
if (sheetName) { if (sheetName) {
@@ -74,20 +214,30 @@
} }
async function loadSheetData(sheetName: string) { async function loadSheetData(sheetName: string) {
if (!$selectedSheet) return; if (!$selectedSheet) {
console.error('Cannot load sheet data: no sheet selected');
return;
}
console.log(
'Loading sheet data for spreadsheet:',
$selectedSheet.spreadsheetId,
'sheet:',
sheetName
);
isLoadingData = true; isLoadingData = true;
error = ''; error = '';
try { try {
// Fetch first 10 rows for headers and preview // Fetch first 10 rows for headers and preview
const range = `${sheetName}!A1:Z10`; const range = `${sheetName}!A1:Z10`;
const data = await getSheetData($selectedSheet.id, range); const data = await getSheetData($selectedSheet.spreadsheetId, range);
if (data && data.length > 0) { if (data && data.length > 0) {
console.log('Loaded sheet data with', data.length, 'rows');
sheetHeaders = data[0]; sheetHeaders = data[0];
previewData = data.slice(1, Math.min(4, data.length)); // Get up to 3 rows for preview previewData = data.slice(1, Math.min(4, data.length)); // Get up to 3 rows for preview
rawSheetData.set(data); // We don't need to set all the raw data here
// Try to auto-map columns // Try to auto-map columns
autoMapColumns(); autoMapColumns();
@@ -96,6 +246,7 @@
loadSavedColumnMapping(); loadSavedColumnMapping();
} else { } else {
error = 'The selected sheet appears to be empty.'; error = 'The selected sheet appears to be empty.';
console.warn('Sheet is empty');
} }
} catch (err) { } catch (err) {
console.error('Error loading sheet data:', err); console.error('Error loading sheet data:', err);
@@ -112,16 +263,18 @@
surname: -1, surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1 pictureUrl: -1,
alreadyPrinted: -1
}; };
// Auto-mapping patterns // Auto-mapping patterns
const patterns = { const patterns: Record<keyof ColumnMappingType, RegExp> = {
name: /first[\s_-]*name|name|given[\s_-]*name|vorname/i, name: /first[\s_-]*name|name|given[\s_-]*name|vorname/i,
surname: /last[\s_-]*name|surname|family[\s_-]*name|nachname/i, surname: /last[\s_-]*name|surname|family[\s_-]*name|nachname/i,
nationality: /nationality|country|nation/i, nationality: /nationality|country|nation/i,
birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i, birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i,
pictureUrl: /photo|picture|image|url|avatar/i pictureUrl: /photo|picture|image|url|avatar/i,
alreadyPrinted: /already[\s_-]*printed|printed|status/i
}; };
sheetHeaders.forEach((header, index) => { sheetHeaders.forEach((header, index) => {
@@ -133,11 +286,38 @@
} }
}); });
// If "Already Printed" column wasn't found, try to find the first empty column
if (mappedIndices.alreadyPrinted === -1 && previewData.length > 0) {
// Check up to 26 columns (A-Z) or the number of headers, whichever is larger
const maxColumns = Math.max(sheetHeaders.length, 26);
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
// 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() === ''
);
// Also check if this column isn't already mapped to another field
const isAlreadyMapped = Object.entries(mappedIndices).some(
([field, index]) => field !== 'alreadyPrinted' && index === colIndex
);
if (isEmpty && !isAlreadyMapped) {
mappedIndices.alreadyPrinted = colIndex;
break;
}
}
}
console.log('Auto-mapped columns:', mappedIndices);
updateMappingStatus(); updateMappingStatus();
} }
function loadSavedColumnMapping() { function loadSavedColumnMapping() {
if (!$selectedSheet || !selectedSheetName) return; if (!$selectedSheet || !selectedSheetName) {
console.log('Cannot load saved column mapping: missing selectedSheet or selectedSheetName');
return;
}
try { try {
const recentSheetsKey = 'esn-recent-sheets'; const recentSheetsKey = 'esn-recent-sheets';
@@ -145,21 +325,31 @@
if (existingData) { if (existingData) {
const recentSheets = JSON.parse(existingData); const recentSheets = JSON.parse(existingData);
const savedSheet = recentSheets.find(sheet => const savedSheet = recentSheets.find(
sheet.id === $selectedSheet.id && sheet.sheetName === selectedSheetName (sheet: SheetInfoType) =>
(sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
); );
if (savedSheet && savedSheet.columnMapping) { if (savedSheet && savedSheet.columnMapping) {
console.log('Found saved column mapping for current sheet:', savedSheet.columnMapping);
// Override auto-mapping with saved mapping // Override auto-mapping with saved mapping
mappedIndices = { mappedIndices = {
name: savedSheet.columnMapping.name ?? -1, name: savedSheet.columnMapping.name ?? -1,
surname: savedSheet.columnMapping.surname ?? -1, surname: savedSheet.columnMapping.surname ?? -1,
nationality: savedSheet.columnMapping.nationality ?? -1, nationality: savedSheet.columnMapping.nationality ?? -1,
birthday: savedSheet.columnMapping.birthday ?? -1, birthday: savedSheet.columnMapping.birthday ?? -1,
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1 pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1
}; };
hasSavedMapping = true;
savedSheetInfo = savedSheet;
updateMappingStatus(); updateMappingStatus();
} else {
console.log('No saved column mapping found for the current sheet');
} }
} }
} catch (err) { } catch (err) {
@@ -167,13 +357,23 @@
} }
} }
function handleColumnMapping(field: string, index: number) { function handleColumnMapping(field: keyof ColumnMappingType, index: number) {
mappedIndices[field] = index; mappedIndices[field] = index;
updateMappingStatus(); updateMappingStatus();
} }
function updateMappingStatus() { function updateMappingStatus() {
mappingComplete = Object.values(mappedIndices).every(index => index !== -1); // Only check required fields for completion
const requiredIndices = {
name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl
};
mappingComplete = Object.values(requiredIndices).every((index) => index !== -1);
console.log('Mapping complete:', mappingComplete);
// Update the column mapping store // Update the column mapping store
columnMapping.set({ columnMapping.set({
@@ -181,7 +381,8 @@
surname: mappedIndices.surname, surname: mappedIndices.surname,
nationality: mappedIndices.nationality, nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday, birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl pictureUrl: mappedIndices.pictureUrl,
alreadyPrinted: mappedIndices.alreadyPrinted
}); });
} }
@@ -195,8 +396,11 @@
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 => const sheetIndex = recentSheets.findIndex(
sheet.id === $selectedSheet.id && sheet.sheetName === selectedSheetName (sheet: SheetInfoType) =>
(sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
); );
const columnMappingData = { const columnMappingData = {
@@ -204,19 +408,26 @@
surname: mappedIndices.surname, surname: mappedIndices.surname,
nationality: mappedIndices.nationality, nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday, birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl pictureUrl: mappedIndices.pictureUrl,
alreadyPrinted: mappedIndices.alreadyPrinted
}; };
if (sheetIndex !== -1) { if (sheetIndex !== -1) {
// Update existing entry // Update existing entry
recentSheets[sheetIndex].columnMapping = columnMappingData; recentSheets[sheetIndex].columnMapping = columnMappingData;
recentSheets[sheetIndex].lastUsed = new Date().toISOString(); recentSheets[sheetIndex].lastUsed = new Date().toISOString();
// Ensure we have consistent property names
recentSheets[sheetIndex].spreadsheetId =
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 = {
id: $selectedSheet.id, spreadsheetId: $selectedSheet.spreadsheetId,
name: $selectedSheet.name, name: $selectedSheet.name,
sheetName: selectedSheetName, sheetMapping: selectedSheetName,
columnMapping: columnMappingData, columnMapping: columnMappingData,
lastUsed: new Date().toISOString() lastUsed: new Date().toISOString()
}; };
@@ -236,45 +447,122 @@
currentStep.set(4); // Move to next step currentStep.set(4); // Move to next step
} }
async function handleShowEditor() {
showMappingEditor = true;
// Load available sheets if they haven't been loaded yet
if (availableSheets.length === 0) {
await loadAvailableSheets();
}
// Ensure we have sheet data if a sheet is already selected
if (selectedSheetName && sheetHeaders.length === 0) {
// Load the sheet data but keep mappings intact
try {
isLoadingData = true;
const range = `${selectedSheetName}!A1:Z10`;
const data = await getSheetData($selectedSheet.spreadsheetId, range);
if (data && data.length > 0) {
sheetHeaders = data[0];
previewData = data.slice(1, Math.min(4, data.length));
}
} catch (err) {
console.error('Error loading sheet data for editor:', err);
} finally {
isLoadingData = false;
}
}
}
</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}
<!-- Simplified view when we have saved mapping -->
<div class="mb-6 rounded-lg border border-green-200 bg-green-50 p-6">
<div class="text-center">
<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"
/>
</svg>
<h3 class="mb-3 text-xl font-semibold text-green-800">Configuration Complete</h3>
<p class="mb-2 text-green-700">
<span class="font-medium">Spreadsheet:</span>
{savedSheetInfo?.name}
</p>
<p class="mb-2 text-green-700">
<span class="font-medium">Sheet:</span>
{selectedSheetName}
</p>
<p class="mb-6 text-green-700">
Column mapping loaded from your previous session.<br />
Everything is ready to proceed to the next step.
</p>
<button
onclick={handleShowEditor}
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="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"
/>
</svg>
Make changes if needed
</button>
</div>
</div>
{: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"
on:click={loadAvailableSheets} onclick={loadAvailableSheets}
> >
Try again Try again
</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">
@@ -288,23 +576,30 @@
</p> </p>
<div> <div>
<label 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:
</label>
<div class="space-y-2"> <div class="space-y-2">
{#each availableSheets as sheetName} {#each availableSheets as sheetName}
<div <div
class="border rounded-lg p-3 cursor-pointer transition-colors hover:bg-gray-50 role="button"
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" tabindex="0"
on:click={() => handleSheetSelect(sheetName)} 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'}"
onclick={() => 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>
@@ -315,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}
@@ -330,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>
@@ -355,7 +654,7 @@
{#each requiredFields as field} {#each requiredFields as field}
<div class="flex items-center"> <div class="flex items-center">
<div class="w-32 flex-shrink-0"> <div class="w-32 flex-shrink-0">
<label class="text-sm font-medium text-gray-700"> <label for={`field-${field.key}`} class="text-sm font-medium text-gray-700">
{field.label} {field.label}
{#if field.required} {#if field.required}
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
@@ -365,13 +664,23 @@
<div class="flex-grow"> <div class="flex-grow">
<select <select
id={`field-${field.key}`}
bind:value={mappedIndices[field.key]} bind:value={mappedIndices[field.key]}
on:change={() => handleColumnMapping(field.key, 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 sheetHeaders as header, index} {#each Array.from({ length: Math.max(sheetHeaders.length, 26) }, (_, i) => i) as index}
<option value={index}>{header}</option> <option value={index}>
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
{#if !sheetHeaders[index]}
(empty)
{/if}
</option>
{/each} {/each}
</select> </select>
</div> </div>
@@ -382,31 +691,41 @@
<!-- 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 sheetHeaders as header, 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
{header} {Object.values(mappedIndices).includes(index)
? 'bg-blue-100'
: ''}"
>
{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 row as cell, 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
{cell} {Object.values(mappedIndices).includes(index)
? 'bg-blue-50'
: ''}"
>
{row[index] || ''}
</td> </td>
{/each} {/each}
</tr> </tr>
@@ -419,31 +738,36 @@
<!-- 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>
{/if} {/if}
</div> </div>
{/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-end"> <div class="flex justify-between">
<button <button
on:click={handleContinue} onclick={() => currentStep.set(2)}
disabled={!mappingComplete} class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
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"
> >
{mappingComplete ? 'Continue →' : 'Complete mapping to continue'} ← Back to Sheet Selection
</button>
<button
onclick={handleContinue}
disabled={!mappingComplete}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{mappingComplete ? 'Continue →' : 'Select a column mapping'}
</button> </button>
</div> </div>
</div> </div>
</div>

View File

@@ -1,4 +1,476 @@
<script lang="ts">
import { onMount } from 'svelte';
import { columnMapping, filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl } from '$lib/google';
import PhotoCard from '../PhotoCard.svelte';
import * as tf from '@tensorflow/tfjs';
import * as blazeface from '@tensorflow-models/blazeface';
let photos = $state<PhotoInfo[]>([]);
let isProcessing = $state(false);
let processedCount = $state(0);
let totalCount = $state(0);
let detector: blazeface.BlazeFaceModel | undefined;
let detectorPromise: Promise<void> | undefined;
interface PhotoInfo {
name: string;
url: string;
status: 'loading' | 'success' | 'error';
objectUrl?: string;
retryCount: number;
cropData?: { x: number; y: number; width: number; height: number };
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
}
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 processPhotosInParallel() {
if (isProcessing) return;
console.log('Starting processPhotos in parallel...');
isProcessing = true;
processedCount = 0;
const validRows = $filteredSheetData.filter((row) => row._isValid);
const photoUrls = new Set<string>();
const photoMap = new Map<string, any[]>();
validRows.forEach((row: any) => {
const photoUrl = row.pictureUrl;
if (photoUrl && photoUrl.trim()) {
const trimmedUrl = photoUrl.trim();
photoUrls.add(trimmedUrl);
if (!photoMap.has(trimmedUrl)) {
photoMap.set(trimmedUrl, []);
}
photoMap.get(trimmedUrl)!.push(row);
}
});
totalCount = photoUrls.size;
console.log(`Found ${totalCount} unique photo URLs`);
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
}));
const concurrencyLimit = 5;
const promises = [];
for (let i = 0; i < photos.length; i++) {
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];
if (!isRetry) {
photo.status = 'loading';
// No need to reassign photos array with $state reactivity
}
try {
let blob: Blob;
if (isGoogleDriveUrl(photo.url)) {
// Download from Google Drive
console.log(`Downloading from Google Drive: ${photo.name}`);
blob = await downloadDriveImage(photo.url);
} else {
// For direct URLs, convert to blob
const response = await fetch(photo.url);
blob = await response.blob();
}
// Check for HEIC/HEIF format and convert if necessary
if (
blob.type === 'image/heic' ||
blob.type === 'image/heif' ||
photo.url.toLowerCase().endsWith('.heic')
) {
console.log(`Converting HEIC image for ${photo.name}...`);
try {
const { default: heic2any } = await import('heic2any');
const conversionResult = await heic2any({
blob,
toType: 'image/jpeg',
quality: 0.9
});
blob = Array.isArray(conversionResult) ? conversionResult[0] : conversionResult;
console.log(`Successfully converted HEIC for ${photo.name}`);
} catch (e) {
console.error(`Failed to convert HEIC image for ${photo.name}:`, e);
throw new Error('HEIC conversion failed');
}
}
const objectUrl = createImageObjectUrl(blob);
// Test if image loads properly
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = (error) => {
console.error(`Failed to load image for ${photo.name}:`, error);
reject(new Error('Failed to load image'));
};
img.src = objectUrl;
});
photo.objectUrl = objectUrl;
photo.status = 'success';
console.log(`Photo loaded successfully: ${photo.name}`);
// Save to pictures store
pictures.update(pics => ({
...pics,
[photo.url]: {
id: photo.url,
blob: blob,
url: objectUrl,
downloaded: true,
faceDetected: false,
faceCount: 0
}
}));
// Automatically run face detection to generate crop
await detectFaceForPhoto(index);
} catch (error) {
console.error(`Failed to load photo for ${photo.name}:`, error);
photo.status = 'error';
}
// No need to reassign photos array with $state reactivity
}
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 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 as [number, number];
let [x2,y2] = face.bottomRight as [number, number];
// Scale to natural image size
const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height;
const faceWidth = (x2 - x1) * scaleX;
const faceHeight = (y2 - y1) * scaleY;
const faceCenterX = (x1 + (x2 - x1)/2) * scaleX;
const faceCenterY = (y1 + (y2 - y1)/2) * scaleY;
// Load crop config from env
const cropRatio = parseFloat(import.meta.env.VITE_CROP_RATIO || '1.0');
const offsetX = parseFloat(import.meta.env.VITE_FACE_OFFSET_X || '0.0');
const offsetY = parseFloat(import.meta.env.VITE_FACE_OFFSET_Y || '0.0');
const cropScale = parseFloat(import.meta.env.VITE_CROP_SCALE || '2.5');
// Compute crop size and center
let cropWidth = faceWidth * cropScale;
let cropHeight = cropWidth / cropRatio;
// If crop is larger than image, scale it down while maintaining aspect ratio
if (cropWidth > img.naturalWidth || cropHeight > img.naturalHeight) {
const widthRatio = img.naturalWidth / cropWidth;
const heightRatio = img.naturalHeight / cropHeight;
const scale = Math.min(widthRatio, heightRatio);
cropWidth *= scale;
cropHeight *= scale;
}
let centerX = faceCenterX + cropWidth * offsetX;
let centerY = faceCenterY + cropHeight * offsetY;
// Clamp center to ensure crop fits
centerX = Math.max(cropWidth/2, Math.min(centerX, img.naturalWidth - cropWidth/2));
centerY = Math.max(cropHeight/2, Math.min(centerY, img.naturalHeight - cropHeight/2));
const cropX = Math.round(centerX - cropWidth/2);
const cropY = Math.round(centerY - cropHeight/2);
const crop = {
x: Math.max(0, cropX),
y: Math.max(0, cropY),
width: Math.round(cropWidth),
height: Math.round(cropHeight)
};
photos[index].cropData = crop;
photos[index].faceDetectionStatus = 'completed';
// Save crop data to store
cropRects.update(crops => ({
...crops,
[photos[index].url]: crop
}));
// Update pictures store with face detection info
pictures.update(pics => ({
...pics,
[photos[index].url]: {
...pics[photos[index].url],
faceDetected: true,
faceCount: predictions.length
}
}));
} else {
photos[index].faceDetectionStatus = 'failed';
}
} catch (error) {
console.error(`Face detection failed for ${photos[index].name}:`, error);
photos[index].faceDetectionStatus = 'failed';
}
// No need to reassign photos array with $state reactivity
}
async function retryPhoto(index: number) {
const photo = photos[index];
if (photo.retryCount >= 3) {
return; // Max retries reached
}
photo.retryCount++;
await loadPhoto(index, true);
}
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]: detail.cropData
}));
// No need to reassign photos array with $state reactivity
}
const canProceed = $derived(() => {
const hasPhotos = photos.length > 0;
const allLoaded = photos.every(photo => photo.status === 'success');
const allCropped = photos.every(photo => photo.cropData);
return hasPhotos && allLoaded && allCropped;
});
// Cleanup object URLs when component is destroyed
function cleanupObjectUrls() {
photos.forEach(photo => {
if (photo.objectUrl && photo.objectUrl.startsWith('blob:')) {
URL.revokeObjectURL(photo.objectUrl);
}
});
}
// Cleanup on unmount using $effect
$effect(() => {
return () => {
cleanupObjectUrls();
};
});
</script>
<div class="p-6"> <div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Review Photos</h2> <div class="max-w-6xl mx-auto">
<p class="text-sm text-gray-700">Photo gallery and review functionality will be implemented here.</p> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Review & Crop Photos
</h2>
<p class="text-sm text-gray-700 mb-4">
Photos are automatically cropped using face detection. Click the pen icon to manually adjust the crop area.
</p>
</div> </div>
<!-- Processing Status -->
{#if isProcessing}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center justify-between">
<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>
<span class="text-sm text-blue-800">
Processing photos...
</span>
</div>
<span class="text-sm text-blue-600">
{processedCount} / {totalCount}
</span>
</div>
{#if totalCount > 0}
<div class="mt-3 w-full bg-blue-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {(processedCount / totalCount) * 100}%"
></div>
</div>
{/if}
</div>
{/if}
<!-- Summary Stats -->
{#if !isProcessing && photos.length > 0}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">Processing Summary</h3>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div class="text-center">
<div class="text-2xl font-bold text-gray-900">{photos.length}</div>
<div class="text-gray-600">Total Photos</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">
{photos.filter(p => p.status === 'success').length}
</div>
<div class="text-gray-600">Loaded</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">
{photos.filter(p => p.faceDetectionStatus === 'completed').length}
</div>
<div class="text-gray-600">Auto-cropped</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-purple-600">
{photos.filter(p => p.cropData).length}
</div>
<div class="text-gray-600">Ready</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-red-600">
{photos.filter(p => p.status === 'error').length}
</div>
<div class="text-gray-600">Failed</div>
</div>
</div>
{#if photos.filter(p => p.status === 'error').length > 0}
<div class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
<p class="text-sm text-yellow-800">
<strong>Note:</strong> Cards will only be generated for photos that load successfully.
</p>
</div>
{/if}
{#if !canProceed() && photos.filter(p => p.status === 'success').length > 0}
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<p class="text-sm text-blue-800">
<strong>Tip:</strong> All photos need to be cropped before proceeding. Face detection runs automatically.
</p>
</div>
{/if}
</div>
{/if}
<!-- Photo Grid -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
{#if photos.length === 0 && !isProcessing}
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 002 2z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No photos found</h3>
<p class="mt-1 text-sm text-gray-500">
Go back to check your column mapping and selected rows.
</p>
</div>
{:else}
<div class="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each photos as photo, index}
<PhotoCard
{photo}
onCropUpdated={(e) => handleCropUpdate(index, e)}
onRetry={() => retryPhoto(index)}
/>
{/each}
</div>
{/if}
</div>
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(4)}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
← Back to Row Filter
</button>
<button
onclick={() => currentStep.set(6)}
disabled={!canProceed()}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{canProceed()
? `Generate ${photos.filter(p => p.status === 'success' && p.cropData).length} Cards `
: 'Waiting for photos to load and crop'}
</button>
</div>
</div>
</div>

View File

@@ -1,4 +1,523 @@
<script lang="ts">
import { onMount } from 'svelte';
import { filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import * as fontkit from 'fontkit';
import {
PDF_DIMENSIONS,
TEXT_PDF_GRID,
PHOTO_PDF_GRID,
TEXT_FIELD_LAYOUT,
PHOTO_FIELD_LAYOUT,
BORDER_CONFIG,
TEXT_CONFIG,
PLACEHOLDER_CONFIG,
calculateGridLayout,
getAbsolutePosition,
getAbsolutePhotoDimensions
} from '$lib/pdfLayout';
let isGenerating = $state(false);
let progress = $state({ stage: '', current: 0, total: 0 });
let generatedFiles = $state<{ name: string; url: string; size: number }[]>([]);
// Load Roboto font
async function loadRobotoFont() {
try {
const fontResponse = await fetch('/fonts/Roboto-Regular.ttf');
if (!fontResponse.ok) {
throw new Error('Failed to load Roboto font');
}
return await fontResponse.arrayBuffer();
} catch (error) {
console.warn('Could not load Roboto font, falling back to standard font:', error);
return null;
}
}
// Crop image using canvas
async function cropImage(imageBlob: Blob, crop: { x: number; y: number; width: number; height: number }): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get canvas context'));
return;
}
img.onload = () => {
// Set canvas size to crop dimensions
canvas.width = crop.width;
canvas.height = crop.height;
// Draw the cropped portion of the image
ctx.drawImage(
img,
crop.x, crop.y, crop.width, crop.height, // Source rectangle
0, 0, crop.width, crop.height // Destination rectangle
);
// Convert canvas to blob then to array buffer
canvas.toBlob((blob) => {
if (blob) {
blob.arrayBuffer().then(buffer => {
resolve(new Uint8Array(buffer));
}).catch(reject);
} else {
reject(new Error('Failed to create blob from canvas'));
}
}, 'image/jpeg', 0.9);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = URL.createObjectURL(imageBlob);
});
}
// PDF generation function
async function generatePDFs() {
if (isGenerating) return;
isGenerating = true;
generatedFiles = [];
try {
console.log("starting PDF generation...");
console.log("filteredSheetData:", $filteredSheetData);
console.log("valid rows:", $filteredSheetData.filter(row => row._isValid));
// Generate text PDF
progress = { stage: 'Generating text PDF...', current: 1, total: 3 };
const textPdfBytes = await generateTextPDF();
const textBlob = new Blob([textPdfBytes], { type: 'application/pdf' });
const textUrl = URL.createObjectURL(textBlob);
generatedFiles.push({
name: 'people_data.pdf',
url: textUrl,
size: textPdfBytes.length
});
console.log("Text PDF generated:", textUrl);
console.log("starting photo PDF generation...");
// Generate photo PDF
progress = { stage: 'Generating photo PDF...', current: 2, total: 3 };
const photoPdfBytes = await generatePhotoPDF();
const photoBlob = new Blob([photoPdfBytes], { type: 'application/pdf' });
const photoUrl = URL.createObjectURL(photoBlob);
generatedFiles.push({
name: 'people_photos.pdf',
url: photoUrl,
size: photoPdfBytes.length
});
progress = { stage: 'Complete!', current: 3, total: 3 };
} catch (error) {
console.error('PDF generation failed:', error);
console.error('Error stack:', error.stack);
console.error('Error details:', {
message: error.message,
name: error.name,
stack: error.stack
});
alert('Failed to generate PDFs: ' + error.message);
} finally {
isGenerating = false;
console.log("PDF generation completed.");
}
}
async function generateTextPDF() {
const pdfDoc = await PDFDocument.create();
// Register fontkit to enable custom font embedding
pdfDoc.registerFontkit(fontkit);
// Load custom Roboto font or fallback to standard font
const robotoFontBytes = await loadRobotoFont();
const font = robotoFontBytes
? await pdfDoc.embedFont(robotoFontBytes)
: await pdfDoc.embedFont(StandardFonts.TimesRoman);
// Calculate grid layout using configuration
const gridLayout = calculateGridLayout(PDF_DIMENSIONS, TEXT_PDF_GRID);
let page = pdfDoc.addPage([PDF_DIMENSIONS.pageWidth, PDF_DIMENSIONS.pageHeight]);
let currentRow = 0;
let currentCol = 0;
const validRows = $filteredSheetData.filter(row => row._isValid);
for (let i = 0; i < validRows.length; i++) {
const row = validRows[i];
console.log(`Processing row ${i}:`, row);
// Calculate cell position
const cellX = PDF_DIMENSIONS.margin + currentCol * gridLayout.cellWidth;
const cellY = PDF_DIMENSIONS.pageHeight - PDF_DIMENSIONS.margin - (currentRow + 1) * gridLayout.cellHeight;
// Get field values safely
const name = row.name || row.Name || '';
const surname = row.surname || row.Surname || row.lastname || row.LastName || '';
const nationality = row.nationality || row.Nationality || row.country || row.Country || '';
const birthday = row.birthday || row.Birthday || row.birthdate || row.Birthdate || row.birth_date || '';
// Draw name using absolute positioning
const namePos = getAbsolutePosition(cellX, cellY, gridLayout.cellHeight, TEXT_FIELD_LAYOUT.name);
page.drawText(`${name} ${surname}`, {
x: namePos.x,
y: namePos.y,
size: namePos.size,
font: font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Draw nationality
const nationalityPos = getAbsolutePosition(cellX, cellY, gridLayout.cellHeight, TEXT_FIELD_LAYOUT.nationality);
page.drawText(`Nationality: ${nationality}`, {
x: nationalityPos.x,
y: nationalityPos.y,
size: nationalityPos.size,
font: font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Draw birthday
const birthdayPos = getAbsolutePosition(cellX, cellY, gridLayout.cellHeight, TEXT_FIELD_LAYOUT.birthday);
page.drawText(`Birthday: ${birthday}`, {
x: birthdayPos.x,
y: birthdayPos.y,
size: birthdayPos.size,
font: font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Draw cell border
page.drawRectangle({
x: cellX,
y: cellY,
width: gridLayout.cellWidth,
height: gridLayout.cellHeight,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width
});
// Move to next cell
currentCol++;
if (currentCol >= gridLayout.cols) {
currentCol = 0;
currentRow++;
if (currentRow >= gridLayout.rows) {
// Add new page
page = pdfDoc.addPage([PDF_DIMENSIONS.pageWidth, PDF_DIMENSIONS.pageHeight]);
currentRow = 0;
}
}
}
return await pdfDoc.save();
}
async function generatePhotoPDF() {
const pdfDoc = await PDFDocument.create();
// Register fontkit to enable custom font embedding
pdfDoc.registerFontkit(fontkit);
// Load custom Roboto font or fallback to standard font
const robotoFontBytes = await loadRobotoFont();
const font = robotoFontBytes
? await pdfDoc.embedFont(robotoFontBytes)
: await pdfDoc.embedFont(StandardFonts.TimesRoman);
// Calculate grid layout using configuration
const gridLayout = calculateGridLayout(PDF_DIMENSIONS, PHOTO_PDF_GRID);
let page = pdfDoc.addPage([PDF_DIMENSIONS.pageWidth, PDF_DIMENSIONS.pageHeight]);
let currentRow = 0;
let currentCol = 0;
const validRows = $filteredSheetData.filter(row => row._isValid);
for (let i = 0; i < validRows.length; i++) {
const row = validRows[i];
// Calculate cell position
const cellX = PDF_DIMENSIONS.margin + currentCol * gridLayout.cellWidth;
const cellY = PDF_DIMENSIONS.pageHeight - PDF_DIMENSIONS.margin - (currentRow + 1) * gridLayout.cellHeight;
// Get photo dimensions using configuration
const photoDims = getAbsolutePhotoDimensions(
cellX, cellY, gridLayout.cellWidth, gridLayout.cellHeight,
PHOTO_FIELD_LAYOUT.photo
);
// Try to get and embed the actual photo
// Use picture URL as the key to lookup data
const pictureUrl = row.pictureUrl || row.picture_url || row.Picture || row.PictureUrl;
const pictureInfo = $pictures[pictureUrl];
const cropData = $cropRects[pictureUrl];
console.log(`Row ${i} (${row.name}):`, {
rowId: row.id,
pictureUrl: pictureUrl,
pictureInfo: pictureInfo ? 'Found' : 'Missing',
cropData: cropData ? cropData : 'Missing',
allPictureIds: Object.keys($pictures),
allCropIds: Object.keys($cropRects)
});
if (pictureInfo && cropData) {
try {
console.log(`Cropping and embedding photo for ${row.name} ${row.surname}`);
// Crop the image
const croppedImageBytes = await cropImage(pictureInfo.blob, cropData);
// Embed the cropped image in the PDF
const image = await pdfDoc.embedJpg(croppedImageBytes);
// Calculate image dimensions to fit within the photo area while maintaining aspect ratio
const imageAspectRatio = image.width / image.height;
const photoAspectRatio = photoDims.width / photoDims.height;
let imageWidth, imageHeight;
if (imageAspectRatio > photoAspectRatio) {
// Image is wider - fit to width
imageWidth = photoDims.width;
imageHeight = photoDims.width / imageAspectRatio;
} else {
// Image is taller - fit to height
imageHeight = photoDims.height;
imageWidth = photoDims.height * imageAspectRatio;
}
// Center the image within the photo area
const imageX = photoDims.x + (photoDims.width - imageWidth) / 2;
const imageY = photoDims.y + (photoDims.height - imageHeight) / 2;
// Draw the image
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight
});
} catch (error) {
console.error(`Failed to embed photo for ${row.name}:`, error);
// Fall back to placeholder if photo embedding fails
page.drawRectangle({
x: photoDims.x,
y: photoDims.y,
width: photoDims.width,
height: photoDims.height,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width
});
page.drawText('Photo failed', {
x: cellX + gridLayout.cellWidth / 2 - 30,
y: cellY + gridLayout.cellHeight / 2,
size: PLACEHOLDER_CONFIG.size,
font: font,
color: rgb(PLACEHOLDER_CONFIG.color.r, PLACEHOLDER_CONFIG.color.g, PLACEHOLDER_CONFIG.color.b)
});
}
} else {
// No photo or crop data available - draw placeholder
page.drawRectangle({
x: photoDims.x,
y: photoDims.y,
width: photoDims.width,
height: photoDims.height,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width
});
page.drawText(PLACEHOLDER_CONFIG.text, {
x: cellX + gridLayout.cellWidth / 2 - 40,
y: cellY + gridLayout.cellHeight / 2,
size: PLACEHOLDER_CONFIG.size,
font: font,
color: rgb(PLACEHOLDER_CONFIG.color.r, PLACEHOLDER_CONFIG.color.g, PLACEHOLDER_CONFIG.color.b)
});
}
// Get field values safely
const name = row.name || row.Name || '';
const surname = row.surname || row.Surname || row.lastname || row.LastName || '';
// Draw name using absolute positioning
const namePos = getAbsolutePosition(cellX, cellY, gridLayout.cellHeight, PHOTO_FIELD_LAYOUT.name);
page.drawText(`${name} ${surname}`, {
x: namePos.x,
y: namePos.y,
size: namePos.size,
font: font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Move to next cell
currentCol++;
if (currentCol >= gridLayout.cols) {
currentCol = 0;
currentRow++;
if (currentRow >= gridLayout.rows) {
// Add new page
page = pdfDoc.addPage([PDF_DIMENSIONS.pageWidth, PDF_DIMENSIONS.pageHeight]);
currentRow = 0;
}
}
}
return await pdfDoc.save();
}
function downloadFile(file: { name: string; url: string }) {
const link = document.createElement('a');
link.href = file.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
</script>
<div class="p-6"> <div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Generate PDFs</h2> <div class="max-w-4xl mx-auto">
<p class="text-sm text-gray-700">PDF generation functionality will be implemented here.</p> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Generate PDFs
</h2>
<p class="text-sm text-gray-700 mb-4">
Create two PDF documents: one with text data and one with photos.
</p>
</div>
<!-- Generation Status -->
{#if isGenerating}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center justify-between mb-3">
<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>
<span class="text-sm text-blue-800">{progress.stage}</span>
</div>
<span class="text-sm text-blue-600">
{progress.current} / {progress.total}
</span>
</div>
<div class="w-full bg-blue-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {(progress.current / progress.total) * 100}%"
></div>
</div>
</div>
{/if}
<!-- Summary -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">Generation Summary</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="text-center">
<div class="text-2xl font-bold text-gray-900">
{$filteredSheetData.filter(row => row._isValid).length}
</div>
<div class="text-gray-600">Records to Process</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">2</div>
<div class="text-gray-600">PDFs to Generate</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">
{generatedFiles.length}
</div>
<div class="text-gray-600">Files Ready</div>
</div>
</div>
</div>
<!-- Generate Button -->
{#if !isGenerating && generatedFiles.length === 0}
<div class="text-center mb-6">
<button
onclick={generatePDFs}
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Generate PDFs
</button>
</div>
{/if}
<!-- Generated Files -->
{#if generatedFiles.length > 0}
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
<div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Generated Files</h3>
</div>
<div class="divide-y divide-gray-200">
{#each generatedFiles as file}
<div class="p-4 flex items-center justify-between">
<div class="flex items-center">
<svg class="w-8 h-8 text-red-600 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<div>
<h4 class="text-sm font-medium text-gray-900">{file.name}</h4>
<p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
</div>
</div>
<button
onclick={() => downloadFile(file)}
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Download
</button>
</div>
{/each}
</div>
</div>
{/if}
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(5)}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
← Back to Gallery
</button>
{#if generatedFiles.length > 0}
<button
onclick={() => currentStep.set(0)}
class="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700"
>
Start Over
</button>
{/if}
</div>
</div>
</div> </div>

View File

@@ -1,4 +1,517 @@
<script lang="ts">
import {
selectedSheet,
columnMapping,
rawSheetData,
filteredSheetData,
currentStep,
sheetData
} from '$lib/stores';
import type { RowData } from '$lib/stores';
import { onMount } from 'svelte';
import { getSheetNames, getSheetData } from '$lib/google';
let searchTerm = '';
let sortColumn = '';
let sortDirection: 'asc' | 'desc' = 'asc';
let selectedRows = new Set<number>();
let selectAll = false;
let processedData: any[] = [];
let filteredData: any[] = [];
let headers: string[] = [];
let isLoading = false;
$: {
// Filter data based on search term
if (searchTerm.trim()) {
filteredData = processedData.filter((row) =>
Object.values(row).some((value) =>
String(value).toLowerCase().includes(searchTerm.toLowerCase())
)
);
} else {
filteredData = processedData;
}
}
$: {
// Sort data if sort column is selected
if (sortColumn && filteredData.length > 0) {
filteredData = [...filteredData].sort((a, b) => {
const aVal = String(a[sortColumn]).toLowerCase();
const bVal = String(b[sortColumn]).toLowerCase();
if (sortDirection === 'asc') {
return aVal.localeCompare(bVal);
} else {
return bVal.localeCompare(aVal);
}
});
}
}
onMount(() => {
console.log('StepRowFilter mounted');
processSheetData();
});
// Fetch raw sheet data from Google Sheets if not already loaded
async function fetchRawSheetData() {
console.log("Fetching raw sheet data...");
const sheetNames = await getSheetNames($selectedSheet.spreadsheetId);
if (sheetNames.length === 0) return;
const sheetName = sheetNames[0];
const range = `${sheetName}!A:Z`;
const data = await getSheetData($selectedSheet.spreadsheetId, range);
rawSheetData.set(data);
}
async function processSheetData() {
isLoading = true;
try {
// Get headers from the mapping
headers = Object.keys($columnMapping);
await fetchRawSheetData();
// Process the data starting from row 2 (skip header row)
processedData = $rawSheetData.slice(1).map((row, index) => {
const processedRow: any = {
_rowIndex: index + 1, // Store original row index
_isValid: true
};
// Map each column according to the column mapping
for (const [field, columnIndex] of Object.entries($columnMapping)) {
if (columnIndex !== -1 && columnIndex !== undefined && columnIndex < row.length) {
processedRow[field] = row[columnIndex] || '';
} else {
processedRow[field] = '';
// Only mark as invalid if it's a required field
if (field !== 'alreadyPrinted') {
processedRow._isValid = false;
}
}
}
// Check if all required fields have values (excluding alreadyPrinted)
const requiredFields = ['name', 'surname', 'nationality', 'birthday', 'pictureUrl'];
const hasAllRequiredFields = requiredFields.every(
(field) => processedRow[field] && String(processedRow[field]).trim() !== ''
);
if (!hasAllRequiredFields) {
processedRow._isValid = false;
}
return processedRow;
});
// Initially select rows based on validity and "Already Printed" status
selectedRows = new Set(
processedData
.filter((row) => {
if (!row._isValid) return false;
// Check "Already Printed" column value
const alreadyPrinted = row.alreadyPrinted;
if (alreadyPrinted) {
const value = String(alreadyPrinted).toLowerCase().trim();
// If the value is "true", "yes", "1", or any truthy value, don't select
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
}
// If empty or falsy, select the row
return true;
})
.map((row) => row._rowIndex)
);
updateSelectAllState();
} finally {
isLoading = false;
}
}
function toggleRowSelection(rowIndex: number) {
if (selectedRows.has(rowIndex)) {
selectedRows.delete(rowIndex);
} else {
selectedRows.add(rowIndex);
}
selectedRows = new Set(selectedRows); // Trigger reactivity
updateSelectAllState();
}
function toggleSelectAll() {
if (selectAll) {
// Deselect all visible valid rows that aren't already printed
filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.delete(row._rowIndex);
}
});
} else {
// Select all visible valid rows that aren't already printed
filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.add(row._rowIndex);
}
});
}
selectedRows = new Set(selectedRows);
updateSelectAllState();
}
function updateSelectAllState() {
const visibleValidUnprintedRows = filteredData.filter(
(row) => row._isValid && !isRowAlreadyPrinted(row)
);
const selectedVisibleValidUnprintedRows = visibleValidUnprintedRows.filter((row) =>
selectedRows.has(row._rowIndex)
);
selectAll =
visibleValidUnprintedRows.length > 0 &&
selectedVisibleValidUnprintedRows.length === visibleValidUnprintedRows.length;
}
function handleSort(column: string) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
}
function getFieldLabel(field: string): string {
const labels: { [key: string]: string } = {
name: 'First Name',
surname: 'Last Name',
nationality: 'Nationality',
birthday: 'Birthday',
pictureUrl: 'Photo URL',
alreadyPrinted: 'Already Printed'
};
return labels[field] || field;
}
function isRowAlreadyPrinted(row: any): boolean {
const alreadyPrinted = row.alreadyPrinted;
if (!alreadyPrinted) return false;
const value = String(alreadyPrinted).toLowerCase().trim();
return value === 'true' || value === 'yes' || value === '1' || value === 'x';
}
function handleContinue() {
// Filter the data to only include selected rows
const selectedData = processedData.filter(
(row) => selectedRows.has(row._rowIndex) && row._isValid
);
// Store the filtered data
filteredSheetData.set(selectedData);
// Move to next step
currentStep.set(5);
}
$: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => {
const row = processedData.find((r) => r._rowIndex === rowIndex);
return row && row._isValid;
}).length;
// Allow proceeding only if at least one valid row is selected
$: canProceed = selectedValidCount > 0;
</script>
<div class="p-6"> <div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Filter Rows</h2> <div class="mb-6">
<p class="text-sm text-gray-700">Row filtering functionality will be implemented here.</p> <h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
<p class="mb-4 text-sm text-gray-700">
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.
</p>
</div>
<!-- Search and Filter Controls -->
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex flex-col gap-4 sm:flex-row">
<!-- Search -->
<div class="flex-grow">
<label for="search" class="mb-2 block text-sm font-medium text-gray-700">
Search rows
</label>
<input
id="search"
type="text"
bind:value={searchTerm}
placeholder="Search in any field..."
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>
<!-- Sort -->
<div class="sm:w-48">
<label for="sort" class="mb-2 block text-sm font-medium text-gray-700"> Sort by </label>
<select
id="sort"
bind:value={sortColumn}
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>
{#each headers as header}
<option value={header}>{getFieldLabel(header)}</option>
{/each}
</select>
</div>
</div>
<!-- Stats -->
<div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
<span>Total rows: {processedData.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>Filtered rows: {filteredData.length}</span>
<span class="font-medium text-blue-600">Selected: {selectedValidCount}</span>
<button
onclick={processSheetData}
disabled={isLoading}
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}
<svg
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" />
</svg>
Refreshing...
{:else}
Refresh Data
{/if}
</button>
</div>
</div>
<!-- Data Table -->
<div class="relative mb-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
{#if filteredData.length === 0 && !isLoading}
<div class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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>
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3>
<p class="mt-1 text-sm text-gray-500">
{searchTerm ? 'No rows match your search criteria.' : 'No data available to display.'}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<!-- Select All Checkbox -->
<th class="px-3 py-3 text-left">
<input
type="checkbox"
bind:checked={selectAll}
onchange={toggleSelectAll}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
disabled={isLoading}
/>
</th>
<!-- Column Headers -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<th
class="cursor-pointer px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:bg-gray-100"
onclick={() => !isLoading && handleSort(header)}
>
<div class="flex items-center space-x-1">
<span>{getFieldLabel(header)}</span>
{#if sortColumn === header}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
{#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"
/>
{: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"
/>
{/if}
</svg>
{/if}
</div>
</th>
{/each}
<!-- Status Column -->
<th
class="px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Status
</th>
</tr>
</thead>
<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}
<tr
class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(
row
)
? 'bg-orange-50'
: ''}"
>
<!-- Selection Checkbox -->
<td class="px-3 py-4">
{#if row._isValid}
<input
type="checkbox"
checked={selectedRows.has(row._rowIndex)}
onchange={() => toggleRowSelection(row._rowIndex)}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{:else}
<div class="h-4 w-4 rounded bg-gray-200"></div>
{/if}
</td>
<!-- Data Columns -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<td class="max-w-xs truncate px-3 py-4 text-sm text-gray-900">
{row[header] || ''}
</td>
{/each}
<!-- Status Column -->
<td class="px-3 py-4 text-sm">
<div class="flex flex-col space-y-1">
{#if row._isValid}
<span
class="inline-flex rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800"
>
Valid
</span>
{:else}
<span
class="inline-flex rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800"
>
Missing data
</span>
{/if}
{#if isRowAlreadyPrinted(row)}
<span
class="inline-flex rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800"
>
Already Printed
</span>
{/if}
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Selection Summary -->
{#if selectedValidCount > 0}
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center">
<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"
/>
</svg>
<span class="text-sm text-blue-800">
<strong>{selectedValidCount}</strong>
{selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation
</span>
</div>
</div>
{/if}
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(3)}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
>
← Back to Colum Selection
</button>
<button
onclick={handleContinue}
disabled={!canProceed}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{canProceed
? `Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} `
: 'Select rows to continue'}
</button>
</div>
</div> </div>

View File

@@ -3,12 +3,12 @@
import { searchSheets } from '$lib/google'; import { searchSheets } from '$lib/google';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let searchQuery = ''; let searchQuery = $state('');
let isLoading = false; let isLoading = $state(false);
let error = ''; let error = $state('');
let searchResults: any[] = []; let searchResults = $state<any[]>([]);
let hasSearched = false; let hasSearched = $state(false);
let recentSheets: any[] = []; let recentSheets = $state<any[]>([]);
const RECENT_SHEETS_KEY = 'esn-recent-sheets'; const RECENT_SHEETS_KEY = 'esn-recent-sheets';
@@ -25,8 +25,8 @@
try { try {
searchResults = await searchSheets(searchQuery); searchResults = await searchSheets(searchQuery);
availableSheets.set( availableSheets.set(
searchResults.map(sheet => ({ searchResults.map((sheet) => ({
id: sheet.id, spreadsheetId: sheet.spreadsheetId || sheet.id,
name: sheet.name, name: sheet.name,
url: sheet.webViewLink url: sheet.webViewLink
})) }))
@@ -56,65 +56,35 @@
} }
} }
function saveToRecentSheets(sheet) {
// Create a copy of the sheet object with just the properties we need
const sheetToSave = {
id: sheet.id,
name: sheet.name,
url: sheet.webViewLink || sheet.url,
iconLink: sheet.iconLink
};
// Remove this sheet if it already exists in the list
recentSheets = recentSheets.filter(s => s.id !== sheetToSave.id);
// Add the sheet to the beginning of the list
recentSheets = [sheetToSave, ...recentSheets];
// Keep only up to 3 recent sheets
if (recentSheets.length > 3) {
recentSheets = recentSheets.slice(0, 3);
}
// Save to localStorage
try {
localStorage.setItem(RECENT_SHEETS_KEY, JSON.stringify(recentSheets));
} catch (err) {
console.error('Error saving recent sheets:', err);
}
}
function handleSelectSheet(sheet) { function handleSelectSheet(sheet) {
const sheetData = { const sheetData = {
id: sheet.id, spreadsheetId: sheet.spreadsheetId || sheet.id,
name: sheet.name, name: sheet.name,
url: sheet.webViewLink || sheet.url url: sheet.webViewLink || sheet.url
}; };
selectedSheet.set(sheetData); selectedSheet.set(sheetData);
saveToRecentSheets(sheet);
} }
let canProceed = $derived($selectedSheet !== null);
function handleContinue() { function handleContinue() {
if (!canProceed) return;
currentStep.set(3); // Move to the column mapping step currentStep.set(3); // Move to the column mapping step
} }
</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>
@@ -124,16 +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();
}}
/> />
<button <button
on:click={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}
@@ -142,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}
@@ -150,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'}
@@ -160,24 +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 class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" (sheet.spreadsheetId || sheet.id)
on:click={() => handleSelectSheet(sheet)} ? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
tabindex="0"
role="button"
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?.id === 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>
@@ -186,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>
@@ -198,31 +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 class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" (sheet.spreadsheetId || sheet.id)
on:click={() => handleSelectSheet(sheet)} ? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
tabindex="0"
role="button"
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?.id === 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>
@@ -231,35 +236,46 @@
{/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}
<!-- Continue button --> <!-- Navigation -->
{#if $selectedSheet} <div class="flex justify-between">
<div class="mt-6 flex justify-end">
<button <button
on:click={handleContinue} onclick={() => currentStep.set(1)}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700" class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
> >
Continue → ← Back to Auth
</button>
<button
onclick={handleContinue}
disabled={!canProceed}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{canProceed ? 'Continue →' : 'Select a sheet to continue'}
</button> </button>
</div> </div>
{/if}
</div>
</div> </div>

View File

@@ -126,3 +126,80 @@ export async function getSheetData(spreadsheetId: string, range: string) {
}); });
return response.result.values || []; return response.result.values || [];
} }
// Extract Google Drive file ID from various URL formats
export function extractDriveFileId(url: string): string | null {
if (!url) return null;
// Handle different Google Drive URL formats
const patterns = [
/\/file\/d\/([a-zA-Z0-9-_]+)/, // https://drive.google.com/file/d/FILE_ID/view
/id=([a-zA-Z0-9-_]+)/, // https://drive.google.com/open?id=FILE_ID
/\/d\/([a-zA-Z0-9-_]+)/, // https://drive.google.com/uc?id=FILE_ID&export=download
/^([a-zA-Z0-9-_]{25,})$/ // Direct file ID
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return match[1];
}
}
return null;
}
// Check if URL is a Google Drive URL
export function isGoogleDriveUrl(url: string): boolean {
return url.includes('drive.google.com') || url.includes('googleapis.com');
}
// Download image from Google Drive using the API
export async function downloadDriveImage(url: string): Promise<Blob> {
const fileId = extractDriveFileId(url);
if (!fileId) {
throw new Error('Could not extract file ID from Google Drive URL');
}
if (!gapi.client.drive) {
throw new Error('Google Drive API not loaded');
}
try {
// Get file metadata first to check if it exists and is accessible
const metadata = await gapi.client.drive.files.get({
fileId: fileId,
fields: 'id,name,mimeType,size'
});
if (!metadata.result.mimeType?.startsWith('image/')) {
throw new Error('File is not an image');
}
// Download the file content
const response = await gapi.client.drive.files.get({
fileId: fileId,
alt: 'media'
});
// The response body is already binary data, convert to blob
const binaryString = response.body;
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: metadata.result.mimeType });
} catch (error) {
console.error('Error downloading from Google Drive:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to download image from Google Drive: ${errorMessage}`);
}
}
// Create an object URL from image data for display
export function createImageObjectUrl(blob: Blob): string {
return URL.createObjectURL(blob);
}

157
src/lib/pdfLayout.ts Normal file
View File

@@ -0,0 +1,157 @@
// PDF Layout Configuration Module
// Centralized configuration for PDF generation layouts
export interface PDFDimensions {
pageWidth: number;
pageHeight: number;
margin: number;
}
export interface GridLayout {
cols: number;
rows: number;
cellWidth: number;
cellHeight: number;
}
export interface TextPosition {
x: number;
y: number;
size: number;
}
export interface PhotoPosition {
x: number;
y: number;
width: number;
height: number;
}
export interface TextFieldLayout {
name: TextPosition;
nationality: TextPosition;
birthday: TextPosition;
}
export interface PhotoFieldLayout {
photo: PhotoPosition;
name: TextPosition;
}
// A4 dimensions in points
export const PDF_DIMENSIONS: PDFDimensions = {
pageWidth: 595.28,
pageHeight: 841.89,
margin: 40
};
// Text PDF Layout (3x7 grid)
export const TEXT_PDF_GRID = {
cols: 3,
rows: 7
};
// Photo PDF Layout (3x5 grid)
export const PHOTO_PDF_GRID = {
cols: 3,
rows: 5
};
// Calculate grid layout
export function calculateGridLayout(
dimensions: PDFDimensions,
grid: { cols: number; rows: number }
): GridLayout {
const cellWidth = (dimensions.pageWidth - 2 * dimensions.margin) / grid.cols;
const cellHeight = (dimensions.pageHeight - 2 * dimensions.margin) / grid.rows;
return {
cols: grid.cols,
rows: grid.rows,
cellWidth,
cellHeight
};
}
// Text PDF Field Positions (relative to cell)
export const TEXT_FIELD_LAYOUT: TextFieldLayout = {
name: {
x: 5, // 5pt from left edge of cell
y: -15, // 15pt from top of cell (negative because PDF coords are bottom-up)
size: 10
},
nationality: {
x: 5, // 5pt from left edge of cell
y: -29, // 29pt from top of cell (15 + 14 line height)
size: 10
},
birthday: {
x: 5, // 5pt from left edge of cell
y: -43, // 43pt from top of cell (15 + 14 + 14 line height)
size: 10
}
};
// Photo PDF Field Positions (relative to cell)
export const PHOTO_FIELD_LAYOUT: PhotoFieldLayout = {
photo: {
x: 10, // 10pt from left edge of cell
y: 40, // 40pt from bottom of cell
width: -20, // cell width minus 20pt (10pt margin on each side)
height: -60 // cell height minus 60pt (40pt bottom margin + 20pt top margin)
},
name: {
x: 10, // 10pt from left edge of cell
y: 20, // 20pt from bottom of cell
size: 10
}
};
// Helper function to get absolute position within a cell
export function getAbsolutePosition(
cellX: number,
cellY: number,
cellHeight: number,
relativePos: TextPosition
): { x: number; y: number; size: number } {
return {
x: cellX + relativePos.x,
y: cellY + cellHeight + relativePos.y, // Convert relative Y to absolute
size: relativePos.size
};
}
// Helper function to get absolute photo dimensions
export function getAbsolutePhotoDimensions(
cellX: number,
cellY: number,
cellWidth: number,
cellHeight: number,
relativePhoto: PhotoPosition
): { x: number; y: number; width: number; height: number } {
return {
x: cellX + relativePhoto.x,
y: cellY + relativePhoto.y,
width: relativePhoto.width < 0 ? cellWidth + relativePhoto.width : relativePhoto.width,
height: relativePhoto.height < 0 ? cellHeight + relativePhoto.height : relativePhoto.height
};
}
// Border configuration
export const BORDER_CONFIG = {
color: { r: 0.8, g: 0.8, b: 0.8 },
width: 1
};
// Text configuration
export const TEXT_CONFIG = {
color: { r: 0, g: 0, b: 0 },
lineHeight: 14
};
// Placeholder text configuration
export const PLACEHOLDER_CONFIG = {
text: 'Photo placeholder',
color: { r: 0.5, g: 0.5, b: 0.5 },
size: 8
};

View File

@@ -9,6 +9,9 @@ export const session = writable<{
// Raw sheet data after import // Raw sheet data after import
export const rawSheetData = writable<string[][]>([]); export const rawSheetData = writable<string[][]>([]);
// Filtered sheet data after row selection
export const filteredSheetData = writable<any[]>([]);
// Column mapping configuration // Column mapping configuration
export const columnMapping = writable<{ export const columnMapping = writable<{
name?: number; name?: number;
@@ -16,6 +19,7 @@ export const columnMapping = writable<{
nationality?: number; nationality?: number;
birthday?: number; birthday?: number;
pictureUrl?: number; pictureUrl?: number;
alreadyPrinted?: number;
}>({}); }>({});
// Processed row data after mapping and validation // Processed row data after mapping and validation
@@ -93,7 +97,7 @@ export const progress = writable<ProgressState>({
// Google Sheets list for search // Google Sheets list for search
export interface SheetInfo { export interface SheetInfo {
id: string; spreadsheetId: string;
name: string; name: string;
url: string; url: string;
} }

Binary file not shown.