Compare commits

...

42 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
Roman Krček
1e96668e48 Fix signout and redesign auth step
All checks were successful
Build Docker image / build (push) Successful in 3m14s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 1m8s
2025-07-30 16:54:17 +02:00
Roman Krček
923300e49b Memory leak fixes 2025-07-30 16:36:06 +02:00
Roman Krček
1aa6cd53fa p-q and limitations to processing only 200 items 2025-07-30 16:06:44 +02:00
Roman Krček
dc1edaae84 Remove unused files 2025-07-29 15:57:33 +02:00
Roman Krček
1fde370890 Rework generation page and settings 2025-07-29 15:56:42 +02:00
Roman Krček
39b15f1314 Fixes for small screens
All checks were successful
Build Docker image / build (push) Successful in 1m26s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 28s
2025-07-19 19:19:22 +02:00
Roman Krček
be47b096d5 Rebrand to Card Forge
All checks were successful
Build Docker image / build (push) Successful in 2m26s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 36s
2025-07-19 18:46:54 +02:00
Roman Krček
e587d1099b Icons
All checks were successful
Build Docker image / build (push) Successful in 1m24s
Build Docker image / verify (push) Successful in 27s
Build Docker image / deploy (push) Successful in 1m12s
2025-07-18 14:31:49 +02:00
Roman Krček
8e41c6d78f Restructuring and navigator
All checks were successful
Build Docker image / build (push) Successful in 2m0s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 27s
2025-07-18 13:59:28 +02:00
Roman Krček
1a8ce546d4 Dependency updates 2025-07-18 13:45:55 +02:00
43 changed files with 3140 additions and 3700 deletions

View File

@@ -2,8 +2,6 @@
PUBLIC_GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE"
# Face Detection Crop Configuration
# Crop aspect ratio (width:height) - e.g., 1.0 for square, 1.5 for 3:2 ratio
PUBLIC_CROP_RATIO=1.0
# Face offset from center (as percentage of crop dimensions)
# Positive values move the face toward bottom-right, negative toward top-left

View File

@@ -37,6 +37,9 @@ jobs:
push: true
tags: "${{ vars.DOCKER_IMAGE }}:latest,${{ vars.DOCKER_IMAGE }}:${{ steps.date.outputs.date }}"
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-from: "mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${{ vars.DOCKER_IMAGE }}:cache"
labels: |

View File

@@ -7,10 +7,13 @@
- Pass fucntions as props instead od dispatching events
- Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax
- when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state.
- USe $props instead of export let!
- USe $props instead of "export let"!
- Use styling from ".github/styling.md" for any UI components.
- Refer to the ".github/core-instructions.md" for the overall structure of the application.
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
- Remain consistent in styling and code structure.
- Avoid unncessary iterations. If problems is mostly solved, stop.
- 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 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
```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 -->
</div>
```
@@ -225,7 +225,7 @@ This document outlines the design system and styling conventions used in the app
### Top Navigation
```html
<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">
<a href="/" class="text-lg font-bold">App Name</a>
<ul class="flex space-x-4">

View File

@@ -9,6 +9,12 @@ RUN npm prune --production
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
WORKDIR /app
COPY --from=builder --chown=node:node /app/build build/

View File

@@ -5,8 +5,8 @@ services:
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.http.routers.esncard-generator.rule=Host(`esncards.orebolt.cz`)"
- "traefik.http.routers.esncard-generator.tls.certresolver=leresolver"
- "traefik.http.routers.esncard-generator.entrypoints=websecure"
- "traefik.http.services.esncard-generator.loadbalancer.server.port=3000"
- "traefik.http.routers.esncard-generator.middlewares=hsts"
- "traefik.http.routers.card-forge.rule=Host(`cardforge.orebolt.cz`)"
- "traefik.http.routers.card-forge.tls.certresolver=leresolver"
- "traefik.http.routers.card-forge.entrypoints=websecure"
- "traefik.http.services.card-forge.loadbalancer.server.port=3000"
- "traefik.http.routers.card-forge.middlewares=hsts"

1484
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "esn-card-generator",
"name": "card-forge",
"private": true,
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -18,6 +18,7 @@
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/uuid": "^10.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
@@ -33,13 +34,14 @@
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive": "^3.0.15",
"@types/gapi.client.sheets": "^4.0.20201031",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/gapi.client.sheets-v4": "^0.0.4",
"@types/google.accounts": "^0.0.17",
"@types/uuid": "^10.0.0",
"fontkit": "^2.0.4",
"heic-convert": "^2.1.0",
"idb": "^8.0.3",
"idb-keyval": "^6.2.2",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
"uuid": "^11.1.0"
}

View File

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

1
src/fontkit.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module 'fontkit';

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,41 +1,72 @@
<script lang="ts">
import { currentStep } from '$lib/stores.js';
import { currentStep } from '$lib/stores.js';
import FeatureList from './splash/FeatureList.svelte';
import { env } from '$env/dynamic/public';
function startWizard() {
currentStep.set(1); // Move to auth step
}
const buildDate = env.PUBLIC_BUILD_DATE;
const gitRef = env.PUBLIC_GIT_REF ? env.PUBLIC_GIT_REF.substring(0, 7) : '';
function startWizard() {
currentStep.set(1); // Move to auth step
}
</script>
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div class="container mx-auto max-w-2xl bg-white p-8 rounded-lg shadow-lg text-center">
<div class="mb-8">
<!-- ESN Logo placeholder -->
<div class="mx-auto mb-6 w-24 h-24 bg-blue-600 rounded-full flex items-center justify-center">
<span class="text-white text-2xl font-bold">ESN</span>
</div>
<h1 class="mb-6 text-3xl font-bold text-gray-800">
ESN Card Generator
</h1>
<p class="text-lg text-gray-700 leading-relaxed mb-6">
Transform your Google Sheets into professional ESN membership cards with photos.
Privacy-first: all processing happens in your browser.
</p>
<div class="text-sm text-gray-500 mb-8">
<p class="mb-2">✓ Import data from Google Sheets</p>
<p class="mb-2">✓ Automatic face detection and cropping</p>
<p class="mb-2">✓ Generate text and photo PDFs</p>
<p>✓ No data stored on our servers</p>
</div>
</div>
<button
on:click={startWizard}
class="bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
Start Creating Cards
</button>
</div>
<div class="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
<div
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-4 flex flex-col items-center">
<!-- Animated ESN Logo -->
<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"
>
<img src="/favicon.svg" alt="ESN Logo" class="h-28 w-28 drop-shadow-lg" />
</div>
<h1
class="mb-2 pb-4 bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-6xl font-extrabold tracking-tight text-transparent"
>
Card Forge
</h1>
<p class="mb-4 text-xl font-medium leading-relaxed text-gray-700">
Transform your Google Sheets into professional ESNcards with photos.
</p>
<p class="mb-4 text-lg leading-relaxed text-gray-600">
<span class="font-semibold text-black-800">Privacy-first</span>: all processing happens in
your browser.
</p>
<FeatureList class="mb-6" />
</div>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="https://youtube.com"
target="_blank"
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
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
><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"
/></svg
>
Watch Tutorial
</a>
<button
onclick={startWizard}
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
</button>
</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>

View File

@@ -1,56 +1,76 @@
<script lang="ts">
import { currentStep } from '$lib/stores.js';
import StepAuth from './wizard/StepAuth.svelte';
import StepSheetSearch from './wizard/StepSheetSearch.svelte';
import StepColumnMap from './wizard/StepColumnMap.svelte';
import StepRowFilter from './wizard/StepRowFilter.svelte';
import StepGallery from './wizard/StepGallery.svelte';
import StepGenerate from './wizard/StepGenerate.svelte';
import { currentStep, steps as stepNames, currentStepName } from '$lib/stores';
import Splash from './Splash.svelte';
import StepAuth from './wizard/StepAuth.svelte';
import StepSheetSearch from './wizard/StepSheetSearch.svelte';
import StepColumnMap from './wizard/StepColumnMap.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 StepGenerate from './wizard/StepGenerate.svelte';
const steps = [
StepAuth,
StepSheetSearch,
StepColumnMap,
StepRowFilter,
StepGallery,
StepGenerate
];
const stepTitles = {
splash: 'Welcome',
auth: 'Authenticate',
search: 'Select Sheet',
mapping: 'Map Columns',
validation: 'Filter Rows',
'card-details': 'Enter Card Details',
'card-select': 'Select Card Type',
gallery: 'Preview Gallery',
generate: 'Generate Cards'
};
const stepTitles = [
'Authenticate',
'Select Sheet',
'Map Columns',
'Filter Rows',
'Review Photos',
'Generate PDFs'
];
let currentTitle = $derived(stepTitles[$currentStepName]);
let currentStepIndex = $derived(stepNames.indexOf($currentStepName));
</script>
<div class="min-h-screen bg-gray-50">
<div class="container mx-auto max-w-4xl p-4">
<!-- Progress indicator -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800">
{stepTitles[$currentStep - 1]}
</h1>
<span class="text-sm text-gray-500">
Step {$currentStep} of {steps.length}
</span>
</div>
<!-- Progress bar -->
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {($currentStep / steps.length) * 100}%"
></div>
</div>
</div>
<div class="bg-gray-100 min-h-screen p-4">
<div class="container mx-auto max-w-5xl pb-10">
{#if $currentStepName !== 'splash'}
<!-- Progress indicator -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800">
{currentTitle}
</h1>
<span class="text-sm text-gray-500">
Step {currentStepIndex} of {stepNames.length - 1}
</span>
</div>
<!-- Step content -->
<div class="bg-white rounded-lg shadow-sm">
<svelte:component this={steps[$currentStep - 1]} />
</div>
</div>
<!-- Progress bar -->
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {(currentStepIndex / (stepNames.length - 1)) * 100}%"
></div>
</div>
</div>
{/if}
<!-- Step content -->
<div class="bg-white rounded-lg shadow-sm">
{#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>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
// Feature list for splash page
</script>
<ul class="mb-8 text-left text-gray-700 grid grid-cols-1 sm:grid-cols-2 gap-2">
<li class="flex items-center gap-2"><span class="text-green-500 font-bold"></span> Import data from Google Sheets</li>
<li class="flex items-center gap-2"><span class="text-green-500 font-bold"></span> Automatic face detection & cropping</li>
<li class="flex items-center gap-2"><span class="text-green-500 font-bold"></span> Generate text & photo PDFs</li>
<li class="flex items-center gap-2"><span class="text-green-500 font-bold"></span> No data stored on our servers</li>
</ul>

View File

@@ -1,80 +1,125 @@
<script lang="ts">
import { currentStep } from '$lib/stores.js';
import { isSignedIn, handleSignIn, handleSignOut, isGoogleApiReady } from '$lib/google';
import { onMount } from 'svelte';
import { currentStep } from '$lib/stores.js';
import {
isSignedIn,
handleSignOut,
requestTokenFromUser,
isGoogleApiReady,
initGoogleClients
} from '$lib/google';
import Navigator from './subcomponents/Navigator.svelte';
function proceed() {
currentStep.set(2);
}
onMount(() => {
if (!$isGoogleApiReady) {
initGoogleClients(() => {
// This callback is called when the Google clients are ready.
});
}
});
function handleSignIn() {
requestTokenFromUser();
}
</script>
<div class="p-6">
<div class="max-w-md mx-auto text-center">
<div class="mb-6">
<div class="mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12.017 11.215c-3.573-2.775-9.317-.362-9.317 4.686C2.7 21.833 6.943 24 12.017 24c5.076 0 9.319-2.167 9.319-8.099 0-5.048-5.744-7.461-9.319-4.686z"/>
<path d="M20.791 5.016c-1.395-1.395-3.61-1.428-5.024-.033l-1.984 1.984v-.002L12.017 8.73 10.25 6.965l-1.984-1.984c-1.414-1.395-3.629-1.362-5.024.033L1.498 6.758c-1.438 1.438-1.438 3.77 0 5.208l1.744 1.744c1.395 1.395 3.61 1.428 5.024.033l1.984-1.984v.002L12.017 9.996l1.767 1.765 1.984 1.984c1.414 1.395 3.629 1.362 5.024-.033l1.744-1.744c1.438-1.438 1.438-3.77 0-5.208L20.791 5.016z"/>
</svg>
</div>
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Connect to Google
</h2>
<p class="text-sm text-gray-700 leading-relaxed mb-6">
Sign in with your Google account to access your Google Sheets and Google Drive for photo downloads.
</p>
<div class="text-xs text-gray-500 mb-6 space-y-1">
<p>Required permissions:</p>
<p>• View your Google Spreadsheets</p>
<p>• View and manage the files in your Google Drive</p>
</div>
</div>
<div class="mb-6">
<h2 class="mb-2 text-xl font-semibold text-gray-900">Connect to Google</h2>
<p class="text-sm text-gray-700">
Sign in with your Google account to access your Google Sheets and Google Drive for photo
downloads.
</p>
</div>
{#if $isSignedIn}
<!-- Authenticated state -->
<div class="bg-green-50 border border-green-300 rounded-lg p-4 mb-4">
<div class="flex items-center justify-center mb-2">
<svg class="w-5 h-5 text-green-600 mr-2" 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 font-medium text-green-800">Authenticated</span>
</div>
<p class="text-sm text-green-800 mb-3">
You are signed in.
</p>
<div class="flex space-x-3 justify-center">
<button
onclick={proceed}
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700"
>
Continue →
</button>
<button
onclick={handleSignOut}
class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium"
>
Sign Out
</button>
</div>
</div>
{:else}
<!-- Unauthenticated state -->
<button
onclick={handleSignIn}
disabled={!$isGoogleApiReady}
class="w-full bg-blue-600 text-white px-4 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{#if $isGoogleApiReady}
Sign In with Google
{:else}
Loading Google API...
{/if}
</button>
{/if}
</div>
<div class="grid gap-8 md:grid-cols-2">
<!-- Left Column: Information -->
<div class="space-y-6 text-gray-700">
<div>
<h4 class="font-semibold text-gray-900">Google Sheets Integration</h4>
<p class="text-sm">
Seamlessly import your data without the hassle of manual copy-pasting.
</p>
</div>
<div>
<h4 class="font-semibold text-gray-900">Google Drive Access</h4>
<p class="text-sm">
Automatically download photos for your cards directly from your Google Drive.
</p>
</div>
<div>
<h4 class="font-semibold text-gray-900">Privacy & Security</h4>
<p class="text-sm">
Your data is processed entirely in your browser. Nothing is ever uploaded to or stored on
our servers. We only request read-only access. All of the data is then removed from your browser
when the work is finished.
</p>
</div>
</div>
<!-- Right Column: Action -->
<div
class="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-8"
>
{#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 -->
<div class="text-center">
<div class="flex items-center justify-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900">Successfully Connected</h3>
</div>
<p class="mb-6 mt-2 text-sm text-gray-600">You are signed in and ready to proceed.</p>
<button
onclick={handleSignOut}
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
Sign Out
</button>
</div>
{:else}
<!-- Unauthenticated state -->
<div class="w-full text-center">
<h3 class="text-lg font-medium text-gray-900">Ready to Connect?</h3>
<p class="mb-6 text-sm text-gray-600">
Click the button below to sign in with your Google account.
</p>
<button
onclick={handleSignIn}
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"
>
Sign In with Google
</button>
</div>
{/if}
</div>
</div> <div class="mt-8">
<Navigator
canProceed={$isSignedIn}
{currentStep}
textBack="Back to Splash"
textForwardDisabled="Sign in to continue"
textForwardEnabled="Continue to Sheet Search"
/>
</div>
</div>

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,28 +1,15 @@
<script lang="ts">
import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores';
import { getSheetNames, getSheetData } from '$lib/google';
import {
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';
// 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;
}
import Navigator from './subcomponents/Navigator.svelte';
let isLoadingSheets = $state(false);
let isLoadingData = $state(false);
@@ -35,32 +22,40 @@
let hasSavedMapping = $state(false);
let showMappingEditor = $state(false);
let savedSheetInfo = $state<SheetInfoType | null>(null);
let mappedIndices = $state<ColumnMappingType>({
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1
alreadyPrinted: -1,
sheetName: ''
});
const requiredFields = [
{ key: 'name', label: 'First Name', required: true },
{ key: 'surname', label: 'Last Name', required: true },
{ key: 'name', label: 'Full Name', required: true },
{ key: 'nationality', label: 'Nationality', required: true },
{ key: 'birthday', label: 'Birthday', required: true },
{ key: 'pictureUrl', label: 'Photo URL', required: true },
{ 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
onMount(async () => {
ensureToken();
if ($selectedSheet) {
console.log('Selected sheet on mount:', $selectedSheet);
// Check if we already have saved mapping data
const recentSheetsData = localStorage.getItem('esn-recent-sheets');
const key = await getRecentSheetsKey();
const recentSheetsData = localStorage.getItem(key);
if (recentSheetsData) {
try {
@@ -68,36 +63,30 @@
if (recentSheets && recentSheets.length > 0) {
// Find a sheet that matches the current spreadsheet
const savedSheet = recentSheets.find(
(sheet: SheetInfoType) =>
sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
);
if (savedSheet) {
console.log('Found saved sheet configuration:', savedSheet);
// We have a saved sheet for this spreadsheet
selectedSheetName = savedSheet.sheetName || savedSheet.sheetMapping || '';
selectedSheetName = savedSheet.columnMapping.sheetName;
savedSheetInfo = savedSheet;
if (savedSheet.columnMapping) {
// Set the mapped indices from saved data
mappedIndices = {
name: savedSheet.columnMapping.name ?? -1,
surname: savedSheet.columnMapping.surname ?? -1,
nationality: savedSheet.columnMapping.nationality ?? -1,
birthday: savedSheet.columnMapping.birthday ?? -1,
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1
name: savedSheet.columnMapping.name,
nationality: savedSheet.columnMapping.nationality,
birthday: savedSheet.columnMapping.birthday,
pictureUrl: savedSheet.columnMapping.pictureUrl,
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted,
sheetName: selectedSheetName
};
hasSavedMapping = true;
updateMappingStatus();
columnMapping.set(mappedIndices);
// Don't load sheet data immediately for better performance
// We'll load it when needed (when editing or continuing)
return; // Skip loading available sheets since we're using saved data
return;
}
}
}
@@ -113,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() {
if (!$selectedSheet) {
console.error('Cannot load available sheets: no sheet selected');
return;
}
console.log('Loading available sheets for spreadsheet:', $selectedSheet.spreadsheetId);
console.log('Loading available sheets for spreadsheet:', $selectedSheet.id);
isLoadingSheets = true;
error = '';
try {
const sheetNames = await getSheetNames($selectedSheet.spreadsheetId);
const sheetNames = await getSheetNames($selectedSheet.id);
console.log('Loaded sheet names:', sheetNames);
availableSheets = sheetNames;
// Don't auto-select any sheet - let user choose
} catch (err) {
console.error('Error loading sheet names:', err);
error = 'Failed to load sheet names. Please try again.';
@@ -192,16 +129,15 @@
selectedSheetName = sheetName;
// Clear any previous data when selecting a new sheet
rawSheetData.set([]);
sheetHeaders = [];
previewData = [];
mappedIndices = {
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1
alreadyPrinted: -1,
sheetName: sheetName
};
mappingComplete = false;
hasSavedMapping = false;
@@ -219,19 +155,14 @@
return;
}
console.log(
'Loading sheet data for spreadsheet:',
$selectedSheet.spreadsheetId,
'sheet:',
sheetName
);
console.log('Loading sheet data for spreadsheet:', $selectedSheet.id, 'sheet:', sheetName);
isLoadingData = true;
error = '';
try {
// Fetch first 10 rows for headers and preview
const range = `${sheetName}!A1:Z10`;
const data = await getSheetData($selectedSheet.spreadsheetId, range);
const data = await getSheetData($selectedSheet.id, range);
if (data && data.length > 0) {
console.log('Loaded sheet data with', data.length, 'rows');
@@ -243,7 +174,7 @@
autoMapColumns();
// Check if we have saved column mapping for this sheet
loadSavedColumnMapping();
await loadSavedColumnMapping();
} else {
error = 'The selected sheet appears to be empty.';
console.warn('Sheet is empty');
@@ -260,17 +191,16 @@
// Reset mappings
mappedIndices = {
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1
alreadyPrinted: -1,
sheetName: selectedSheetName
};
// Auto-mapping patterns
const patterns: Record<keyof ColumnMappingType, RegExp> = {
name: /first[\s_-]*name|name|given[\s_-]*name|vorname/i,
surname: /last[\s_-]*name|surname|family[\s_-]*name|nachname/i,
const patterns: Record<keyof Omit<ColumnMappingType, 'sheetName'>, RegExp> = {
name: /full[\s_-]*name|name/i,
nationality: /nationality|country|nation/i,
birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i,
pictureUrl: /photo|picture|image|url|avatar/i,
@@ -279,8 +209,9 @@
sheetHeaders.forEach((header, index) => {
for (const [field, pattern] of Object.entries(patterns)) {
if (pattern.test(header) && mappedIndices[field] === -1) {
mappedIndices[field] = index;
const key = field as keyof ColumnMappingType;
if (pattern.test(header) && mappedIndices[key] === -1) {
mappedIndices[key] = index;
break;
}
}
@@ -299,7 +230,11 @@
// Also check if this column isn't already mapped to another field
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) {
@@ -313,23 +248,20 @@
updateMappingStatus();
}
function loadSavedColumnMapping() {
async function loadSavedColumnMapping() {
if (!$selectedSheet || !selectedSheetName) {
console.log('Cannot load saved column mapping: missing selectedSheet or selectedSheetName');
return;
}
try {
const recentSheetsKey = 'esn-recent-sheets';
const existingData = localStorage.getItem(recentSheetsKey);
const key = await getRecentSheetsKey();
const existingData = localStorage.getItem(key);
if (existingData) {
const recentSheets = JSON.parse(existingData);
const savedSheet = recentSheets.find(
(sheet: SheetInfoType) =>
(sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
);
if (savedSheet && savedSheet.columnMapping) {
@@ -338,11 +270,11 @@
// Override auto-mapping with saved mapping
mappedIndices = {
name: savedSheet.columnMapping.name ?? -1,
surname: savedSheet.columnMapping.surname ?? -1,
nationality: savedSheet.columnMapping.nationality ?? -1,
birthday: savedSheet.columnMapping.birthday ?? -1,
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1,
sheetName: selectedSheetName
};
hasSavedMapping = true;
@@ -358,18 +290,32 @@
}
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;
updateMappingStatus();
}
function updateMappingStatus() {
if (!mappedIndices) {
mappingComplete = false;
return;
}
// Only check required fields for completion
const requiredIndices = {
name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl
pictureUrl: mappedIndices.pictureUrl,
sheetName: selectedSheetName
};
mappingComplete = Object.values(requiredIndices).every((index) => index !== -1);
@@ -378,56 +324,46 @@
// Update the column mapping store
columnMapping.set({
name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl,
alreadyPrinted: mappedIndices.alreadyPrinted
alreadyPrinted: mappedIndices.alreadyPrinted,
sheetName: selectedSheetName
});
}
function handleContinue() {
async function handleContinue() {
if (!mappingComplete || !$selectedSheet || !selectedSheetName) return;
// Save column mapping to localStorage for the selected sheet
try {
const recentSheetsKey = 'esn-recent-sheets';
const existingData = localStorage.getItem(recentSheetsKey);
const key = await getRecentSheetsKey();
const existingData = localStorage.getItem(key);
let recentSheets = existingData ? JSON.parse(existingData) : [];
// Find the current sheet in recent sheets and update its column mapping
const sheetIndex = recentSheets.findIndex(
(sheet: SheetInfoType) =>
(sheet.id === $selectedSheet.spreadsheetId ||
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
);
const columnMappingData = {
name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl,
alreadyPrinted: mappedIndices.alreadyPrinted
alreadyPrinted: mappedIndices.alreadyPrinted,
sheetName: selectedSheetName
};
if (sheetIndex !== -1) {
// Update existing entry
recentSheets[sheetIndex].columnMapping = columnMappingData;
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 {
// Add new entry
const newEntry = {
spreadsheetId: $selectedSheet.spreadsheetId,
id: $selectedSheet.id,
name: $selectedSheet.name,
sheetMapping: selectedSheetName,
columnMapping: columnMappingData,
lastUsed: new Date().toISOString()
};
@@ -440,12 +376,10 @@
}
}
localStorage.setItem(recentSheetsKey, JSON.stringify(recentSheets));
localStorage.setItem(key, JSON.stringify(recentSheets));
} catch (err) {
console.error('Failed to save column mapping to localStorage:', err);
}
currentStep.set(4); // Move to next step
}
async function handleShowEditor() {
@@ -462,7 +396,7 @@
try {
isLoadingData = true;
const range = `${selectedSheetName}!A1:Z10`;
const data = await getSheetData($selectedSheet.spreadsheetId, range);
const data = await getSheetData($selectedSheet.id, range);
if (data && data.length > 0) {
sheetHeaders = data[0];
@@ -489,42 +423,44 @@
{#if hasSavedMapping && !showMappingEditor}
<!-- Simplified view when we have saved mapping -->
<div class="mb-6 rounded-lg border border-green-200 bg-green-50 p-6">
<div class="text-center">
<svg class="mx-auto mb-4 h-16 w-16 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<h3 class="mb-3 text-xl font-semibold text-green-800">Configuration Complete</h3>
<p class="mb-2 text-green-700">
<span class="font-medium">Spreadsheet:</span>
{savedSheetInfo?.name}
</p>
<p class="mb-2 text-green-700">
<span class="font-medium">Sheet:</span>
{selectedSheetName}
</p>
<p class="mb-6 text-green-700">
Column mapping loaded from your previous session.<br />
Everything is ready to proceed to the next step.
</p>
<button
onclick={handleShowEditor}
class="inline-flex items-center rounded-lg border border-green-300 px-4 py-2 text-sm font-medium text-green-700 transition-colors hover:bg-green-100 hover:text-green-900"
>
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex">
<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
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"
fill-rule="evenodd"
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"
/>
</svg>
Make changes if needed
</button>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<div>
<h3 class="text-sm font-medium text-blue-800">Saved Configuration Found</h3>
<div class="mt-2 text-sm text-blue-700">
<p>
Using saved mapping for sheet <span class="font-semibold"
>"{selectedSheetName}"</span
>
from spreadsheet <span class="font-semibold">"{savedSheetInfo?.name}"</span>.
</p>
</div>
</div>
<div class="mt-3 md:mt-0 md:ml-6">
<button
onclick={handleShowEditor}
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"
>
Edit Mapping
</button>
</div>
</div>
</div>
</div>
{:else}
@@ -736,16 +672,56 @@
</div>
{/if}
<!-- Mapping status -->
{#if mappingComplete}
<div class="rounded border border-green-200 bg-green-50 p-3">
<p class="text-sm text-green-800">
✓ All required fields are mapped! You can continue to the next step.
</p>
<div class="rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex">
<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>
</div>
</div>
</div>
{:else}
<div class="rounded border border-yellow-200 bg-yellow-50 p-3">
<p class="text-sm text-yellow-800">Please map all required fields to continue.</p>
<div class="rounded-md bg-yellow-50 p-4">
<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>
{/if}
</div>
@@ -754,20 +730,12 @@
{/if}
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(2)}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
>
← Back to Sheet Selection
</button>
<button
onclick={handleContinue}
disabled={!mappingComplete}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{mappingComplete ? 'Continue →' : 'Select a column mapping'}
</button>
</div>
<Navigator
canProceed={mappingComplete}
{currentStep}
textBack="Back to Sheet Selection"
textForwardDisabled="Select a column mapping"
textForwardEnabled="Continue to Row Selection"
onForward={handleContinue}
/>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,182 +1,162 @@
<script lang="ts">
import {
selectedSheet,
sheetData,
columnMapping,
rawSheetData,
filteredSheetData,
selectedSheet,
currentStep,
sheetData
} 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 { onMount } from 'svelte';
import { getSheetNames, getSheetData } from '$lib/google';
import { parseAndFormatDate } from '$lib/utils/date';
let searchTerm = '';
let sortColumn = '';
let sortDirection: 'asc' | 'desc' = 'asc';
let selectedRows = new Set<number>();
let selectAll = false;
let processedData: any[] = [];
let filteredData: any[] = [];
let headers: string[] = [];
let isLoading = false;
let isLoading = $state(true);
let error = $state<string | null>(null);
let rows = $state<RowData[]>([]);
$: {
// Filter data based on search term
if (searchTerm.trim()) {
filteredData = processedData.filter((row) =>
Object.values(row).some((value) =>
String(value).toLowerCase().includes(searchTerm.toLowerCase())
)
);
} else {
filteredData = processedData;
}
}
let sortColumn = $state<keyof RowData | null>(null);
let sortDirection = $state<'asc' | 'desc'>('asc');
let lastCheckedId: string | null = $state(null);
$: {
// 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();
const ROW_LIMIT = 200;
if (sortDirection === 'asc') {
return aVal.localeCompare(bVal);
} else {
return bVal.localeCompare(aVal);
}
});
}
}
onMount(() => {
console.log('StepRowFilter mounted');
processSheetData();
});
// Fetch raw sheet data from Google Sheets if not already loaded
async function fetchRawSheetData() {
console.log("Fetching raw sheet data...");
const sheetNames = await getSheetNames($selectedSheet.spreadsheetId);
if (sheetNames.length === 0) return;
const sheetName = sheetNames[0];
const range = `${sheetName}!A:Z`;
const data = await getSheetData($selectedSheet.spreadsheetId, range);
rawSheetData.set(data);
}
async function processSheetData() {
// Fetch and process data from the Google Sheet
async function fetchAndProcessData() {
isLoading = true;
error = null;
try {
// Get headers from the mapping
headers = Object.keys($columnMapping);
const sheet = $selectedSheet;
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)
processedData = $rawSheetData.slice(1).map((row, index) => {
const processedRow: any = {
_rowIndex: index + 1, // Store original row index
_isValid: true
};
const range = `${mapping.sheetName}!A:Z`;
const rawData = await getSheetData(sheet.id, range);
// Map each column according to the column mapping
for (const [field, columnIndex] of Object.entries($columnMapping)) {
if (columnIndex !== -1 && columnIndex !== undefined && columnIndex < row.length) {
processedRow[field] = row[columnIndex] || '';
} else {
processedRow[field] = '';
// Only mark as invalid if it's a required field
if (field !== 'alreadyPrinted') {
processedRow._isValid = false;
}
if (!rawData || rawData.length < 2) {
// 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
}
}
// 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() !== ''
);
const alreadyPrinted =
mapping.alreadyPrinted !== -1
? (row[mapping.alreadyPrinted] || '').toLowerCase() === 'true'
: false;
if (!hasAllRequiredFields) {
processedRow._isValid = false;
}
const isValid = !!(name && pictureUrl);
return processedRow;
});
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);
// Initially select rows based on validity and "Already Printed" status
selectedRows = new Set(
processedData
.filter((row) => {
if (!row._isValid) return false;
// Check "Already Printed" column value
const alreadyPrinted = row.alreadyPrinted;
if (alreadyPrinted) {
const value = String(alreadyPrinted).toLowerCase().trim();
// If the value is "true", "yes", "1", or any truthy value, don't select
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
}
// If empty or falsy, select the row
return true;
})
.map((row) => row._rowIndex)
);
updateSelectAllState();
rows = processedData;
} catch (e: any) {
error = e.message || 'An unknown error occurred while fetching data.';
console.error(e);
rows = [];
} finally {
isLoading = false;
}
}
function toggleRowSelection(rowIndex: number) {
if (selectedRows.has(rowIndex)) {
selectedRows.delete(rowIndex);
} else {
selectedRows.add(rowIndex);
}
selectedRows = new Set(selectedRows); // Trigger reactivity
updateSelectAllState();
}
function handleRowClick(event: MouseEvent, clickedId: string) {
const clickedRow = rows.find((r) => r.id === clickedId);
if (!clickedRow || !clickedRow._valid) return;
function toggleSelectAll() {
if (selectAll) {
// Deselect all visible valid rows that aren't already printed
filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.delete(row._rowIndex);
// 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 {
// Select all visible valid rows that aren't already printed
filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.add(row._rowIndex);
}
});
// 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);
updateSelectAllState();
// Update the last checked ID for the next shift-click
lastCheckedId = clickedId;
}
function updateSelectAllState() {
const visibleValidUnprintedRows = filteredData.filter(
(row) => row._isValid && !isRowAlreadyPrinted(row)
);
const selectedVisibleValidUnprintedRows = visibleValidUnprintedRows.filter((row) =>
selectedRows.has(row._rowIndex)
);
// Run on component mount
onMount(() => {
ensureToken();
fetchAndProcessData();
});
selectAll =
visibleValidUnprintedRows.length > 0 &&
selectedVisibleValidUnprintedRows.length === visibleValidUnprintedRows.length;
// 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 {
// Uncheck all
rows.forEach((row) => (row._checked = false));
}
}
function handleSort(column: string) {
// Function to handle sorting
function sortBy(column: keyof RowData) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
@@ -185,107 +165,79 @@
}
}
function getFieldLabel(field: string): string {
const labels: { [key: string]: string } = {
name: 'First Name',
surname: 'Last Name',
nationality: 'Nationality',
birthday: 'Birthday',
pictureUrl: 'Photo URL',
alreadyPrinted: 'Already Printed'
};
return labels[field] || field;
}
// Derived state for sorted data to be displayed
const displayData = $derived.by(() => {
if (!sortColumn) return rows;
function isRowAlreadyPrinted(row: any): boolean {
const alreadyPrinted = row.alreadyPrinted;
if (!alreadyPrinted) return false;
return [...rows].sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
const value = String(alreadyPrinted).toLowerCase().trim();
return value === 'true' || value === 'yes' || value === '1' || value === 'x';
}
if (aValue === bValue) return 0;
let comparison = 0;
if (aValue > bValue) {
comparison = 1;
} else {
comparison = -1;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
});
// Derived state: master checkbox reflects if first 200 eligible items in current view are selected
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() {
// Filter the data to only include selected rows
const selectedData = processedData.filter(
(row) => selectedRows.has(row._rowIndex) && row._isValid
);
// Store the filtered data
filteredSheetData.set(selectedData);
// Move to next step
currentStep.set(5);
$sheetData = rows.filter((row) => row._checked);
}
$: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => {
const row = processedData.find((r) => r._rowIndex === rowIndex);
return row && row._isValid;
}).length;
// Allow proceeding only if at least one valid row is selected
$: canProceed = selectedValidCount > 0;
</script>
<div class="p-6">
<div class="mb-6">
<h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
<p class="mb-4 text-sm text-gray-700">
Review your data and select which rows you want to include in the card generation. Only rows
with all required fields will be available for selection.
</p>
</div>
<!-- Search and Filter Controls -->
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex flex-col gap-4 sm:flex-row">
<!-- Search -->
<div class="flex-grow">
<label for="search" class="mb-2 block text-sm font-medium text-gray-700">
Search rows
</label>
<input
id="search"
type="text"
bind:value={searchTerm}
placeholder="Search in any field..."
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<!-- Sort -->
<div class="sm:w-48">
<label for="sort" class="mb-2 block text-sm font-medium text-gray-700"> Sort by </label>
<select
id="sort"
bind:value={sortColumn}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="">No sorting</option>
{#each headers as header}
<option value={header}>{getFieldLabel(header)}</option>
{/each}
</select>
</div>
<div class="container max-w-none p-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>
<p class="text-sm text-gray-700">
Review your data and select which rows to include. Select a batch of max 200 items by using
the top checkbox.
</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>
</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>
<div class="flex flex-col space-y-2">
{#if $selectedSheet?.id}
<a
href={`https://docs.google.com/spreadsheets/d/${$selectedSheet.id}/edit`}
target="_blank"
rel="noopener noreferrer"
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"
>
Open Sheet
</a>
{/if}
<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}
class="ml-auto inline-flex items-center rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-wait disabled:opacity-50"
>
{#if isLoading}
<svg
class="mr-2 h-4 w-4 animate-spin"
class="-ml-1 mr-2 h-5 w-5 animate-spin text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -297,8 +249,12 @@
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
></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>
Refreshing...
{:else}
@@ -308,210 +264,156 @@
</div>
</div>
<!-- Data Table -->
<div class="relative mb-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
{#if filteredData.length === 0 && !isLoading}
<div class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3>
<p class="mt-1 text-sm text-gray-500">
{searchTerm ? 'No rows match your search criteria.' : 'No data available to display.'}
</p>
{#if isLoading}
<div class="py-12 text-center">
<p class="text-lg">Loading data from Google Sheet...</p>
<p class="text-gray-500">Please wait a moment.</p>
</div>
{:else if error}
<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
fill-rule="evenodd"
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"
clip-rule="evenodd"
/>
</svg>
</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>
{:else}
</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">
The selected sheet appears to be empty or could not be read.
</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<!-- Select All Checkbox -->
<th class="px-3 py-3 text-left">
<th class="px-4 py-3 text-left">
<input
type="checkbox"
bind:checked={selectAll}
onchange={toggleSelectAll}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
disabled={isLoading}
onchange={toggleSelectAll}
checked={allValidRowsSelected}
/>
</th>
<!-- Column Headers -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<th
class="cursor-pointer px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:bg-gray-100"
onclick={() => !isLoading && handleSort(header)}
>
<div class="flex items-center space-x-1">
<span>{getFieldLabel(header)}</span>
{#if sortColumn === header}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
{#if sortDirection === 'asc'}
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
{:else}
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
{/if}
</svg>
{/if}
</div>
</th>
{/each}
<!-- Status Column -->
<th
class="px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
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('_rowIndex')}>#</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('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>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<!-- Loading skeleton rows -->
{#each Array(5) as _, index}
<tr class="hover:bg-gray-50">
<!-- Selection Checkbox Skeleton -->
<td class="px-3 py-4">
<div class="h-4 w-4 animate-pulse rounded bg-gray-200"></div>
</td>
<!-- Data Columns Skeletons -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<td class="px-3 py-4">
<div
class="h-4 animate-pulse rounded bg-gray-200"
style="width: {Math.random() * 40 + 60}%"
></div>
</td>
{/each}
<!-- Status Column Skeleton -->
<td class="px-3 py-4">
<div class="flex flex-col space-y-1">
<div class="h-6 w-16 animate-pulse rounded-full bg-gray-200"></div>
</div>
</td>
</tr>
{/each}
{:else}
<!-- Actual data rows -->
{#each filteredData as row}
<tr
class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(
row
)
? 'bg-orange-50'
: ''}"
>
<!-- Selection Checkbox -->
<td class="px-3 py-4">
{#if row._isValid}
<input
type="checkbox"
checked={selectedRows.has(row._rowIndex)}
onchange={() => toggleRowSelection(row._rowIndex)}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{:else}
<div class="h-4 w-4 rounded bg-gray-200"></div>
{/if}
</td>
<!-- Data Columns -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<td class="max-w-xs truncate px-3 py-4 text-sm text-gray-900">
{row[header] || ''}
</td>
{/each}
<!-- Status Column -->
<td class="px-3 py-4 text-sm">
<div class="flex flex-col space-y-1">
{#if row._isValid}
<span
class="inline-flex rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800"
>
Valid
</span>
{:else}
<span
class="inline-flex rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800"
>
Missing data
</span>
{/if}
{#if isRowAlreadyPrinted(row)}
<span
class="inline-flex rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800"
>
Already Printed
</span>
{/if}
</div>
</td>
</tr>
{/each}
{/if}
{#each displayData as row (row.id)}
<tr
class="hover:bg-gray-50"
class:bg-gray-100={!row._valid}
class:text-gray-400={!row._valid || row.alreadyPrinted}
class:bg-orange-50={row.alreadyPrinted}
>
<td class="px-4 py-3">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-200"
checked={row._checked}
disabled={!row._valid || (selectedCount >= ROW_LIMIT && !row._checked)}
onclick={(e) => handleRowClick(e, row.id)}
/>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">{row._rowIndex}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">{row.name}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">{row.nationality}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">{row.birthday}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<a
href={row.pictureUrl}
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:underline"
title={row.pictureUrl}>link</a
>
</td>
<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
>
{:else}
<span
class="inline-flex rounded-full bg-gray-100 px-2 text-xs font-semibold leading-5 text-gray-800"
>No</span
>
{/if}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
{#if row._valid}
<span
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
>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Selection Summary -->
{#if selectedValidCount > 0}
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center">
<svg class="mr-2 h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<span class="text-sm text-blue-800">
<strong>{selectedValidCount}</strong>
{selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation
</span>
</div>
</div>
{/if}
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(3)}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
>
← Back to Colum Selection
</button>
<button
onclick={handleContinue}
disabled={!canProceed}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{canProceed
? `Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} `
: 'Select rows to continue'}
</button>
<div class="mt-6">
<Navigator
canProceed={selectedCount > 0}
currentStep={currentStep}
onForward={handleContinue}
textBack="Back to Column Mapping"
textForwardEnabled="Continue to Card Details"
textForwardDisabled="Select at least one valid row"
/>
</div>
</div>

View File

@@ -1,7 +1,10 @@
<script lang="ts">
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
import { searchSheets } from '$lib/google';
import { selectedSheet, currentStep } from '$lib/stores';
import type { SheetInfoType } from '$lib/stores';
import { searchSheets, ensureToken, userEmail } from '$lib/google';
import { hashString } from '$lib/utils';
import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte';
let searchQuery = $state('');
let isLoading = $state(false);
@@ -10,9 +13,16 @@
let hasSearched = $state(false);
let recentSheets = $state<any[]>([]);
const RECENT_SHEETS_KEY = 'esn-recent-sheets';
async function getRecentSheetsKey() {
const email = $userEmail;
if (email) {
return `recentSheets_${await hashString(email)}`;
}
return 'recentSheets_anonymous';
}
onMount(() => {
ensureToken();
loadRecentSheets();
});
@@ -24,53 +34,42 @@
try {
searchResults = await searchSheets(searchQuery);
availableSheets.set(
searchResults.map((sheet) => ({
spreadsheetId: sheet.spreadsheetId || sheet.id,
name: sheet.name,
url: sheet.webViewLink
}))
);
hasSearched = true;
} catch (err) {
console.error('Error searching sheets:', err);
error = 'Failed to search sheets. Please check your connection and try again.';
searchResults = [];
availableSheets.set([]);
} finally {
isLoading = false;
}
}
function loadRecentSheets() {
async function loadRecentSheets() {
try {
const saved = localStorage.getItem(RECENT_SHEETS_KEY);
const key = await getRecentSheetsKey();
const saved = localStorage.getItem(key);
if (saved) {
recentSheets = JSON.parse(saved);
}
} catch (err) {
console.error('Error loading recent sheets:', err);
// If there's an error, clear the stored value
localStorage.removeItem(RECENT_SHEETS_KEY);
const key = await getRecentSheetsKey();
localStorage.removeItem(key);
recentSheets = [];
}
}
function handleSelectSheet(sheet) {
const sheetData = {
spreadsheetId: sheet.spreadsheetId || sheet.id,
const sheetData: SheetInfoType = {
id: sheet.id,
name: sheet.name,
url: sheet.webViewLink || sheet.url
webViewLink: sheet.webViewLink
};
selectedSheet.set(sheetData);
}
let canProceed = $derived($selectedSheet !== null);
function handleContinue() {
if (!canProceed) return;
currentStep.set(3); // Move to the column mapping step
}
let canProceed = $derived($selectedSheet.id !== '');
</script>
<div class="p-6">
@@ -94,7 +93,7 @@
type="text"
bind:value={searchQuery}
placeholder="Type sheet name..."
class="flex-grow rounded-l-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-600"
class="flex-grow min-w-0 rounded-l-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-600"
onkeydown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
@@ -135,8 +134,8 @@
<div class="space-y-3">
{#each searchResults as sheet}
<div
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
(sheet.spreadsheetId || sheet.id)
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.id ===
(sheet.id || sheet.id)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
@@ -146,19 +145,19 @@
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
}}
>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between">
<div>
<p class="font-medium text-gray-900">{sheet.name}</p>
<p class="mt-1 text-xs text-gray-500">ID: {sheet.id}</p>
<p class="mt-1 text-xs text-gray-500 break-all whitespace-normal" title={sheet.id}>ID: {sheet.id}</p>
</div>
<div class="flex items-center">
{#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
<img src={sheet.iconLink} alt="Sheet icon" class="my-2 mr-2 h-5 w-5" />
{/if}
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
<svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
{#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">
<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"
@@ -199,8 +198,8 @@
<div class="space-y-3">
{#each recentSheets as sheet}
<div
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
(sheet.spreadsheetId || sheet.id)
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.id ===
(sheet.id || sheet.id)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
@@ -221,7 +220,7 @@
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
{/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">
<path
fill-rule="evenodd"
@@ -262,20 +261,11 @@
{/if}
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(1)}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
>
← Back to Auth
</button>
<button
onclick={handleContinue}
disabled={!canProceed}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{canProceed ? 'Continue →' : 'Select a sheet to continue'}
</button>
</div>
<Navigator
{canProceed}
{currentStep}
textBack="Back to Auth"
textForwardDisabled="Select a sheet"
textForwardEnabled="Continue to Column Mapping"
/>
</div>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
let {
canProceed = true,
currentStep,
textBack = 'Back',
textForwardDisabled = 'Next',
textForwardEnabled = 'Next',
onBack,
onForward,
nextDisabled = false
} = $props<{
canProceed?: boolean;
currentStep?: any;
textBack?: string;
textForwardDisabled?: string;
textForwardEnabled?: string;
onBack?: () => void;
onForward?: () => void;
nextDisabled?: boolean;
}>();
async function handleBack() {
if (onBack) {
await onBack();
} else if (currentStep) {
currentStep.set($currentStep - 1);
}
}
async function handleForward() {
if (onForward) {
await onForward();
}
if (currentStep) {
currentStep.set($currentStep + 1);
}
}
</script>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-between">
{#if onBack || currentStep}
<button
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"
>
<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="M15 19l-7-7 7-7" />
</svg>
<span>{textBack}</span>
</button>
{/if}
<button
onclick={handleForward}
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"
>
<span>{canProceed && !nextDisabled ? textForwardEnabled : textForwardDisabled}</span>
<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" />
</svg>
</button>
</div>

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import type { PhotoDimensions } from '$lib/cards/types';
import PhotoCrop from './PhotoCrop.svelte';
let { photo, onCropUpdated, onRetry } = $props<{
let { photo, onCropUpdated, onRetry, photoDimensions } = $props<{
photo: {
name: string;
url: string;
@@ -13,6 +14,7 @@
};
onCropUpdated: (detail: any) => void;
onRetry: () => void;
photoDimensions: PhotoDimensions;
}>();
let showCropper = $state(false);
@@ -78,7 +80,7 @@
{#if photo.status === 'loading'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center">
<div class="h-48 bg-gray-200 flex items-center justify-center">
<div class="flex flex-col items-center">
<div
class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"
@@ -94,7 +96,7 @@
{:else if photo.status === 'success' && photo.objectUrl}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative">
<div
class="h-48 bg-gray-100 flex items-center justify-center relative overflow-hidden"
class="h-48 bg-gray-200 flex items-center justify-center relative overflow-hidden"
bind:this={imageContainer}
>
<img
@@ -108,8 +110,8 @@
{/if}
</div>
<div class="p-3 flex items-center justify-between">
<div>
<div class="esnSection p-3 flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
{#if photo.faceDetectionStatus === 'completed'}
<span class="text-xs text-green-600">Face detected</span>
@@ -125,7 +127,7 @@
</div>
<button
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"
aria-label="Edit Crop"
>
@@ -145,6 +147,7 @@
imageUrl={photo.objectUrl}
personName={photo.name}
initialCropData={photo.cropData}
{photoDimensions}
onClose={() => (showCropper = false)}
onCropUpdated={handleCropUpdated}
/>
@@ -152,7 +155,7 @@
</div>
{:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center">
<div class="h-48 bg-gray-200 flex items-center justify-center">
<div class="flex flex-col items-center text-center p-4">
<svg
class="w-12 h-12 text-red-400 mb-2"

View File

@@ -1,8 +1,15 @@
<script lang="ts">
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;
personName: string;
initialCropData?: { x: number; y: number; width: number; height: number };
@@ -10,6 +17,7 @@
cropData: { x: number; y: number; width: number; height: number };
}) => void;
onClose: () => void;
photoDimensions: PhotoDimensions;
}>();
let canvas: HTMLCanvasElement;
@@ -27,16 +35,14 @@
// Interaction state
let isDragging = false;
let isResizing = false;
let dragStart = { x: 0, y: 0 };
let resizeHandle = '';
// Canvas dimensions
let canvasWidth = 600;
let canvasHeight = 400;
// Get crop ratio from environment
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0');
// Use the photo card aspect ratio from the selected card's dimensions
const cropRatio = photoDimensions.width / photoDimensions.height;
onMount(() => {
ctx = canvas.getContext('2d')!;
@@ -127,25 +133,6 @@
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height);
// Draw resize handles
const handleSize = 12; // Increased from 8 for easier grabbing
ctx.fillStyle = '#3b82f6';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
// Corner handles with white borders for better visibility
const handles = [
{ x: crop.x - handleSize/2, y: crop.y - handleSize/2, cursor: 'nw-resize' },
{ x: crop.x + crop.width - handleSize/2, y: crop.y - handleSize/2, cursor: 'ne-resize' },
{ x: crop.x - handleSize/2, y: crop.y + crop.height - handleSize/2, cursor: 'sw-resize' },
{ x: crop.x + crop.width - handleSize/2, y: crop.y + crop.height - handleSize/2, cursor: 'se-resize' },
];
handles.forEach(handle => {
ctx.fillRect(handle.x, handle.y, handleSize, handleSize);
ctx.strokeRect(handle.x, handle.y, handleSize, handleSize);
});
}
function getMousePos(e: MouseEvent) {
@@ -157,31 +144,12 @@
}
function isInCropArea(x: number, y: number) {
return x >= crop.x && x <= crop.x + crop.width &&
y >= crop.y && y <= crop.y + crop.height;
}
function getResizeHandle(x: number, y: number) {
const handleSize = 12; // Match the drawing size
const tolerance = handleSize;
if (Math.abs(x - crop.x) <= tolerance && Math.abs(y - crop.y) <= tolerance) return 'nw';
if (Math.abs(x - (crop.x + crop.width)) <= tolerance && Math.abs(y - crop.y) <= tolerance) return 'ne';
if (Math.abs(x - crop.x) <= tolerance && Math.abs(y - (crop.y + crop.height)) <= tolerance) return 'sw';
if (Math.abs(x - (crop.x + crop.width)) <= tolerance && Math.abs(y - (crop.y + crop.height)) <= tolerance) return 'se';
return '';
return x >= crop.x && x <= crop.x + crop.width && y >= crop.y && y <= crop.y + crop.height;
}
function handleMouseDown(e: MouseEvent) {
const pos = getMousePos(e);
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
isResizing = true;
resizeHandle = handle;
dragStart = pos;
} else if (isInCropArea(pos.x, pos.y)) {
if (isInCropArea(pos.x, pos.y)) {
isDragging = true;
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
}
@@ -189,87 +157,14 @@
function handleMouseMove(e: MouseEvent) {
const pos = getMousePos(e);
if (isResizing) {
const dx = pos.x - dragStart.x;
const dy = pos.y - dragStart.y;
const newCrop = { ...crop };
// Use primary axis movement for more predictable resizing
switch (resizeHandle) {
case 'nw':
// Use the dominant movement direction
const primaryDelta = Math.abs(dx) > Math.abs(dy) ? dx : dy * cropRatio;
const newWidth = Math.max(20, crop.width - primaryDelta);
const newHeight = newWidth / cropRatio;
newCrop.x = Math.max(0, crop.x + crop.width - newWidth);
newCrop.y = Math.max(0, crop.y + crop.height - newHeight);
newCrop.width = newWidth;
newCrop.height = newHeight;
break;
case 'ne':
// For NE, primarily follow horizontal movement
const newWidthNE = Math.max(20, crop.width + dx);
const newHeightNE = newWidthNE / cropRatio;
newCrop.width = newWidthNE;
newCrop.height = newHeightNE;
newCrop.y = Math.max(0, crop.y + crop.height - newHeightNE);
break;
case 'sw':
// For SW, primarily follow horizontal movement
const newWidthSW = Math.max(20, crop.width - dx);
const newHeightSW = newWidthSW / cropRatio;
newCrop.x = Math.max(0, crop.x + crop.width - newWidthSW);
newCrop.width = newWidthSW;
newCrop.height = newHeightSW;
break;
case 'se':
// For SE, primarily follow horizontal movement
const newWidthSE = Math.max(20, crop.width + dx);
const newHeightSE = newWidthSE / cropRatio;
newCrop.width = newWidthSE;
newCrop.height = newHeightSE;
break;
}
// Ensure crop stays within canvas bounds
if (newCrop.x + newCrop.width > canvasWidth) {
newCrop.width = canvasWidth - newCrop.x;
newCrop.height = newCrop.width / cropRatio;
}
if (newCrop.y + newCrop.height > canvasHeight) {
newCrop.height = canvasHeight - newCrop.y;
newCrop.width = newCrop.height * cropRatio;
}
// Adjust position if crop extends beyond bounds after resizing
if (newCrop.x + newCrop.width > canvasWidth) {
newCrop.x = canvasWidth - newCrop.width;
}
if (newCrop.y + newCrop.height > canvasHeight) {
newCrop.y = canvasHeight - newCrop.height;
}
crop = newCrop;
drawCanvas();
} else if (isDragging) {
if (isDragging) {
crop.x = Math.max(0, Math.min(canvasWidth - crop.width, pos.x - dragStart.x));
crop.y = Math.max(0, Math.min(canvasHeight - crop.height, pos.y - dragStart.y));
drawCanvas();
} else {
// Update cursor based on hover state
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
canvas.style.cursor = handle + '-resize';
} else if (isInCropArea(pos.x, pos.y)) {
if (isInCropArea(pos.x, pos.y)) {
canvas.style.cursor = 'move';
} else {
canvas.style.cursor = 'default';
@@ -279,11 +174,39 @@
function handleMouseUp() {
isDragging = false;
isResizing = false;
resizeHandle = '';
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() {
// Scale crop rectangle back to original image dimensions
const scaleX = image.width / canvasWidth;
@@ -344,16 +267,52 @@
</button>
</div>
<div class="mb-4 p-2 rounded-md text-center">
<div class="relative mb-4 p-2 rounded-md text-center">
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
class="mx-auto cursor-move"
class="mx-auto"
style="max-width: 100%; height: auto;"
></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 class="flex justify-end space-x-3">

View File

@@ -1,111 +1,205 @@
import { writable } from 'svelte/store';
import { writable, get } from 'svelte/store';
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 isGoogleApiReady = writable(false); // To track GAPI client readiness
export const userEmail = writable<string | null>(null);
let tokenClient: google.accounts.oauth2.TokenClient;
let gapiInited = false;
let gsiInited = false;
const TOKEN_KEY = 'google_oauth_token';
export function initGoogleClient(callback: () => void) {
const script = document.createElement('script');
script.src = 'https://apis.google.com/js/api.js';
script.onload = () => {
gapi.load('client', async () => {
await gapi.client.init({
// NOTE: API KEY IS NOT REQUIRED FOR THIS IMPLEMENTATION
// apiKey: 'YOUR_API_KEY',
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();
});
};
document.body.appendChild(script);
// This function ensures both GAPI (for Sheets/Drive APIs) and GSI (for auth) are loaded in the correct order.
export function initGoogleClients(callback: () => void) {
// If everything is already initialized, just run the callback.
if (gapiInited && gsiInited) {
isGoogleApiReady.set(true); // Ensure it's set if called again
callback();
return;
}
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;
}
tokenClient = google.accounts.oauth2.initTokenClient({
client_id: clientId,
scope: 'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets.readonly',
callback: (tokenResponse) => {
if (tokenResponse?.access_token) {
// Set token in gapi client
gapi.client.setToken({ access_token: tokenResponse.access_token });
isSignedIn.set(true);
// Persist token with expiration
const expiresInSeconds = tokenResponse.expires_in
? Number(tokenResponse.expires_in)
: 0;
const expiresInMs = expiresInSeconds * 1000;
const record = {
access_token: tokenResponse.access_token,
expires_at: expiresInMs ? Date.now() + expiresInMs : Date.now() + 3600 * 1000
};
localStorage.setItem(TOKEN_KEY, JSON.stringify(record));
}
},
});
};
document.body.appendChild(scriptGsi);
// 1. Load GAPI script for Sheets/Drive APIs first.
if (!gapiInited) {
const gapiScript = document.createElement('script');
gapiScript.src = 'https://apis.google.com/js/api.js';
gapiScript.async = true;
gapiScript.defer = true;
document.head.appendChild(gapiScript);
gapiScript.onload = () => {
gapi.load('client', () => {
gapi.client
.init({
discoveryDocs: [
'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest'
]
})
.then(() => {
gapiInited = true;
// Now that GAPI is ready, initialize the GSI client.
initGsiClient(callback);
});
});
};
} else {
// GAPI is already ready, just ensure GSI is initialized.
initGsiClient(callback);
}
}
export function handleSignIn() {
if (gapi.client.getToken() === null) {
tokenClient.requestAccessToken({ prompt: 'consent' });
} else {
tokenClient.requestAccessToken({ prompt: '' });
}
/**
* Fetches user's email and stores it.
*/
async function fetchUserInfo(token: string) {
try {
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: {
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 = gapi.client.getToken();
if (token !== null) {
google.accounts.oauth2.revoke(token.access_token, () => {
gapi.client.setToken(null);
isSignedIn.set(false);
});
}
const token = get(accessToken);
if (token && gsiInited) {
google.accounts.oauth2.revoke(token, () => {
console.log('User token revoked.');
});
}
// Clear all tokens and states
if (gapiInited) {
gapi.client.setToken(null);
}
accessToken.set(null);
isSignedIn.set(false);
userEmail.set(null);
console.log('User signed out.');
}
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');
}
const response = await gapi.client.drive.files.list({
q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`,
fields: 'files(id, name, iconLink, webViewLink)',
pageSize: 20,
supportsAllDrives: true,
includeItemsFromAllDrives: true,
corpora: 'allDrives'
});
return response.result.files || [];
}
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');
}
const response = await gapi.client.sheets.spreadsheets.get({
@@ -121,7 +215,8 @@ export async function getSheetNames(spreadsheetId: 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');
}
const response = await gapi.client.sheets.spreadsheets.values.get({
@@ -160,13 +255,14 @@ export function isGoogleDriveUrl(url: string): boolean {
// Download image from Google Drive using the API
export async function downloadDriveImage(url: string): Promise<Blob> {
await ensureToken();
const fileId = extractDriveFileId(url);
if (!fileId) {
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');
}

View File

@@ -1,157 +1,95 @@
// PDF Layout Configuration Module
// Centralized configuration for PDF generation layouts
import { get } from 'idb-keyval';
export interface PDFDimensions {
pageWidth: number;
pageHeight: number;
margin: number;
}
// Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm)
export const MM_TO_PT = 72 / 25.4;
export interface GridLayout {
cols: number;
rows: number;
cellWidth: number;
cellHeight: number;
cols: number;
rows: number;
cellWidth: number; // mm
cellHeight: number; // mm
}
export interface TextPosition {
x: number;
y: number;
size: number;
// Function to retrieve a blob from IndexedDB
export async function getImageBlob(url: string): Promise<Blob | undefined> {
return await get(url);
}
export interface PhotoPosition {
x: number;
y: number;
width: number;
height: number;
}
export interface TextFieldLayout {
name: TextPosition;
nationality: TextPosition;
birthday: TextPosition;
}
export interface PhotoFieldLayout {
photo: PhotoPosition;
name: TextPosition;
}
// A4 dimensions in points
export const PDF_DIMENSIONS: PDFDimensions = {
pageWidth: 595.28,
pageHeight: 841.89,
margin: 40
};
// Text PDF Layout (3x7 grid)
export const TEXT_PDF_GRID = {
cols: 3,
rows: 7
};
// Photo PDF Layout (3x5 grid)
export const PHOTO_PDF_GRID = {
cols: 3,
rows: 5
};
// Calculate grid layout
export function calculateGridLayout(
dimensions: PDFDimensions,
grid: { cols: number; rows: number }
// Calculate how many cards can fit on a page.
export function calculateGrid(
pageWidth: number,
pageHeight: number,
margin: number,
cardWidth: number,
cardHeight: number
): GridLayout {
const cellWidth = (dimensions.pageWidth - 2 * dimensions.margin) / grid.cols;
const cellHeight = (dimensions.pageHeight - 2 * dimensions.margin) / grid.rows;
return {
cols: grid.cols,
rows: grid.rows,
cellWidth,
cellHeight
};
const printableWidth = pageWidth - 2 * margin;
const printableHeight = pageHeight - 2 * margin;
const cols = Math.floor(printableWidth / cardWidth);
const rows = Math.floor(printableHeight / cardHeight);
return {
cols,
rows,
cellWidth: cardWidth,
cellHeight: cardHeight
};
}
// Text PDF Field Positions (relative to cell)
export const TEXT_FIELD_LAYOUT: TextFieldLayout = {
name: {
x: 5, // 5pt from left edge of cell
y: -15, // 15pt from top of cell (negative because PDF coords are bottom-up)
size: 10
},
nationality: {
x: 5, // 5pt from left edge of cell
y: -29, // 29pt from top of cell (15 + 14 line height)
size: 10
},
birthday: {
x: 5, // 5pt from left edge of cell
y: -43, // 43pt from top of cell (15 + 14 + 14 line height)
size: 10
}
};
// Photo PDF Field Positions (relative to cell)
export const PHOTO_FIELD_LAYOUT: PhotoFieldLayout = {
photo: {
x: 10, // 10pt from left edge of cell
y: 40, // 40pt from bottom of cell
width: -20, // cell width minus 20pt (10pt margin on each side)
height: -60 // cell height minus 60pt (40pt bottom margin + 20pt top margin)
},
name: {
x: 10, // 10pt from left edge of cell
y: 20, // 20pt from bottom of cell
size: 10
}
};
// Helper function to get absolute position within a cell
export function getAbsolutePosition(
cellX: number,
cellY: number,
cellHeight: number,
relativePos: TextPosition
// Helper function to get absolute position in points for pdf-lib
export function getAbsolutePositionPt(
cellX_mm: number,
cellY_mm: number,
pageHeight_mm: number,
relativePos_mm: any
): { x: number; y: number; size: number } {
return {
x: cellX + relativePos.x,
y: cellY + cellHeight + relativePos.y, // Convert relative Y to absolute
size: relativePos.size
};
const absoluteX_mm = cellX_mm + relativePos_mm.x;
// pdf-lib Y-coordinate is from bottom, so we invert
const absoluteY_mm = pageHeight_mm - (cellY_mm + relativePos_mm.y);
return {
x: absoluteX_mm * MM_TO_PT,
y: absoluteY_mm * MM_TO_PT,
size: relativePos_mm.size // size is already in points
};
}
// Helper function to get absolute photo dimensions
export function getAbsolutePhotoDimensions(
cellX: number,
cellY: number,
cellWidth: number,
cellHeight: number,
relativePhoto: PhotoPosition
// Helper function to get absolute photo dimensions in points for pdf-lib
export function getAbsolutePhotoDimensionsPt(
cellX_mm: number,
cellY_mm: number,
pageHeight_mm: number,
relativePhoto_mm: any
): { x: number; y: number; width: number; height: number } {
return {
x: cellX + relativePhoto.x,
y: cellY + relativePhoto.y,
width: relativePhoto.width < 0 ? cellWidth + relativePhoto.width : relativePhoto.width,
height: relativePhoto.height < 0 ? cellHeight + relativePhoto.height : relativePhoto.height
};
const absoluteX_mm = cellX_mm + relativePhoto_mm.x;
// pdf-lib Y-coordinate is from bottom, so we invert and account for height
const absoluteY_mm = pageHeight_mm - (cellY_mm + relativePhoto_mm.y + relativePhoto_mm.height);
return {
x: absoluteX_mm * MM_TO_PT,
y: absoluteY_mm * MM_TO_PT,
width: relativePhoto_mm.width * MM_TO_PT,
height: relativePhoto_mm.height * MM_TO_PT
};
}
// Border configuration
export const BORDER_CONFIG = {
color: { r: 0.8, g: 0.8, b: 0.8 },
width: 1
color: { r: 0, g: 0, b: 0 },
width: 0.5 // in points
};
// Text configuration
export const TEXT_CONFIG = {
color: { r: 0, g: 0, b: 0 },
lineHeight: 14
color: { r: 0, g: 0, b: 0 },
lineHeight: 14 // in points
};
// Placeholder text configuration
export const PLACEHOLDER_CONFIG = {
text: 'Photo placeholder',
color: { r: 0.5, g: 0.5, b: 0.5 },
size: 8
text: 'Photo placeholder',
color: { r: 0.5, g: 0.5, b: 0.5 },
size: 8 // in points
};

14
src/lib/pdfSettings.ts Normal file
View File

@@ -0,0 +1,14 @@
// User-configurable settings for PDF generation
export interface PageSettings {
pageWidth: number; // mm
pageHeight: number; // mm
margin: number; // mm
}
// A4 Page dimensions in millimeters
export const PAGE_SETTINGS: PageSettings = {
pageWidth: 210,
pageHeight: 297,
margin: 15
};

View File

@@ -1,146 +1,133 @@
import { writable, derived } from 'svelte/store';
// This file is holy and shall not be edited by Copilot!
// User session and authentication
export const session = writable<{
token?: string;
user?: { name: string; email: string };
}>({});
// Raw sheet data after import
export const rawSheetData = writable<string[][]>([]);
// Filtered sheet data after row selection
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 {
id: string;
name: string;
surname: string;
nationality: string;
birthday: string;
pictureUrl: string;
valid: boolean;
included: boolean;
age?: number;
validationErrors: string[];
// Data structure column mapping
export interface ColumnMappingType {
name: number;
nationality: number;
birthday: number;
pictureUrl: number;
alreadyPrinted: number;
sheetName: string;
}
export const sheetData = writable<RowData[]>([]);
// Data structure for a row in the sheet
export interface RowData {
id: string; // Unique identifier
name: string;
nationality: string;
birthday: string;
pictureUrl: string;
alreadyPrinted: boolean;
_rowIndex: number;
_checked: boolean;
_valid: boolean;
}
// Picture storage and metadata
export interface PictureBlobInfo {
id: string;
blob: Blob;
url: string;
downloaded: boolean;
faceDetected: boolean;
faceCount: number;
export interface PictureBlobInfoType {
id: string;
url: string;
downloaded: boolean;
faceDetected: boolean;
faceCount: number;
}
export const pictures = writable<Record<string, PictureBlobInfo>>({});
// Crop rectangles for each photo
export interface Crop {
x: number;
y: number;
width: number;
height: number;
// CropType rectangles for each photo
export interface CropType {
x: number;
y: number;
width: 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
export const currentStep = writable<number>(0);
export const steps = [
'splash',
'auth',
'search',
'mapping',
'validation',
'gallery',
'generate'
'splash',
'auth',
'search',
'mapping',
'validation',
'card-details',
'card-select',
'gallery',
'generate'
] as const;
export type WizardStep = typeof steps[number];
export const currentStepName = derived(
currentStep,
($currentStep) => steps[$currentStep]
currentStep,
($currentStep) => steps[$currentStep]
);
// Progress tracking
export interface ProgressState {
stage: string;
current: number;
total: number;
message: string;
stage: string;
current: number;
total: number;
message: string;
}
export const progress = writable<ProgressState>({
stage: '',
current: 0,
total: 0,
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
stage: '',
current: 0,
total: 0,
message: ''
});

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">
import { onMount } from 'svelte';
import { initGoogleClient } from '$lib/google';
import { initGoogleClients } from '$lib/google';
import '../app.css';
let { children } = $props();
onMount(() => {
initGoogleClient(() => {
// You can add any logic here to run after the client is initialized
initGoogleClients(() => {
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);
}
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) => {
@@ -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) => {
@@ -64,7 +69,9 @@ self.addEventListener('fetch', (event) => {
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());
}

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

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -1 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
<!-- Simple ESN-style card icon no outline, larger photo box -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="200"
height="120"
viewBox="0 0 200 120"
stroke-width="1"
stroke-linecap="round"
>
<!-- Rounded-rectangle clip path (matches card shape) -->
<defs>
<clipPath id="cardClip">
<rect x="0" y="0" width="200" height="120" rx="12" ry="12"/>
</clipPath>
</defs>
<!-- Everything is clipped to the card -->
<g clip-path="url(#cardClip)">
<!-- Card background -->
<rect x="0" y="0" width="200" height="120" rx="12" ry="12" fill="#ffffff"/>
<!-- Blue side strip -->
<rect x="0" y="0" width="40" height="120" fill="#0077c8"/>
<!-- Larger photo placeholder -->
<rect
x="14" y="18"
width="60" height="80"
rx="6" ry="6"
fill="#ffffff"
stroke="#bdbdbd"
/>
<!-- Four text bars (no stroke) -->
<rect x="78" y="24" width="104" height="8" rx="4" ry="4" fill="#e8f0fe"/>
<rect x="78" y="44" width="104" height="8" rx="4" ry="4" fill="#e8f0fe"/>
<rect x="78" y="64" width="104" height="8" rx="4" ry="4" fill="#e8f0fe"/>
<rect x="78" y="84" width="104" height="8" rx="4" ry="4" fill="#e8f0fe"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -3,10 +3,23 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
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;