Compare commits
32 Commits
1e96668e48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90265110f | ||
|
|
97460c018c | ||
|
|
74910e3346 | ||
|
|
20b21de69e | ||
|
|
68e4d0b77b | ||
|
|
e43101648b | ||
|
|
c6c3bbc024 | ||
|
|
d845021f7e | ||
|
|
dcba02260a | ||
|
|
9de5646519 | ||
|
|
2b3371e67f | ||
|
|
a9dc5888e6 | ||
|
|
1a2329b6c1 | ||
|
|
82395afa6e | ||
|
|
be7bdc551a | ||
|
|
44de5d9ad6 | ||
|
|
f5c2063586 | ||
|
|
667c18a746 | ||
|
|
7276e9ff89 | ||
|
|
99ab5cfb4f | ||
|
|
6f7843405c | ||
|
|
c95f96594f | ||
|
|
6ed1f985e0 | ||
|
|
c6cc9c6658 | ||
|
|
7fb72c7d75 | ||
|
|
ebb14e9e1a | ||
|
|
3af8c116a4 | ||
|
|
e9987009c7 | ||
|
|
d8b4eea3ef | ||
|
|
2f730fdbbb | ||
|
|
b5814ed552 | ||
|
|
052e5975fd |
@@ -2,8 +2,6 @@
|
|||||||
PUBLIC_GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE"
|
PUBLIC_GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE"
|
||||||
|
|
||||||
# Face Detection Crop Configuration
|
# Face Detection Crop Configuration
|
||||||
# Crop aspect ratio (width:height) - e.g., 1.0 for square, 1.5 for 3:2 ratio
|
|
||||||
PUBLIC_CROP_RATIO=1.0
|
|
||||||
|
|
||||||
# Face offset from center (as percentage of crop dimensions)
|
# Face offset from center (as percentage of crop dimensions)
|
||||||
# Positive values move the face toward bottom-right, negative toward top-left
|
# Positive values move the face toward bottom-right, negative toward top-left
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: "${{ vars.DOCKER_IMAGE }}:latest,${{ vars.DOCKER_IMAGE }}:${{ steps.date.outputs.date }}"
|
tags: "${{ vars.DOCKER_IMAGE }}:latest,${{ vars.DOCKER_IMAGE }}:${{ steps.date.outputs.date }}"
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
PUBLIC_GIT_REF=${{ env.GITHUB_SHA }}
|
||||||
|
PUBLIC_BUILD_DATE=${{ steps.date.outputs.date }}
|
||||||
cache-to: "mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${{ vars.DOCKER_IMAGE }}:cache"
|
cache-to: "mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${{ vars.DOCKER_IMAGE }}:cache"
|
||||||
cache-from: "mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${{ vars.DOCKER_IMAGE }}:cache"
|
cache-from: "mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${{ vars.DOCKER_IMAGE }}:cache"
|
||||||
labels: |
|
labels: |
|
||||||
|
|||||||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -7,7 +7,7 @@
|
|||||||
- Pass fucntions as props instead od dispatching events
|
- Pass fucntions as props instead od dispatching events
|
||||||
- Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax
|
- Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax
|
||||||
- when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state.
|
- when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state.
|
||||||
- USe $props instead of export let!
|
- USe $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.
|
||||||
@@ -15,3 +15,5 @@
|
|||||||
- Avoid unncessary iterations. If problems is mostly solved, stop.
|
- Avoid unncessary iterations. If problems is mostly solved, stop.
|
||||||
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.
|
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.
|
||||||
- Do not do what you're not being asked. Stick to scope of my request.
|
- Do not do what you're not being asked. Stick to scope of my request.
|
||||||
|
- Do not edit stores.ts ! Unless is explicitly allow you to.
|
||||||
|
- Focus only on files that are relevant. Do not venture to fix other things.
|
||||||
4
.github/styling.md
vendored
4
.github/styling.md
vendored
@@ -89,7 +89,7 @@ This document outlines the design system and styling conventions used in the app
|
|||||||
|
|
||||||
### Container Pattern
|
### Container Pattern
|
||||||
```html
|
```html
|
||||||
<div class="container mx-auto max-w-2xl bg-white p-4">
|
<div class="container mx-auto max-w-5xl bg-white p-4">
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
@@ -225,7 +225,7 @@ This document outlines the design system and styling conventions used in the app
|
|||||||
### Top Navigation
|
### Top Navigation
|
||||||
```html
|
```html
|
||||||
<nav class="border-b border-gray-300 bg-gray-50 p-4 text-gray-900">
|
<nav class="border-b border-gray-300 bg-gray-50 p-4 text-gray-900">
|
||||||
<div class="container mx-auto max-w-2xl">
|
<div class="container mx-auto max-w-5xl">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<a href="/" class="text-lg font-bold">App Name</a>
|
<a href="/" class="text-lg font-bold">App Name</a>
|
||||||
<ul class="flex space-x-4">
|
<ul class="flex space-x-4">
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ RUN npm prune --production
|
|||||||
|
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
ARG PUBLIC_GIT_REF
|
||||||
|
ARG PUBLIC_BUILD_DATE
|
||||||
|
ENV PUBLIC_GIT_REF=$PUBLIC_GIT_REF
|
||||||
|
ENV PUBLIC_BUILD_DATE=$PUBLIC_BUILD_DATE
|
||||||
|
|
||||||
USER node:node
|
USER node:node
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder --chown=node:node /app/build build/
|
COPY --from=builder --chown=node:node /app/build build/
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -15,7 +15,6 @@
|
|||||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||||
"@types/gapi.client.sheets-v4": "^0.0.4",
|
"@types/gapi.client.sheets-v4": "^0.0.4",
|
||||||
"@types/google.accounts": "^0.0.17",
|
"@types/google.accounts": "^0.0.17",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"fontkit": "^2.0.4",
|
"fontkit": "^2.0.4",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-convert": "^2.1.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
@@ -29,6 +28,7 @@
|
|||||||
"@sveltejs/kit": "^2.22.0",
|
"@sveltejs/kit": "^2.22.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
@@ -631,6 +631,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/uuid": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@webgpu/types": {
|
"node_modules/@webgpu/types": {
|
||||||
@@ -2019,6 +2022,8 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "card-forge",
|
"name": "card-forge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@sveltejs/kit": "^2.22.0",
|
"@sveltejs/kit": "^2.22.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
@@ -36,7 +37,6 @@
|
|||||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||||
"@types/gapi.client.sheets-v4": "^0.0.4",
|
"@types/gapi.client.sheets-v4": "^0.0.4",
|
||||||
"@types/google.accounts": "^0.0.17",
|
"@types/google.accounts": "^0.0.17",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"fontkit": "^2.0.4",
|
"fontkit": "^2.0.4",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-convert": "^2.1.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
|||||||
31
src/lib/cards/esncard_2026.ts
Normal file
31
src/lib/cards/esncard_2026.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Card } from './types';
|
||||||
|
|
||||||
|
// User-configurable settings for PDF generation
|
||||||
|
export const ESNCard2026: Card = {
|
||||||
|
name: 'ESNcard 2026',
|
||||||
|
image: '/cards/2026.webp',
|
||||||
|
textCard: {
|
||||||
|
width: 50, // mm
|
||||||
|
height: 35 // mm
|
||||||
|
},
|
||||||
|
photoCard: {
|
||||||
|
width: 32, // mm
|
||||||
|
height: 45 // mm
|
||||||
|
},
|
||||||
|
photo: {
|
||||||
|
width: 28, // mm
|
||||||
|
height: 38 // mm
|
||||||
|
},
|
||||||
|
textFields: {
|
||||||
|
name: { x: 3, y: 5, size: 9 },
|
||||||
|
nationality: { x: 3, y: 14, size: 9 },
|
||||||
|
birthday: { x: 33, y: 14, size: 9 },
|
||||||
|
studiesAt: { x: 3, y: 23, size: 9 },
|
||||||
|
esnSection: { x: 3, y: 32, size: 9 },
|
||||||
|
validityStart: { x: 33, y: 32, size: 9 }
|
||||||
|
},
|
||||||
|
photoFields: {
|
||||||
|
photo: { x: 2, y: 2, width: 28, height: 38 },
|
||||||
|
name: { x: 2, y: 42, size: 7 }
|
||||||
|
}
|
||||||
|
};
|
||||||
31
src/lib/cards/esncard_anniversary.ts
Normal file
31
src/lib/cards/esncard_anniversary.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Card } from './types';
|
||||||
|
|
||||||
|
// User-configurable settings for PDF generation
|
||||||
|
export const ESNCardAnniversary: Card = {
|
||||||
|
name: 'ESNcard Anniversary',
|
||||||
|
image: '/cards/esncard_anniversary.png',
|
||||||
|
textCard: {
|
||||||
|
width: 45, // mm
|
||||||
|
height: 30 // mm
|
||||||
|
},
|
||||||
|
photoCard: {
|
||||||
|
width: 29, // mm
|
||||||
|
height: 41 // mm
|
||||||
|
},
|
||||||
|
photo: {
|
||||||
|
width: 27, // mm
|
||||||
|
height: 37 // mm
|
||||||
|
},
|
||||||
|
textFields: {
|
||||||
|
name: { x: 2, y: 4, size: 8 },
|
||||||
|
nationality: { x: 2, y: 12, size: 8 },
|
||||||
|
birthday: { x: 30, y: 12, size: 8 },
|
||||||
|
studiesAt: { x: 2, y: 20, size: 8 },
|
||||||
|
esnSection: { x: 2, y: 28, size: 8 },
|
||||||
|
validityStart: { x: 30, y: 28, size: 8 }
|
||||||
|
},
|
||||||
|
photoFields: {
|
||||||
|
photo: { x: 2, y: 2, width: 26, height: 36 },
|
||||||
|
name: { x: 2, y: 40, size: 6 }
|
||||||
|
}
|
||||||
|
};
|
||||||
5
src/lib/cards/index.ts
Normal file
5
src/lib/cards/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ESNCardAnniversary } from './esncard_anniversary';
|
||||||
|
// import { ESNCard2026 } from './esncard_2026';
|
||||||
|
import type { Card } from './types';
|
||||||
|
|
||||||
|
export const cardTypes: Card[] = [ESNCardAnniversary];
|
||||||
46
src/lib/cards/types/index.ts
Normal file
46
src/lib/cards/types/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export interface CardDimensions {
|
||||||
|
width: number; // mm
|
||||||
|
height: number; // mm
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoDimensions {
|
||||||
|
width: number; // mm
|
||||||
|
height: number; // mm
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextPosition {
|
||||||
|
x: number; // mm, relative to cell top-left
|
||||||
|
y: number; // mm, relative to cell top-left
|
||||||
|
size: number; // font size in points
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoPosition {
|
||||||
|
x: number; // mm, relative to cell top-left
|
||||||
|
y: number; // mm, relative to cell top-left
|
||||||
|
width: number; // mm
|
||||||
|
height: number; // mm
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFieldLayout {
|
||||||
|
name: TextPosition;
|
||||||
|
nationality: TextPosition;
|
||||||
|
birthday: TextPosition;
|
||||||
|
studiesAt: TextPosition;
|
||||||
|
esnSection: TextPosition;
|
||||||
|
validityStart: TextPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoFieldLayout {
|
||||||
|
photo: PhotoPosition;
|
||||||
|
name: TextPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Card {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
textCard: CardDimensions;
|
||||||
|
photoCard: CardDimensions;
|
||||||
|
photo: PhotoDimensions;
|
||||||
|
textFields: TextFieldLayout;
|
||||||
|
photoFields: PhotoFieldLayout;
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { currentStep } from '$lib/stores.js';
|
import { currentStep } from '$lib/stores.js';
|
||||||
import FeatureList from './splash/FeatureList.svelte';
|
import FeatureList from './splash/FeatureList.svelte';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
const buildDate = env.PUBLIC_BUILD_DATE;
|
||||||
|
const gitRef = env.PUBLIC_GIT_REF ? env.PUBLIC_GIT_REF.substring(0, 7) : '';
|
||||||
|
|
||||||
|
|
||||||
function startWizard() {
|
function startWizard() {
|
||||||
currentStep.set(1); // Move to auth step
|
currentStep.set(1); // Move to auth step
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center bg-gray-100 p-4">
|
<div class="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
|
||||||
<div
|
<div
|
||||||
class="container mx-auto max-w-4xl rounded-lg border border-gray-200 bg-white/90 p-10 text-center shadow-xl"
|
class="container mx-auto max-w-5xl rounded-lg border border-gray-200 bg-white/90 p-10 text-center shadow-xl"
|
||||||
>
|
>
|
||||||
<div class="mb-10 flex flex-col items-center">
|
<div class="mb-4 flex flex-col items-center">
|
||||||
<!-- Animated ESN Logo -->
|
<!-- Animated ESN Logo -->
|
||||||
<div
|
<div
|
||||||
class="mx-auto mb-6 flex h-40 w-40 items-center justify-center rounded-full bg-gradient-to-tr from-blue-400 via-purple-400 to-pink-400"
|
class="mx-auto mb-6 flex h-40 w-40 items-center justify-center rounded-full bg-gradient-to-tr from-blue-400 via-purple-400 to-pink-400"
|
||||||
@@ -23,39 +28,45 @@
|
|||||||
>
|
>
|
||||||
Card Forge
|
Card Forge
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mb-4 text-xl leading-relaxed font-medium text-gray-700">
|
<p class="mb-4 text-xl font-medium leading-relaxed text-gray-700">
|
||||||
Transform your Google Sheets into professional ESNcards with photos.
|
Transform your Google Sheets into professional ESNcards with photos.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4 text-lg leading-relaxed text-gray-600">
|
<p class="mb-4 text-lg leading-relaxed text-gray-600">
|
||||||
<span class="font-semibold text-black-800"
|
<span class="font-semibold text-black-800">Privacy-first</span>: all processing happens in
|
||||||
>Privacy-first</span
|
your browser.
|
||||||
>: all processing happens in your browser.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="mb-6">
|
<FeatureList class="mb-6" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||||
<a
|
<a
|
||||||
href="https://youtube.com"
|
href="https://youtube.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="inline-flex items-center gap-2 rounded-lg bg-pink-100 px-4 py-2 font-semibold text-pink-700 transition-colors hover:bg-pink-200"
|
class="flex w-64 items-center justify-center gap-2 rounded-lg bg-pink-400 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-pink-400"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5"
|
class="h-6 w-6"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
d="M23.498 6.186a2.998 2.998 0 0 0-2.115-2.117C19.073 3.5 12 3.5 12 3.5s-7.073 0-9.383.569A2.998 2.998 0 0 0 .502 6.186C0 8.497 0 12 0 12s0 3.503.502 5.814a2.998 2.998 0 0 0 2.115 2.117C4.927 20.5 12 20.5 12 20.5s7.073 0 9.383-.569a2.998 2.998 0 0 0 2.115-2.117C24 15.503 24 12 24 12s0-3.503-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
d="M23.498 6.186a2.998 2.998 0 0 0-2.115-2.117C19.073 3.5 12 3.5 12 3.5s-7.073 0-9.383.569A2.998 2.998 0 0 0 .502 6.186C0 8.497 0 12 0 12s0 3.503.502 5.814a2.998 2.998 0 0 0 2.115 2.117C4.927 20.5 12 20.5 12 20.5s7.073 0 9.383-.569a2.998 2.998 0 0 0 2.115-2.117C24 15.503 24 12 24 12s0-3.503-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
Watch how Card Forge works
|
Watch Tutorial
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
<FeatureList class="mb-8" />
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onclick={startWizard}
|
onclick={startWizard}
|
||||||
class="rounded-lg bg-blue-600 bg-gradient-to-r px-10 py-4 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105"
|
class="w-64 rounded-lg bg-blue-600 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Start Creating Cards
|
Start Creating Cards
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="mt-4 text-center">
|
||||||
|
{#if buildDate && gitRef}
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Build: {gitRef} {buildDate}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { currentStep } from '$lib/stores.js';
|
import { currentStep, steps as stepNames, currentStepName } from '$lib/stores';
|
||||||
|
import Splash from './Splash.svelte';
|
||||||
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';
|
||||||
import StepRowFilter from './wizard/StepRowFilter.svelte';
|
import StepRowFilter from './wizard/StepRowFilter.svelte';
|
||||||
|
import StepCardDetails from './wizard/StepCardDetails.svelte';
|
||||||
|
import StepCardSelect from './wizard/StepCardSelect.svelte';
|
||||||
import StepGallery from './wizard/StepGallery.svelte';
|
import StepGallery from './wizard/StepGallery.svelte';
|
||||||
import StepGenerate from './wizard/StepGenerate.svelte';
|
import StepGenerate from './wizard/StepGenerate.svelte';
|
||||||
|
|
||||||
const steps = [
|
const stepTitles = {
|
||||||
StepAuth,
|
splash: 'Welcome',
|
||||||
StepSheetSearch,
|
auth: 'Authenticate',
|
||||||
StepColumnMap,
|
search: 'Select Sheet',
|
||||||
StepRowFilter,
|
mapping: 'Map Columns',
|
||||||
StepGallery,
|
validation: 'Filter Rows',
|
||||||
StepGenerate
|
'card-details': 'Enter Card Details',
|
||||||
];
|
'card-select': 'Select Card Type',
|
||||||
|
gallery: 'Preview Gallery',
|
||||||
|
generate: 'Generate Cards'
|
||||||
|
};
|
||||||
|
|
||||||
const stepTitles = [
|
let currentTitle = $derived(stepTitles[$currentStepName]);
|
||||||
'Authenticate',
|
let currentStepIndex = $derived(stepNames.indexOf($currentStepName));
|
||||||
'Select Sheet',
|
|
||||||
'Map Columns',
|
|
||||||
'Filter Rows',
|
|
||||||
'Review Photos',
|
|
||||||
'Generate PDFs'
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-gray-100 min-h-screen p-4">
|
<div class="bg-gray-100 min-h-screen p-4">
|
||||||
<div class="container mx-auto max-w-4xl pb-10">
|
<div class="container mx-auto max-w-5xl pb-10">
|
||||||
|
{#if $currentStepName !== 'splash'}
|
||||||
<!-- Progress indicator -->
|
<!-- Progress indicator -->
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h1 class="text-2xl font-bold text-gray-800">
|
<h1 class="text-2xl font-bold text-gray-800">
|
||||||
{stepTitles[$currentStep - 1]}
|
{currentTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<span class="text-sm text-gray-500">
|
<span class="text-sm text-gray-500">
|
||||||
Step {$currentStep} of {steps.length}
|
Step {currentStepIndex} of {stepNames.length - 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,14 +44,33 @@
|
|||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
style="width: {($currentStep / steps.length) * 100}%"
|
style="width: {(currentStepIndex / (stepNames.length - 1)) * 100}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- 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]} />
|
{#if $currentStepName === 'splash'}
|
||||||
|
<Splash />
|
||||||
|
{:else if $currentStepName === 'auth'}
|
||||||
|
<StepAuth />
|
||||||
|
{:else if $currentStepName === 'search'}
|
||||||
|
<StepSheetSearch />
|
||||||
|
{:else if $currentStepName === 'mapping'}
|
||||||
|
<StepColumnMap />
|
||||||
|
{:else if $currentStepName === 'validation'}
|
||||||
|
<StepRowFilter />
|
||||||
|
{:else if $currentStepName === 'card-details'}
|
||||||
|
<StepCardDetails />
|
||||||
|
{:else if $currentStepName === 'card-select'}
|
||||||
|
<StepCardSelect />
|
||||||
|
{:else if $currentStepName === 'gallery'}
|
||||||
|
<StepGallery />
|
||||||
|
{:else if $currentStepName === 'generate'}
|
||||||
|
<StepGenerate />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { currentStep } from '$lib/stores.js';
|
import { currentStep } from '$lib/stores.js';
|
||||||
import { isSignedIn, handleSignIn, handleSignOut, isGoogleApiReady } from '$lib/google';
|
import {
|
||||||
|
isSignedIn,
|
||||||
|
handleSignOut,
|
||||||
|
requestTokenFromUser,
|
||||||
|
isGoogleApiReady,
|
||||||
|
initGoogleClients
|
||||||
|
} from '$lib/google';
|
||||||
import Navigator from './subcomponents/Navigator.svelte';
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!$isGoogleApiReady) {
|
||||||
|
initGoogleClients(() => {
|
||||||
|
// This callback is called when the Google clients are ready.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSignIn() {
|
||||||
|
requestTokenFromUser();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
@@ -42,7 +61,15 @@
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-8"
|
class="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-8"
|
||||||
>
|
>
|
||||||
{#if $isSignedIn}
|
{#if !$isGoogleApiReady}
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
class="h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
|
||||||
|
></div>
|
||||||
|
<span class="text-sm text-gray-600">Loading Google services...</span>
|
||||||
|
</div>
|
||||||
|
{:else if $isSignedIn}
|
||||||
<!-- Authenticated state -->
|
<!-- Authenticated state -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
@@ -79,34 +106,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={handleSignIn}
|
onclick={handleSignIn}
|
||||||
disabled={!$isGoogleApiReady}
|
|
||||||
class="flex w-full items-center justify-center rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
class="flex w-full items-center justify-center rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||||
>
|
>
|
||||||
{#if $isGoogleApiReady}
|
|
||||||
Sign In with Google
|
Sign In with Google
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="mr-2 h-5 w-5 animate-spin text-white"
|
|
||||||
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"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Loading Google API...
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
102
src/lib/components/wizard/StepCardDetails.svelte
Normal file
102
src/lib/components/wizard/StepCardDetails.svelte
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { currentStep, cardDetails } from '$lib/stores';
|
||||||
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let esnSection = $state('');
|
||||||
|
let studiesAt = $state('');
|
||||||
|
let validityStart = $state('');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
validityStart = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedesnSection = localStorage.getItem('esnSection');
|
||||||
|
if (savedesnSection) {
|
||||||
|
esnSection = savedesnSection;
|
||||||
|
}
|
||||||
|
const savedStudiesAt = localStorage.getItem('studiesAt');
|
||||||
|
if (savedStudiesAt) {
|
||||||
|
studiesAt = savedStudiesAt;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to access localStorage on mount:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let canProceed = $derived(esnSection.trim() !== '' && studiesAt.trim() !== '' && validityStart.trim() !== '');
|
||||||
|
|
||||||
|
function handleContinue() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('esnSection', esnSection);
|
||||||
|
localStorage.setItem('studiesAt', studiesAt);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save to localStorage:', error);
|
||||||
|
}
|
||||||
|
// Include new field; spread in case store has more fields defined elsewhere
|
||||||
|
$cardDetails = { ...$cardDetails, esnSection, studiesAt, validityStart } as any;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="mb-2 text-xl font-semibold text-gray-900">Enter Card Details</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-700">
|
||||||
|
Please provide the following details to be printed on the cards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="esnSection" class="mb-2 block text-sm font-medium text-gray-700">
|
||||||
|
ESN Section
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="esnSection"
|
||||||
|
type="text"
|
||||||
|
bind:value={esnSection}
|
||||||
|
placeholder="e.g., ESN VUT Brno"
|
||||||
|
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="studiesAt" class="mb-2 block text-sm font-medium text-gray-700">
|
||||||
|
Studies At
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="studiesAt"
|
||||||
|
type="text"
|
||||||
|
bind:value={studiesAt}
|
||||||
|
placeholder="e.g., Brno University of Technology"
|
||||||
|
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="validityStart" class="mb-2 block text-sm font-medium text-gray-700">
|
||||||
|
Card Validity Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="validityStart"
|
||||||
|
type="date"
|
||||||
|
bind:value={validityStart}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
Default date is today, but future date can be selected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10">
|
||||||
|
<Navigator
|
||||||
|
{canProceed}
|
||||||
|
{currentStep}
|
||||||
|
onForward={handleContinue}
|
||||||
|
textBack="Back to Row Selection"
|
||||||
|
textForwardEnabled="Continue to Card Selection"
|
||||||
|
textForwardDisabled="Please fill out all fields"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
79
src/lib/components/wizard/StepCardSelect.svelte
Normal file
79
src/lib/components/wizard/StepCardSelect.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { currentStep, selectedCard } from '$lib/stores';
|
||||||
|
import { cardTypes } from '$lib/cards';
|
||||||
|
import type { Card } from '$lib/cards/types';
|
||||||
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
|
|
||||||
|
let selected: Card | null = $state(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const savedCardName = localStorage.getItem('selectedCardName');
|
||||||
|
if (savedCardName) {
|
||||||
|
const foundCard = cardTypes.find((c) => c.name === savedCardName);
|
||||||
|
if (foundCard) {
|
||||||
|
selected = foundCard;
|
||||||
|
selectedCard.set(foundCard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectCard(card: Card) {
|
||||||
|
selected = card;
|
||||||
|
selectedCard.set(card);
|
||||||
|
localStorage.setItem('selectedCardName', card.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNext() {
|
||||||
|
if (selected) {
|
||||||
|
currentStep.set($currentStep + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBack() {
|
||||||
|
currentStep.set($currentStep - 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">Select Card Type</h2>
|
||||||
|
<p class="text-sm text-gray-700 mb-4">
|
||||||
|
Choose the type of card you want to generate. This will determine the layout and dimensions of the final PDFs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Type Selector -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
{#each cardTypes as card (card.name)}
|
||||||
|
<button
|
||||||
|
class="relative rounded-lg border-2 p-2 transition-all duration-200"
|
||||||
|
class:border-blue-600={selected?.name === card.name}
|
||||||
|
class:border-gray-200={selected?.name !== card.name}
|
||||||
|
class:shadow-lg={selected?.name === card.name}
|
||||||
|
onclick={() => selectCard(card)}
|
||||||
|
>
|
||||||
|
<img src={card.image} alt={card.name} class="w-full h-auto rounded-md mb-2" />
|
||||||
|
<p class="text-sm font-medium text-center text-gray-800">{card.name}</p>
|
||||||
|
{#if selected?.name === card.name}
|
||||||
|
<div
|
||||||
|
class="absolute top-2 right-2 bg-blue-600 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Navigator onForward={onNext} onBack={onBack} nextDisabled={!selected} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,30 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores';
|
import {
|
||||||
import { getSheetNames, getSheetData } from '$lib/google';
|
selectedSheet,
|
||||||
|
currentStep,
|
||||||
|
columnMapping,
|
||||||
|
} from '$lib/stores';
|
||||||
|
import { userEmail } from '$lib/google';
|
||||||
|
import { hashString } from '$lib/utils';
|
||||||
|
import type { ColumnMappingType, SheetInfoType } from '$lib/stores';
|
||||||
|
import { getSheetNames, getSheetData, ensureToken } from '$lib/google';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Navigator from './subcomponents/Navigator.svelte';
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
|
|
||||||
// Type definitions for better TypeScript support
|
|
||||||
interface ColumnMappingType {
|
|
||||||
name: number;
|
|
||||||
surname: number;
|
|
||||||
nationality: number;
|
|
||||||
birthday: number;
|
|
||||||
pictureUrl: number;
|
|
||||||
alreadyPrinted: number;
|
|
||||||
[key: string]: number; // Index signature to allow string indexing
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SheetInfoType {
|
|
||||||
id?: string;
|
|
||||||
spreadsheetId?: string;
|
|
||||||
name: string;
|
|
||||||
sheetName?: string;
|
|
||||||
sheetMapping?: string;
|
|
||||||
columnMapping?: ColumnMappingType;
|
|
||||||
lastUsed?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let isLoadingSheets = $state(false);
|
let isLoadingSheets = $state(false);
|
||||||
let isLoadingData = $state(false);
|
let isLoadingData = $state(false);
|
||||||
let availableSheets = $state<string[]>([]);
|
let availableSheets = $state<string[]>([]);
|
||||||
@@ -36,32 +22,40 @@
|
|||||||
let hasSavedMapping = $state(false);
|
let hasSavedMapping = $state(false);
|
||||||
let showMappingEditor = $state(false);
|
let showMappingEditor = $state(false);
|
||||||
let savedSheetInfo = $state<SheetInfoType | null>(null);
|
let savedSheetInfo = $state<SheetInfoType | null>(null);
|
||||||
|
|
||||||
let mappedIndices = $state<ColumnMappingType>({
|
let mappedIndices = $state<ColumnMappingType>({
|
||||||
name: -1,
|
name: -1,
|
||||||
surname: -1,
|
|
||||||
nationality: -1,
|
nationality: -1,
|
||||||
birthday: -1,
|
birthday: -1,
|
||||||
pictureUrl: -1,
|
pictureUrl: -1,
|
||||||
alreadyPrinted: -1
|
alreadyPrinted: -1,
|
||||||
|
sheetName: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const requiredFields = [
|
const requiredFields = [
|
||||||
{ key: 'name', label: 'First Name', required: true },
|
{ key: 'name', label: 'Full Name', required: true },
|
||||||
{ key: 'surname', label: 'Last Name', required: true },
|
|
||||||
{ key: 'nationality', label: 'Nationality', required: true },
|
{ key: 'nationality', label: 'Nationality', required: true },
|
||||||
{ key: 'birthday', label: 'Birthday', required: true },
|
{ key: 'birthday', label: 'Birthday', required: true },
|
||||||
{ key: 'pictureUrl', label: 'Photo URL', required: true },
|
{ key: 'pictureUrl', label: 'Photo URL', required: true },
|
||||||
{ key: 'alreadyPrinted', label: 'Already Printed', required: false }
|
{ key: 'alreadyPrinted', label: 'Already Printed', required: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function getRecentSheetsKey() {
|
||||||
|
const email = $userEmail;
|
||||||
|
if (email) {
|
||||||
|
return `recentSheets_${await hashString(email)}`;
|
||||||
|
}
|
||||||
|
return 'recentSheets_anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
// Load available sheets when component mounts
|
// Load available sheets when component mounts
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
ensureToken();
|
||||||
if ($selectedSheet) {
|
if ($selectedSheet) {
|
||||||
console.log('Selected sheet on mount:', $selectedSheet);
|
console.log('Selected sheet on mount:', $selectedSheet);
|
||||||
|
|
||||||
// Check if we already have saved mapping data
|
// Check if we already have saved mapping data
|
||||||
const recentSheetsData = localStorage.getItem('recent-sheets');
|
const key = await getRecentSheetsKey();
|
||||||
|
const recentSheetsData = localStorage.getItem(key);
|
||||||
|
|
||||||
if (recentSheetsData) {
|
if (recentSheetsData) {
|
||||||
try {
|
try {
|
||||||
@@ -69,36 +63,30 @@
|
|||||||
if (recentSheets && recentSheets.length > 0) {
|
if (recentSheets && recentSheets.length > 0) {
|
||||||
// Find a sheet that matches the current spreadsheet
|
// Find a sheet that matches the current spreadsheet
|
||||||
const savedSheet = recentSheets.find(
|
const savedSheet = recentSheets.find(
|
||||||
(sheet: SheetInfoType) =>
|
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
|
||||||
sheet.id === $selectedSheet.spreadsheetId ||
|
|
||||||
sheet.spreadsheetId === $selectedSheet.spreadsheetId
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (savedSheet) {
|
if (savedSheet) {
|
||||||
console.log('Found saved sheet configuration:', savedSheet);
|
console.log('Found saved sheet configuration:', savedSheet);
|
||||||
// We have a saved sheet for this spreadsheet
|
// We have a saved sheet for this spreadsheet
|
||||||
selectedSheetName = savedSheet.sheetName || savedSheet.sheetMapping || '';
|
selectedSheetName = savedSheet.columnMapping.sheetName;
|
||||||
savedSheetInfo = savedSheet;
|
savedSheetInfo = savedSheet;
|
||||||
|
|
||||||
if (savedSheet.columnMapping) {
|
if (savedSheet.columnMapping) {
|
||||||
// Set the mapped indices from saved data
|
// Set the mapped indices from saved data
|
||||||
mappedIndices = {
|
mappedIndices = {
|
||||||
name: savedSheet.columnMapping.name ?? -1,
|
name: savedSheet.columnMapping.name,
|
||||||
surname: savedSheet.columnMapping.surname ?? -1,
|
nationality: savedSheet.columnMapping.nationality,
|
||||||
nationality: savedSheet.columnMapping.nationality ?? -1,
|
birthday: savedSheet.columnMapping.birthday,
|
||||||
birthday: savedSheet.columnMapping.birthday ?? -1,
|
pictureUrl: savedSheet.columnMapping.pictureUrl,
|
||||||
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
|
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted,
|
||||||
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1
|
sheetName: selectedSheetName
|
||||||
};
|
};
|
||||||
|
|
||||||
hasSavedMapping = true;
|
hasSavedMapping = true;
|
||||||
updateMappingStatus();
|
updateMappingStatus();
|
||||||
columnMapping.set(mappedIndices);
|
|
||||||
|
|
||||||
// Don't load sheet data immediately for better performance
|
return;
|
||||||
// We'll load it when needed (when editing or continuing)
|
|
||||||
|
|
||||||
return; // Skip loading available sheets since we're using saved data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,72 +102,20 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load sheet data quietly (for previously saved sheets)
|
|
||||||
async function loadSheetDataQuietly(sheetName: string) {
|
|
||||||
if (!$selectedSheet || !sheetName) {
|
|
||||||
console.error('Cannot load sheet data: missing selectedSheet or sheetName', {
|
|
||||||
selectedSheet: $selectedSheet,
|
|
||||||
sheetName: sheetName
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(
|
|
||||||
'Loading sheet data quietly for spreadsheet:',
|
|
||||||
$selectedSheet.spreadsheetId,
|
|
||||||
'sheet:',
|
|
||||||
sheetName
|
|
||||||
);
|
|
||||||
|
|
||||||
// Make sure we verify the sheet exists before trying to load it
|
|
||||||
if (availableSheets.length === 0) {
|
|
||||||
// We need to load available sheets first
|
|
||||||
await loadAvailableSheets();
|
|
||||||
|
|
||||||
// If after loading sheets, we still don't have the sheet, show the editor
|
|
||||||
if (!availableSheets.includes(sheetName)) {
|
|
||||||
console.warn(`Sheet "${sheetName}" not found in spreadsheet, showing editor`);
|
|
||||||
showMappingEditor = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch first 10 rows for headers only
|
|
||||||
const range = `${sheetName}!A1:Z10`;
|
|
||||||
const data = await getSheetData($selectedSheet.spreadsheetId, range);
|
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
console.log('Loaded sheet data with', data.length, 'rows');
|
|
||||||
sheetHeaders = data[0];
|
|
||||||
previewData = data.slice(1, Math.min(4, data.length)); // Get up to 3 rows for preview
|
|
||||||
// Don't set the rawSheetData here as that will be loaded in the next step
|
|
||||||
} else {
|
|
||||||
console.warn(`No data returned for sheet "${sheetName}", showing editor`);
|
|
||||||
showMappingEditor = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading sheet data quietly:', err, 'for sheet:', sheetName);
|
|
||||||
// If there's an error, show the full editor so the user can select a sheet
|
|
||||||
showMappingEditor = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAvailableSheets() {
|
async function loadAvailableSheets() {
|
||||||
if (!$selectedSheet) {
|
if (!$selectedSheet) {
|
||||||
console.error('Cannot load available sheets: no sheet selected');
|
console.error('Cannot load available sheets: no sheet selected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Loading available sheets for spreadsheet:', $selectedSheet.spreadsheetId);
|
console.log('Loading available sheets for spreadsheet:', $selectedSheet.id);
|
||||||
isLoadingSheets = true;
|
isLoadingSheets = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sheetNames = await getSheetNames($selectedSheet.spreadsheetId);
|
const sheetNames = await getSheetNames($selectedSheet.id);
|
||||||
console.log('Loaded sheet names:', sheetNames);
|
console.log('Loaded sheet names:', sheetNames);
|
||||||
availableSheets = sheetNames;
|
availableSheets = sheetNames;
|
||||||
// Don't auto-select any sheet - let user choose
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading sheet names:', err);
|
console.error('Error loading sheet names:', err);
|
||||||
error = 'Failed to load sheet names. Please try again.';
|
error = 'Failed to load sheet names. Please try again.';
|
||||||
@@ -193,16 +129,15 @@
|
|||||||
selectedSheetName = sheetName;
|
selectedSheetName = sheetName;
|
||||||
|
|
||||||
// Clear any previous data when selecting a new sheet
|
// Clear any previous data when selecting a new sheet
|
||||||
rawSheetData.set([]);
|
|
||||||
sheetHeaders = [];
|
sheetHeaders = [];
|
||||||
previewData = [];
|
previewData = [];
|
||||||
mappedIndices = {
|
mappedIndices = {
|
||||||
name: -1,
|
name: -1,
|
||||||
surname: -1,
|
|
||||||
nationality: -1,
|
nationality: -1,
|
||||||
birthday: -1,
|
birthday: -1,
|
||||||
pictureUrl: -1,
|
pictureUrl: -1,
|
||||||
alreadyPrinted: -1
|
alreadyPrinted: -1,
|
||||||
|
sheetName: sheetName
|
||||||
};
|
};
|
||||||
mappingComplete = false;
|
mappingComplete = false;
|
||||||
hasSavedMapping = false;
|
hasSavedMapping = false;
|
||||||
@@ -220,19 +155,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log('Loading sheet data for spreadsheet:', $selectedSheet.id, 'sheet:', sheetName);
|
||||||
'Loading sheet data for spreadsheet:',
|
|
||||||
$selectedSheet.spreadsheetId,
|
|
||||||
'sheet:',
|
|
||||||
sheetName
|
|
||||||
);
|
|
||||||
isLoadingData = true;
|
isLoadingData = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch first 10 rows for headers and preview
|
// Fetch first 10 rows for headers and preview
|
||||||
const range = `${sheetName}!A1:Z10`;
|
const range = `${sheetName}!A1:Z10`;
|
||||||
const data = await getSheetData($selectedSheet.spreadsheetId, range);
|
const data = await getSheetData($selectedSheet.id, range);
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
console.log('Loaded sheet data with', data.length, 'rows');
|
console.log('Loaded sheet data with', data.length, 'rows');
|
||||||
@@ -244,7 +174,7 @@
|
|||||||
autoMapColumns();
|
autoMapColumns();
|
||||||
|
|
||||||
// Check if we have saved column mapping for this sheet
|
// Check if we have saved column mapping for this sheet
|
||||||
loadSavedColumnMapping();
|
await loadSavedColumnMapping();
|
||||||
} else {
|
} else {
|
||||||
error = 'The selected sheet appears to be empty.';
|
error = 'The selected sheet appears to be empty.';
|
||||||
console.warn('Sheet is empty');
|
console.warn('Sheet is empty');
|
||||||
@@ -261,17 +191,16 @@
|
|||||||
// Reset mappings
|
// Reset mappings
|
||||||
mappedIndices = {
|
mappedIndices = {
|
||||||
name: -1,
|
name: -1,
|
||||||
surname: -1,
|
|
||||||
nationality: -1,
|
nationality: -1,
|
||||||
birthday: -1,
|
birthday: -1,
|
||||||
pictureUrl: -1,
|
pictureUrl: -1,
|
||||||
alreadyPrinted: -1
|
alreadyPrinted: -1,
|
||||||
|
sheetName: selectedSheetName
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-mapping patterns
|
// Auto-mapping patterns
|
||||||
const patterns: Record<keyof ColumnMappingType, RegExp> = {
|
const patterns: Record<keyof Omit<ColumnMappingType, 'sheetName'>, RegExp> = {
|
||||||
name: /first[\s_-]*name|name|given[\s_-]*name|vorname/i,
|
name: /full[\s_-]*name|name/i,
|
||||||
surname: /last[\s_-]*name|surname|family[\s_-]*name|nachname/i,
|
|
||||||
nationality: /nationality|country|nation/i,
|
nationality: /nationality|country|nation/i,
|
||||||
birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i,
|
birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i,
|
||||||
pictureUrl: /photo|picture|image|url|avatar/i,
|
pictureUrl: /photo|picture|image|url|avatar/i,
|
||||||
@@ -280,8 +209,9 @@
|
|||||||
|
|
||||||
sheetHeaders.forEach((header, index) => {
|
sheetHeaders.forEach((header, index) => {
|
||||||
for (const [field, pattern] of Object.entries(patterns)) {
|
for (const [field, pattern] of Object.entries(patterns)) {
|
||||||
if (pattern.test(header) && mappedIndices[field] === -1) {
|
const key = field as keyof ColumnMappingType;
|
||||||
mappedIndices[field] = index;
|
if (pattern.test(header) && mappedIndices[key] === -1) {
|
||||||
|
mappedIndices[key] = index;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,7 +230,11 @@
|
|||||||
|
|
||||||
// Also check if this column isn't already mapped to another field
|
// Also check if this column isn't already mapped to another field
|
||||||
const isAlreadyMapped = Object.entries(mappedIndices).some(
|
const isAlreadyMapped = Object.entries(mappedIndices).some(
|
||||||
([field, index]) => field !== 'alreadyPrinted' && index === colIndex
|
([field, index]) =>
|
||||||
|
field !== 'alreadyPrinted' &&
|
||||||
|
index === colIndex &&
|
||||||
|
field !== 'sheetName' &&
|
||||||
|
index === colIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isEmpty && !isAlreadyMapped) {
|
if (isEmpty && !isAlreadyMapped) {
|
||||||
@@ -314,23 +248,20 @@
|
|||||||
updateMappingStatus();
|
updateMappingStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSavedColumnMapping() {
|
async function loadSavedColumnMapping() {
|
||||||
if (!$selectedSheet || !selectedSheetName) {
|
if (!$selectedSheet || !selectedSheetName) {
|
||||||
console.log('Cannot load saved column mapping: missing selectedSheet or selectedSheetName');
|
console.log('Cannot load saved column mapping: missing selectedSheet or selectedSheetName');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const recentSheetsKey = 'recent-sheets';
|
const key = await getRecentSheetsKey();
|
||||||
const existingData = localStorage.getItem(recentSheetsKey);
|
const existingData = localStorage.getItem(key);
|
||||||
|
|
||||||
if (existingData) {
|
if (existingData) {
|
||||||
const recentSheets = JSON.parse(existingData);
|
const recentSheets = JSON.parse(existingData);
|
||||||
const savedSheet = recentSheets.find(
|
const savedSheet = recentSheets.find(
|
||||||
(sheet: SheetInfoType) =>
|
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
|
||||||
(sheet.id === $selectedSheet.spreadsheetId ||
|
|
||||||
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
|
|
||||||
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (savedSheet && savedSheet.columnMapping) {
|
if (savedSheet && savedSheet.columnMapping) {
|
||||||
@@ -339,11 +270,11 @@
|
|||||||
// Override auto-mapping with saved mapping
|
// Override auto-mapping with saved mapping
|
||||||
mappedIndices = {
|
mappedIndices = {
|
||||||
name: savedSheet.columnMapping.name ?? -1,
|
name: savedSheet.columnMapping.name ?? -1,
|
||||||
surname: savedSheet.columnMapping.surname ?? -1,
|
|
||||||
nationality: savedSheet.columnMapping.nationality ?? -1,
|
nationality: savedSheet.columnMapping.nationality ?? -1,
|
||||||
birthday: savedSheet.columnMapping.birthday ?? -1,
|
birthday: savedSheet.columnMapping.birthday ?? -1,
|
||||||
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
|
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
|
||||||
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1
|
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1,
|
||||||
|
sheetName: selectedSheetName
|
||||||
};
|
};
|
||||||
|
|
||||||
hasSavedMapping = true;
|
hasSavedMapping = true;
|
||||||
@@ -359,18 +290,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleColumnMapping(field: keyof ColumnMappingType, index: number) {
|
function handleColumnMapping(field: keyof ColumnMappingType, index: number) {
|
||||||
|
if (!mappedIndices) {
|
||||||
|
mappedIndices = {
|
||||||
|
name: -1,
|
||||||
|
nationality: -1,
|
||||||
|
birthday: -1,
|
||||||
|
pictureUrl: -1,
|
||||||
|
alreadyPrinted: -1,
|
||||||
|
sheetName: selectedSheetName
|
||||||
|
};
|
||||||
|
}
|
||||||
mappedIndices[field] = index;
|
mappedIndices[field] = index;
|
||||||
updateMappingStatus();
|
updateMappingStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMappingStatus() {
|
function updateMappingStatus() {
|
||||||
|
if (!mappedIndices) {
|
||||||
|
mappingComplete = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Only check required fields for completion
|
// Only check required fields for completion
|
||||||
const requiredIndices = {
|
const requiredIndices = {
|
||||||
name: mappedIndices.name,
|
name: mappedIndices.name,
|
||||||
surname: mappedIndices.surname,
|
|
||||||
nationality: mappedIndices.nationality,
|
nationality: mappedIndices.nationality,
|
||||||
birthday: mappedIndices.birthday,
|
birthday: mappedIndices.birthday,
|
||||||
pictureUrl: mappedIndices.pictureUrl
|
pictureUrl: mappedIndices.pictureUrl,
|
||||||
|
sheetName: selectedSheetName
|
||||||
};
|
};
|
||||||
|
|
||||||
mappingComplete = Object.values(requiredIndices).every((index) => index !== -1);
|
mappingComplete = Object.values(requiredIndices).every((index) => index !== -1);
|
||||||
@@ -379,56 +324,46 @@
|
|||||||
// Update the column mapping store
|
// Update the column mapping store
|
||||||
columnMapping.set({
|
columnMapping.set({
|
||||||
name: mappedIndices.name,
|
name: mappedIndices.name,
|
||||||
surname: mappedIndices.surname,
|
|
||||||
nationality: mappedIndices.nationality,
|
nationality: mappedIndices.nationality,
|
||||||
birthday: mappedIndices.birthday,
|
birthday: mappedIndices.birthday,
|
||||||
pictureUrl: mappedIndices.pictureUrl,
|
pictureUrl: mappedIndices.pictureUrl,
|
||||||
alreadyPrinted: mappedIndices.alreadyPrinted
|
alreadyPrinted: mappedIndices.alreadyPrinted,
|
||||||
|
sheetName: selectedSheetName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContinue() {
|
async function handleContinue() {
|
||||||
if (!mappingComplete || !$selectedSheet || !selectedSheetName) return;
|
if (!mappingComplete || !$selectedSheet || !selectedSheetName) return;
|
||||||
|
|
||||||
// Save column mapping to localStorage for the selected sheet
|
// Save column mapping to localStorage for the selected sheet
|
||||||
try {
|
try {
|
||||||
const recentSheetsKey = 'recent-sheets';
|
const key = await getRecentSheetsKey();
|
||||||
const existingData = localStorage.getItem(recentSheetsKey);
|
const existingData = localStorage.getItem(key);
|
||||||
let recentSheets = existingData ? JSON.parse(existingData) : [];
|
let recentSheets = existingData ? JSON.parse(existingData) : [];
|
||||||
|
|
||||||
// Find the current sheet in recent sheets and update its column mapping
|
// Find the current sheet in recent sheets and update its column mapping
|
||||||
const sheetIndex = recentSheets.findIndex(
|
const sheetIndex = recentSheets.findIndex(
|
||||||
(sheet: SheetInfoType) =>
|
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
|
||||||
(sheet.id === $selectedSheet.spreadsheetId ||
|
|
||||||
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
|
|
||||||
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const columnMappingData = {
|
const columnMappingData = {
|
||||||
name: mappedIndices.name,
|
name: mappedIndices.name,
|
||||||
surname: mappedIndices.surname,
|
|
||||||
nationality: mappedIndices.nationality,
|
nationality: mappedIndices.nationality,
|
||||||
birthday: mappedIndices.birthday,
|
birthday: mappedIndices.birthday,
|
||||||
pictureUrl: mappedIndices.pictureUrl,
|
pictureUrl: mappedIndices.pictureUrl,
|
||||||
alreadyPrinted: mappedIndices.alreadyPrinted
|
alreadyPrinted: mappedIndices.alreadyPrinted,
|
||||||
|
sheetName: selectedSheetName
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sheetIndex !== -1) {
|
if (sheetIndex !== -1) {
|
||||||
// Update existing entry
|
// Update existing entry
|
||||||
recentSheets[sheetIndex].columnMapping = columnMappingData;
|
recentSheets[sheetIndex].columnMapping = columnMappingData;
|
||||||
recentSheets[sheetIndex].lastUsed = new Date().toISOString();
|
recentSheets[sheetIndex].lastUsed = new Date().toISOString();
|
||||||
|
|
||||||
// Ensure we have consistent property names
|
|
||||||
recentSheets[sheetIndex].spreadsheetId =
|
|
||||||
recentSheets[sheetIndex].spreadsheetId || recentSheets[sheetIndex].id;
|
|
||||||
recentSheets[sheetIndex].sheetMapping =
|
|
||||||
recentSheets[sheetIndex].sheetMapping || recentSheets[sheetIndex].sheetName;
|
|
||||||
} else {
|
} else {
|
||||||
// Add new entry
|
// Add new entry
|
||||||
const newEntry = {
|
const newEntry = {
|
||||||
spreadsheetId: $selectedSheet.spreadsheetId,
|
id: $selectedSheet.id,
|
||||||
name: $selectedSheet.name,
|
name: $selectedSheet.name,
|
||||||
sheetMapping: selectedSheetName,
|
|
||||||
columnMapping: columnMappingData,
|
columnMapping: columnMappingData,
|
||||||
lastUsed: new Date().toISOString()
|
lastUsed: new Date().toISOString()
|
||||||
};
|
};
|
||||||
@@ -441,7 +376,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(recentSheetsKey, JSON.stringify(recentSheets));
|
localStorage.setItem(key, JSON.stringify(recentSheets));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save column mapping to localStorage:', err);
|
console.error('Failed to save column mapping to localStorage:', err);
|
||||||
}
|
}
|
||||||
@@ -461,7 +396,7 @@
|
|||||||
try {
|
try {
|
||||||
isLoadingData = true;
|
isLoadingData = true;
|
||||||
const range = `${selectedSheetName}!A1:Z10`;
|
const range = `${selectedSheetName}!A1:Z10`;
|
||||||
const data = await getSheetData($selectedSheet.spreadsheetId, range);
|
const data = await getSheetData($selectedSheet.id, range);
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
sheetHeaders = data[0];
|
sheetHeaders = data[0];
|
||||||
@@ -488,44 +423,46 @@
|
|||||||
|
|
||||||
{#if hasSavedMapping && !showMappingEditor}
|
{#if hasSavedMapping && !showMappingEditor}
|
||||||
<!-- Simplified view when we have saved mapping -->
|
<!-- Simplified view when we have saved mapping -->
|
||||||
<div class="mb-6 rounded-lg border border-green-200 bg-green-50 p-6">
|
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
<div class="text-center">
|
<div class="flex">
|
||||||
<svg class="mx-auto mb-4 h-16 w-16 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 text-blue-400"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
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"
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zm-4 4a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="mb-3 text-xl font-semibold text-green-800">Configuration Complete</h3>
|
</div>
|
||||||
<p class="mb-2 text-green-700">
|
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||||
<span class="font-medium">Spreadsheet:</span>
|
<div>
|
||||||
{savedSheetInfo?.name}
|
<h3 class="text-sm font-medium text-blue-800">Saved Configuration Found</h3>
|
||||||
</p>
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
<p class="mb-2 text-green-700">
|
<p>
|
||||||
<span class="font-medium">Sheet:</span>
|
Using saved mapping for sheet <span class="font-semibold"
|
||||||
{selectedSheetName}
|
>"{selectedSheetName}"</span
|
||||||
</p>
|
>
|
||||||
<p class="mb-6 text-green-700">
|
from spreadsheet <span class="font-semibold">"{savedSheetInfo?.name}"</span>.
|
||||||
Column mapping loaded from your previous session.<br />
|
|
||||||
Everything is ready to proceed to the next step.
|
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 md:mt-0 md:ml-6">
|
||||||
<button
|
<button
|
||||||
onclick={handleShowEditor}
|
onclick={handleShowEditor}
|
||||||
class="inline-flex items-center rounded-lg border border-green-300 px-4 py-2 text-sm font-medium text-green-700 transition-colors hover:bg-green-100 hover:text-green-900"
|
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
|
||||||
>
|
>
|
||||||
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
Edit Mapping
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Make changes if needed
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Sheet Selection -->
|
<!-- Sheet Selection -->
|
||||||
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
@@ -735,16 +672,56 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Mapping status -->
|
|
||||||
{#if mappingComplete}
|
{#if mappingComplete}
|
||||||
<div class="rounded border border-green-200 bg-green-50 p-3">
|
<div class="rounded-md border border-green-200 bg-green-50 p-4">
|
||||||
<p class="text-sm text-green-800">
|
<div class="flex">
|
||||||
✓ All required fields are mapped! You can continue to the next step.
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 text-green-400"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-green-800">
|
||||||
|
All required fields are mapped. You can now proceed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded border border-yellow-200 bg-yellow-50 p-3">
|
<div class="rounded-md bg-yellow-50 p-4">
|
||||||
<p class="text-sm text-yellow-800">Please map all required fields to continue.</p>
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 text-yellow-400"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 3.01-1.742 3.01H4.42c-1.53 0-2.493-1.676-1.743-3.01l5.58-9.92zM10 5a1 1 0 011 1v3a1 1 0 01-2 0V6a1 1 0 011-1zm1 5a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
Please map all required fields (<span class="text-red-500">*</span>) to
|
||||||
|
continue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -758,7 +735,7 @@
|
|||||||
{currentStep}
|
{currentStep}
|
||||||
textBack="Back to Sheet Selection"
|
textBack="Back to Sheet Selection"
|
||||||
textForwardDisabled="Select a column mapping"
|
textForwardDisabled="Select a column mapping"
|
||||||
textForwardEnabled="Continue"
|
textForwardEnabled="Continue to Row Selection"
|
||||||
onForward={handleContinue}
|
onForward={handleContinue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import { columnMapping, filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
|
import { columnMapping, sheetData, currentStep, pictures, cropRects, selectedCard } from '$lib/stores';
|
||||||
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl } from '$lib/google';
|
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl, ensureToken } from '$lib/google';
|
||||||
import Navigator from './subcomponents/Navigator.svelte';
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
import PhotoCard from './subcomponents/PhotoCard.svelte';
|
import PhotoCard from './subcomponents/PhotoCard.svelte';
|
||||||
import * as tf from '@tensorflow/tfjs';
|
import * as tf from '@tensorflow/tfjs';
|
||||||
@@ -39,21 +39,47 @@
|
|||||||
console.log('BlazeFace model loaded');
|
console.log('BlazeFace model loaded');
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
return detectorPromise;
|
return detectorPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a downscaled JPEG preview to reduce memory usage for UI rendering
|
||||||
|
async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(original);
|
||||||
|
let { width, height } = bitmap;
|
||||||
|
const maxDim = Math.max(width, height);
|
||||||
|
// If image is already at or below the threshold, keep it as-is
|
||||||
|
if (maxDim <= maxSide) {
|
||||||
|
bitmap.close();
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
const scale = Math.min(1, maxSide / maxDim);
|
||||||
|
const targetW = Math.max(1, Math.round(width * scale));
|
||||||
|
const targetH = Math.max(1, Math.round(height * scale));
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = targetW;
|
||||||
|
canvas.height = targetH;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Canvas 2D context unavailable');
|
||||||
|
ctx.drawImage(bitmap, 0, 0, targetW, targetH);
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||||
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', quality)
|
||||||
|
);
|
||||||
|
return blob;
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to original if downscale fails
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Force memory cleanup
|
// Force memory cleanup
|
||||||
async function forceMemoryCleanup() {
|
async function forceMemoryCleanup() {
|
||||||
await tf.nextFrame(); // Wait for any pending GPU operations
|
await tf.nextFrame(); // Wait for any pending GPU operations
|
||||||
|
|
||||||
// Log memory state without aggressive cleanup
|
|
||||||
const memInfo = tf.memory();
|
|
||||||
console.log('Memory status:', {
|
|
||||||
tensors: memInfo.numTensors,
|
|
||||||
dataBuffers: memInfo.numDataBuffers,
|
|
||||||
bytes: memInfo.numBytes
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only run garbage collection if available, don't dispose variables
|
// Only run garbage collection if available, don't dispose variables
|
||||||
if (typeof window !== 'undefined' && 'gc' in window) {
|
if (typeof window !== 'undefined' && 'gc' in window) {
|
||||||
(window as any).gc();
|
(window as any).gc();
|
||||||
@@ -61,8 +87,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function processPhotosInParallel() {
|
async function processPhotosInParallel() {
|
||||||
if (isProcessing) return;
|
|
||||||
|
|
||||||
console.log('Starting processPhotos with queues...');
|
console.log('Starting processPhotos with queues...');
|
||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
processedCount = 0;
|
processedCount = 0;
|
||||||
@@ -73,10 +97,11 @@
|
|||||||
console.log('Cleared IndexedDB.');
|
console.log('Cleared IndexedDB.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Could not clear IndexedDB:', e);
|
console.error('Could not clear IndexedDB:', e);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize queues with more conservative concurrency
|
// Initialize queues with more conservative concurrency
|
||||||
downloadQueue = new PQueue({ concurrency: 3 }); // Reduced from 5
|
downloadQueue = new PQueue({ concurrency: 4 }); // Reduced from 5
|
||||||
faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety
|
faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety
|
||||||
|
|
||||||
// When both queues are idle, we're done
|
// When both queues are idle, we're done
|
||||||
@@ -95,7 +120,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const validRows = $filteredSheetData.filter((row) => row._isValid);
|
const validRows = $sheetData.filter((row) => row._valid);
|
||||||
const photoUrls = new Set<string>();
|
const photoUrls = new Set<string>();
|
||||||
const photoMap = new Map<string, any[]>();
|
const photoMap = new Map<string, any[]>();
|
||||||
|
|
||||||
@@ -115,7 +140,7 @@
|
|||||||
console.log(`Found ${totalCount} unique photo URLs to process.`);
|
console.log(`Found ${totalCount} unique photo URLs to process.`);
|
||||||
|
|
||||||
photos = Array.from(photoUrls).map((url) => ({
|
photos = Array.from(photoUrls).map((url) => ({
|
||||||
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname,
|
name: photoMap.get(url)![0].name,
|
||||||
url,
|
url,
|
||||||
status: 'loading' as const,
|
status: 'loading' as const,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
@@ -130,16 +155,13 @@
|
|||||||
|
|
||||||
// Initialize detector and process photos
|
// Initialize detector and process photos
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
console.log('StepGallery mounted');
|
ensureToken();
|
||||||
initializeDetector(); // Start loading model
|
initializeDetector(); // Start loading model
|
||||||
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
|
if ($sheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
|
||||||
console.log('Processing photos for gallery step');
|
console.log('Processing photos for gallery step');
|
||||||
processPhotosInParallel();
|
processPhotosInParallel();
|
||||||
} else {
|
} else {
|
||||||
console.log('No data to process:', {
|
console.log('No data to process: !');
|
||||||
dataLength: $filteredSheetData.length,
|
|
||||||
pictureUrlMapping: $columnMapping.pictureUrl
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,7 +170,6 @@
|
|||||||
|
|
||||||
if (!isRetry) {
|
if (!isRetry) {
|
||||||
photo.status = 'loading';
|
photo.status = 'loading';
|
||||||
// No need to reassign photos array with $state reactivity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -156,7 +177,6 @@
|
|||||||
|
|
||||||
if (isGoogleDriveUrl(photo.url)) {
|
if (isGoogleDriveUrl(photo.url)) {
|
||||||
// Download from Google Drive
|
// Download from Google Drive
|
||||||
console.log(`Downloading from Google Drive: ${photo.name}`);
|
|
||||||
blob = await downloadDriveImage(photo.url);
|
blob = await downloadDriveImage(photo.url);
|
||||||
} else {
|
} else {
|
||||||
// For direct URLs, convert to blob
|
// For direct URLs, convert to blob
|
||||||
@@ -170,7 +190,6 @@
|
|||||||
blob.type === 'image/heif' ||
|
blob.type === 'image/heif' ||
|
||||||
photo.url.toLowerCase().endsWith('.heic')
|
photo.url.toLowerCase().endsWith('.heic')
|
||||||
) {
|
) {
|
||||||
console.log(`HEIC detected for ${photo.name}. Starting conversion in background.`);
|
|
||||||
photo.status = 'loading'; // Visually indicate something is happening
|
photo.status = 'loading'; // Visually indicate something is happening
|
||||||
// Don't await this, let it run in the background
|
// Don't await this, let it run in the background
|
||||||
convertHeicPhoto(index, blob);
|
convertHeicPhoto(index, blob);
|
||||||
@@ -190,8 +209,6 @@
|
|||||||
async function convertHeicPhoto(index: number, blob: Blob) {
|
async function convertHeicPhoto(index: number, blob: Blob) {
|
||||||
const photo = photos[index];
|
const photo = photos[index];
|
||||||
try {
|
try {
|
||||||
console.log(`Converting HEIC with heic-convert for ${photo.name}...`);
|
|
||||||
|
|
||||||
// Dynamically import the browser-specific version of the library
|
// Dynamically import the browser-specific version of the library
|
||||||
const { default: convert } = await import('heic-convert/browser');
|
const { default: convert } = await import('heic-convert/browser');
|
||||||
|
|
||||||
@@ -202,9 +219,10 @@
|
|||||||
quality: 0.9
|
quality: 0.9
|
||||||
});
|
});
|
||||||
|
|
||||||
const convertedBlob = new Blob([outputBuffer], { type: 'image/jpeg' });
|
const buffer = outputBuffer instanceof Uint8Array
|
||||||
|
? outputBuffer.buffer.slice(outputBuffer.byteOffset, outputBuffer.byteOffset + outputBuffer.byteLength)
|
||||||
console.log(`Successfully converted HEIC for ${photo.name}`);
|
: outputBuffer;
|
||||||
|
const convertedBlob = new Blob([buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||||
|
|
||||||
// Now that it's converted, process it like any other image
|
// Now that it's converted, process it like any other image
|
||||||
await processLoadedBlob(index, convertedBlob);
|
await processLoadedBlob(index, convertedBlob);
|
||||||
@@ -219,27 +237,30 @@
|
|||||||
async function processLoadedBlob(index: number, blob: Blob) {
|
async function processLoadedBlob(index: number, blob: Blob) {
|
||||||
const photo = photos[index];
|
const photo = photos[index];
|
||||||
try {
|
try {
|
||||||
const objectUrl = createImageObjectUrl(blob);
|
// Downsize once and use this for storage, preview, and detection
|
||||||
|
const resizedBlob = await createPreviewBlob(blob, 1600, 0.85);
|
||||||
|
await set(photo.url, resizedBlob);
|
||||||
|
const objectUrl = createImageObjectUrl(resizedBlob);
|
||||||
|
|
||||||
// Test if image loads properly
|
// Test if downsized image loads properly
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => resolve();
|
img.onload = () => resolve();
|
||||||
img.onerror = (error) => {
|
img.onerror = (error) => {
|
||||||
console.error(`Failed to load image for ${photo.name}:`, error);
|
console.error(`Failed to load downsized image for ${photo.name}:`, error);
|
||||||
reject(new Error('Failed to load image'));
|
reject(new Error('Failed to load image'));
|
||||||
};
|
};
|
||||||
img.src = objectUrl;
|
img.src = objectUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Revoke any previous preview URL to avoid leaks
|
||||||
|
if (photo.objectUrl && photo.objectUrl.startsWith('blob:') && photo.objectUrl !== objectUrl) {
|
||||||
|
URL.revokeObjectURL(photo.objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
photo.objectUrl = objectUrl;
|
photo.objectUrl = objectUrl;
|
||||||
photo.status = 'success';
|
photo.status = 'success';
|
||||||
console.log(`Photo loaded successfully: ${photo.name}`);
|
|
||||||
|
|
||||||
// Save blob to IndexedDB instead of the store
|
|
||||||
await set(photo.url, blob);
|
|
||||||
|
|
||||||
// Save to pictures store, but without the blob to save memory
|
|
||||||
pictures.update((pics) => ({
|
pictures.update((pics) => ({
|
||||||
...pics,
|
...pics,
|
||||||
[photo.url]: {
|
[photo.url]: {
|
||||||
@@ -251,7 +272,7 @@
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add face detection to its queue
|
// Add face detection to its queue using the downsized image shown in UI
|
||||||
faceDetectionQueue.add(() => detectFaceForPhoto(index));
|
faceDetectionQueue.add(() => detectFaceForPhoto(index));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to process blob for ${photo.name}:`, error);
|
console.error(`Failed to process blob for ${photo.name}:`, error);
|
||||||
@@ -275,45 +296,52 @@
|
|||||||
photo.faceDetectionStatus = 'processing';
|
photo.faceDetectionStatus = 'processing';
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
|
// Use the downsized UI image to keep coordinates aligned
|
||||||
img.src = photo.objectUrl!;
|
img.src = photo.objectUrl!;
|
||||||
await new Promise((r, e) => {
|
await new Promise((r, e) => {
|
||||||
img.onload = r;
|
img.onload = r;
|
||||||
img.onerror = e;
|
img.onerror = e;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create tensor and manually dispose it after use
|
// Create tensor; run estimation (avoid tf.tidy here to not dispose returned tensors prematurely)
|
||||||
imageTensor = tf.browser.fromPixels(img);
|
imageTensor = tf.browser.fromPixels(img);
|
||||||
const predictions = await detector.estimateFaces(imageTensor, false);
|
const predictions: any[] = await detector.estimateFaces(imageTensor, false);
|
||||||
|
|
||||||
// Log memory usage for debugging
|
|
||||||
const memInfo = tf.memory();
|
|
||||||
console.log(`TensorFlow.js memory after face detection for ${photo.name}:`, {
|
|
||||||
numTensors: memInfo.numTensors,
|
|
||||||
numDataBuffers: memInfo.numDataBuffers,
|
|
||||||
numBytes: memInfo.numBytes
|
|
||||||
});
|
|
||||||
|
|
||||||
if (predictions.length > 0) {
|
if (predictions.length > 0) {
|
||||||
const getProbability = (p: number | tf.Tensor) =>
|
const tensorToNumArray = (v: any): number[] => {
|
||||||
typeof p === 'number' ? p : p.dataSync()[0];
|
if (Array.isArray(v)) return v as number[];
|
||||||
|
if (typeof v === 'number') return [v];
|
||||||
|
if (v && typeof v.dataSync === 'function') {
|
||||||
|
const arr = Array.from(v.dataSync() as Float32Array);
|
||||||
|
if (typeof v.dispose === 'function') v.dispose();
|
||||||
|
return arr as number[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
const getProbability = (p: any) => tensorToNumArray(p)[0] ?? 0;
|
||||||
|
|
||||||
const face = predictions.sort(
|
const face = predictions.sort(
|
||||||
(a, b) => getProbability(b.probability!) - getProbability(a.probability!)
|
(a, b) => getProbability(b.probability!) - getProbability(a.probability!)
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
const topLeft = face.topLeft as [number, number];
|
const topLeftArr = tensorToNumArray(face.topLeft);
|
||||||
const bottomRight = face.bottomRight as [number, number];
|
const bottomRightArr = tensorToNumArray(face.bottomRight);
|
||||||
|
const topLeft = [topLeftArr[0], topLeftArr[1]] as [number, number];
|
||||||
|
const bottomRight = [bottomRightArr[0], bottomRightArr[1]] as [number, number];
|
||||||
|
|
||||||
let [x1, y1] = topLeft;
|
let [x1, y1] = topLeft;
|
||||||
let [x2, y2] = bottomRight;
|
let [x2, y2] = bottomRight;
|
||||||
const scaleX = img.naturalWidth / img.width;
|
// Use natural sizes; detection ran on original if provided
|
||||||
const scaleY = img.naturalHeight / img.height;
|
const scaleX = 1;
|
||||||
|
const scaleY = 1;
|
||||||
const faceWidth = (x2 - x1) * scaleX;
|
const faceWidth = (x2 - x1) * scaleX;
|
||||||
const faceHeight = (y2 - y1) * scaleY;
|
const faceHeight = (y2 - y1) * scaleY;
|
||||||
const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX;
|
const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX;
|
||||||
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
|
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
|
||||||
|
|
||||||
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0');
|
// Use the photo card aspect ratio from the selected card
|
||||||
|
const photoDimensions = $selectedCard!.photo;
|
||||||
|
const cropRatio = photoDimensions.width / photoDimensions.height;
|
||||||
const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0');
|
const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0');
|
||||||
const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0');
|
const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0');
|
||||||
const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5');
|
const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5');
|
||||||
@@ -439,6 +467,11 @@
|
|||||||
faceDetectionQueue.clear();
|
faceDetectionQueue.clear();
|
||||||
}
|
}
|
||||||
cleanupObjectUrls();
|
cleanupObjectUrls();
|
||||||
|
// Dispose the detector model if possible to release GPU/CPU memory
|
||||||
|
if (detector && typeof (detector as any).dispose === 'function') {
|
||||||
|
(detector as any).dispose();
|
||||||
|
}
|
||||||
|
detector = undefined;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -518,23 +551,6 @@
|
|||||||
<div class="text-gray-600">Failed</div>
|
<div class="text-gray-600">Failed</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if photos.filter((p) => p.status === 'error').length > 0}
|
|
||||||
<div class="mt-4 rounded border border-yellow-200 bg-yellow-50 p-3">
|
|
||||||
<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 rounded border border-blue-200 bg-blue-50 p-3">
|
|
||||||
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -565,6 +581,7 @@
|
|||||||
{#each photos as photo, index}
|
{#each photos as photo, index}
|
||||||
<PhotoCard
|
<PhotoCard
|
||||||
{photo}
|
{photo}
|
||||||
|
photoDimensions={$selectedCard!.photo}
|
||||||
onCropUpdated={(e) => handleCropUpdate(index, e)}
|
onCropUpdated={(e) => handleCropUpdate(index, e)}
|
||||||
onRetry={() => retryPhoto(index)}
|
onRetry={() => retryPhoto(index)}
|
||||||
/>
|
/>
|
||||||
@@ -577,8 +594,8 @@
|
|||||||
<Navigator
|
<Navigator
|
||||||
canProceed={canProceed()}
|
canProceed={canProceed()}
|
||||||
{currentStep}
|
{currentStep}
|
||||||
textBack="Back to Row Filter"
|
textBack="Back to Card Selection"
|
||||||
textForwardDisabled="Waiting from photos"
|
textForwardDisabled="Waiting for photos"
|
||||||
textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
|
textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
|
import { sheetData, currentStep, pictures, cropRects, cardDetails, selectedCard } from '$lib/stores';
|
||||||
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
import type { Card } from '$lib/cards/types';
|
||||||
|
import { PDFDocument, StandardFonts, rgb, type PDFPage } from 'pdf-lib';
|
||||||
import * as fontkit from 'fontkit';
|
import * as fontkit from 'fontkit';
|
||||||
import { clear } from 'idb-keyval';
|
import { clear } from 'idb-keyval';
|
||||||
import {
|
import {
|
||||||
BORDER_CONFIG,
|
BORDER_CONFIG,
|
||||||
TEXT_CONFIG,
|
TEXT_CONFIG,
|
||||||
PLACEHOLDER_CONFIG,
|
|
||||||
calculateGrid,
|
calculateGrid,
|
||||||
getAbsolutePositionPt,
|
getAbsolutePositionPt,
|
||||||
getAbsolutePhotoDimensionsPt,
|
getAbsolutePhotoDimensionsPt,
|
||||||
getImageBlob,
|
getImageBlob,
|
||||||
MM_TO_PT
|
MM_TO_PT
|
||||||
} from '$lib/pdfLayout';
|
} from '$lib/pdfLayout';
|
||||||
import {
|
import { PAGE_SETTINGS } from '$lib/pdfSettings';
|
||||||
PAGE_SETTINGS,
|
import type { PageSettings } from '$lib/pdfSettings';
|
||||||
TEXT_CARD_DIMENSIONS,
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
PHOTO_CARD_DIMENSIONS,
|
|
||||||
TEXT_FIELD_LAYOUT,
|
|
||||||
PHOTO_FIELD_LAYOUT
|
|
||||||
} from '$lib/pdfSettings';
|
|
||||||
|
|
||||||
type FileGenerationState = 'idle' | 'generating' | 'done' | 'error';
|
type FileGenerationState = 'idle' | 'generating' | 'done' | 'error';
|
||||||
|
|
||||||
@@ -31,11 +27,12 @@
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
size: number | null;
|
size: number | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
downloadName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialFiles: GeneratedFile[] = [
|
const initialFiles: GeneratedFile[] = [
|
||||||
{
|
{
|
||||||
name: 'people_data.pdf',
|
name: 'esncards_text.pdf',
|
||||||
displayName: 'Text PDF',
|
displayName: 'Text PDF',
|
||||||
state: 'idle',
|
state: 'idle',
|
||||||
url: null,
|
url: null,
|
||||||
@@ -43,7 +40,7 @@
|
|||||||
error: null
|
error: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'people_photos.pdf',
|
name: 'esncards_photos.pdf',
|
||||||
displayName: 'Photos PDF',
|
displayName: 'Photos PDF',
|
||||||
state: 'idle',
|
state: 'idle',
|
||||||
url: null,
|
url: null,
|
||||||
@@ -70,13 +67,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
handleGenerateAll();
|
||||||
// Add event listener for page unload
|
// Add event listener for page unload
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
|
||||||
// Start generation automatically when the component mounts
|
|
||||||
handleGenerate('people_data.pdf');
|
|
||||||
handleGenerate('people_photos.pdf');
|
|
||||||
|
|
||||||
// Cleanup function when component unmounts
|
// Cleanup function when component unmounts
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
@@ -84,6 +78,22 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleGenerateAll() {
|
||||||
|
if (!$selectedCard) return;
|
||||||
|
|
||||||
|
// Reset file states and revoke old URLs
|
||||||
|
files.forEach((f) => {
|
||||||
|
if (f.url) URL.revokeObjectURL(f.url);
|
||||||
|
});
|
||||||
|
files = JSON.parse(JSON.stringify(initialFiles));
|
||||||
|
|
||||||
|
// Generate both
|
||||||
|
await handleGenerate('esncards_text.pdf');
|
||||||
|
await handleGenerate('esncards_photos.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
const generationStarted = $derived(files.some((f) => f.state !== 'idle'));
|
||||||
|
|
||||||
// Load Roboto font
|
// Load Roboto font
|
||||||
async function loadRobotoFont() {
|
async function loadRobotoFont() {
|
||||||
try {
|
try {
|
||||||
@@ -98,6 +108,118 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a formatted timestamp string
|
||||||
|
function getTimestamp(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = d.getDate().toString().padStart(2, '0');
|
||||||
|
const hours = d.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = d.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}-${hours}-${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a very wide 'H' (10 cm length) at the top and left margins as registration marks
|
||||||
|
function drawHMarks(page: PDFPage, font: any, pageSettings: PageSettings) {
|
||||||
|
const color = rgb(0, 0, 0); // pure black
|
||||||
|
const lengthPt = 100 * MM_TO_PT; // 10 cm
|
||||||
|
const tickLenPt = 2 * MM_TO_PT; // 2 mm tick
|
||||||
|
const strokePt = 0.7; // visual thickness
|
||||||
|
|
||||||
|
// Top margin center
|
||||||
|
const centerTopX = (pageSettings.pageWidth / 2) * MM_TO_PT;
|
||||||
|
const centerTopY = (pageSettings.pageHeight - pageSettings.margin / 2) * MM_TO_PT;
|
||||||
|
const leftTopX = centerTopX - lengthPt / 2;
|
||||||
|
const rightTopX = centerTopX + lengthPt / 2;
|
||||||
|
|
||||||
|
// Horizontal bar (top)
|
||||||
|
page.drawRectangle({
|
||||||
|
x: leftTopX,
|
||||||
|
y: centerTopY - strokePt / 2,
|
||||||
|
width: lengthPt,
|
||||||
|
height: strokePt,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
// Left vertical tick (top)
|
||||||
|
page.drawRectangle({
|
||||||
|
x: leftTopX - strokePt / 2,
|
||||||
|
y: centerTopY - tickLenPt / 2,
|
||||||
|
width: strokePt,
|
||||||
|
height: tickLenPt,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
// Right vertical tick (top)
|
||||||
|
page.drawRectangle({
|
||||||
|
x: rightTopX - strokePt / 2,
|
||||||
|
y: centerTopY - tickLenPt / 2,
|
||||||
|
width: strokePt,
|
||||||
|
height: tickLenPt,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
|
||||||
|
// Label under the top bar, centered
|
||||||
|
const label = 'Print gauge - if not 10 cm long, the page is not printed correctly!';
|
||||||
|
const labelSize = 7;
|
||||||
|
const labelWidth = font.widthOfTextAtSize(label, labelSize);
|
||||||
|
const labelX = centerTopX - labelWidth / 2; // center horizontally
|
||||||
|
const labelY = centerTopY - 3 * MM_TO_PT; // ~3mm below the bar
|
||||||
|
page.drawText(label, {
|
||||||
|
x: labelX,
|
||||||
|
y: labelY,
|
||||||
|
size: labelSize,
|
||||||
|
font,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
|
||||||
|
// Left margin center (vertical bar)
|
||||||
|
const centerLeftX = (pageSettings.margin / 2) * MM_TO_PT;
|
||||||
|
const centerLeftY = (pageSettings.pageHeight / 2) * MM_TO_PT;
|
||||||
|
|
||||||
|
// Vertical bar (left)
|
||||||
|
page.drawRectangle({
|
||||||
|
x: centerLeftX - strokePt / 2,
|
||||||
|
y: centerLeftY - lengthPt / 2,
|
||||||
|
width: strokePt,
|
||||||
|
height: lengthPt,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
// Top horizontal tick (left)
|
||||||
|
page.drawRectangle({
|
||||||
|
x: centerLeftX - tickLenPt / 2,
|
||||||
|
y: centerLeftY + lengthPt / 2 - strokePt / 2,
|
||||||
|
width: tickLenPt,
|
||||||
|
height: strokePt,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
// Bottom horizontal tick (left)
|
||||||
|
page.drawRectangle({
|
||||||
|
x: centerLeftX - tickLenPt / 2,
|
||||||
|
y: centerLeftY - lengthPt / 2 - strokePt / 2,
|
||||||
|
width: tickLenPt,
|
||||||
|
height: strokePt,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a YYYY-MM-DD string into "DD MM YY"
|
||||||
|
function formatDateDDMMYY(value: string): string {
|
||||||
|
if (!value) return '';
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
// Expects YYYY-MM-DD and splits it
|
||||||
|
const parts = trimmed.split('-');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [y, mo, d] = parts;
|
||||||
|
if (y.length === 4 && mo.length === 2 && d.length === 2) {
|
||||||
|
return `${d} ${mo} ${y.slice(-2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any other format that might slip through
|
||||||
|
console.warn(`Unexpected date format received in formatDateDDMMYY: "${trimmed}"`);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
// Crop image using canvas
|
// Crop image using canvas
|
||||||
async function cropImage(
|
async function cropImage(
|
||||||
imageBlob: Blob,
|
imageBlob: Blob,
|
||||||
@@ -164,9 +286,14 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const pdfBytes =
|
const pdfBytes =
|
||||||
fileName === 'people_data.pdf' ? await generateTextPDF() : await generatePhotoPDF();
|
fileName === 'esncards_text.pdf' ? await generateTextPDF() : await generatePhotoPDF();
|
||||||
|
|
||||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
// Convert Uint8Array to ArrayBuffer slice to satisfy BlobPart typing
|
||||||
|
const arrayBuffer = pdfBytes.buffer.slice(
|
||||||
|
pdfBytes.byteOffset,
|
||||||
|
pdfBytes.byteOffset + pdfBytes.byteLength
|
||||||
|
);
|
||||||
|
const blob = new Blob([arrayBuffer as ArrayBuffer], { type: 'application/pdf' });
|
||||||
|
|
||||||
// Revoke old URL if it exists
|
// Revoke old URL if it exists
|
||||||
if (fileToUpdate.url) {
|
if (fileToUpdate.url) {
|
||||||
@@ -177,12 +304,9 @@
|
|||||||
fileToUpdate.size = pdfBytes.length;
|
fileToUpdate.size = pdfBytes.length;
|
||||||
fileToUpdate.state = 'done';
|
fileToUpdate.state = 'done';
|
||||||
|
|
||||||
// Check if both PDFs are done, then clear sensitive data
|
const timestamp = getTimestamp();
|
||||||
const allDone = files.every((f) => f.state === 'done' || f.state === 'error');
|
const baseName = fileName.replace('.pdf', '');
|
||||||
if (allDone) {
|
fileToUpdate.downloadName = `${baseName}_${timestamp}.pdf`;
|
||||||
console.log('All PDFs generated, clearing sensitive data...');
|
|
||||||
await clearSensitiveData();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`PDF generation failed for ${fileName}:`, error);
|
console.error(`PDF generation failed for ${fileName}:`, error);
|
||||||
fileToUpdate.state = 'error';
|
fileToUpdate.state = 'error';
|
||||||
@@ -191,6 +315,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generateTextPDF() {
|
async function generateTextPDF() {
|
||||||
|
const card = $selectedCard;
|
||||||
|
if (!card) throw new Error('No card type selected');
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.create();
|
const pdfDoc = await PDFDocument.create();
|
||||||
pdfDoc.registerFontkit(fontkit);
|
pdfDoc.registerFontkit(fontkit);
|
||||||
|
|
||||||
@@ -203,8 +330,8 @@
|
|||||||
PAGE_SETTINGS.pageWidth,
|
PAGE_SETTINGS.pageWidth,
|
||||||
PAGE_SETTINGS.pageHeight,
|
PAGE_SETTINGS.pageHeight,
|
||||||
PAGE_SETTINGS.margin,
|
PAGE_SETTINGS.margin,
|
||||||
TEXT_CARD_DIMENSIONS.width,
|
card.textCard.width,
|
||||||
TEXT_CARD_DIMENSIONS.height
|
card.textCard.height
|
||||||
);
|
);
|
||||||
const pageDimsPt = {
|
const pageDimsPt = {
|
||||||
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
|
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
|
||||||
@@ -212,10 +339,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
|
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
|
||||||
|
drawHMarks(page, font, PAGE_SETTINGS);
|
||||||
let currentRow = 0;
|
let currentRow = 0;
|
||||||
let currentCol = 0;
|
let currentCol = 0;
|
||||||
|
|
||||||
const validRows = $filteredSheetData.filter((row) => row._isValid);
|
const validRows = $sheetData.filter((row) => row._valid);
|
||||||
|
const details = $cardDetails;
|
||||||
|
const studiesAtAll = details?.studiesAt ?? '';
|
||||||
|
const esnSectionAll = details?.esnSection ?? '';
|
||||||
|
const validityStartAll = details?.validityStart ?? '';
|
||||||
|
|
||||||
for (let i = 0; i < validRows.length; i++) {
|
for (let i = 0; i < validRows.length; i++) {
|
||||||
const row = validRows[i];
|
const row = validRows[i];
|
||||||
@@ -225,46 +357,87 @@
|
|||||||
const cellY_mm = PAGE_SETTINGS.margin + currentRow * gridLayout.cellHeight;
|
const cellY_mm = PAGE_SETTINGS.margin + currentRow * gridLayout.cellHeight;
|
||||||
|
|
||||||
// Get field values
|
// Get field values
|
||||||
const name = row.name || row.Name || '';
|
const name = row.name;
|
||||||
const surname = row.surname || row.Surname || row.lastname || row.LastName || '';
|
const nationality = row.nationality;
|
||||||
const nationality = row.nationality || row.Nationality || row.country || row.Country || '';
|
const birthday = row.birthday;
|
||||||
const birthday =
|
const studiesAt = studiesAtAll;
|
||||||
row.birthday || row.Birthday || row.birthdate || row.Birthdate || row.birth_date || '';
|
const esnSection = esnSectionAll;
|
||||||
|
const validityStart = validityStartAll;
|
||||||
|
const birthdayFmt = formatDateDDMMYY(birthday);
|
||||||
|
const validityStartFmt = formatDateDDMMYY(validityStart);
|
||||||
|
|
||||||
// Draw name
|
// Row 1: Name
|
||||||
const namePos = getAbsolutePositionPt(
|
const namePos = getAbsolutePositionPt(
|
||||||
cellX_mm,
|
cellX_mm,
|
||||||
cellY_mm,
|
cellY_mm,
|
||||||
PAGE_SETTINGS.pageHeight,
|
PAGE_SETTINGS.pageHeight,
|
||||||
TEXT_FIELD_LAYOUT.name
|
card.textFields.name
|
||||||
);
|
);
|
||||||
page.drawText(`${name} ${surname}`, {
|
page.drawText(`${name}`, {
|
||||||
...namePos,
|
...namePos,
|
||||||
font,
|
font,
|
||||||
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw nationality
|
// Row 2 left: Nationality
|
||||||
const natPos = getAbsolutePositionPt(
|
const natPos = getAbsolutePositionPt(
|
||||||
cellX_mm,
|
cellX_mm,
|
||||||
cellY_mm,
|
cellY_mm,
|
||||||
PAGE_SETTINGS.pageHeight,
|
PAGE_SETTINGS.pageHeight,
|
||||||
TEXT_FIELD_LAYOUT.nationality
|
card.textFields.nationality
|
||||||
);
|
);
|
||||||
page.drawText(`Nationality: ${nationality}`, {
|
page.drawText(`${nationality}`, {
|
||||||
...natPos,
|
...natPos,
|
||||||
font,
|
font,
|
||||||
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw birthday
|
// Row 2 right: Date of birth
|
||||||
const bdayPos = getAbsolutePositionPt(
|
const bdayPos = getAbsolutePositionPt(
|
||||||
cellX_mm,
|
cellX_mm,
|
||||||
cellY_mm,
|
cellY_mm,
|
||||||
PAGE_SETTINGS.pageHeight,
|
PAGE_SETTINGS.pageHeight,
|
||||||
TEXT_FIELD_LAYOUT.birthday
|
card.textFields.birthday
|
||||||
);
|
);
|
||||||
page.drawText(`Birthday: ${birthday}`, {
|
// Row 3: Studies at
|
||||||
|
const studiesPos = getAbsolutePositionPt(
|
||||||
|
cellX_mm,
|
||||||
|
cellY_mm,
|
||||||
|
PAGE_SETTINGS.pageHeight,
|
||||||
|
card.textFields.studiesAt
|
||||||
|
);
|
||||||
|
page.drawText(`${studiesAt}`, {
|
||||||
|
...studiesPos,
|
||||||
|
font,
|
||||||
|
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 4 left: ESN Section
|
||||||
|
const sectionPos = getAbsolutePositionPt(
|
||||||
|
cellX_mm,
|
||||||
|
cellY_mm,
|
||||||
|
PAGE_SETTINGS.pageHeight,
|
||||||
|
card.textFields.esnSection
|
||||||
|
);
|
||||||
|
page.drawText(`${esnSection}`, {
|
||||||
|
...sectionPos,
|
||||||
|
font,
|
||||||
|
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 4 right: Valid from
|
||||||
|
const validPos = getAbsolutePositionPt(
|
||||||
|
cellX_mm,
|
||||||
|
cellY_mm,
|
||||||
|
PAGE_SETTINGS.pageHeight,
|
||||||
|
card.textFields.validityStart
|
||||||
|
);
|
||||||
|
page.drawText(`${validityStartFmt}`, {
|
||||||
|
...validPos,
|
||||||
|
font,
|
||||||
|
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
||||||
|
});
|
||||||
|
page.drawText(`${birthdayFmt}`, {
|
||||||
...bdayPos,
|
...bdayPos,
|
||||||
font,
|
font,
|
||||||
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
||||||
@@ -287,6 +460,7 @@
|
|||||||
currentRow++;
|
currentRow++;
|
||||||
if (currentRow >= gridLayout.rows) {
|
if (currentRow >= gridLayout.rows) {
|
||||||
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
|
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
|
||||||
|
drawHMarks(page, font, PAGE_SETTINGS);
|
||||||
currentRow = 0;
|
currentRow = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,6 +470,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generatePhotoPDF() {
|
async function generatePhotoPDF() {
|
||||||
|
const card = $selectedCard;
|
||||||
|
if (!card) throw new Error('No card type selected');
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.create();
|
const pdfDoc = await PDFDocument.create();
|
||||||
pdfDoc.registerFontkit(fontkit);
|
pdfDoc.registerFontkit(fontkit);
|
||||||
|
|
||||||
@@ -308,8 +485,8 @@
|
|||||||
PAGE_SETTINGS.pageWidth,
|
PAGE_SETTINGS.pageWidth,
|
||||||
PAGE_SETTINGS.pageHeight,
|
PAGE_SETTINGS.pageHeight,
|
||||||
PAGE_SETTINGS.margin,
|
PAGE_SETTINGS.margin,
|
||||||
PHOTO_CARD_DIMENSIONS.width,
|
card.photoCard.width,
|
||||||
PHOTO_CARD_DIMENSIONS.height
|
card.photoCard.height
|
||||||
);
|
);
|
||||||
const pageDimsPt = {
|
const pageDimsPt = {
|
||||||
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
|
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
|
||||||
@@ -317,10 +494,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
|
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
|
||||||
|
drawHMarks(page, font, PAGE_SETTINGS);
|
||||||
let currentRow = 0;
|
let currentRow = 0;
|
||||||
let currentCol = 0;
|
let currentCol = 0;
|
||||||
|
|
||||||
const validRows = $filteredSheetData.filter((row) => row._isValid);
|
const validRows = $sheetData.filter((row) => row._valid);
|
||||||
|
|
||||||
for (let i = 0; i < validRows.length; i++) {
|
for (let i = 0; i < validRows.length; i++) {
|
||||||
const row = validRows[i];
|
const row = validRows[i];
|
||||||
@@ -334,10 +512,10 @@
|
|||||||
cellX_mm,
|
cellX_mm,
|
||||||
cellY_mm,
|
cellY_mm,
|
||||||
PAGE_SETTINGS.pageHeight,
|
PAGE_SETTINGS.pageHeight,
|
||||||
PHOTO_FIELD_LAYOUT.photo
|
card.photoFields.photo
|
||||||
);
|
);
|
||||||
|
|
||||||
const pictureUrl = row.pictureUrl || row.picture_url || row.Picture || row.PictureUrl;
|
const pictureUrl = row.pictureUrl;
|
||||||
const pictureInfo = $pictures[pictureUrl];
|
const pictureInfo = $pictures[pictureUrl];
|
||||||
const cropData = $cropRects[pictureUrl];
|
const cropData = $cropRects[pictureUrl];
|
||||||
|
|
||||||
@@ -398,15 +576,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw name
|
// Draw name
|
||||||
const name = row.name || row.Name || '';
|
const name = row.name;
|
||||||
const surname = row.surname || row.Surname || row.lastname || row.LastName || '';
|
|
||||||
const namePos = getAbsolutePositionPt(
|
const namePos = getAbsolutePositionPt(
|
||||||
cellX_mm,
|
cellX_mm,
|
||||||
cellY_mm,
|
cellY_mm,
|
||||||
PAGE_SETTINGS.pageHeight,
|
PAGE_SETTINGS.pageHeight,
|
||||||
PHOTO_FIELD_LAYOUT.name
|
card.photoFields.name
|
||||||
);
|
);
|
||||||
page.drawText(`${name} ${surname}`, {
|
page.drawText(`${name}`, {
|
||||||
...namePos,
|
...namePos,
|
||||||
font,
|
font,
|
||||||
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
|
||||||
@@ -419,6 +596,7 @@
|
|||||||
currentRow++;
|
currentRow++;
|
||||||
if (currentRow >= gridLayout.rows) {
|
if (currentRow >= gridLayout.rows) {
|
||||||
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
|
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
|
||||||
|
drawHMarks(page, font, PAGE_SETTINGS);
|
||||||
currentRow = 0;
|
currentRow = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,7 +609,7 @@
|
|||||||
if (!file.url) return;
|
if (!file.url) return;
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = file.url;
|
link.href = file.url;
|
||||||
link.download = file.name;
|
link.download = file.downloadName || file.name;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
@@ -461,43 +639,28 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">Generating PDFs...</h2>
|
||||||
Generating PDFs
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-700 mb-4">
|
<p class="text-sm text-gray-700 mb-4">
|
||||||
Create two PDF documents: one with text data and one with photos.
|
Your PDF documents are being created. Please wait a moment.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary -->
|
{#if files.some((f) => f.state === 'generating')}
|
||||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4">
|
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Generation Summary</h3>
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
<div
|
||||||
<div class="text-center">
|
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
|
||||||
<div class="text-2xl font-bold text-gray-900">
|
></div>
|
||||||
{$filteredSheetData.filter((row) => row._isValid).length}
|
<span class="text-sm text-blue-800"> Processing... </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">Records to Process</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-blue-600">{files.length}</div>
|
|
||||||
<div class="text-gray-600">PDFs to Generate</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-green-600">
|
|
||||||
{files.filter((f) => f.state === 'done').length}
|
|
||||||
</div>
|
|
||||||
<div class="text-gray-600">Files Ready</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{#if generationStarted}
|
||||||
<!-- Generated Files -->
|
<!-- Generated Files -->
|
||||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
|
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
|
||||||
<div class="p-4 border-b border-gray-200">
|
<div class="p-4 border-b border-gray-200">
|
||||||
@@ -545,27 +708,26 @@
|
|||||||
<p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
<p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
||||||
{:else if file.state === 'error'}
|
{:else if file.state === 'error'}
|
||||||
<p class="text-xs text-red-500">Error: {file.error}</p>
|
<p class="text-xs text-red-500">Error: {file.error}</p>
|
||||||
|
{:else if file.state === 'generating'}
|
||||||
|
<p class="text-xs text-gray-500">Generating...</p>
|
||||||
|
{:else if file.state === 'idle'}
|
||||||
|
<p class="text-xs text-gray-500">Waiting...</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if file.state === 'idle'}
|
{#if file.state === 'idle'}
|
||||||
<button
|
<div class="px-4 py-2 text-gray-500 text-sm">Waiting...</div>
|
||||||
onclick={() => handleGenerate(file.name)}
|
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Generate
|
|
||||||
</button>
|
|
||||||
{:else if file.state === 'generating'}
|
{:else if file.state === 'generating'}
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
|
aria-label="Generating..."
|
||||||
class="px-4 py-2 bg-gray-400 text-white rounded-md text-sm font-medium cursor-wait"
|
class="px-4 py-2 bg-gray-400 text-white rounded-md text-sm font-medium cursor-wait"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
|
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||||
></div>
|
></div>
|
||||||
Generating...
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{:else if file.state === 'done'}
|
{:else if file.state === 'done'}
|
||||||
@@ -587,24 +749,19 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<div class="flex justify-between">
|
<div class="mt-10">
|
||||||
<button
|
<Navigator
|
||||||
onclick={() => currentStep.set(5)}
|
{currentStep}
|
||||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
|
onForward={resetAndStartOver}
|
||||||
>
|
canProceed={files.some((f) => f.state === 'done' || f.state === 'error')}
|
||||||
← Back to Gallery
|
textBack="Back to Gallery"
|
||||||
</button>
|
textForwardEnabled="Start Over"
|
||||||
|
textForwardDisabled="Generate PDFs to Continue"
|
||||||
{#if files.some((f) => f.state === 'done' || f.state === 'error')}
|
hideForwardUntilProceedable={true}
|
||||||
<button
|
/>
|
||||||
onclick={resetAndStartOver}
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,178 +1,162 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
selectedSheet,
|
sheetData,
|
||||||
columnMapping,
|
columnMapping,
|
||||||
rawSheetData,
|
selectedSheet,
|
||||||
filteredSheetData,
|
|
||||||
currentStep,
|
currentStep,
|
||||||
sheetData
|
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
|
import type { RowData } from '$lib/stores';
|
||||||
|
import { getSheetData, ensureToken } from '$lib/google';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
import Navigator from './subcomponents/Navigator.svelte';
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getSheetNames, getSheetData } from '$lib/google';
|
import { parseAndFormatDate } from '$lib/utils/date';
|
||||||
|
|
||||||
let searchTerm = '';
|
let isLoading = $state(true);
|
||||||
let sortColumn = '';
|
let error = $state<string | null>(null);
|
||||||
let sortDirection: 'asc' | 'desc' = 'asc';
|
let rows = $state<RowData[]>([]);
|
||||||
let selectedRows = new Set<number>();
|
|
||||||
let selectAll = false;
|
|
||||||
let processedData: any[] = [];
|
|
||||||
let filteredData: any[] = [];
|
|
||||||
let headers: string[] = [];
|
|
||||||
let isLoading = false;
|
|
||||||
|
|
||||||
$: {
|
let sortColumn = $state<keyof RowData | null>(null);
|
||||||
// Filter data based on search term
|
let sortDirection = $state<'asc' | 'desc'>('asc');
|
||||||
if (searchTerm.trim()) {
|
let lastCheckedId: string | null = $state(null);
|
||||||
filteredData = processedData.filter((row) =>
|
|
||||||
Object.values(row).some((value) =>
|
|
||||||
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
filteredData = processedData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
const ROW_LIMIT = 200;
|
||||||
// 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') {
|
// Fetch and process data from the Google Sheet
|
||||||
return aVal.localeCompare(bVal);
|
async function fetchAndProcessData() {
|
||||||
} 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;
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
try {
|
try {
|
||||||
// Get headers from the mapping
|
const sheet = $selectedSheet;
|
||||||
headers = Object.keys($columnMapping);
|
const mapping = $columnMapping;
|
||||||
|
|
||||||
await fetchRawSheetData();
|
if (!sheet || !mapping || !mapping.sheetName) {
|
||||||
|
error = 'Sheet information or column mapping is missing.';
|
||||||
|
isLoading = false;
|
||||||
|
rows = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process the data starting from row 2 (skip header row)
|
const range = `${mapping.sheetName}!A:Z`;
|
||||||
processedData = $rawSheetData.slice(1).map((row, index) => {
|
const rawData = await getSheetData(sheet.id, range);
|
||||||
const processedRow: any = {
|
|
||||||
_rowIndex: index + 1, // Store original row index
|
if (!rawData || rawData.length < 2) {
|
||||||
_isValid: true
|
// Handle case with no data or only headers
|
||||||
|
rows = [];
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRows = rawData.slice(1);
|
||||||
|
|
||||||
|
const processedData = dataRows
|
||||||
|
.map((row, index): RowData | null => {
|
||||||
|
const name = mapping.name !== -1 ? row[mapping.name] || '' : '';
|
||||||
|
const pictureUrl = mapping.pictureUrl !== -1 ? row[mapping.pictureUrl] || '' : '';
|
||||||
|
|
||||||
|
const birthdayRaw = mapping.birthday !== -1 ? row[mapping.birthday] : '';
|
||||||
|
const birthday = parseAndFormatDate(birthdayRaw);
|
||||||
|
|
||||||
|
if (!name && !pictureUrl) {
|
||||||
|
return null; // Skip entirely empty rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyPrinted =
|
||||||
|
mapping.alreadyPrinted !== -1
|
||||||
|
? (row[mapping.alreadyPrinted] || '').toLowerCase() === 'true'
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const isValid = !!(name && pictureUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
name,
|
||||||
|
nationality: mapping.nationality !== -1 ? row[mapping.nationality] || '' : '',
|
||||||
|
birthday,
|
||||||
|
pictureUrl,
|
||||||
|
alreadyPrinted,
|
||||||
|
_rowIndex: index + 1,
|
||||||
|
_valid: isValid,
|
||||||
|
_checked: false
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is RowData => row !== null);
|
||||||
|
|
||||||
// Map each column according to the column mapping
|
rows = processedData;
|
||||||
for (const [field, columnIndex] of Object.entries($columnMapping)) {
|
} catch (e: any) {
|
||||||
if (columnIndex !== -1 && columnIndex !== undefined && columnIndex < row.length) {
|
error = e.message || 'An unknown error occurred while fetching data.';
|
||||||
processedRow[field] = row[columnIndex] || '';
|
console.error(e);
|
||||||
} else {
|
rows = [];
|
||||||
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
|
|
||||||
const rowsToConsider = processedData.filter((row) => {
|
|
||||||
if (!row._isValid) return false;
|
|
||||||
const alreadyPrinted = row.alreadyPrinted;
|
|
||||||
if (alreadyPrinted) {
|
|
||||||
const value = String(alreadyPrinted).toLowerCase().trim();
|
|
||||||
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialSelection = rowsToConsider.map((row) => row._rowIndex);
|
|
||||||
selectedRows = new Set(initialSelection);
|
|
||||||
|
|
||||||
updateSelectAllState();
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRowSelection(rowIndex: number) {
|
function handleRowClick(event: MouseEvent, clickedId: string) {
|
||||||
if (selectedRows.has(rowIndex)) {
|
const clickedRow = rows.find((r) => r.id === clickedId);
|
||||||
selectedRows.delete(rowIndex);
|
if (!clickedRow || !clickedRow._valid) return;
|
||||||
} else {
|
|
||||||
selectedRows.add(rowIndex);
|
// Handle shift-clicking for range selection
|
||||||
|
if (event.shiftKey && lastCheckedId) {
|
||||||
|
const lastIndex = displayData.findIndex((r) => r.id === lastCheckedId);
|
||||||
|
const currentIndex = displayData.findIndex((r) => r.id === clickedId);
|
||||||
|
|
||||||
|
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||||
|
const start = Math.min(lastIndex, currentIndex);
|
||||||
|
const end = Math.max(lastIndex, currentIndex);
|
||||||
|
const isChecked = !clickedRow._checked; // The state to apply to the range
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
const rowToSelect = displayData[i];
|
||||||
|
if (rowToSelect && rowToSelect._valid) {
|
||||||
|
// Prevent checking more than the limit
|
||||||
|
if (isChecked && selectedCount >= ROW_LIMIT && !rowToSelect._checked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rowToSelect._checked = isChecked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal click, just toggle the state
|
||||||
|
if (!clickedRow._checked && selectedCount >= ROW_LIMIT) {
|
||||||
|
// Do not allow checking more than the limit
|
||||||
|
} else {
|
||||||
|
clickedRow._checked = !clickedRow._checked;
|
||||||
}
|
}
|
||||||
selectedRows = new Set(selectedRows); // Trigger reactivity
|
|
||||||
updateSelectAllState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelectAll() {
|
// Update the last checked ID for the next shift-click
|
||||||
if (selectAll) {
|
lastCheckedId = clickedId;
|
||||||
// Deselect all visible valid rows
|
|
||||||
filteredData.forEach((row) => {
|
|
||||||
if (row._isValid) {
|
|
||||||
selectedRows.delete(row._rowIndex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run on component mount
|
||||||
|
onMount(() => {
|
||||||
|
ensureToken();
|
||||||
|
fetchAndProcessData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Function to toggle select-all: selects first 200 eligible items in current view
|
||||||
|
function toggleSelectAll(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const shouldCheck = target.checked;
|
||||||
|
|
||||||
|
// Determine eligible rows in the current display order
|
||||||
|
const eligible = displayData.filter((r) => r._valid && !r.alreadyPrinted);
|
||||||
|
const firstBatch = eligible.slice(0, ROW_LIMIT);
|
||||||
|
|
||||||
|
if (shouldCheck) {
|
||||||
|
// Check only the first batch, uncheck the rest
|
||||||
|
rows.forEach((row) => (row._checked = false));
|
||||||
|
firstBatch.forEach((row) => (row._checked = true));
|
||||||
} else {
|
} else {
|
||||||
// Select all visible valid rows that aren't already printed
|
// Uncheck all
|
||||||
const rowsToSelect = filteredData.filter(
|
rows.forEach((row) => (row._checked = false));
|
||||||
(row) => row._isValid && !isRowAlreadyPrinted(row) && !selectedRows.has(row._rowIndex)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of rowsToSelect) {
|
|
||||||
selectedRows.add(row._rowIndex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectedRows = new Set(selectedRows);
|
|
||||||
updateSelectAllState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectAllState() {
|
// Function to handle sorting
|
||||||
const visibleValidUnprintedRows = filteredData.filter(
|
function sortBy(column: keyof RowData) {
|
||||||
(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) {
|
if (sortColumn === column) {
|
||||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
@@ -181,104 +165,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFieldLabel(field: string): string {
|
// Derived state for sorted data to be displayed
|
||||||
const labels: { [key: string]: string } = {
|
const displayData = $derived.by(() => {
|
||||||
name: 'First Name',
|
if (!sortColumn) return rows;
|
||||||
surname: 'Last Name',
|
|
||||||
nationality: 'Nationality',
|
return [...rows].sort((a, b) => {
|
||||||
birthday: 'Birthday',
|
const aValue = a[sortColumn];
|
||||||
pictureUrl: 'Photo URL',
|
const bValue = b[sortColumn];
|
||||||
alreadyPrinted: 'Already Printed'
|
|
||||||
};
|
if (aValue === bValue) return 0;
|
||||||
return labels[field] || field;
|
|
||||||
|
let comparison = 0;
|
||||||
|
if (aValue > bValue) {
|
||||||
|
comparison = 1;
|
||||||
|
} else {
|
||||||
|
comparison = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRowAlreadyPrinted(row: any): boolean {
|
return sortDirection === 'asc' ? comparison : -comparison;
|
||||||
const alreadyPrinted = row.alreadyPrinted;
|
});
|
||||||
if (!alreadyPrinted) return false;
|
});
|
||||||
|
|
||||||
const value = String(alreadyPrinted).toLowerCase().trim();
|
// Derived state: master checkbox reflects if first 200 eligible items in current view are selected
|
||||||
return value === 'true' || value === 'yes' || value === '1' || value === 'x';
|
const allValidRowsSelected = $derived.by(() => {
|
||||||
}
|
const eligible = displayData.filter((r) => r._valid && !r.alreadyPrinted);
|
||||||
|
const firstBatch = eligible.slice(0, ROW_LIMIT);
|
||||||
|
if (firstBatch.length === 0) return false;
|
||||||
|
return firstBatch.every((row) => row._checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCount = $derived(rows.filter((row) => row._checked).length);
|
||||||
|
|
||||||
function handleContinue() {
|
function handleContinue() {
|
||||||
// Filter the data to only include selected rows
|
$sheetData = rows.filter((row) => row._checked);
|
||||||
const selectedData = processedData.filter(
|
|
||||||
(row) => selectedRows.has(row._rowIndex) && row._isValid
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store the filtered data
|
|
||||||
filteredSheetData.set(selectedData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: 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>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="container max-w-none p-6">
|
||||||
<div class="mb-6">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
|
<h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
<p class="mb-4 text-sm text-gray-700">
|
Review your data and select which rows to include. Select a batch of max 200 items by using
|
||||||
Review your data and select which rows you want to include in the card generation. Only rows
|
the top checkbox.
|
||||||
with all required fields will be available for selection.
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-700">
|
||||||
|
Tip: Hold <kbd
|
||||||
|
class="rounded-md border border-gray-400 bg-gray-200 px-1.5 py-0.5 text-xs font-semibold"
|
||||||
|
>Shift</kbd
|
||||||
|
> and click two checkboxes to select a range of rows.
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-700">
|
||||||
|
Already printed or invalid data is marked in the status column.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
<!-- Search and Filter Controls -->
|
{#if $selectedSheet?.id}
|
||||||
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<a
|
||||||
<div class="flex flex-col gap-4 sm:flex-row">
|
href={`https://docs.google.com/spreadsheets/d/${$selectedSheet.id}/edit`}
|
||||||
<!-- Search -->
|
target="_blank"
|
||||||
<div class="flex-grow">
|
rel="noopener noreferrer"
|
||||||
<label for="search" class="mb-2 block text-sm font-medium text-gray-700">
|
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
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>
|
Open Sheet
|
||||||
{#each headers as header}
|
</a>
|
||||||
<option value={header}>{getFieldLabel(header)}</option>
|
{/if}
|
||||||
{/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
|
<button
|
||||||
onclick={processSheetData}
|
onclick={fetchAndProcessData}
|
||||||
|
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-wait disabled:opacity-50"
|
||||||
disabled={isLoading}
|
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}
|
{#if isLoading}
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-4 w-4 animate-spin"
|
class="-ml-1 mr-2 h-5 w-5 animate-spin text-gray-500"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -290,8 +249,12 @@
|
|||||||
r="10"
|
r="10"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="4"
|
stroke-width="4"
|
||||||
/>
|
></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
Refreshing...
|
Refreshing...
|
||||||
{:else}
|
{:else}
|
||||||
@@ -301,201 +264,156 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data Table -->
|
{#if isLoading}
|
||||||
<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">
|
<div class="py-12 text-center">
|
||||||
<svg
|
<p class="text-lg">Loading data from Google Sheet...</p>
|
||||||
class="mx-auto h-12 w-12 text-gray-400"
|
<p class="text-gray-500">Please wait a moment.</p>
|
||||||
fill="none"
|
</div>
|
||||||
viewBox="0 0 24 24"
|
{:else if error}
|
||||||
stroke="currentColor"
|
<div
|
||||||
|
class="rounded-md border border-red-400 bg-red-50 p-4"
|
||||||
>
|
>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
fill-rule="evenodd"
|
||||||
stroke-linejoin="round"
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
|
||||||
stroke-width="2"
|
clip-rule="evenodd"
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3>
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">An Error Occurred</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<div class="py-12 text-center">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">No Data Found</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
{searchTerm ? 'No rows match your search criteria.' : 'No data available to display.'}
|
The selected sheet appears to be empty or could not be read.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded-lg border border-gray-200">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<!-- Select All Checkbox -->
|
<th class="px-4 py-3 text-left">
|
||||||
<th class="px-3 py-3 text-left">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={selectAll}
|
|
||||||
onchange={toggleSelectAll}
|
|
||||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
disabled={isLoading}
|
onchange={toggleSelectAll}
|
||||||
|
checked={allValidRowsSelected}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<!-- Column Headers -->
|
|
||||||
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
|
|
||||||
<th
|
<th
|
||||||
class="cursor-pointer px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:bg-gray-100"
|
class="cursor-pointer px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600 hover:bg-gray-100"
|
||||||
onclick={() => !isLoading && handleSort(header)}
|
onclick={() => sortBy('_rowIndex')}>#</th
|
||||||
>
|
>
|
||||||
<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
|
<th
|
||||||
class="px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
class="cursor-pointer px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => sortBy('name')}>Full Name</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="cursor-pointer px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => sortBy('nationality')}>Nationality</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="cursor-pointer px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => sortBy('birthday')}>Birthday</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="cursor-pointer px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => sortBy('pictureUrl')}>Picture URL</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="cursor-pointer px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => sortBy('alreadyPrinted')}>Printed</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="cursor-pointer px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => sortBy('_valid')}>Status</th
|
||||||
>
|
>
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
{#if isLoading}
|
{#each displayData as row (row.id)}
|
||||||
<!-- 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
|
<tr
|
||||||
class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(
|
class="hover:bg-gray-50"
|
||||||
row
|
class:bg-gray-100={!row._valid}
|
||||||
)
|
class:text-gray-400={!row._valid || row.alreadyPrinted}
|
||||||
? 'bg-orange-50'
|
class:bg-orange-50={row.alreadyPrinted}
|
||||||
: ''}"
|
|
||||||
>
|
>
|
||||||
<!-- Selection Checkbox -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-3 py-4">
|
|
||||||
{#if row._isValid}
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedRows.has(row._rowIndex)}
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-200"
|
||||||
onchange={() => toggleRowSelection(row._rowIndex)}
|
checked={row._checked}
|
||||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
disabled={!row._valid || (selectedCount >= ROW_LIMIT && !row._checked)}
|
||||||
|
onclick={(e) => handleRowClick(e, row.id)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="h-4 w-4 rounded bg-gray-200"></div>
|
|
||||||
{/if}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm">{row._rowIndex}</td>
|
||||||
<!-- Data Columns -->
|
<td class="whitespace-nowrap px-4 py-3 text-sm">{row.name}</td>
|
||||||
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
|
<td class="whitespace-nowrap px-4 py-3 text-sm">{row.nationality}</td>
|
||||||
<td class="max-w-xs truncate px-3 py-4 text-sm text-gray-900">
|
<td class="whitespace-nowrap px-4 py-3 text-sm">{row.birthday}</td>
|
||||||
{row[header] || ''}
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
</td>
|
<a
|
||||||
{/each}
|
href={row.pictureUrl}
|
||||||
|
target="_blank"
|
||||||
<!-- Status Column -->
|
rel="noopener noreferrer"
|
||||||
<td class="px-3 py-4 text-sm">
|
class="text-blue-600 hover:underline"
|
||||||
<div class="flex flex-col space-y-1">
|
title={row.pictureUrl}>link</a
|
||||||
{#if row._isValid}
|
>
|
||||||
<span
|
</td>
|
||||||
class="inline-flex rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800"
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
{#if row.alreadyPrinted}
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full bg-orange-100 px-2 text-xs font-semibold leading-5 text-orange-800"
|
||||||
|
>Yes</span
|
||||||
>
|
>
|
||||||
Valid
|
|
||||||
</span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class="inline-flex rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800"
|
class="inline-flex rounded-full bg-gray-100 px-2 text-xs font-semibold leading-5 text-gray-800"
|
||||||
|
>No</span
|
||||||
>
|
>
|
||||||
Missing data
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</td>
|
||||||
{#if isRowAlreadyPrinted(row)}
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
{#if row._valid}
|
||||||
<span
|
<span
|
||||||
class="inline-flex rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800"
|
class="inline-flex rounded-full bg-green-100 px-2 text-xs font-semibold leading-5 text-green-800"
|
||||||
|
>Valid</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full bg-red-100 px-2 text-xs font-semibold leading-5 text-red-800"
|
||||||
|
>Invalid</span
|
||||||
>
|
>
|
||||||
Already Printed
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Navigation -->
|
<div class="mt-6">
|
||||||
<Navigator
|
<Navigator
|
||||||
canProceed={canProceed}
|
canProceed={selectedCount > 0}
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
textBack="Back to Colum Selection"
|
|
||||||
textForwardDisabled="Select rows to continue"
|
|
||||||
textForwardEnabled={`Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} →`}
|
|
||||||
onForward={handleContinue}
|
onForward={handleContinue}
|
||||||
/>
|
textBack="Back to Column Mapping"
|
||||||
|
textForwardEnabled="Continue to Card Details"
|
||||||
|
textForwardDisabled="Select at least one valid row"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
|
import { selectedSheet, currentStep } from '$lib/stores';
|
||||||
import { searchSheets } from '$lib/google';
|
import type { SheetInfoType } from '$lib/stores';
|
||||||
|
import { searchSheets, ensureToken, userEmail } from '$lib/google';
|
||||||
|
import { hashString } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Navigator from './subcomponents/Navigator.svelte';
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
|
|
||||||
@@ -11,9 +13,16 @@
|
|||||||
let hasSearched = $state(false);
|
let hasSearched = $state(false);
|
||||||
let recentSheets = $state<any[]>([]);
|
let recentSheets = $state<any[]>([]);
|
||||||
|
|
||||||
const RECENT_SHEETS_KEY = 'recent-sheets';
|
async function getRecentSheetsKey() {
|
||||||
|
const email = $userEmail;
|
||||||
|
if (email) {
|
||||||
|
return `recentSheets_${await hashString(email)}`;
|
||||||
|
}
|
||||||
|
return 'recentSheets_anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
ensureToken();
|
||||||
loadRecentSheets();
|
loadRecentSheets();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,48 +34,42 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
searchResults = await searchSheets(searchQuery);
|
searchResults = await searchSheets(searchQuery);
|
||||||
availableSheets.set(
|
|
||||||
searchResults.map((sheet) => ({
|
|
||||||
spreadsheetId: sheet.spreadsheetId || sheet.id,
|
|
||||||
name: sheet.name,
|
|
||||||
url: sheet.webViewLink
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
hasSearched = true;
|
hasSearched = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error searching sheets:', err);
|
console.error('Error searching sheets:', err);
|
||||||
error = 'Failed to search sheets. Please check your connection and try again.';
|
error = 'Failed to search sheets. Please check your connection and try again.';
|
||||||
searchResults = [];
|
searchResults = [];
|
||||||
availableSheets.set([]);
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRecentSheets() {
|
async function loadRecentSheets() {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(RECENT_SHEETS_KEY);
|
const key = await getRecentSheetsKey();
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
recentSheets = JSON.parse(saved);
|
recentSheets = JSON.parse(saved);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading recent sheets:', err);
|
console.error('Error loading recent sheets:', err);
|
||||||
// If there's an error, clear the stored value
|
// If there's an error, clear the stored value
|
||||||
localStorage.removeItem(RECENT_SHEETS_KEY);
|
const key = await getRecentSheetsKey();
|
||||||
|
localStorage.removeItem(key);
|
||||||
recentSheets = [];
|
recentSheets = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectSheet(sheet) {
|
function handleSelectSheet(sheet) {
|
||||||
const sheetData = {
|
const sheetData: SheetInfoType = {
|
||||||
spreadsheetId: sheet.spreadsheetId || sheet.id,
|
id: sheet.id,
|
||||||
name: sheet.name,
|
name: sheet.name,
|
||||||
url: sheet.webViewLink || sheet.url
|
webViewLink: sheet.webViewLink
|
||||||
};
|
};
|
||||||
selectedSheet.set(sheetData);
|
selectedSheet.set(sheetData);
|
||||||
}
|
}
|
||||||
|
|
||||||
let canProceed = $derived($selectedSheet !== null);
|
let canProceed = $derived($selectedSheet.id !== '');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
@@ -131,8 +134,8 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each searchResults as sheet}
|
{#each searchResults as sheet}
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
|
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.id ===
|
||||||
(sheet.spreadsheetId || sheet.id)
|
(sheet.id || sheet.id)
|
||||||
? 'border-blue-500 bg-blue-50'
|
? 'border-blue-500 bg-blue-50'
|
||||||
: 'border-gray-200'}"
|
: 'border-gray-200'}"
|
||||||
onclick={() => handleSelectSheet(sheet)}
|
onclick={() => handleSelectSheet(sheet)}
|
||||||
@@ -153,7 +156,7 @@
|
|||||||
<img src={sheet.iconLink} alt="Sheet icon" class="my-2 mr-2 h-5 w-5" />
|
<img src={sheet.iconLink} alt="Sheet icon" class="my-2 mr-2 h-5 w-5" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
|
{#if $selectedSheet?.id === (sheet.id || sheet.id)}
|
||||||
<svg class="h-5 w-5 text-blue-600 my-2" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="h-5 w-5 text-blue-600 my-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
@@ -195,8 +198,8 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each recentSheets as sheet}
|
{#each recentSheets as sheet}
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
|
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.id ===
|
||||||
(sheet.spreadsheetId || sheet.id)
|
(sheet.id || sheet.id)
|
||||||
? 'border-blue-500 bg-blue-50'
|
? 'border-blue-500 bg-blue-50'
|
||||||
: 'border-gray-200'}"
|
: 'border-gray-200'}"
|
||||||
onclick={() => handleSelectSheet(sheet)}
|
onclick={() => handleSelectSheet(sheet)}
|
||||||
@@ -217,7 +220,7 @@
|
|||||||
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
|
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
|
{#if $selectedSheet.id === sheet.id}
|
||||||
<svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
@@ -263,6 +266,6 @@
|
|||||||
{currentStep}
|
{currentStep}
|
||||||
textBack="Back to Auth"
|
textBack="Back to Auth"
|
||||||
textForwardDisabled="Select a sheet"
|
textForwardDisabled="Select a sheet"
|
||||||
textForwardEnabled="Continue"
|
textForwardEnabled="Continue to Column Mapping"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { on } from 'svelte/events';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
canProceed,
|
canProceed = true,
|
||||||
currentStep,
|
currentStep,
|
||||||
textBack,
|
textBack = 'Back',
|
||||||
textForwardDisabled,
|
textForwardDisabled = 'Next',
|
||||||
textForwardEnabled,
|
textForwardEnabled = 'Next',
|
||||||
onBack,
|
onBack,
|
||||||
onForward
|
onForward,
|
||||||
|
nextDisabled = false
|
||||||
} = $props<{
|
} = $props<{
|
||||||
canProceed: boolean;
|
canProceed?: boolean;
|
||||||
currentStep: any;
|
currentStep?: any;
|
||||||
textBack: string;
|
textBack?: string;
|
||||||
textForwardDisabled: string;
|
textForwardDisabled?: string;
|
||||||
textForwardEnabled: string;
|
textForwardEnabled?: string;
|
||||||
onBack?: () => void | null;
|
onBack?: () => void;
|
||||||
onForward?: () => void | null;
|
onForward?: () => void;
|
||||||
|
nextDisabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
async function handleBack() {
|
async function handleBack() {
|
||||||
if (onBack) {
|
if (onBack) {
|
||||||
// Allow custom back logic if provided
|
|
||||||
await onBack();
|
await onBack();
|
||||||
}
|
} else if (currentStep) {
|
||||||
currentStep.set($currentStep - 1);
|
currentStep.set($currentStep - 1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleForward() {
|
async function handleForward() {
|
||||||
if (onForward) {
|
if (onForward) {
|
||||||
// Allow custom forward logic if provided
|
|
||||||
await onForward();
|
await onForward();
|
||||||
}
|
}
|
||||||
|
if (currentStep) {
|
||||||
currentStep.set($currentStep + 1);
|
currentStep.set($currentStep + 1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:justify-between">
|
||||||
|
{#if onBack || currentStep}
|
||||||
<button
|
<button
|
||||||
onclick={handleBack}
|
onclick={handleBack}
|
||||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300 sm:w-auto"
|
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300 sm:w-auto"
|
||||||
@@ -46,13 +48,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{textBack}</span>
|
<span>{textBack}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={handleForward}
|
onclick={handleForward}
|
||||||
disabled={!canProceed}
|
disabled={!canProceed || nextDisabled}
|
||||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400 sm:w-auto"
|
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400 sm:w-auto"
|
||||||
>
|
>
|
||||||
<span>{canProceed ? textForwardEnabled : textForwardDisabled}</span>
|
<span>{canProceed && !nextDisabled ? textForwardEnabled : textForwardDisabled}</span>
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { PhotoDimensions } from '$lib/cards/types';
|
||||||
import PhotoCrop from './PhotoCrop.svelte';
|
import PhotoCrop from './PhotoCrop.svelte';
|
||||||
|
|
||||||
let { photo, onCropUpdated, onRetry } = $props<{
|
let { photo, onCropUpdated, onRetry, photoDimensions } = $props<{
|
||||||
photo: {
|
photo: {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
};
|
};
|
||||||
onCropUpdated: (detail: any) => void;
|
onCropUpdated: (detail: any) => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
|
photoDimensions: PhotoDimensions;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let showCropper = $state(false);
|
let showCropper = $state(false);
|
||||||
@@ -108,8 +110,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-3 flex items-center justify-between">
|
<div class="esnSection p-3 flex items-center justify-between gap-2">
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
|
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
|
||||||
{#if photo.faceDetectionStatus === 'completed'}
|
{#if photo.faceDetectionStatus === 'completed'}
|
||||||
<span class="text-xs text-green-600">Face detected</span>
|
<span class="text-xs text-green-600">Face detected</span>
|
||||||
@@ -125,7 +127,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showCropper = true)}
|
onclick={() => (showCropper = true)}
|
||||||
class="p-1 text-gray-500 hover:text-blue-600"
|
class="p-1 text-gray-500 hover:text-blue-600 shrink-0"
|
||||||
title="Edit Crop"
|
title="Edit Crop"
|
||||||
aria-label="Edit Crop"
|
aria-label="Edit Crop"
|
||||||
>
|
>
|
||||||
@@ -145,6 +147,7 @@
|
|||||||
imageUrl={photo.objectUrl}
|
imageUrl={photo.objectUrl}
|
||||||
personName={photo.name}
|
personName={photo.name}
|
||||||
initialCropData={photo.cropData}
|
initialCropData={photo.cropData}
|
||||||
|
{photoDimensions}
|
||||||
onClose={() => (showCropper = false)}
|
onClose={() => (showCropper = false)}
|
||||||
onCropUpdated={handleCropUpdated}
|
onCropUpdated={handleCropUpdated}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { env } from '$env/dynamic/public';
|
import type { PhotoDimensions } from '$lib/cards/types';
|
||||||
|
|
||||||
let { imageUrl, personName, initialCropData, onCropUpdated, onClose } = $props<{
|
let {
|
||||||
|
imageUrl,
|
||||||
|
personName,
|
||||||
|
initialCropData,
|
||||||
|
onCropUpdated,
|
||||||
|
onClose,
|
||||||
|
photoDimensions
|
||||||
|
} = $props<{
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
personName: string;
|
personName: string;
|
||||||
initialCropData?: { x: number; y: number; width: number; height: number };
|
initialCropData?: { x: number; y: number; width: number; height: number };
|
||||||
@@ -10,6 +17,7 @@
|
|||||||
cropData: { x: number; y: number; width: number; height: number };
|
cropData: { x: number; y: number; width: number; height: number };
|
||||||
}) => void;
|
}) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
photoDimensions: PhotoDimensions;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
@@ -27,16 +35,14 @@
|
|||||||
|
|
||||||
// Interaction state
|
// Interaction state
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let isResizing = false;
|
|
||||||
let dragStart = { x: 0, y: 0 };
|
let dragStart = { x: 0, y: 0 };
|
||||||
let resizeHandle = '';
|
|
||||||
|
|
||||||
// Canvas dimensions
|
// Canvas dimensions
|
||||||
let canvasWidth = 600;
|
let canvasWidth = 600;
|
||||||
let canvasHeight = 400;
|
let canvasHeight = 400;
|
||||||
|
|
||||||
// Get crop ratio from environment
|
// Use the photo card aspect ratio from the selected card's dimensions
|
||||||
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0');
|
const cropRatio = photoDimensions.width / photoDimensions.height;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
ctx = canvas.getContext('2d')!;
|
ctx = canvas.getContext('2d')!;
|
||||||
@@ -127,25 +133,6 @@
|
|||||||
ctx.strokeStyle = '#3b82f6';
|
ctx.strokeStyle = '#3b82f6';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height);
|
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) {
|
function getMousePos(e: MouseEvent) {
|
||||||
@@ -157,31 +144,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isInCropArea(x: number, y: number) {
|
function isInCropArea(x: number, y: number) {
|
||||||
return x >= crop.x && x <= crop.x + crop.width &&
|
return x >= crop.x && x <= crop.x + crop.width && y >= crop.y && y <= crop.y + crop.height;
|
||||||
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) {
|
function handleMouseDown(e: MouseEvent) {
|
||||||
const pos = getMousePos(e);
|
const pos = getMousePos(e);
|
||||||
const handle = getResizeHandle(pos.x, pos.y);
|
if (isInCropArea(pos.x, pos.y)) {
|
||||||
|
|
||||||
if (handle) {
|
|
||||||
isResizing = true;
|
|
||||||
resizeHandle = handle;
|
|
||||||
dragStart = pos;
|
|
||||||
} else if (isInCropArea(pos.x, pos.y)) {
|
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
|
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
|
||||||
}
|
}
|
||||||
@@ -190,86 +158,13 @@
|
|||||||
function handleMouseMove(e: MouseEvent) {
|
function handleMouseMove(e: MouseEvent) {
|
||||||
const pos = getMousePos(e);
|
const pos = getMousePos(e);
|
||||||
|
|
||||||
if (isResizing) {
|
if (isDragging) {
|
||||||
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.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));
|
crop.y = Math.max(0, Math.min(canvasHeight - crop.height, pos.y - dragStart.y));
|
||||||
drawCanvas();
|
drawCanvas();
|
||||||
} else {
|
} else {
|
||||||
// Update cursor based on hover state
|
// Update cursor based on hover state
|
||||||
const handle = getResizeHandle(pos.x, pos.y);
|
if (isInCropArea(pos.x, pos.y)) {
|
||||||
if (handle) {
|
|
||||||
canvas.style.cursor = handle + '-resize';
|
|
||||||
} else if (isInCropArea(pos.x, pos.y)) {
|
|
||||||
canvas.style.cursor = 'move';
|
canvas.style.cursor = 'move';
|
||||||
} else {
|
} else {
|
||||||
canvas.style.cursor = 'default';
|
canvas.style.cursor = 'default';
|
||||||
@@ -279,11 +174,39 @@
|
|||||||
|
|
||||||
function handleMouseUp() {
|
function handleMouseUp() {
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
isResizing = false;
|
|
||||||
resizeHandle = '';
|
|
||||||
canvas.style.cursor = 'default';
|
canvas.style.cursor = 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zoom(factor: number) {
|
||||||
|
const center = {
|
||||||
|
x: crop.x + crop.width / 2,
|
||||||
|
y: crop.y + crop.height / 2
|
||||||
|
};
|
||||||
|
|
||||||
|
let newWidth = crop.width * factor;
|
||||||
|
let newHeight = newWidth / cropRatio;
|
||||||
|
|
||||||
|
// Clamp to min/max size
|
||||||
|
newWidth = Math.max(20, Math.min(canvasWidth, newWidth));
|
||||||
|
newHeight = newWidth / cropRatio;
|
||||||
|
|
||||||
|
if (newHeight > canvasHeight) {
|
||||||
|
newHeight = canvasHeight;
|
||||||
|
newWidth = newHeight * cropRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
crop.width = newWidth;
|
||||||
|
crop.height = newHeight;
|
||||||
|
crop.x = center.x - newWidth / 2;
|
||||||
|
crop.y = center.y - newHeight / 2;
|
||||||
|
|
||||||
|
// Ensure it stays within bounds after zooming
|
||||||
|
crop.x = Math.max(0, Math.min(canvasWidth - crop.width, crop.x));
|
||||||
|
crop.y = Math.max(0, Math.min(canvasHeight - crop.height, crop.y));
|
||||||
|
|
||||||
|
drawCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
// Scale crop rectangle back to original image dimensions
|
// Scale crop rectangle back to original image dimensions
|
||||||
const scaleX = image.width / canvasWidth;
|
const scaleX = image.width / canvasWidth;
|
||||||
@@ -344,16 +267,52 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 p-2 rounded-md text-center">
|
<div class="relative mb-4 p-2 rounded-md text-center">
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={canvas}
|
bind:this={canvas}
|
||||||
onmousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
onmousemove={handleMouseMove}
|
onmousemove={handleMouseMove}
|
||||||
onmouseup={handleMouseUp}
|
onmouseup={handleMouseUp}
|
||||||
onmouseleave={handleMouseUp}
|
onmouseleave={handleMouseUp}
|
||||||
class="mx-auto cursor-move"
|
class="mx-auto"
|
||||||
style="max-width: 100%; height: auto;"
|
style="max-width: 100%; height: auto;"
|
||||||
></canvas>
|
></canvas>
|
||||||
|
<div class="absolute bottom-4 right-4 flex space-x-2">
|
||||||
|
<button
|
||||||
|
onclick={() => zoom(1 / 1.1)}
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-700 bg-opacity-50 text-white hover:bg-opacity-75"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="h-6 w-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 10a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H4.75A.75.75 0 014 10z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => zoom(1.1)}
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-700 bg-opacity-50 text-white hover:bg-opacity-75"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="h-6 w-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
|
|||||||
@@ -1,129 +1,205 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable, get } from 'svelte/store';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
export const isGoogleApiReady = writable(false);
|
// Store state: undefined = not yet known, null = failed/logged out, string = token
|
||||||
|
export const accessToken = writable<string | null | undefined>(undefined);
|
||||||
export const isSignedIn = writable(false);
|
export const isSignedIn = writable(false);
|
||||||
|
export const isGoogleApiReady = writable(false); // To track GAPI client readiness
|
||||||
|
export const userEmail = writable<string | null>(null);
|
||||||
|
|
||||||
let tokenClient: google.accounts.oauth2.TokenClient;
|
let tokenClient: google.accounts.oauth2.TokenClient;
|
||||||
|
let gapiInited = false;
|
||||||
|
let gsiInited = false;
|
||||||
|
|
||||||
const TOKEN_KEY = 'google_oauth_token';
|
// This function ensures both GAPI (for Sheets/Drive APIs) and GSI (for auth) are loaded in the correct order.
|
||||||
export function initGoogleClient(callback: () => void) {
|
export function initGoogleClients(callback: () => void) {
|
||||||
const script = document.createElement('script');
|
// If everything is already initialized, just run the callback.
|
||||||
script.src = 'https://apis.google.com/js/api.js';
|
if (gapiInited && gsiInited) {
|
||||||
script.onload = () => {
|
isGoogleApiReady.set(true); // Ensure it's set if called again
|
||||||
gapi.load('client', async () => {
|
|
||||||
await gapi.client.init({
|
|
||||||
discoveryDocs: [
|
|
||||||
'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
|
|
||||||
'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
isGoogleApiReady.set(true);
|
|
||||||
// Restore token from storage if available
|
|
||||||
const saved = localStorage.getItem(TOKEN_KEY);
|
|
||||||
if (saved) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(saved);
|
|
||||||
if (data.access_token && data.expires_at && data.expires_at > Date.now()) {
|
|
||||||
gapi.client.setToken({ access_token: data.access_token });
|
|
||||||
isSignedIn.set(true);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
callback();
|
callback();
|
||||||
});
|
|
||||||
};
|
|
||||||
document.body.appendChild(script);
|
|
||||||
|
|
||||||
const scriptGsi = document.createElement('script');
|
|
||||||
scriptGsi.src = 'https://accounts.google.com/gsi/client';
|
|
||||||
scriptGsi.onload = () => {
|
|
||||||
const clientId = env.PUBLIC_GOOGLE_CLIENT_ID;
|
|
||||||
if (!clientId) {
|
|
||||||
console.error('PUBLIC_GOOGLE_CLIENT_ID is not set in the environment.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokenClient = google.accounts.oauth2.initTokenClient({
|
|
||||||
client_id: clientId,
|
// 1. Load GAPI script for Sheets/Drive APIs first.
|
||||||
scope: 'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets.readonly',
|
if (!gapiInited) {
|
||||||
callback: (tokenResponse) => {
|
const gapiScript = document.createElement('script');
|
||||||
if (tokenResponse?.access_token) {
|
gapiScript.src = 'https://apis.google.com/js/api.js';
|
||||||
// Set token in gapi client
|
gapiScript.async = true;
|
||||||
gapi.client.setToken({ access_token: tokenResponse.access_token });
|
gapiScript.defer = true;
|
||||||
isSignedIn.set(true);
|
document.head.appendChild(gapiScript);
|
||||||
// Persist token with expiration
|
gapiScript.onload = () => {
|
||||||
const expiresInSeconds = tokenResponse.expires_in
|
gapi.load('client', () => {
|
||||||
? Number(tokenResponse.expires_in)
|
gapi.client
|
||||||
: 0;
|
.init({
|
||||||
const expiresInMs = expiresInSeconds * 1000;
|
discoveryDocs: [
|
||||||
const record = {
|
'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
|
||||||
access_token: tokenResponse.access_token,
|
'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest'
|
||||||
expires_at: expiresInMs ? Date.now() + expiresInMs : Date.now() + 3600 * 1000
|
]
|
||||||
};
|
})
|
||||||
localStorage.setItem(TOKEN_KEY, JSON.stringify(record));
|
.then(() => {
|
||||||
}
|
gapiInited = true;
|
||||||
},
|
// Now that GAPI is ready, initialize the GSI client.
|
||||||
|
initGsiClient(callback);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
document.body.appendChild(scriptGsi);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleSignIn() {
|
|
||||||
if (gapi.client.getToken() === null) {
|
|
||||||
tokenClient.requestAccessToken({ prompt: 'consent' });
|
|
||||||
} else {
|
} else {
|
||||||
tokenClient.requestAccessToken({ prompt: '' });
|
// GAPI is already ready, just ensure GSI is initialized.
|
||||||
|
initGsiClient(callback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleSignOut() {
|
/**
|
||||||
const savedToken = localStorage.getItem(TOKEN_KEY);
|
* Fetches user's email and stores it.
|
||||||
if (savedToken) {
|
*/
|
||||||
|
async function fetchUserInfo(token: string) {
|
||||||
try {
|
try {
|
||||||
const tokenData = JSON.parse(savedToken);
|
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||||
if (tokenData.access_token) {
|
headers: {
|
||||||
google.accounts.oauth2.revoke(tokenData.access_token, () => {
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch user info');
|
||||||
|
}
|
||||||
|
const profile = await response.json();
|
||||||
|
userEmail.set(profile.email);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user info:', error);
|
||||||
|
userEmail.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load GSI script for Auth. This should only be called after GAPI is ready.
|
||||||
|
function initGsiClient(callback: () => void) {
|
||||||
|
if (gsiInited) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gsiScript = document.createElement('script');
|
||||||
|
gsiScript.src = 'https://accounts.google.com/gsi/client';
|
||||||
|
gsiScript.async = true;
|
||||||
|
gsiScript.defer = true;
|
||||||
|
document.head.appendChild(gsiScript);
|
||||||
|
gsiScript.onload = () => {
|
||||||
|
gsiInited = true;
|
||||||
|
tokenClient = google.accounts.oauth2.initTokenClient({
|
||||||
|
client_id: env.PUBLIC_GOOGLE_CLIENT_ID,
|
||||||
|
scope:
|
||||||
|
'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
callback: (tokenResponse) => {
|
||||||
|
// This callback handles responses from all token requests.
|
||||||
|
if (tokenResponse.error) {
|
||||||
|
console.error('Google token error:', tokenResponse.error);
|
||||||
|
accessToken.set(null);
|
||||||
|
isSignedIn.set(false);
|
||||||
|
if (gapiInited) gapi.client.setToken(null);
|
||||||
|
} else if (tokenResponse.access_token) {
|
||||||
|
const token = tokenResponse.access_token;
|
||||||
|
accessToken.set(token);
|
||||||
|
isSignedIn.set(true);
|
||||||
|
// Also set the token for the GAPI client
|
||||||
|
if (gapiInited) gapi.client.setToken({ access_token: token });
|
||||||
|
fetchUserInfo(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
isGoogleApiReady.set(true);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to get a token silently.
|
||||||
|
* This is for background tasks and on-load checks.
|
||||||
|
* It will not show a consent prompt to the user.
|
||||||
|
*/
|
||||||
|
export function ensureToken(): Promise<string> {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
initGoogleClients(() => {
|
||||||
|
const currentToken = get(accessToken);
|
||||||
|
// If we already have a valid token, resolve immediately.
|
||||||
|
if (currentToken) {
|
||||||
|
res(currentToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsubscribe: () => void;
|
||||||
|
unsubscribe = accessToken.subscribe((t) => {
|
||||||
|
// undefined means we are still waiting for the initial token request.
|
||||||
|
if (t) { // Got a token.
|
||||||
|
if (unsubscribe) unsubscribe();
|
||||||
|
res(t);
|
||||||
|
} else if (t === null) { // Got an explicit null, meaning auth failed.
|
||||||
|
if (unsubscribe) unsubscribe();
|
||||||
|
rej(new Error('Failed to retrieve access token. The user may need to sign in.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no token, request one silently.
|
||||||
|
// The result is handled by the callback in initGsiClient, which updates the store and resolves the promise.
|
||||||
|
if (get(accessToken) === undefined) {
|
||||||
|
tokenClient.requestAccessToken({ prompt: '' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user for consent to grant a token.
|
||||||
|
* This should be called when a user clicks a "Sign In" button.
|
||||||
|
*/
|
||||||
|
export function requestTokenFromUser() {
|
||||||
|
initGoogleClients(() => {
|
||||||
|
if (tokenClient) {
|
||||||
|
tokenClient.requestAccessToken({ prompt: 'consent' });
|
||||||
|
} else {
|
||||||
|
console.error("requestTokenFromUser called before Google client was initialized.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the user out, revokes the token, and clears all local state.
|
||||||
|
*/
|
||||||
|
export function handleSignOut() {
|
||||||
|
const token = get(accessToken);
|
||||||
|
if (token && gsiInited) {
|
||||||
|
google.accounts.oauth2.revoke(token, () => {
|
||||||
console.log('User token revoked.');
|
console.log('User token revoked.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing token from localStorage', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disables automatic sign-in on the next page load.
|
// Clear all tokens and states
|
||||||
google.accounts.id.disableAutoSelect();
|
if (gapiInited) {
|
||||||
|
|
||||||
// Clear gapi client token
|
|
||||||
gapi.client.setToken(null);
|
gapi.client.setToken(null);
|
||||||
|
}
|
||||||
// Clear token from localStorage
|
accessToken.set(null);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
|
|
||||||
// Update signed in state
|
|
||||||
isSignedIn.set(false);
|
isSignedIn.set(false);
|
||||||
|
userEmail.set(null);
|
||||||
|
|
||||||
console.log('User signed out.');
|
console.log('User signed out.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSheets(query: string) {
|
export async function searchSheets(query: string) {
|
||||||
if (!gapi.client.drive) {
|
await ensureToken(); // Ensure we are authenticated before making a call
|
||||||
|
if (!gapi.client || !gapi.client.drive) {
|
||||||
throw new Error('Google Drive API not loaded');
|
throw new Error('Google Drive API not loaded');
|
||||||
}
|
}
|
||||||
const response = await gapi.client.drive.files.list({
|
const response = await gapi.client.drive.files.list({
|
||||||
q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`,
|
q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`,
|
||||||
fields: 'files(id, name, iconLink, webViewLink)',
|
fields: 'files(id, name, iconLink, webViewLink)',
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
|
supportsAllDrives: true,
|
||||||
|
includeItemsFromAllDrives: true,
|
||||||
|
corpora: 'allDrives'
|
||||||
});
|
});
|
||||||
return response.result.files || [];
|
return response.result.files || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSheetNames(spreadsheetId: string) {
|
export async function getSheetNames(spreadsheetId: string) {
|
||||||
if (!gapi.client.sheets) {
|
await ensureToken();
|
||||||
|
if (!gapi.client || !gapi.client.sheets) {
|
||||||
throw new Error('Google Sheets API not loaded');
|
throw new Error('Google Sheets API not loaded');
|
||||||
}
|
}
|
||||||
const response = await gapi.client.sheets.spreadsheets.get({
|
const response = await gapi.client.sheets.spreadsheets.get({
|
||||||
@@ -139,7 +215,8 @@ export async function getSheetNames(spreadsheetId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSheetData(spreadsheetId: string, range: string) {
|
export async function getSheetData(spreadsheetId: string, range: string) {
|
||||||
if (!gapi.client.sheets) {
|
await ensureToken();
|
||||||
|
if (!gapi.client || !gapi.client.sheets) {
|
||||||
throw new Error('Google Sheets API not loaded');
|
throw new Error('Google Sheets API not loaded');
|
||||||
}
|
}
|
||||||
const response = await gapi.client.sheets.spreadsheets.values.get({
|
const response = await gapi.client.sheets.spreadsheets.values.get({
|
||||||
@@ -178,13 +255,14 @@ export function isGoogleDriveUrl(url: string): boolean {
|
|||||||
|
|
||||||
// Download image from Google Drive using the API
|
// Download image from Google Drive using the API
|
||||||
export async function downloadDriveImage(url: string): Promise<Blob> {
|
export async function downloadDriveImage(url: string): Promise<Blob> {
|
||||||
|
await ensureToken();
|
||||||
const fileId = extractDriveFileId(url);
|
const fileId = extractDriveFileId(url);
|
||||||
|
|
||||||
if (!fileId) {
|
if (!fileId) {
|
||||||
throw new Error('Could not extract file ID from Google Drive URL');
|
throw new Error('Could not extract file ID from Google Drive URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!gapi.client.drive) {
|
if (!gapi.client || !gapi.client.drive) {
|
||||||
throw new Error('Google Drive API not loaded');
|
throw new Error('Google Drive API not loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
// PDF Layout Configuration Module
|
|
||||||
// Centralized configuration for PDF generation layouts, using millimeters.
|
|
||||||
|
|
||||||
import {
|
|
||||||
PHOTO_DIMENSIONS,
|
|
||||||
TEXT_FIELD_LAYOUT,
|
|
||||||
PHOTO_FIELD_LAYOUT
|
|
||||||
} from './pdfSettings';
|
|
||||||
import { get } from 'idb-keyval';
|
import { get } from 'idb-keyval';
|
||||||
|
|
||||||
// Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm)
|
// Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm)
|
||||||
@@ -84,8 +76,8 @@ export function getAbsolutePhotoDimensionsPt(
|
|||||||
|
|
||||||
// Border configuration
|
// Border configuration
|
||||||
export const BORDER_CONFIG = {
|
export const BORDER_CONFIG = {
|
||||||
color: { r: 0.8, g: 0.8, b: 0.8 },
|
color: { r: 0, g: 0, b: 0 },
|
||||||
width: 1 // in points
|
width: 0.5 // in points
|
||||||
};
|
};
|
||||||
|
|
||||||
// Text configuration
|
// Text configuration
|
||||||
|
|||||||
@@ -6,91 +6,9 @@ export interface PageSettings {
|
|||||||
margin: number; // mm
|
margin: number; // mm
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardDimensions {
|
|
||||||
width: number; // mm
|
|
||||||
height: number; // mm
|
|
||||||
}
|
|
||||||
|
|
||||||
// A4 Page dimensions in millimeters
|
// A4 Page dimensions in millimeters
|
||||||
export const PAGE_SETTINGS: PageSettings = {
|
export const PAGE_SETTINGS: PageSettings = {
|
||||||
pageWidth: 210,
|
pageWidth: 210,
|
||||||
pageHeight: 297,
|
pageHeight: 297,
|
||||||
margin: 10
|
margin: 15
|
||||||
};
|
|
||||||
|
|
||||||
// Dimensions for a single card in the text PDF.
|
|
||||||
// These dimensions will be used to calculate how many cards can fit on a page.
|
|
||||||
export const TEXT_CARD_DIMENSIONS: CardDimensions = {
|
|
||||||
width: 63,
|
|
||||||
height: 40
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dimensions for a single card in the photo PDF.
|
|
||||||
export const PHOTO_CARD_DIMENSIONS: CardDimensions = {
|
|
||||||
width: 25,
|
|
||||||
height: 35
|
|
||||||
};
|
|
||||||
|
|
||||||
// Photo dimensions within the photo card
|
|
||||||
export const PHOTO_DIMENSIONS = {
|
|
||||||
width: 20, // mm
|
|
||||||
height: 35 // mm
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface TextPosition {
|
|
||||||
x: number; // mm, relative to cell top-left
|
|
||||||
y: number; // mm, relative to cell top-left
|
|
||||||
size: number; // font size in points
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PhotoPosition {
|
|
||||||
x: number; // mm, relative to cell top-left
|
|
||||||
y: number; // mm, relative to cell top-left
|
|
||||||
width: number; // mm
|
|
||||||
height: number; // mm
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextFieldLayout {
|
|
||||||
name: TextPosition;
|
|
||||||
nationality: TextPosition;
|
|
||||||
birthday: TextPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PhotoFieldLayout {
|
|
||||||
photo: PhotoPosition;
|
|
||||||
name: TextPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text PDF Field Positions (in mm, relative to cell top-left)
|
|
||||||
export const TEXT_FIELD_LAYOUT: TextFieldLayout = {
|
|
||||||
name: {
|
|
||||||
x: 2,
|
|
||||||
y: 5,
|
|
||||||
size: 10 // font size in points
|
|
||||||
},
|
|
||||||
nationality: {
|
|
||||||
x: 2,
|
|
||||||
y: 10,
|
|
||||||
size: 10
|
|
||||||
},
|
|
||||||
birthday: {
|
|
||||||
x: 2,
|
|
||||||
y: 15,
|
|
||||||
size: 10
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Photo PDF Field Positions (in mm, relative to cell top-left)
|
|
||||||
export const PHOTO_FIELD_LAYOUT: PhotoFieldLayout = {
|
|
||||||
photo: {
|
|
||||||
x: 2, // 2mm from left of cell
|
|
||||||
y: 2, // 2mm from top of cell
|
|
||||||
width: PHOTO_DIMENSIONS.width,
|
|
||||||
height: PHOTO_DIMENSIONS.height
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
x: 2, // 2mm from left of cell
|
|
||||||
y: PHOTO_DIMENSIONS.height + 0, // Below the photo + 5mm gap
|
|
||||||
size: 5 // font size in points
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,64 +1,99 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
import { writable, derived } from 'svelte/store';
|
||||||
|
|
||||||
|
|
||||||
|
// This file is holy and shall not be edited by Copilot!
|
||||||
|
|
||||||
|
|
||||||
// User session and authentication
|
// User session and authentication
|
||||||
export const session = writable<{
|
export const session = writable<{
|
||||||
token?: string;
|
token?: string;
|
||||||
user?: { name: string; email: string };
|
user?: { name: string; email: string };
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
// Raw sheet data after import
|
// Data structure column mapping
|
||||||
export const rawSheetData = writable<string[][]>([]);
|
export interface ColumnMappingType {
|
||||||
|
name: number;
|
||||||
|
nationality: number;
|
||||||
|
birthday: number;
|
||||||
|
pictureUrl: number;
|
||||||
|
alreadyPrinted: number;
|
||||||
|
sheetName: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Filtered sheet data after row selection
|
// Data structure for a row in the sheet
|
||||||
export const filteredSheetData = writable<any[]>([]);
|
|
||||||
|
|
||||||
// Column mapping configuration
|
|
||||||
export const columnMapping = writable<{
|
|
||||||
name?: number;
|
|
||||||
surname?: number;
|
|
||||||
nationality?: number;
|
|
||||||
birthday?: number;
|
|
||||||
pictureUrl?: number;
|
|
||||||
alreadyPrinted?: number;
|
|
||||||
}>({});
|
|
||||||
|
|
||||||
// Processed row data after mapping and validation
|
|
||||||
export interface RowData {
|
export interface RowData {
|
||||||
id: string;
|
id: string; // Unique identifier
|
||||||
name: string;
|
name: string;
|
||||||
surname: string;
|
|
||||||
nationality: string;
|
nationality: string;
|
||||||
birthday: string;
|
birthday: string;
|
||||||
pictureUrl: string;
|
pictureUrl: string;
|
||||||
valid: boolean;
|
alreadyPrinted: boolean;
|
||||||
included: boolean;
|
_rowIndex: number;
|
||||||
age?: number;
|
_checked: boolean;
|
||||||
validationErrors: string[];
|
_valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sheetData = writable<RowData[]>([]);
|
|
||||||
|
|
||||||
// Picture storage and metadata
|
// Picture storage and metadata
|
||||||
export interface PictureBlobInfo {
|
export interface PictureBlobInfoType {
|
||||||
id: string;
|
id: string;
|
||||||
blob: Blob;
|
|
||||||
url: string;
|
url: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
faceDetected: boolean;
|
faceDetected: boolean;
|
||||||
faceCount: number;
|
faceCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pictures = writable<Record<string, PictureBlobInfo>>({});
|
// CropType rectangles for each photo
|
||||||
|
export interface CropType {
|
||||||
// Crop rectangles for each photo
|
|
||||||
export interface Crop {
|
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cropRects = writable<Record<string, Crop>>({});
|
// Google Sheets list for search
|
||||||
|
export interface SheetInfoType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
webViewLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card details type
|
||||||
|
export interface CardDetailsType {
|
||||||
|
esnSection: string;
|
||||||
|
studiesAt: string;
|
||||||
|
validityStart: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Column mapping configuration
|
||||||
|
export const columnMapping = writable<ColumnMappingType>({
|
||||||
|
name: -1,
|
||||||
|
nationality: -1,
|
||||||
|
birthday: -1,
|
||||||
|
pictureUrl: -1,
|
||||||
|
alreadyPrinted: -1,
|
||||||
|
sheetName: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store to hold the processed sheet data
|
||||||
|
export const sheetData = writable<RowData[]>([]);
|
||||||
|
|
||||||
|
// Store and hold the processed picture data
|
||||||
|
export const pictures = writable<Record<string, PictureBlobInfoType>>({});
|
||||||
|
|
||||||
|
// Store and hold the crop rectangles from face detection
|
||||||
|
export const cropRects = writable<Record<string, CropType>>({});
|
||||||
|
|
||||||
|
// Store and hold the selected sheet
|
||||||
|
export const selectedSheet = writable<SheetInfoType>({ id: '', name: '', webViewLink: '' });
|
||||||
|
|
||||||
|
// Card details for generation
|
||||||
|
export const cardDetails = writable<CardDetailsType | null>(null);
|
||||||
|
|
||||||
|
// Selected card type for generation
|
||||||
|
import type { Card } from '$lib/cards/types';
|
||||||
|
export const selectedCard = writable<Card | null>(null);
|
||||||
|
|
||||||
// Wizard state management
|
// Wizard state management
|
||||||
export const currentStep = writable<number>(0);
|
export const currentStep = writable<number>(0);
|
||||||
@@ -69,6 +104,8 @@ export const steps = [
|
|||||||
'search',
|
'search',
|
||||||
'mapping',
|
'mapping',
|
||||||
'validation',
|
'validation',
|
||||||
|
'card-details',
|
||||||
|
'card-select',
|
||||||
'gallery',
|
'gallery',
|
||||||
'generate'
|
'generate'
|
||||||
] as const;
|
] as const;
|
||||||
@@ -94,53 +131,3 @@ export const progress = writable<ProgressState>({
|
|||||||
total: 0,
|
total: 0,
|
||||||
message: ''
|
message: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Google Sheets list for search
|
|
||||||
export interface SheetInfo {
|
|
||||||
spreadsheetId: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const availableSheets = writable<SheetInfo[]>([]);
|
|
||||||
|
|
||||||
// Selected sheet
|
|
||||||
export const selectedSheet = writable<SheetInfo | null>(null);
|
|
||||||
|
|
||||||
// Validation derived stores
|
|
||||||
export const validRowCount = derived(
|
|
||||||
sheetData,
|
|
||||||
($sheetData) => $sheetData.filter(row => row.valid && row.included).length
|
|
||||||
);
|
|
||||||
|
|
||||||
export const invalidRowCount = derived(
|
|
||||||
sheetData,
|
|
||||||
($sheetData) => $sheetData.filter(row => !row.valid).length
|
|
||||||
);
|
|
||||||
|
|
||||||
export const totalRowCount = derived(
|
|
||||||
sheetData,
|
|
||||||
($sheetData) => $sheetData.length
|
|
||||||
);
|
|
||||||
|
|
||||||
// Face detection status
|
|
||||||
export const faceDetectionProgress = writable<{
|
|
||||||
completed: number;
|
|
||||||
total: number;
|
|
||||||
currentImage: string;
|
|
||||||
}>({
|
|
||||||
completed: 0,
|
|
||||||
total: 0,
|
|
||||||
currentImage: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
// PDF generation status
|
|
||||||
export const pdfGenerationStatus = writable<{
|
|
||||||
generating: boolean;
|
|
||||||
stage: 'preparing' | 'text-pdf' | 'photo-pdf' | 'complete';
|
|
||||||
progress: number;
|
|
||||||
}>({
|
|
||||||
generating: false,
|
|
||||||
stage: 'preparing',
|
|
||||||
progress: 0
|
|
||||||
});
|
|
||||||
|
|||||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Hashes a string using the SHA-256 algorithm.
|
||||||
|
* @param input The string to hash.
|
||||||
|
* @returns A promise that resolves to the hex-encoded hash string.
|
||||||
|
*/
|
||||||
|
export async function hashString(input: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
68
src/lib/utils/date.ts
Normal file
68
src/lib/utils/date.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Parses a date string from various common formats and returns it in YYYY-MM-DD format.
|
||||||
|
* Handles ISO (YYYY-MM-DD), European (DD.MM.YYYY), and US (MM/DD/YYYY) formats,
|
||||||
|
* as well as Excel-style serial numbers.
|
||||||
|
* @param value The date string to parse.
|
||||||
|
* @returns The formatted date string or the original value if parsing fails.
|
||||||
|
*/
|
||||||
|
export function parseAndFormatDate(value: string | number | undefined): string {
|
||||||
|
if (value === undefined || value === null || value === '') return '';
|
||||||
|
|
||||||
|
const trimmed = value.toString().trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
let date: Date | null = null;
|
||||||
|
|
||||||
|
// 1. Try direct parsing (handles ISO 8601 like YYYY-MM-DD)
|
||||||
|
const directParse = new Date(trimmed);
|
||||||
|
if (!isNaN(directParse.getTime()) && trimmed.match(/^\d{4}/)) {
|
||||||
|
date = directParse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Regex for MM/DD/YYYY or MM.DD.YYYY or MM-DD-YYYY (common in Google Forms)
|
||||||
|
if (!date) {
|
||||||
|
const mdyMatch = trimmed.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})$/);
|
||||||
|
if (mdyMatch) {
|
||||||
|
const [, m, d, y] = mdyMatch;
|
||||||
|
// Basic validation to avoid mixing up DMY and MDY for ambiguous dates like 01/02/2023
|
||||||
|
// If the first part is > 12, it's likely a day (DMY), so we'll let the next block handle it.
|
||||||
|
if (parseInt(m) <= 12) {
|
||||||
|
const year = y.length === 2 ? parseInt(`20${y}`) : parseInt(y);
|
||||||
|
date = new Date(year, parseInt(m) - 1, parseInt(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Regex for DD/MM/YYYY or DD.MM.YYYY or DD-MM-YYYY
|
||||||
|
if (!date) {
|
||||||
|
const dmyMatch = trimmed.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})$/);
|
||||||
|
if (dmyMatch) {
|
||||||
|
const [, d, m, y] = dmyMatch;
|
||||||
|
const year = y.length === 2 ? parseInt(`20${y}`) : parseInt(y);
|
||||||
|
// Month is 0-indexed in JS
|
||||||
|
date = new Date(year, parseInt(m) - 1, parseInt(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle Excel serial date number (days since 1900-01-01, with Excel's leap year bug)
|
||||||
|
if (!date && /^\d{5}$/.test(trimmed)) {
|
||||||
|
const serial = parseInt(trimmed, 10);
|
||||||
|
// Excel's epoch starts on day 1, which it considers 1900-01-01.
|
||||||
|
// JS Date epoch is 1970-01-01.
|
||||||
|
// Days between 1900-01-01 and 1970-01-01 is 25569.
|
||||||
|
// Excel incorrectly thinks 1900 was a leap year, so we subtract 1 for dates after Feb 1900.
|
||||||
|
const excelEpochDiff = serial > 60 ? 25567 : 25568;
|
||||||
|
const utcMilliseconds = (serial - excelEpochDiff) * 86400 * 1000;
|
||||||
|
date = new Date(utcMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a valid date, format it. Otherwise, return original.
|
||||||
|
if (date && !isNaN(date.getTime())) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed; // Fallback
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { initGoogleClient } from '$lib/google';
|
import { initGoogleClients } from '$lib/google';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initGoogleClient(() => {
|
initGoogleClients(() => {
|
||||||
// You can add any logic here to run after the client is initialized
|
|
||||||
console.log('Google API client initialized');
|
console.log('Google API client initialized');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
1
src/routes/.layout.server.ts
Normal file
1
src/routes/.layout.server.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
@@ -16,7 +16,8 @@ self.addEventListener('install', (event) => {
|
|||||||
await cache.addAll(ASSETS);
|
await cache.addAll(ASSETS);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(addFilesToCache());
|
// Precache and activate this SW immediately so new versions take control
|
||||||
|
event.waitUntil(Promise.all([addFilesToCache(), self.skipWaiting()]))
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
@@ -27,7 +28,11 @@ self.addEventListener('activate', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(deleteOldCaches());
|
// Clean old caches and take control of existing clients immediately
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
await deleteOldCaches();
|
||||||
|
await self.clients.claim();
|
||||||
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
@@ -64,7 +69,9 @@ self.addEventListener('fetch', (event) => {
|
|||||||
throw new Error('invalid response from fetch');
|
throw new Error('invalid response from fetch');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
|
// Only cache successful same-origin GET responses at runtime
|
||||||
|
if (response.status === 200 && url.origin === self.location.origin) {
|
||||||
cache.put(event.request, response.clone());
|
cache.put(event.request, response.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
src/types/heic-convert-browser.d.ts
vendored
Normal file
4
src/types/heic-convert-browser.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'heic-convert/browser' {
|
||||||
|
const convert: (options: { buffer: Uint8Array; format: 'JPEG' | 'PNG'; quality?: number }) => Promise<ArrayBuffer | Uint8Array>;
|
||||||
|
export default convert;
|
||||||
|
}
|
||||||
BIN
static/cards/2026.webp
Normal file
BIN
static/cards/2026.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
static/cards/esncard_anniversary.png
Normal file
BIN
static/cards/esncard_anniversary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 426 KiB |
@@ -3,10 +3,23 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://svelte.dev/docs/kit/integrations
|
|
||||||
// for more information about preprocessors
|
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
kit: { adapter: adapter() }
|
kit: { adapter: adapter() },
|
||||||
|
csp: {
|
||||||
|
mode: 'hash',
|
||||||
|
directives: {
|
||||||
|
'default-src': ["'self'"],
|
||||||
|
'script-src': ["'self'"],
|
||||||
|
'style-src': ["'self'"],
|
||||||
|
'img-src': ["'self'", 'data:'],
|
||||||
|
'connect-src': ["'self'", 'https://www.googleapis.com'],
|
||||||
|
'font-src': ["'self'"],
|
||||||
|
'object-src': ["'none'"],
|
||||||
|
'frame-ancestors': ["'none'"],
|
||||||
|
'base-uri': ["'self'"],
|
||||||
|
'form-action': ["'self'"]
|
||||||
|
},
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user