Compare commits

..

32 Commits

Author SHA1 Message Date
Roman Krček
b90265110f Update splash
All checks were successful
Build Docker image / build (push) Successful in 1m43s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 30s
2025-11-18 13:37:32 +01:00
Roman Krček
97460c018c Fix date issues
All checks were successful
Build Docker image / build (push) Successful in 3m2s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 43s
2025-11-18 13:32:55 +01:00
Roman Krček
74910e3346 Resize photos and make thicker borders on text
All checks were successful
Build Docker image / build (push) Successful in 3m53s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 29s
2025-10-28 18:19:38 +01:00
Roman Krček
20b21de69e Fix deprecated methods 2025-09-17 21:47:37 +02:00
Roman Krček
68e4d0b77b Add loader for google services
All checks were successful
Build Docker image / build (push) Successful in 8m8s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 30s
2025-09-08 16:04:50 +02:00
Roman Krček
e43101648b Recent sheets per user 2025-09-08 15:45:36 +02:00
Roman Krček
c6c3bbc024 Fix manual resizing 2025-09-08 15:19:05 +02:00
Roman Krček
d845021f7e Allow to search all drives 2025-09-07 22:37:10 +02:00
Roman Krček
dcba02260a Add button to sheet and shift selector 2025-09-07 22:15:03 +02:00
Roman Krček
9de5646519 Change to just full name 2025-09-07 22:03:21 +02:00
Roman Krček
2b3371e67f Remove 2026 made-up card to not confuse people
All checks were successful
Build Docker image / build (push) Successful in 4m37s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 53s
2025-08-12 08:51:30 +02:00
Roman Krček
a9dc5888e6 Added card types
All checks were successful
Build Docker image / build (push) Successful in 2m5s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 46s
2025-08-11 18:30:07 +02:00
Roman Krček
1a2329b6c1 Fine-tuning the layout
All checks were successful
Build Docker image / build (push) Successful in 1m43s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 1m32s
2025-08-11 17:28:32 +02:00
Roman Krček
82395afa6e Better caching
All checks were successful
Build Docker image / build (push) Successful in 2m20s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 36s
2025-08-11 16:47:08 +02:00
Roman Krček
be7bdc551a Performace optimization
All checks were successful
Build Docker image / build (push) Successful in 1m40s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 35s
2025-08-11 16:42:55 +02:00
Roman Krček
44de5d9ad6 Proper sizing in the layout
All checks were successful
Build Docker image / build (push) Successful in 3m19s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 49s
2025-08-11 16:13:53 +02:00
Roman Krček
f5c2063586 Added row limiting
All checks were successful
Build Docker image / build (push) Successful in 3m22s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 48s
2025-08-08 09:25:25 +02:00
Roman Krček
667c18a746 Fixed mistaken git ref
All checks were successful
Build Docker image / build (push) Successful in 1m35s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 34s
2025-08-07 16:48:12 +02:00
Roman Krček
7276e9ff89 Added build information
All checks were successful
Build Docker image / build (push) Successful in 1m37s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 31s
2025-08-07 16:44:01 +02:00
Roman Krček
99ab5cfb4f Reduce verbosity of sensitive data 2025-08-07 16:34:36 +02:00
Roman Krček
6f7843405c Better navigation
All checks were successful
Build Docker image / build (push) Successful in 3m21s
Build Docker image / deploy (push) Successful in 8s
Build Docker image / verify (push) Successful in 1m35s
2025-08-07 16:30:46 +02:00
Roman Krček
c95f96594f Security handening 2025-08-07 16:28:07 +02:00
Roman Krček
6ed1f985e0 Fixed the rest
All checks were successful
Build Docker image / build (push) Successful in 2m29s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 40s
2025-08-06 15:08:45 +02:00
Roman Krček
c6cc9c6658 Fixed sheet local storage 2025-08-06 14:35:12 +02:00
Roman Krček
7fb72c7d75 Fiexed column mapping storage 2025-08-06 14:34:52 +02:00
Roman Krček
ebb14e9e1a Fixed card details 2025-08-06 14:34:44 +02:00
Roman Krček
3af8c116a4 Fixed column mapping 2025-08-06 14:27:56 +02:00
Roman Krček
e9987009c7 Fixed sheet search 2025-08-06 13:47:37 +02:00
Roman Krček
d8b4eea3ef Updated stores 2025-08-06 13:45:03 +02:00
Roman Krček
2f730fdbbb Add section and validity date
All checks were successful
Build Docker image / build (push) Successful in 4m13s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 55s
2025-08-06 12:31:44 +02:00
Roman Krček
b5814ed552 Reworked row filter 2025-07-31 08:28:13 +02:00
Roman Krček
052e5975fd Restyling of column mapping 2025-07-30 17:50:23 +02:00
37 changed files with 1925 additions and 1467 deletions

View File

@@ -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

View File

@@ -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: |

View File

@@ -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
View File

@@ -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">

View File

@@ -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
View File

@@ -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"

View File

@@ -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",

View 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 }
}
};

View 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
View 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];

View 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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/> />

View File

@@ -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">

View File

@@ -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');
} }

View File

@@ -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

View File

@@ -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
}
}; };

View File

@@ -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
View 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
View 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
}

View File

@@ -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');
}); });
}); });

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

View File

@@ -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;