Compare commits
10 Commits
c6ea10e1d6
...
ceececfd99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceececfd99 | ||
|
|
2e228126be | ||
|
|
9bbd02dd67 | ||
|
|
c77c96c1c7 | ||
|
|
2072e57585 | ||
|
|
c695664784 | ||
|
|
4f119dc121 | ||
|
|
ffa427d42c | ||
|
|
735e13731c | ||
|
|
3ea48272b2 |
13
.env.example
13
.env.example
@@ -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
|
||||||
|
|||||||
10
.github/copilot-instructions.md
vendored
10
.github/copilot-instructions.md
vendored
@@ -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
139
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
185
src/lib/components/PhotoCard.svelte
Normal file
185
src/lib/components/PhotoCard.svelte
Normal 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}
|
||||||
374
src/lib/components/PhotoCrop.svelte
Normal file
374
src/lib/components/PhotoCrop.svelte
Normal 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>
|
||||||
@@ -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">
|
||||||
@@ -49,17 +51,6 @@
|
|||||||
<!-- Step content -->
|
<!-- Step content -->
|
||||||
<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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,265 +1,281 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
|
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
|
||||||
import { searchSheets } from '$lib/google';
|
import { searchSheets } from '$lib/google';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadRecentSheets();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleSearch() {
|
const RECENT_SHEETS_KEY = 'esn-recent-sheets';
|
||||||
if (!searchQuery.trim()) return;
|
|
||||||
|
|
||||||
isLoading = true;
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
searchResults = await searchSheets(searchQuery);
|
|
||||||
availableSheets.set(
|
|
||||||
searchResults.map(sheet => ({
|
|
||||||
id: sheet.id,
|
|
||||||
name: sheet.name,
|
|
||||||
url: sheet.webViewLink
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
hasSearched = true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error searching sheets:', err);
|
|
||||||
error = 'Failed to search sheets. Please check your connection and try again.';
|
|
||||||
searchResults = [];
|
|
||||||
availableSheets.set([]);
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRecentSheets() {
|
onMount(() => {
|
||||||
try {
|
loadRecentSheets();
|
||||||
const saved = localStorage.getItem(RECENT_SHEETS_KEY);
|
});
|
||||||
if (saved) {
|
|
||||||
recentSheets = JSON.parse(saved);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading recent sheets:', err);
|
|
||||||
// If there's an error, clear the stored value
|
|
||||||
localStorage.removeItem(RECENT_SHEETS_KEY);
|
|
||||||
recentSheets = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
async function handleSearch() {
|
||||||
const sheetData = {
|
if (!searchQuery.trim()) return;
|
||||||
id: sheet.id,
|
|
||||||
name: sheet.name,
|
|
||||||
url: sheet.webViewLink || sheet.url
|
|
||||||
};
|
|
||||||
|
|
||||||
selectedSheet.set(sheetData);
|
|
||||||
saveToRecentSheets(sheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleContinue() {
|
isLoading = true;
|
||||||
currentStep.set(3); // Move to the column mapping step
|
error = '';
|
||||||
}
|
|
||||||
|
try {
|
||||||
|
searchResults = await searchSheets(searchQuery);
|
||||||
|
availableSheets.set(
|
||||||
|
searchResults.map((sheet) => ({
|
||||||
|
spreadsheetId: sheet.spreadsheetId || sheet.id,
|
||||||
|
name: sheet.name,
|
||||||
|
url: sheet.webViewLink
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
hasSearched = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching sheets:', err);
|
||||||
|
error = 'Failed to search sheets. Please check your connection and try again.';
|
||||||
|
searchResults = [];
|
||||||
|
availableSheets.set([]);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRecentSheets() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(RECENT_SHEETS_KEY);
|
||||||
|
if (saved) {
|
||||||
|
recentSheets = JSON.parse(saved);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading recent sheets:', err);
|
||||||
|
// If there's an error, clear the stored value
|
||||||
|
localStorage.removeItem(RECENT_SHEETS_KEY);
|
||||||
|
recentSheets = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectSheet(sheet) {
|
||||||
|
const sheetData = {
|
||||||
|
spreadsheetId: sheet.spreadsheetId || sheet.id,
|
||||||
|
name: sheet.name,
|
||||||
|
url: sheet.webViewLink || sheet.url
|
||||||
|
};
|
||||||
|
selectedSheet.set(sheetData);
|
||||||
|
}
|
||||||
|
|
||||||
|
let canProceed = $derived($selectedSheet !== null);
|
||||||
|
|
||||||
|
function handleContinue() {
|
||||||
|
if (!canProceed) return;
|
||||||
|
currentStep.set(3); // Move to the column mapping step
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="mb-6">
|
||||||
<div class="mb-6">
|
<h2 class="mb-2 text-xl font-semibold text-gray-900">Select Google Sheet</h2>
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
Select Google Sheet
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-700 mb-4">
|
|
||||||
Search for and select the Google Sheet containing your member data.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search input -->
|
<p class="mb-4 text-sm text-gray-700">
|
||||||
<div class="mb-6">
|
Search for and select the Google Sheet containing your member data.
|
||||||
<label for="sheet-search" class="block text-sm font-medium text-gray-700 mb-2">
|
</p>
|
||||||
Search sheets
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex">
|
|
||||||
<input
|
|
||||||
id="sheet-search"
|
|
||||||
type="text"
|
|
||||||
bind:value={searchQuery}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
on:click={handleSearch}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
{:else}
|
|
||||||
Search
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
<!-- Search input -->
|
||||||
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6">
|
<div class="mb-6">
|
||||||
<p class="text-sm text-red-800">{error}</p>
|
<label for="sheet-search" class="mb-2 block text-sm font-medium text-gray-700">
|
||||||
</div>
|
Search sheets
|
||||||
{/if}
|
</label>
|
||||||
|
|
||||||
<!-- Results -->
|
<div class="flex">
|
||||||
{#if hasSearched}
|
<input
|
||||||
<div class="mb-6">
|
id="sheet-search"
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
type="text"
|
||||||
{searchResults.length
|
bind:value={searchQuery}
|
||||||
? `Found ${searchResults.length} matching sheets`
|
placeholder="Type sheet name..."
|
||||||
: 'No matching sheets found'}
|
class="flex-grow rounded-l-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-600"
|
||||||
</h3>
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSearch();
|
||||||
{#if searchResults.length}
|
}}
|
||||||
<div class="space-y-3">
|
/>
|
||||||
{#each searchResults as sheet}
|
|
||||||
<div
|
|
||||||
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50
|
|
||||||
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
|
|
||||||
on:click={() => handleSelectSheet(sheet)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-gray-900">{sheet.name}</p>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">ID: {sheet.id}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
{#if sheet.iconLink}
|
|
||||||
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $selectedSheet?.id === sheet.id}
|
|
||||||
<svg class="w-5 h-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>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="text-center py-8 bg-gray-50 rounded-lg border border-gray-200">
|
|
||||||
<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>
|
|
||||||
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- If we have recent sheets and haven't searched yet, show them -->
|
|
||||||
{#if recentSheets.length > 0 && !hasSearched}
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Recent sheets
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each recentSheets as sheet}
|
|
||||||
<div
|
|
||||||
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50
|
|
||||||
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
|
|
||||||
on:click={() => handleSelectSheet(sheet)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-gray-900">{sheet.name}</p>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Recently used</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
{#if sheet.iconLink}
|
|
||||||
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $selectedSheet?.id === sheet.id}
|
|
||||||
<svg class="w-5 h-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>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 mt-4 pt-4">
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Or search for a different sheet above
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200 mb-6">
|
|
||||||
<svg 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>
|
|
||||||
<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">
|
|
||||||
Enter a name or keyword to find your Google Sheets
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Continue button -->
|
<button
|
||||||
{#if $selectedSheet}
|
onclick={handleSearch}
|
||||||
<div class="mt-6 flex justify-end">
|
disabled={isLoading || !searchQuery.trim()}
|
||||||
<button
|
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"
|
||||||
on:click={handleContinue}
|
>
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
|
{#if isLoading}
|
||||||
>
|
<div
|
||||||
Continue →
|
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
</button>
|
></div>
|
||||||
</div>
|
{:else}
|
||||||
{/if}
|
Search
|
||||||
</div>
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
|
||||||
|
<p class="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
{#if hasSearched}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="mb-3 text-sm font-medium text-gray-700">
|
||||||
|
{searchResults.length
|
||||||
|
? `Found ${searchResults.length} matching sheets`
|
||||||
|
: 'No matching sheets found'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if searchResults.length}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each searchResults as sheet}
|
||||||
|
<div
|
||||||
|
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
|
||||||
|
(sheet.spreadsheetId || sheet.id)
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200'}"
|
||||||
|
onclick={() => handleSelectSheet(sheet)}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">ID: {sheet.id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
{#if sheet.iconLink}
|
||||||
|
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- If we have recent sheets and haven't searched yet, show them -->
|
||||||
|
{#if recentSheets.length > 0 && !hasSearched}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="mb-3 text-sm font-medium text-gray-700">Recent sheets</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each recentSheets as sheet}
|
||||||
|
<div
|
||||||
|
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
|
||||||
|
(sheet.spreadsheetId || sheet.id)
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200'}"
|
||||||
|
onclick={() => handleSelectSheet(sheet)}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Recently used</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
{#if sheet.iconLink}
|
||||||
|
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 border-t border-gray-200 pt-4">
|
||||||
|
<p class="text-xs text-gray-500">Or search for a different sheet above</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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">Enter a name or keyword to find your Google Sheets</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button
|
||||||
|
onclick={() => currentStep.set(1)}
|
||||||
|
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
157
src/lib/pdfLayout.ts
Normal 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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
static/fonts/Roboto-Regular.ttf
Normal file
BIN
static/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
Reference in New Issue
Block a user