Compare commits

..

56 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
Roman Krček
fa6b8312c6 Perf improvements for HEIC photos
All checks were successful
Build Docker image / build (push) Successful in 1m57s
Build Docker image / verify (push) Successful in 27s
Build Docker image / deploy (push) Successful in 3s
2025-07-18 11:11:20 +02:00
Roman Krček
94e34fbc75 Change environment variable handling
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 27s
2025-07-18 10:34:09 +02:00
Roman Krček
162a158a85 Added CICD
All checks were successful
Build Docker image / build (push) Successful in 3m20s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 34s
2025-07-18 09:45:09 +02:00
Roman Krček
e8dcb700b5 Cropping regression fix v2 2025-07-18 09:36:14 +02:00
Roman Krček
ceececfd99 Fixed cropping regression 2025-07-18 09:33:21 +02:00
Roman Krček
2e228126be Heic support 2025-07-18 09:17:46 +02:00
Roman Krček
9bbd02dd67 Improve the the cropping process, UI and UX 2025-07-18 09:11:17 +02:00
Roman Krček
c77c96c1c7 Working POC created 2025-07-17 21:50:14 +02:00
Roman Krček
2072e57585 PDF text generation working so far 2025-07-17 21:40:48 +02:00
Roman Krček
c695664784 Crop works nicely 2025-07-17 21:12:26 +02:00
Roman Krček
4f119dc121 Fixed other two components 2025-07-17 21:00:47 +02:00
Roman Krček
ffa427d42c Fixed first two steps 2025-07-17 20:41:09 +02:00
Roman Krček
735e13731c Cropping mostly done 2025-07-17 18:08:26 +02:00
Roman Krček
3ea48272b2 Step RowFiltering done 2025-07-17 16:34:02 +02:00
46 changed files with 4885 additions and 2469 deletions

View File

@@ -1,2 +1,13 @@
# Your Google Cloud OAuth 2.0 Client ID # Your Google Cloud OAuth 2.0 Client ID
VITE_GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE" PUBLIC_GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE"
# Face Detection Crop Configuration
# Face offset from center (as percentage of crop dimensions)
# Positive values move the face toward bottom-right, negative toward top-left
PUBLIC_FACE_OFFSET_X=0.0
PUBLIC_FACE_OFFSET_Y=-0.1
# Crop scale multiplier based on face width
# 1.0 = crop width equals face width, 2.0 = crop is 2x face width
PUBLIC_CROP_SCALE=2.5

View File

@@ -0,0 +1,78 @@
name: Build Docker image
run-name: ${{ gitea.actor }} is running the CI pipeline
on:
push:
branches:
- main
schedule:
- cron: "0 22 1 * *" # First of every month
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get date for image label
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: git.orebolt.cz
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
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: |
org.opencontainers.image.created=${{ steps.date.outputs.date }}
org.opencontainers.image.authors=Roman Krček
org.opencontainers.image.source=${{ env.GITHUB_REPOSITORY }}
org.opencontainers.image.revision=${{ env.GITHUB_SHA }}
org.opencontainers.image.vendor=Orebolt.cz
org.opencontainers.image.ref.name=${{ env.GITHUB_REF }}
org.opencontainers.image.title=${{ vars.APP_NAME }}
deploy:
needs: build
steps:
- name: Trigger Komodo Deploy
env:
URL: ${{ secrets.KOMODO_URL }}
SECRET: ${{ secrets.KOMODO_SECRET }}
BODY_FILE: ${{ github.event_path }}
run: |
SIG="sha256=$(openssl dgst -sha256 -hmac "$SECRET" "$BODY_FILE" | cut -d' ' -f2)"
curl -fsSL -X POST "$URL" \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: $SIG" \
-H 'X-GitHub-Event: push' \
-H "X-GitHub-Delivery: $GITHUB_RUN_ID.$GITHUB_RUN_NUMBER" \
--data @"$BODY_FILE"
verify:
needs: build
steps:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: '${{ vars.DOCKER_IMAGE }}:latest'
format: 'table'

View File

@@ -2,7 +2,18 @@
- You are a helpful AI assistant that helps developers write code. - You are a helpful AI assistant that helps developers write code.
- This code is written in Svelte 5 - This code is written in Svelte 5
- It's important to only use modern Svelte 5 syntax, runes, and features. - It's important to only use modern Svelte 5 syntax, runes, and features.
- Do not use $:, do not use eventDispatching as they are both deprecated
- Use $effect, $state, $derived, eg. let { value } = $state(initialValue);
- Pass fucntions as props instead od dispatching events
- Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax
- when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state.
- USe $props instead of "export let"!
- Use styling from ".github/styling.md" for any UI components. - Use styling from ".github/styling.md" for any UI components.
- Refer to the ".github/core-instructions.md" for the overall structure of the application. - Refer to the ".github/core-instructions.md" for the overall structure of the application.
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task. - Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
- Remain consistent in styling and code structure. - Remain consistent in styling and code structure.
- Avoid unncessary iterations. If problems is mostly solved, stop.
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.
- Do not do what you're not being asked. Stick to scope of my request.
- Do not edit stores.ts ! Unless is explicitly allow you to.
- Focus only on files that are relevant. Do not venture to fix other things.

4
.github/styling.md vendored
View File

@@ -89,7 +89,7 @@ This document outlines the design system and styling conventions used in the app
### Container Pattern ### Container Pattern
```html ```html
<div class="container mx-auto max-w-2xl bg-white p-4"> <div class="container mx-auto max-w-5xl bg-white p-4">
<!-- Content --> <!-- Content -->
</div> </div>
``` ```
@@ -225,7 +225,7 @@ This document outlines the design system and styling conventions used in the app
### Top Navigation ### Top Navigation
```html ```html
<nav class="border-b border-gray-300 bg-gray-50 p-4 text-gray-900"> <nav class="border-b border-gray-300 bg-gray-50 p-4 text-gray-900">
<div class="container mx-auto max-w-2xl"> <div class="container mx-auto max-w-5xl">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a href="/" class="text-lg font-bold">App Name</a> <a href="/" class="text-lg font-bold">App Name</a>
<ul class="flex space-x-4"> <ul class="flex space-x-4">

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install
COPY . ./
RUN npm run build
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/
COPY --from=builder --chown=node:node /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
ENV NODE_ENV=production
CMD [ "node", "build" ]

12
docker-compose-prod.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
app:
image: ${DOCKER_REGISTRY}/${DOCKER_USER}/${DOCKER_IMAGE}:latest
restart: unless-stopped
env_file: .env
labels:
- "traefik.enable=true"
- "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"

7
docker-compose.yml Normal file
View File

@@ -0,0 +1,7 @@
---
services:
app:
image: ${DOCKER_REGISTRY}/${DOCKER_USER}/${DOCKER_IAMGE}:latest
restart: unless-stopped
ports:
- "3000:3000"

1597
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, "private": true,
"version": "0.0.1", "version": "0.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -18,6 +18,7 @@
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/uuid": "^10.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
@@ -29,14 +30,18 @@
"vite-plugin-devtools-json": "^0.2.0" "vite-plugin-devtools-json": "^0.2.0"
}, },
"dependencies": { "dependencies": {
"@tensorflow-models/blazeface": "^0.1.0",
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0", "@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
"@types/gapi.client.drive": "^3.0.15", "@types/gapi.client.drive-v3": "^0.0.5",
"@types/gapi.client.sheets": "^4.0.20201031", "@types/gapi.client.sheets-v4": "^0.0.4",
"@types/google.accounts": "^0.0.17", "@types/google.accounts": "^0.0.17",
"@types/uuid": "^10.0.0", "fontkit": "^2.0.4",
"heic-convert": "^2.1.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"idb-keyval": "^6.2.2",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"
} }

View File

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

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"> <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';
const buildDate = env.PUBLIC_BUILD_DATE;
const gitRef = env.PUBLIC_GIT_REF ? env.PUBLIC_GIT_REF.substring(0, 7) : '';
function startWizard() { function startWizard() {
currentStep.set(1); // Move to auth step currentStep.set(1); // Move to auth step
} }
</script> </script>
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4"> <div class="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
<div class="container mx-auto max-w-2xl bg-white p-8 rounded-lg shadow-lg text-center"> <div
<div class="mb-8"> class="container mx-auto max-w-5xl rounded-lg border border-gray-200 bg-white/90 p-10 text-center shadow-xl"
<!-- ESN Logo placeholder --> >
<div class="mx-auto mb-6 w-24 h-24 bg-blue-600 rounded-full flex items-center justify-center"> <div class="mb-4 flex flex-col items-center">
<span class="text-white text-2xl font-bold">ESN</span> <!-- 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> </div>
<h1
<h1 class="mb-6 text-3xl font-bold text-gray-800"> 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"
ESN Card Generator >
Card Forge
</h1> </h1>
<p class="mb-4 text-xl font-medium leading-relaxed text-gray-700">
<p class="text-lg text-gray-700 leading-relaxed mb-6"> Transform your Google Sheets into professional ESNcards with photos.
Transform your Google Sheets into professional ESN membership cards with photos.
Privacy-first: all processing happens in your browser.
</p> </p>
<p class="mb-4 text-lg leading-relaxed text-gray-600">
<div class="text-sm text-gray-500 mb-8"> <span class="font-semibold text-black-800">Privacy-first</span>: all processing happens in
<p class="mb-2">✓ Import data from Google Sheets</p> your browser.
<p class="mb-2">✓ Automatic face detection and cropping</p> </p>
<p class="mb-2">✓ Generate text and photo PDFs</p> <FeatureList class="mb-6" />
<p>✓ No data stored on our servers</p>
</div> </div>
</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 <button
on:click={startWizard} onclick={startWizard}
class="bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors" class="w-64 rounded-lg bg-blue-600 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-blue-700"
> >
Start Creating Cards Start Creating Cards
</button> </button>
</div> </div>
</div> </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,39 +1,42 @@
<script lang="ts"> <script lang="ts">
import { currentStep } from '$lib/stores.js'; import { currentStep, steps as stepNames, currentStepName } from '$lib/stores';
import Splash from './Splash.svelte';
import StepAuth from './wizard/StepAuth.svelte'; import StepAuth from './wizard/StepAuth.svelte';
import StepSheetSearch from './wizard/StepSheetSearch.svelte'; import StepSheetSearch from './wizard/StepSheetSearch.svelte';
import StepColumnMap from './wizard/StepColumnMap.svelte'; import StepColumnMap from './wizard/StepColumnMap.svelte';
// Additional steps to be added as they are implemented 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 = [ const stepTitles = {
StepAuth, splash: 'Welcome',
StepSheetSearch, auth: 'Authenticate',
StepColumnMap 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 = [ let currentTitle = $derived(stepTitles[$currentStepName]);
'Authenticate', let currentStepIndex = $derived(stepNames.indexOf($currentStepName));
'Select Sheet',
'Map Columns'
];
function goToPreviousStep() {
if ($currentStep > 1) {
currentStep.update(n => n - 1);
}
}
</script> </script>
<div class="min-h-screen bg-gray-50"> <div class="bg-gray-100 min-h-screen p-4">
<div class="container mx-auto max-w-4xl p-4"> <div class="container mx-auto max-w-5xl pb-10">
{#if $currentStepName !== 'splash'}
<!-- Progress indicator --> <!-- Progress indicator -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6"> <div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800"> <h1 class="text-2xl font-bold text-gray-800">
{stepTitles[$currentStep - 1]} {currentTitle}
</h1> </h1>
<span class="text-sm text-gray-500"> <span class="text-sm text-gray-500">
Step {$currentStep} of {steps.length} Step {currentStepIndex} of {stepNames.length - 1}
</span> </span>
</div> </div>
@@ -41,25 +44,33 @@
<div class="w-full bg-gray-200 rounded-full h-2"> <div class="w-full bg-gray-200 rounded-full h-2">
<div <div
class="bg-blue-600 h-2 rounded-full transition-all duration-300" class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {($currentStep / steps.length) * 100}%" style="width: {(currentStepIndex / (stepNames.length - 1)) * 100}%"
></div> ></div>
</div> </div>
</div> </div>
{/if}
<!-- Step content --> <!-- Step content -->
<div class="bg-white rounded-lg shadow-sm"> <div class="bg-white rounded-lg shadow-sm">
<svelte:component this={steps[$currentStep - 1]} /> {#if $currentStepName === 'splash'}
</div> <Splash />
{:else if $currentStepName === 'auth'}
<!-- Navigation --> <StepAuth />
<div class="flex justify-between mt-6"> {:else if $currentStepName === 'search'}
<button <StepSheetSearch />
on:click={goToPreviousStep} {:else if $currentStepName === 'mapping'}
disabled={$currentStep <= 1} <StepColumnMap />
class="px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed" {:else if $currentStepName === 'validation'}
> <StepRowFilter />
← Previous {:else if $currentStepName === 'card-details'}
</button> <StepCardDetails />
{:else if $currentStepName === 'card-select'}
<StepCardSelect />
{:else if $currentStepName === 'gallery'}
<StepGallery />
{:else if $currentStepName === 'generate'}
<StepGenerate />
{/if}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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"> <script lang="ts">
import { onMount } from 'svelte';
import { currentStep } from '$lib/stores.js'; import { currentStep } from '$lib/stores.js';
import { isSignedIn, handleSignIn, handleSignOut, isGoogleApiReady } from '$lib/google'; import {
isSignedIn,
handleSignOut,
requestTokenFromUser,
isGoogleApiReady,
initGoogleClients
} from '$lib/google';
import Navigator from './subcomponents/Navigator.svelte';
function proceed() { onMount(() => {
currentStep.set(2); if (!$isGoogleApiReady) {
initGoogleClients(() => {
// This callback is called when the Google clients are ready.
});
}
});
function handleSignIn() {
requestTokenFromUser();
} }
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-md mx-auto text-center">
<div class="mb-6"> <div class="mb-6">
<div class="mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center"> <h2 class="mb-2 text-xl font-semibold text-gray-900">Connect to Google</h2>
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 24 24"> <p class="text-sm text-gray-700">
<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"/> Sign in with your Google account to access your Google Sheets and Google Drive for photo
<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"/> downloads.
</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> </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>
{#if $isSignedIn} <div class="grid gap-8 md:grid-cols-2">
<!-- Authenticated state --> <!-- Left Column: Information -->
<div class="bg-green-50 border border-green-300 rounded-lg p-4 mb-4"> <div class="space-y-6 text-gray-700">
<div class="flex items-center justify-center mb-2"> <div>
<svg class="w-5 h-5 text-green-600 mr-2" fill="currentColor" viewBox="0 0 20 20"> <h4 class="font-semibold text-gray-900">Google Sheets Integration</h4>
<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"/> <p class="text-sm">
</svg> Seamlessly import your data without the hassle of manual copy-pasting.
<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> </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>
<div class="flex space-x-3 justify-center"> <!-- Right Column: Action -->
<button <div
on:click={proceed} class="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-8"
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700"
> >
Continue → {#if !$isGoogleApiReady}
</button> <!-- 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 <button
on:click={handleSignOut} onclick={handleSignOut}
class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium" 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 Sign Out
</button> </button>
</div> </div>
</div>
{:else} {:else}
<!-- Unauthenticated state --> <!-- 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 <button
on:click={handleSignIn} onclick={handleSignIn}
disabled={!$isGoogleApiReady} class="flex w-full items-center justify-center rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
class="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 Sign In with Google
{:else}
Loading Google API...
{/if}
</button> </button>
</div>
{/if} {/if}
</div> </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> </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,49 +1,121 @@
<script lang="ts"> <script lang="ts">
import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores'; import {
import { getSheetNames, getSheetData } from '$lib/google'; selectedSheet,
currentStep,
columnMapping,
} from '$lib/stores';
import { userEmail } from '$lib/google';
import { hashString } from '$lib/utils';
import type { ColumnMappingType, SheetInfoType } from '$lib/stores';
import { getSheetNames, getSheetData, ensureToken } from '$lib/google';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte';
let isLoadingSheets = false; let isLoadingSheets = $state(false);
let isLoadingData = false; let isLoadingData = $state(false);
let availableSheets: string[] = []; let availableSheets = $state<string[]>([]);
let selectedSheetName = ''; let selectedSheetName = $state('');
let error = ''; let error = $state('');
let sheetHeaders: string[] = []; let sheetHeaders = $state<string[]>([]);
let previewData: string[][] = []; let previewData = $state<string[][]>([]);
let mappingComplete = false; let mappingComplete = $state(false);
let hasSavedMapping = $state(false);
const requiredFields = [ let showMappingEditor = $state(false);
{ key: 'name', label: 'First Name', required: true }, let savedSheetInfo = $state<SheetInfoType | null>(null);
{ key: 'surname', label: 'Last Name', required: true }, let mappedIndices = $state<ColumnMappingType>({
{ key: 'nationality', label: 'Nationality', required: true },
{ key: 'birthday', label: 'Birthday', required: true },
{ key: 'pictureUrl', label: 'Photo URL', required: true }
];
let mappedIndices = {
name: -1, name: -1,
surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1 pictureUrl: -1,
}; alreadyPrinted: -1,
sheetName: ''
});
const requiredFields = [
{ 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 // Load available sheets when component mounts
onMount(async () => { onMount(async () => {
ensureToken();
if ($selectedSheet) { if ($selectedSheet) {
console.log('Selected sheet on mount:', $selectedSheet);
// Check if we already have saved mapping data
const key = await getRecentSheetsKey();
const recentSheetsData = localStorage.getItem(key);
if (recentSheetsData) {
try {
const recentSheets = JSON.parse(recentSheetsData);
if (recentSheets && recentSheets.length > 0) {
// Find a sheet that matches the current spreadsheet
const savedSheet = recentSheets.find(
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
);
if (savedSheet) {
console.log('Found saved sheet configuration:', savedSheet);
// We have a saved sheet for this spreadsheet
selectedSheetName = savedSheet.columnMapping.sheetName;
savedSheetInfo = savedSheet;
if (savedSheet.columnMapping) {
// Set the mapped indices from saved data
mappedIndices = {
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();
return;
}
}
}
} catch (err) {
console.error('Error parsing saved sheets data:', err);
}
}
// If no saved data was found or it couldn't be used, load sheets as usual
await loadAvailableSheets(); await loadAvailableSheets();
} else {
console.error('No spreadsheet selected on mount');
} }
}); });
async function loadAvailableSheets() { async function loadAvailableSheets() {
if (!$selectedSheet) return; if (!$selectedSheet) {
console.error('Cannot load available sheets: no sheet selected');
return;
}
console.log('Loading available sheets for spreadsheet:', $selectedSheet.id);
isLoadingSheets = true; isLoadingSheets = true;
error = ''; error = '';
try { try {
availableSheets = await getSheetNames($selectedSheet.id); const sheetNames = await getSheetNames($selectedSheet.id);
// Don't auto-select any sheet - let user choose console.log('Loaded sheet names:', sheetNames);
availableSheets = sheetNames;
} catch (err) { } catch (err) {
console.error('Error loading sheet names:', err); console.error('Error loading sheet names:', err);
error = 'Failed to load sheet names. Please try again.'; error = 'Failed to load sheet names. Please try again.';
@@ -53,19 +125,23 @@
} }
function handleSheetSelect(sheetName: string) { function handleSheetSelect(sheetName: string) {
console.log('Sheet selected:', sheetName);
selectedSheetName = sheetName; selectedSheetName = sheetName;
// Clear any previous data when selecting a new sheet // Clear any previous data when selecting a new sheet
rawSheetData.set([]);
sheetHeaders = []; sheetHeaders = [];
previewData = []; previewData = [];
mappedIndices = { mappedIndices = {
name: -1, name: -1,
surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1 pictureUrl: -1,
alreadyPrinted: -1,
sheetName: sheetName
}; };
mappingComplete = false; mappingComplete = false;
hasSavedMapping = false;
showMappingEditor = true;
// Load sheet data // Load sheet data
if (sheetName) { if (sheetName) {
@@ -74,8 +150,12 @@
} }
async function loadSheetData(sheetName: string) { async function loadSheetData(sheetName: string) {
if (!$selectedSheet) return; if (!$selectedSheet) {
console.error('Cannot load sheet data: no sheet selected');
return;
}
console.log('Loading sheet data for spreadsheet:', $selectedSheet.id, 'sheet:', sheetName);
isLoadingData = true; isLoadingData = true;
error = ''; error = '';
@@ -85,17 +165,19 @@
const data = await getSheetData($selectedSheet.id, range); const data = await getSheetData($selectedSheet.id, range);
if (data && data.length > 0) { if (data && data.length > 0) {
console.log('Loaded sheet data with', data.length, 'rows');
sheetHeaders = data[0]; sheetHeaders = data[0];
previewData = data.slice(1, Math.min(4, data.length)); // Get up to 3 rows for preview previewData = data.slice(1, Math.min(4, data.length)); // Get up to 3 rows for preview
rawSheetData.set(data); // We don't need to set all the raw data here
// Try to auto-map columns // Try to auto-map columns
autoMapColumns(); autoMapColumns();
// Check if we have saved column mapping for this sheet // Check if we have saved column mapping for this sheet
loadSavedColumnMapping(); await loadSavedColumnMapping();
} else { } else {
error = 'The selected sheet appears to be empty.'; error = 'The selected sheet appears to be empty.';
console.warn('Sheet is empty');
} }
} catch (err) { } catch (err) {
console.error('Error loading sheet data:', err); console.error('Error loading sheet data:', err);
@@ -109,57 +191,97 @@
// Reset mappings // Reset mappings
mappedIndices = { mappedIndices = {
name: -1, name: -1,
surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1 pictureUrl: -1,
alreadyPrinted: -1,
sheetName: selectedSheetName
}; };
// Auto-mapping patterns // Auto-mapping patterns
const patterns = { const patterns: Record<keyof Omit<ColumnMappingType, 'sheetName'>, RegExp> = {
name: /first[\s_-]*name|name|given[\s_-]*name|vorname/i, name: /full[\s_-]*name|name/i,
surname: /last[\s_-]*name|surname|family[\s_-]*name|nachname/i,
nationality: /nationality|country|nation/i, nationality: /nationality|country|nation/i,
birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i, birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i,
pictureUrl: /photo|picture|image|url|avatar/i pictureUrl: /photo|picture|image|url|avatar/i,
alreadyPrinted: /already[\s_-]*printed|printed|status/i
}; };
sheetHeaders.forEach((header, index) => { sheetHeaders.forEach((header, index) => {
for (const [field, pattern] of Object.entries(patterns)) { for (const [field, pattern] of Object.entries(patterns)) {
if (pattern.test(header) && mappedIndices[field] === -1) { const key = field as keyof ColumnMappingType;
mappedIndices[field] = index; if (pattern.test(header) && mappedIndices[key] === -1) {
mappedIndices[key] = index;
break; break;
} }
} }
}); });
// If "Already Printed" column wasn't found, try to find the first empty column
if (mappedIndices.alreadyPrinted === -1 && previewData.length > 0) {
// Check up to 26 columns (A-Z) or the number of headers, whichever is larger
const maxColumns = Math.max(sheetHeaders.length, 26);
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
// Check if this column is empty (all preview rows are empty for this column)
const isEmpty = previewData.every(
(row) => !row[colIndex] || String(row[colIndex]).trim() === ''
);
// Also check if this column isn't already mapped to another field
const isAlreadyMapped = Object.entries(mappedIndices).some(
([field, index]) =>
field !== 'alreadyPrinted' &&
index === colIndex &&
field !== 'sheetName' &&
index === colIndex
);
if (isEmpty && !isAlreadyMapped) {
mappedIndices.alreadyPrinted = colIndex;
break;
}
}
}
console.log('Auto-mapped columns:', mappedIndices);
updateMappingStatus(); updateMappingStatus();
} }
function loadSavedColumnMapping() { async function loadSavedColumnMapping() {
if (!$selectedSheet || !selectedSheetName) return; if (!$selectedSheet || !selectedSheetName) {
console.log('Cannot load saved column mapping: missing selectedSheet or selectedSheetName');
return;
}
try { try {
const recentSheetsKey = 'esn-recent-sheets'; const key = await getRecentSheetsKey();
const existingData = localStorage.getItem(recentSheetsKey); const existingData = localStorage.getItem(key);
if (existingData) { if (existingData) {
const recentSheets = JSON.parse(existingData); const recentSheets = JSON.parse(existingData);
const savedSheet = recentSheets.find(sheet => const savedSheet = recentSheets.find(
sheet.id === $selectedSheet.id && sheet.sheetName === selectedSheetName (sheet: SheetInfoType) => sheet.id === $selectedSheet.id
); );
if (savedSheet && savedSheet.columnMapping) { if (savedSheet && savedSheet.columnMapping) {
console.log('Found saved column mapping for current sheet:', savedSheet.columnMapping);
// Override auto-mapping with saved mapping // Override auto-mapping with saved mapping
mappedIndices = { mappedIndices = {
name: savedSheet.columnMapping.name ?? -1, name: savedSheet.columnMapping.name ?? -1,
surname: savedSheet.columnMapping.surname ?? -1,
nationality: savedSheet.columnMapping.nationality ?? -1, nationality: savedSheet.columnMapping.nationality ?? -1,
birthday: savedSheet.columnMapping.birthday ?? -1, birthday: savedSheet.columnMapping.birthday ?? -1,
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1 pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1,
sheetName: selectedSheetName
}; };
hasSavedMapping = true;
savedSheetInfo = savedSheet;
updateMappingStatus(); updateMappingStatus();
} else {
console.log('No saved column mapping found for the current sheet');
} }
} }
} catch (err) { } catch (err) {
@@ -167,44 +289,70 @@
} }
} }
function handleColumnMapping(field: string, index: number) { function handleColumnMapping(field: keyof ColumnMappingType, index: number) {
if (!mappedIndices) {
mappedIndices = {
name: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1,
sheetName: selectedSheetName
};
}
mappedIndices[field] = index; mappedIndices[field] = index;
updateMappingStatus(); updateMappingStatus();
} }
function updateMappingStatus() { function updateMappingStatus() {
mappingComplete = Object.values(mappedIndices).every(index => index !== -1); if (!mappedIndices) {
mappingComplete = false;
return;
}
// Only check required fields for completion
const requiredIndices = {
name: mappedIndices.name,
nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl,
sheetName: selectedSheetName
};
mappingComplete = Object.values(requiredIndices).every((index) => index !== -1);
console.log('Mapping complete:', mappingComplete);
// Update the column mapping store // Update the column mapping store
columnMapping.set({ columnMapping.set({
name: mappedIndices.name, name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality, nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday, birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl pictureUrl: mappedIndices.pictureUrl,
alreadyPrinted: mappedIndices.alreadyPrinted,
sheetName: selectedSheetName
}); });
} }
function handleContinue() { async function handleContinue() {
if (!mappingComplete || !$selectedSheet || !selectedSheetName) return; if (!mappingComplete || !$selectedSheet || !selectedSheetName) return;
// Save column mapping to localStorage for the selected sheet // Save column mapping to localStorage for the selected sheet
try { try {
const recentSheetsKey = 'esn-recent-sheets'; const key = await getRecentSheetsKey();
const existingData = localStorage.getItem(recentSheetsKey); const existingData = localStorage.getItem(key);
let recentSheets = existingData ? JSON.parse(existingData) : []; let recentSheets = existingData ? JSON.parse(existingData) : [];
// Find the current sheet in recent sheets and update its column mapping // Find the current sheet in recent sheets and update its column mapping
const sheetIndex = recentSheets.findIndex(sheet => const sheetIndex = recentSheets.findIndex(
sheet.id === $selectedSheet.id && sheet.sheetName === selectedSheetName (sheet: SheetInfoType) => sheet.id === $selectedSheet.id
); );
const columnMappingData = { const columnMappingData = {
name: mappedIndices.name, name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality, nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday, birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl pictureUrl: mappedIndices.pictureUrl,
alreadyPrinted: mappedIndices.alreadyPrinted,
sheetName: selectedSheetName
}; };
if (sheetIndex !== -1) { if (sheetIndex !== -1) {
@@ -216,7 +364,6 @@
const newEntry = { const newEntry = {
id: $selectedSheet.id, id: $selectedSheet.id,
name: $selectedSheet.name, name: $selectedSheet.name,
sheetName: selectedSheetName,
columnMapping: columnMappingData, columnMapping: columnMappingData,
lastUsed: new Date().toISOString() lastUsed: new Date().toISOString()
}; };
@@ -229,52 +376,129 @@
} }
} }
localStorage.setItem(recentSheetsKey, JSON.stringify(recentSheets)); localStorage.setItem(key, JSON.stringify(recentSheets));
} catch (err) { } catch (err) {
console.error('Failed to save column mapping to localStorage:', err); console.error('Failed to save column mapping to localStorage:', err);
} }
}
currentStep.set(4); // Move to next step async function handleShowEditor() {
showMappingEditor = true;
// Load available sheets if they haven't been loaded yet
if (availableSheets.length === 0) {
await loadAvailableSheets();
}
// Ensure we have sheet data if a sheet is already selected
if (selectedSheetName && sheetHeaders.length === 0) {
// Load the sheet data but keep mappings intact
try {
isLoadingData = true;
const range = `${selectedSheetName}!A1:Z10`;
const data = await getSheetData($selectedSheet.id, range);
if (data && data.length > 0) {
sheetHeaders = data[0];
previewData = data.slice(1, Math.min(4, data.length));
}
} catch (err) {
console.error('Error loading sheet data for editor:', err);
} finally {
isLoadingData = false;
}
}
} }
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-3xl mx-auto">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2"> <h2 class="mb-2 text-xl font-semibold text-gray-900">Select Sheet and Map Columns</h2>
Select Sheet and Map Columns
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="mb-4 text-sm text-gray-700">
First, select which sheet contains your member data, then map the columns to the required fields. First, select which sheet contains your member data, then map the columns to the required
fields.
</p> </p>
</div> </div>
{#if hasSavedMapping && !showMappingEditor}
<!-- Simplified view when we have saved mapping -->
<div class="mb-6 rounded-lg border border-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
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>
</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}
<!-- Sheet Selection --> <!-- Sheet Selection -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6"> <div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="text-sm font-medium text-gray-700 mb-3"> <h3 class="mb-3 text-sm font-medium text-gray-700">Step 1: Select Sheet</h3>
Step 1: Select Sheet
</h3>
{#if isLoadingSheets} {#if isLoadingSheets}
<div class="flex items-center"> <div class="flex items-center">
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div> <div
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-gray-600">Loading sheets...</span> <span class="text-sm text-gray-600">Loading sheets...</span>
</div> </div>
{:else if error} {:else if error}
<div class="bg-red-50 border border-red-300 rounded-lg p-3 mb-3"> <div class="mb-3 rounded-lg border border-red-300 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p> <p class="text-sm text-red-800">{error}</p>
<button <button
class="mt-2 text-sm text-blue-600 hover:text-blue-800" class="mt-2 text-sm text-blue-600 hover:text-blue-800"
on:click={loadAvailableSheets} onclick={loadAvailableSheets}
> >
Try again Try again
</button> </button>
</div> </div>
{:else if availableSheets.length === 0} {:else if availableSheets.length === 0}
<div class="text-center py-6 bg-white rounded border border-gray-200"> <div class="rounded border border-gray-200 bg-white py-6 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No sheets found</h3> <h3 class="mt-2 text-sm font-medium text-gray-900">No sheets found</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
@@ -288,23 +512,30 @@
</p> </p>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-3"> <p class="mb-3 block text-sm font-medium text-gray-700">Choose sheet:</p>
Choose sheet:
</label>
<div class="space-y-2"> <div class="space-y-2">
{#each availableSheets as sheetName} {#each availableSheets as sheetName}
<div <div
class="border rounded-lg p-3 cursor-pointer transition-colors hover:bg-gray-50 role="button"
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" tabindex="0"
on:click={() => handleSheetSelect(sheetName)} class="cursor-pointer rounded-lg border p-3 transition-colors hover:bg-gray-50
{selectedSheetName === sheetName
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSheetSelect(sheetName)}
onkeydown={(e) => e.key === 'Enter' && handleSheetSelect(sheetName)}
> >
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-4 h-4 rounded-full border-2 flex items-center justify-center <div
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-500' : 'border-gray-300'}"> class="flex h-4 w-4 items-center justify-center rounded-full border-2
{selectedSheetName === sheetName
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'}"
>
{#if selectedSheetName === sheetName} {#if selectedSheetName === sheetName}
<div class="w-2 h-2 rounded-full bg-white"></div> <div class="h-2 w-2 rounded-full bg-white"></div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -315,8 +546,12 @@
{#if selectedSheetName === sheetName} {#if selectedSheetName === sheetName}
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@@ -330,23 +565,23 @@
</div> </div>
<!-- Column Mapping Section --> <!-- Column Mapping Section -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6"> <div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="text-sm font-medium text-gray-700 mb-3"> <h3 class="mb-3 text-sm font-medium text-gray-700">Step 2: Map Columns</h3>
Step 2: Map Columns
</h3>
{#if isLoadingData} {#if isLoadingData}
<div class="flex items-center"> <div class="flex items-center">
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div> <div
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-gray-600">Loading sheet data...</span> <span class="text-sm text-gray-600">Loading sheet data...</span>
</div> </div>
{:else if sheetHeaders.length === 0} {:else if sheetHeaders.length === 0}
<div class="text-center py-8 text-gray-500"> <div class="py-8 text-center text-gray-500">
<p class="text-sm">Select a sheet above to map columns</p> <p class="text-sm">Select a sheet above to map columns</p>
</div> </div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-gray-600 mb-4"> <p class="mb-4 text-sm text-gray-600">
Map the columns from your sheet to the required fields: Map the columns from your sheet to the required fields:
</p> </p>
@@ -355,7 +590,7 @@
{#each requiredFields as field} {#each requiredFields as field}
<div class="flex items-center"> <div class="flex items-center">
<div class="w-32 flex-shrink-0"> <div class="w-32 flex-shrink-0">
<label class="text-sm font-medium text-gray-700"> <label for={`field-${field.key}`} class="text-sm font-medium text-gray-700">
{field.label} {field.label}
{#if field.required} {#if field.required}
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
@@ -365,13 +600,23 @@
<div class="flex-grow"> <div class="flex-grow">
<select <select
id={`field-${field.key}`}
bind:value={mappedIndices[field.key]} bind:value={mappedIndices[field.key]}
on:change={() => handleColumnMapping(field.key, mappedIndices[field.key])} onchange={() =>
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" handleColumnMapping(
field.key as keyof ColumnMappingType,
mappedIndices[field.key]
)}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
> >
<option value={-1}>-- Select column --</option> <option value={-1}>-- Select column --</option>
{#each sheetHeaders as header, index} {#each Array.from({ length: Math.max(sheetHeaders.length, 26) }, (_, i) => i) as index}
<option value={index}>{header}</option> <option value={index}>
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
{#if !sheetHeaders[index]}
(empty)
{/if}
</option>
{/each} {/each}
</select> </select>
</div> </div>
@@ -382,31 +627,41 @@
<!-- Data preview --> <!-- Data preview -->
{#if previewData.length > 0} {#if previewData.length > 0}
<div class="mt-6"> <div class="mt-6">
<h4 class="text-sm font-medium text-gray-700 mb-3">Data Preview:</h4> <h4 class="mb-3 text-sm font-medium text-gray-700">Data Preview:</h4>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg"> <table
class="min-w-full divide-y divide-gray-200 rounded-lg border border-gray-200"
>
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
{#each sheetHeaders as header, index} {#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, previewData[0]?.length || 0), 26) }, (_, i) => i) as index}
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <th
{Object.values(mappedIndices).includes(index) ? 'bg-blue-100' : ''}"> class="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase
{header} {Object.values(mappedIndices).includes(index)
? 'bg-blue-100'
: ''}"
>
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
{#if Object.values(mappedIndices).includes(index)} {#if Object.values(mappedIndices).includes(index)}
<div class="text-blue-600 text-xs mt-1"> <div class="mt-1 text-xs text-blue-600">
{requiredFields.find(f => mappedIndices[f.key] === index)?.label} {requiredFields.find((f) => mappedIndices[f.key] === index)?.label}
</div> </div>
{/if} {/if}
</th> </th>
{/each} {/each}
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200 bg-white">
{#each previewData as row} {#each previewData as row}
<tr> <tr>
{#each row as cell, index} {#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, row.length), 26) }, (_, i) => i) as index}
<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate <td
{Object.values(mappedIndices).includes(index) ? 'bg-blue-50' : ''}"> class="max-w-xs truncate px-3 py-2 text-sm text-gray-500
{cell} {Object.values(mappedIndices).includes(index)
? 'bg-blue-50'
: ''}"
>
{row[index] || ''}
</td> </td>
{/each} {/each}
</tr> </tr>
@@ -417,33 +672,70 @@
</div> </div>
{/if} {/if}
<!-- Mapping status -->
{#if mappingComplete} {#if mappingComplete}
<div class="bg-green-50 border border-green-200 rounded p-3"> <div class="rounded-md border border-green-200 bg-green-50 p-4">
<p class="text-sm text-green-800"> <div class="flex">
✓ All required fields are mapped! You can continue to the next step. <div class="flex-shrink-0">
<svg
class="h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
All required fields are mapped. You can now proceed.
</p> </p>
</div> </div>
</div>
</div>
{:else} {:else}
<div class="bg-yellow-50 border border-yellow-200 rounded p-3"> <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"> <p class="text-sm text-yellow-800">
Please map all required fields to continue. Please map all required fields (<span class="text-red-500">*</span>) to
continue.
</p> </p>
</div> </div>
{/if} </div>
</div> </div>
{/if} {/if}
</div> </div>
{/if}
</div>
{/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-end"> <Navigator
<button canProceed={mappingComplete}
on:click={handleContinue} {currentStep}
disabled={!mappingComplete} textBack="Back to Sheet Selection"
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed" textForwardDisabled="Select a column mapping"
> textForwardEnabled="Continue to Row Selection"
{mappingComplete ? 'Continue →' : 'Complete mapping to continue'} onForward={handleContinue}
</button> />
</div>
</div>
</div> </div>

View File

@@ -1,4 +1,601 @@
<script lang="ts">
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
import { columnMapping, sheetData, currentStep, pictures, cropRects, selectedCard } from '$lib/stores';
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl, ensureToken } from '$lib/google';
import Navigator from './subcomponents/Navigator.svelte';
import PhotoCard from './subcomponents/PhotoCard.svelte';
import * as tf from '@tensorflow/tfjs';
import * as blazeface from '@tensorflow-models/blazeface';
import PQueue from 'p-queue';
import { set, clear } from 'idb-keyval';
let photos = $state<PhotoInfo[]>([]);
let isProcessing = $state(false);
let processedCount = $state(0);
let totalCount = $state(0);
let detector: blazeface.BlazeFaceModel | undefined;
let detectorPromise: Promise<void> | undefined;
let downloadQueue: PQueue;
let faceDetectionQueue: PQueue;
interface PhotoInfo {
name: string;
url: string;
status: 'loading' | 'success' | 'error';
objectUrl?: string;
retryCount: number;
cropData?: { x: number; y: number; width: number; height: number };
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
}
function initializeDetector() {
if (!detectorPromise) {
detectorPromise = (async () => {
console.log('Initializing face detector...');
await tf.setBackend('webgl');
await tf.ready();
detector = await blazeface.load();
console.log('BlazeFace model loaded');
})();
}
return detectorPromise;
}
// Create a downscaled JPEG preview to reduce memory usage for UI rendering
async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85): Promise<Blob> {
try {
const bitmap = await createImageBitmap(original);
let { width, height } = bitmap;
const maxDim = Math.max(width, height);
// If image is already at or below the threshold, keep it as-is
if (maxDim <= maxSide) {
bitmap.close();
return original;
}
const scale = Math.min(1, maxSide / maxDim);
const targetW = Math.max(1, Math.round(width * scale));
const targetH = Math.max(1, Math.round(height * scale));
const canvas = document.createElement('canvas');
canvas.width = targetW;
canvas.height = targetH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context unavailable');
ctx.drawImage(bitmap, 0, 0, targetW, targetH);
bitmap.close();
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', quality)
);
return blob;
} catch (e) {
// Fallback to original if downscale fails
return original;
}
}
// Force memory cleanup
async function forceMemoryCleanup() {
await tf.nextFrame(); // Wait for any pending GPU operations
// Only run garbage collection if available, don't dispose variables
if (typeof window !== 'undefined' && 'gc' in window) {
(window as any).gc();
}
}
async function processPhotosInParallel() {
console.log('Starting processPhotos with queues...');
isProcessing = true;
processedCount = 0;
try {
// Clear previous session's images from IndexedDB
await clear();
console.log('Cleared IndexedDB.');
} catch (e) {
console.error('Could not clear IndexedDB:', e);
return;
}
// Initialize queues with more conservative concurrency
downloadQueue = new PQueue({ concurrency: 4 }); // Reduced from 5
faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety
// When both queues are idle, we're done
downloadQueue.on('idle', async () => {
if (faceDetectionQueue.size === 0 && faceDetectionQueue.pending === 0) {
await forceMemoryCleanup(); // Clean up memory when processing is complete
isProcessing = false;
console.log('All queues are idle. Processing complete.');
}
});
faceDetectionQueue.on('idle', async () => {
if (downloadQueue.size === 0 && downloadQueue.pending === 0) {
await forceMemoryCleanup(); // Clean up memory when processing is complete
isProcessing = false;
console.log('All queues are idle. Processing complete.');
}
});
const validRows = $sheetData.filter((row) => row._valid);
const photoUrls = new Set<string>();
const photoMap = new Map<string, any[]>();
validRows.forEach((row: any) => {
const photoUrl = row.pictureUrl;
if (photoUrl && photoUrl.trim()) {
const trimmedUrl = photoUrl.trim();
photoUrls.add(trimmedUrl);
if (!photoMap.has(trimmedUrl)) {
photoMap.set(trimmedUrl, []);
}
photoMap.get(trimmedUrl)!.push(row);
}
});
totalCount = photoUrls.size;
console.log(`Found ${totalCount} unique photo URLs to process.`);
photos = Array.from(photoUrls).map((url) => ({
name: photoMap.get(url)![0].name,
url,
status: 'loading' as const,
retryCount: 0,
faceDetectionStatus: 'pending' as const
}));
// Add all photos to the download queue
for (let i = 0; i < photos.length; i++) {
downloadQueue.add(() => loadPhoto(i));
}
}
// Initialize detector and process photos
onMount(() => {
ensureToken();
initializeDetector(); // Start loading model
if ($sheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
console.log('Processing photos for gallery step');
processPhotosInParallel();
} else {
console.log('No data to process: !');
}
});
async function loadPhoto(index: number, isRetry = false) {
const photo = photos[index];
if (!isRetry) {
photo.status = 'loading';
}
try {
let blob: Blob;
if (isGoogleDriveUrl(photo.url)) {
// Download from Google Drive
blob = await downloadDriveImage(photo.url);
} else {
// For direct URLs, convert to blob
const response = await fetch(photo.url);
blob = await response.blob();
}
// Check for HEIC/HEIF format. If so, start conversion but don't block.
if (
blob.type === 'image/heic' ||
blob.type === 'image/heif' ||
photo.url.toLowerCase().endsWith('.heic')
) {
photo.status = 'loading'; // Visually indicate something is happening
// Don't await this, let it run in the background
convertHeicPhoto(index, blob);
return; // End loadPhoto here for HEIC, conversion will handle the rest
}
// For non-HEIC images, proceed as normal
await processLoadedBlob(index, blob);
} catch (error) {
console.error(`Failed to load photo for ${photo.name}:`, error);
photo.status = 'error';
// Since this step failed, we still count it as "processed" to not stall the progress bar
processedCount++;
}
}
async function convertHeicPhoto(index: number, blob: Blob) {
const photo = photos[index];
try {
// Dynamically import the browser-specific version of the library
const { default: convert } = await import('heic-convert/browser');
const inputBuffer = await blob.arrayBuffer();
const outputBuffer = await convert({
buffer: new Uint8Array(inputBuffer), // heic-convert expects a Uint8Array
format: 'JPEG',
quality: 0.9
});
const buffer = outputBuffer instanceof Uint8Array
? outputBuffer.buffer.slice(outputBuffer.byteOffset, outputBuffer.byteOffset + outputBuffer.byteLength)
: outputBuffer;
const convertedBlob = new Blob([buffer as ArrayBuffer], { type: 'image/jpeg' });
// Now that it's converted, process it like any other image
await processLoadedBlob(index, convertedBlob);
} catch (e) {
console.error(`Failed to convert HEIC image for ${photo.name}:`, e);
photo.status = 'error';
// Since this step failed, we still count it as "processed" to not stall the progress bar
processedCount++;
}
}
async function processLoadedBlob(index: number, blob: Blob) {
const photo = photos[index];
try {
// Downsize once and use this for storage, preview, and detection
const resizedBlob = await createPreviewBlob(blob, 1600, 0.85);
await set(photo.url, resizedBlob);
const objectUrl = createImageObjectUrl(resizedBlob);
// Test if downsized image loads properly
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = (error) => {
console.error(`Failed to load downsized image for ${photo.name}:`, error);
reject(new Error('Failed to load image'));
};
img.src = objectUrl;
});
// Revoke any previous preview URL to avoid leaks
if (photo.objectUrl && photo.objectUrl.startsWith('blob:') && photo.objectUrl !== objectUrl) {
URL.revokeObjectURL(photo.objectUrl);
}
photo.objectUrl = objectUrl;
photo.status = 'success';
pictures.update((pics) => ({
...pics,
[photo.url]: {
id: photo.url,
url: objectUrl,
downloaded: true,
faceDetected: false,
faceCount: 0
}
}));
// Add face detection to its queue using the downsized image shown in UI
faceDetectionQueue.add(() => detectFaceForPhoto(index));
} catch (error) {
console.error(`Failed to process blob for ${photo.name}:`, error);
photo.status = 'error';
// Since this step failed, we still count it as "processed" to not stall the progress bar
processedCount++;
}
}
async function detectFaceForPhoto(index: number) {
const photo = photos[index];
let imageTensor;
try {
await initializeDetector(); // Ensure detector is loaded
if (!detector) {
photo.faceDetectionStatus = 'failed';
console.error('Face detector not available.');
return;
}
photo.faceDetectionStatus = 'processing';
const img = new Image();
img.crossOrigin = 'anonymous';
// Use the downsized UI image to keep coordinates aligned
img.src = photo.objectUrl!;
await new Promise((r, e) => {
img.onload = r;
img.onerror = e;
});
// Create tensor; run estimation (avoid tf.tidy here to not dispose returned tensors prematurely)
imageTensor = tf.browser.fromPixels(img);
const predictions: any[] = await detector.estimateFaces(imageTensor, false);
if (predictions.length > 0) {
const tensorToNumArray = (v: any): number[] => {
if (Array.isArray(v)) return v as number[];
if (typeof v === 'number') return [v];
if (v && typeof v.dataSync === 'function') {
const arr = Array.from(v.dataSync() as Float32Array);
if (typeof v.dispose === 'function') v.dispose();
return arr as number[];
}
return [];
};
const getProbability = (p: any) => tensorToNumArray(p)[0] ?? 0;
const face = predictions.sort(
(a, b) => getProbability(b.probability!) - getProbability(a.probability!)
)[0];
const topLeftArr = tensorToNumArray(face.topLeft);
const bottomRightArr = tensorToNumArray(face.bottomRight);
const topLeft = [topLeftArr[0], topLeftArr[1]] as [number, number];
const bottomRight = [bottomRightArr[0], bottomRightArr[1]] as [number, number];
let [x1, y1] = topLeft;
let [x2, y2] = bottomRight;
// Use natural sizes; detection ran on original if provided
const scaleX = 1;
const scaleY = 1;
const faceWidth = (x2 - x1) * scaleX;
const faceHeight = (y2 - y1) * scaleY;
const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX;
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
// Use the photo card aspect ratio from the selected card
const photoDimensions = $selectedCard!.photo;
const cropRatio = photoDimensions.width / photoDimensions.height;
const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0');
const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0');
const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5');
let cropWidth = faceWidth * cropScale;
let cropHeight = cropWidth / cropRatio;
if (cropWidth > img.naturalWidth || cropHeight > img.naturalHeight) {
const widthRatio = img.naturalWidth / cropWidth;
const heightRatio = img.naturalHeight / cropHeight;
const scale = Math.min(widthRatio, heightRatio);
cropWidth *= scale;
cropHeight *= scale;
}
let centerX = faceCenterX + cropWidth * offsetX;
let centerY = faceCenterY + cropHeight * offsetY;
centerX = Math.max(cropWidth / 2, Math.min(centerX, img.naturalWidth - cropWidth / 2));
centerY = Math.max(
cropHeight / 2,
Math.min(centerY, img.naturalHeight - cropHeight / 2)
);
const cropX = centerX - cropWidth / 2;
const cropY = centerY - cropHeight / 2;
const crop = {
x: Math.round(Math.max(0, cropX)),
y: Math.round(Math.max(0, cropY)),
width: Math.round(cropWidth),
height: Math.round(cropHeight)
};
photo.cropData = crop;
photo.faceDetectionStatus = 'completed';
cropRects.update((crops) => ({
...crops,
[photo.url]: crop
}));
pictures.update((pics) => ({
...pics,
[photo.url]: {
...pics[photo.url],
faceDetected: true,
faceCount: predictions.length
}
}));
} else {
photo.faceDetectionStatus = 'failed';
}
} catch (error) {
console.error(`Face detection failed for ${photo.name}:`, error);
photo.faceDetectionStatus = 'failed';
} finally {
// Manually dispose of the input tensor to prevent memory leaks
if (imageTensor) {
imageTensor.dispose();
}
// Add a small delay to allow GPU memory to be freed before next operation
await new Promise(resolve => setTimeout(resolve, 100));
// This is the final step for a photo, so we increment the processed count here.
processedCount++;
}
}
async function retryPhoto(index: number) {
const photo = photos[index];
if (photo.retryCount >= 3) {
return; // Max retries reached
}
photo.retryCount++;
// Add the retry attempt back to the download queue
downloadQueue.add(() => loadPhoto(index, true));
}
function handleCropUpdate(
index: number,
detail: { cropData: { x: number; y: number; width: number; height: number } }
) {
photos[index].cropData = detail.cropData;
photos[index].faceDetectionStatus = 'manual';
// Save updated crop data to store
cropRects.update((crops) => ({
...crops,
[photos[index].url]: detail.cropData
}));
// No need to reassign photos array with $state reactivity
}
// Cleanup object URLs when component is destroyed
function cleanupObjectUrls() {
photos.forEach((photo) => {
if (photo.objectUrl && photo.objectUrl.startsWith('blob:')) {
URL.revokeObjectURL(photo.objectUrl);
}
});
}
const canProceed = $derived(() => {
const hasPhotos = photos.length > 0;
const allLoaded = photos.every((photo) => photo.status === 'success');
const allCropped = photos.every((photo) => photo.cropData);
return hasPhotos && allLoaded && allCropped;
});
// Cleanup on unmount using $effect
$effect(() => {
return () => {
// Clear queues on component unmount to stop any ongoing processing
if (downloadQueue) {
downloadQueue.clear();
}
if (faceDetectionQueue) {
faceDetectionQueue.clear();
}
cleanupObjectUrls();
// Dispose the detector model if possible to release GPU/CPU memory
if (detector && typeof (detector as any).dispose === 'function') {
(detector as any).dispose();
}
detector = undefined;
};
});
</script>
<div class="p-6"> <div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Review Photos</h2> <div class="mb-6">
<p class="text-sm text-gray-700">Photo gallery and review functionality will be implemented here.</p> <h2 class="mb-2 text-xl font-semibold text-gray-900">Review & Crop Photos</h2>
<p class="mb-4 text-sm text-gray-700">
Photos are automatically cropped using face detection. Click the pen icon to manually adjust
the crop area.
</p>
</div>
<!-- Processing Status -->
{#if isProcessing}
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-blue-800"> Processing photos... </span>
</div>
<span class="text-sm text-blue-600">
{processedCount} / {totalCount}
</span>
</div>
{#if totalCount > 0}
<div class="mt-3 h-2 w-full rounded-full bg-blue-200">
<div
class="h-2 rounded-full bg-blue-600 transition-all duration-300"
style="width: {(processedCount / totalCount) * 100}%"
></div>
</div>
{/if}
</div>
{/if}
<!-- Summary Stats -->
{#if !isProcessing && photos.length > 0}
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-medium text-gray-700">Processing Summary</h3>
<div class="grid grid-cols-2 gap-4 text-sm md:grid-cols-5">
<div class="text-center">
<div class="text-2xl font-bold text-gray-900">{photos.length}</div>
<div class="text-gray-600">Total Photos</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">
{photos.filter((p) => p.status === 'success').length}
</div>
<div class="text-gray-600">Loaded</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">
{photos.filter((p) => p.faceDetectionStatus === 'completed').length}
</div>
<div class="text-gray-600">Auto-cropped</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-purple-600">
{photos.filter((p) => p.cropData).length}
</div>
<div class="text-gray-600">Ready</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-red-600">
{photos.filter((p) => p.status === 'error').length}
</div>
<div class="text-gray-600">Failed</div>
</div>
</div>
</div>
{/if}
<!-- Photo Grid -->
<div class="mb-6 overflow-hidden rounded-lg bg-white">
{#if photos.length === 0 && !isProcessing}
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 002 2z"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No photos found</h3>
<p class="mt-1 text-sm text-gray-500">
Go back to check your column mapping and selected rows.
</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3">
{#each photos as photo, index}
<PhotoCard
{photo}
photoDimensions={$selectedCard!.photo}
onCropUpdated={(e) => handleCropUpdate(index, e)}
onRetry={() => retryPhoto(index)}
/>
{/each}
</div>
{/if}
</div>
<!-- Navigation -->
<Navigator
canProceed={canProceed()}
{currentStep}
textBack="Back to Card Selection"
textForwardDisabled="Waiting for photos"
textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
/>
</div> </div>

View File

@@ -1,4 +1,767 @@
<script lang="ts">
import { onMount } from 'svelte';
import { sheetData, currentStep, pictures, cropRects, cardDetails, selectedCard } from '$lib/stores';
import type { Card } from '$lib/cards/types';
import { PDFDocument, StandardFonts, rgb, type PDFPage } from 'pdf-lib';
import * as fontkit from 'fontkit';
import { clear } from 'idb-keyval';
import {
BORDER_CONFIG,
TEXT_CONFIG,
calculateGrid,
getAbsolutePositionPt,
getAbsolutePhotoDimensionsPt,
getImageBlob,
MM_TO_PT
} from '$lib/pdfLayout';
import { PAGE_SETTINGS } from '$lib/pdfSettings';
import type { PageSettings } from '$lib/pdfSettings';
import Navigator from './subcomponents/Navigator.svelte';
type FileGenerationState = 'idle' | 'generating' | 'done' | 'error';
type GeneratedFile = {
name: string;
displayName: string;
state: FileGenerationState;
url: string | null;
size: number | null;
error: string | null;
downloadName?: string;
};
const initialFiles: GeneratedFile[] = [
{
name: 'esncards_text.pdf',
displayName: 'Text PDF',
state: 'idle',
url: null,
size: null,
error: null
},
{
name: 'esncards_photos.pdf',
displayName: 'Photos PDF',
state: 'idle',
url: null,
size: null,
error: null
}
];
let files = $state<GeneratedFile[]>(JSON.parse(JSON.stringify(initialFiles)));
// Cleanup function to clear IndexedDB and sensitive data
async function clearSensitiveData() {
try {
await clear(); // Clear all data from IndexedDB
console.log('IndexedDB cleared for security');
} catch (error) {
console.error('Failed to clear IndexedDB:', error);
}
}
// Handle tab close or page unload
function handleBeforeUnload() {
clearSensitiveData();
}
onMount(() => {
handleGenerateAll();
// Add event listener for page unload
window.addEventListener('beforeunload', handleBeforeUnload);
// Cleanup function when component unmounts
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
clearSensitiveData();
};
});
async function handleGenerateAll() {
if (!$selectedCard) return;
// Reset file states and revoke old URLs
files.forEach((f) => {
if (f.url) URL.revokeObjectURL(f.url);
});
files = JSON.parse(JSON.stringify(initialFiles));
// Generate both
await handleGenerate('esncards_text.pdf');
await handleGenerate('esncards_photos.pdf');
}
const generationStarted = $derived(files.some((f) => f.state !== 'idle'));
// Load Roboto font
async function loadRobotoFont() {
try {
const fontResponse = await fetch('/fonts/Roboto-Regular.ttf');
if (!fontResponse.ok) {
throw new Error('Failed to load Roboto font');
}
return await fontResponse.arrayBuffer();
} catch (error: any) {
console.warn('Could not load Roboto font, falling back to standard font:', error);
return null;
}
}
// Create a formatted timestamp string
function getTimestamp(): string {
const d = new Date();
const year = d.getFullYear();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
const hours = d.getHours().toString().padStart(2, '0');
const minutes = d.getMinutes().toString().padStart(2, '0');
return `${year}-${month}-${day}-${hours}-${minutes}`;
}
// Draw a very wide 'H' (10 cm length) at the top and left margins as registration marks
function drawHMarks(page: PDFPage, font: any, pageSettings: PageSettings) {
const color = rgb(0, 0, 0); // pure black
const lengthPt = 100 * MM_TO_PT; // 10 cm
const tickLenPt = 2 * MM_TO_PT; // 2 mm tick
const strokePt = 0.7; // visual thickness
// Top margin center
const centerTopX = (pageSettings.pageWidth / 2) * MM_TO_PT;
const centerTopY = (pageSettings.pageHeight - pageSettings.margin / 2) * MM_TO_PT;
const leftTopX = centerTopX - lengthPt / 2;
const rightTopX = centerTopX + lengthPt / 2;
// Horizontal bar (top)
page.drawRectangle({
x: leftTopX,
y: centerTopY - strokePt / 2,
width: lengthPt,
height: strokePt,
color
});
// Left vertical tick (top)
page.drawRectangle({
x: leftTopX - strokePt / 2,
y: centerTopY - tickLenPt / 2,
width: strokePt,
height: tickLenPt,
color
});
// Right vertical tick (top)
page.drawRectangle({
x: rightTopX - strokePt / 2,
y: centerTopY - tickLenPt / 2,
width: strokePt,
height: tickLenPt,
color
});
// Label under the top bar, centered
const label = 'Print gauge - if not 10 cm long, the page is not printed correctly!';
const labelSize = 7;
const labelWidth = font.widthOfTextAtSize(label, labelSize);
const labelX = centerTopX - labelWidth / 2; // center horizontally
const labelY = centerTopY - 3 * MM_TO_PT; // ~3mm below the bar
page.drawText(label, {
x: labelX,
y: labelY,
size: labelSize,
font,
color
});
// Left margin center (vertical bar)
const centerLeftX = (pageSettings.margin / 2) * MM_TO_PT;
const centerLeftY = (pageSettings.pageHeight / 2) * MM_TO_PT;
// Vertical bar (left)
page.drawRectangle({
x: centerLeftX - strokePt / 2,
y: centerLeftY - lengthPt / 2,
width: strokePt,
height: lengthPt,
color
});
// Top horizontal tick (left)
page.drawRectangle({
x: centerLeftX - tickLenPt / 2,
y: centerLeftY + lengthPt / 2 - strokePt / 2,
width: tickLenPt,
height: strokePt,
color
});
// Bottom horizontal tick (left)
page.drawRectangle({
x: centerLeftX - tickLenPt / 2,
y: centerLeftY - lengthPt / 2 - strokePt / 2,
width: tickLenPt,
height: strokePt,
color
});
}
// Format a YYYY-MM-DD string into "DD MM YY"
function formatDateDDMMYY(value: string): string {
if (!value) return '';
const trimmed = value.trim();
// Expects YYYY-MM-DD and splits it
const parts = trimmed.split('-');
if (parts.length === 3) {
const [y, mo, d] = parts;
if (y.length === 4 && mo.length === 2 && d.length === 2) {
return `${d} ${mo} ${y.slice(-2)}`;
}
}
// Fallback for any other format that might slip through
console.warn(`Unexpected date format received in formatDateDDMMYY: "${trimmed}"`);
return trimmed;
}
// Crop image using canvas
async function cropImage(
imageBlob: Blob,
crop: { x: number; y: number; width: number; height: number }
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get canvas context'));
return;
}
img.onload = () => {
// Set canvas size to crop dimensions
canvas.width = crop.width;
canvas.height = crop.height;
// Draw the cropped portion of the image
ctx.drawImage(
img,
crop.x,
crop.y,
crop.width,
crop.height, // Source rectangle
0,
0,
crop.width,
crop.height // Destination rectangle
);
// Convert canvas to blob then to array buffer
canvas.toBlob(
(blob) => {
if (blob) {
blob
.arrayBuffer()
.then((buffer) => {
resolve(new Uint8Array(buffer));
})
.catch(reject);
} else {
reject(new Error('Failed to create blob from canvas'));
}
},
'image/jpeg',
0.9
);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = URL.createObjectURL(imageBlob);
});
}
async function handleGenerate(fileName: string) {
const fileToUpdate = files.find((f) => f.name === fileName);
if (!fileToUpdate || fileToUpdate.state === 'generating') return;
fileToUpdate.state = 'generating';
fileToUpdate.error = null;
try {
const pdfBytes =
fileName === 'esncards_text.pdf' ? await generateTextPDF() : await generatePhotoPDF();
// Convert Uint8Array to ArrayBuffer slice to satisfy BlobPart typing
const arrayBuffer = pdfBytes.buffer.slice(
pdfBytes.byteOffset,
pdfBytes.byteOffset + pdfBytes.byteLength
);
const blob = new Blob([arrayBuffer as ArrayBuffer], { type: 'application/pdf' });
// Revoke old URL if it exists
if (fileToUpdate.url) {
URL.revokeObjectURL(fileToUpdate.url);
}
fileToUpdate.url = URL.createObjectURL(blob);
fileToUpdate.size = pdfBytes.length;
fileToUpdate.state = 'done';
const timestamp = getTimestamp();
const baseName = fileName.replace('.pdf', '');
fileToUpdate.downloadName = `${baseName}_${timestamp}.pdf`;
} catch (error: any) {
console.error(`PDF generation failed for ${fileName}:`, error);
fileToUpdate.state = 'error';
fileToUpdate.error = error.message || 'An unknown error occurred';
}
}
async function generateTextPDF() {
const card = $selectedCard;
if (!card) throw new Error('No card type selected');
const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit);
const robotoFontBytes = await loadRobotoFont();
const font = robotoFontBytes
? await pdfDoc.embedFont(robotoFontBytes)
: await pdfDoc.embedFont(StandardFonts.TimesRoman);
const gridLayout = calculateGrid(
PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin,
card.textCard.width,
card.textCard.height
);
const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
height: PAGE_SETTINGS.pageHeight * MM_TO_PT
};
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font, PAGE_SETTINGS);
let currentRow = 0;
let currentCol = 0;
const validRows = $sheetData.filter((row) => row._valid);
const details = $cardDetails;
const studiesAtAll = details?.studiesAt ?? '';
const esnSectionAll = details?.esnSection ?? '';
const validityStartAll = details?.validityStart ?? '';
for (let i = 0; i < validRows.length; i++) {
const row = validRows[i];
// Calculate cell position in mm
const cellX_mm = PAGE_SETTINGS.margin + currentCol * gridLayout.cellWidth;
const cellY_mm = PAGE_SETTINGS.margin + currentRow * gridLayout.cellHeight;
// Get field values
const name = row.name;
const nationality = row.nationality;
const birthday = row.birthday;
const studiesAt = studiesAtAll;
const esnSection = esnSectionAll;
const validityStart = validityStartAll;
const birthdayFmt = formatDateDDMMYY(birthday);
const validityStartFmt = formatDateDDMMYY(validityStart);
// Row 1: Name
const namePos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.name
);
page.drawText(`${name}`, {
...namePos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Row 2 left: Nationality
const natPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.nationality
);
page.drawText(`${nationality}`, {
...natPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Row 2 right: Date of birth
const bdayPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.birthday
);
// Row 3: Studies at
const studiesPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.studiesAt
);
page.drawText(`${studiesAt}`, {
...studiesPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Row 4 left: ESN Section
const sectionPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.esnSection
);
page.drawText(`${esnSection}`, {
...sectionPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Row 4 right: Valid from
const validPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.validityStart
);
page.drawText(`${validityStartFmt}`, {
...validPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
page.drawText(`${birthdayFmt}`, {
...bdayPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Draw cell border in points
page.drawRectangle({
x: cellX_mm * MM_TO_PT,
y: pageDimsPt.height - (cellY_mm + gridLayout.cellHeight) * MM_TO_PT,
width: gridLayout.cellWidth * MM_TO_PT,
height: gridLayout.cellHeight * MM_TO_PT,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width
});
// Move to next cell
currentCol++;
if (currentCol >= gridLayout.cols) {
currentCol = 0;
currentRow++;
if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font, PAGE_SETTINGS);
currentRow = 0;
}
}
}
return await pdfDoc.save();
}
async function generatePhotoPDF() {
const card = $selectedCard;
if (!card) throw new Error('No card type selected');
const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit);
const robotoFontBytes = await loadRobotoFont();
const font = robotoFontBytes
? await pdfDoc.embedFont(robotoFontBytes)
: await pdfDoc.embedFont(StandardFonts.TimesRoman);
const gridLayout = calculateGrid(
PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin,
card.photoCard.width,
card.photoCard.height
);
const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
height: PAGE_SETTINGS.pageHeight * MM_TO_PT
};
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font, PAGE_SETTINGS);
let currentRow = 0;
let currentCol = 0;
const validRows = $sheetData.filter((row) => row._valid);
for (let i = 0; i < validRows.length; i++) {
const row = validRows[i];
// Calculate cell position in mm
const cellX_mm = PAGE_SETTINGS.margin + currentCol * gridLayout.cellWidth;
const cellY_mm = PAGE_SETTINGS.margin + currentRow * gridLayout.cellHeight;
// Get photo dimensions in points
const photoDimsPt = getAbsolutePhotoDimensionsPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.photoFields.photo
);
const pictureUrl = row.pictureUrl;
const pictureInfo = $pictures[pictureUrl];
const cropData = $cropRects[pictureUrl];
if (pictureInfo && cropData) {
try {
// Get blob from IndexedDB instead of the store
const imageBlob = await getImageBlob(pictureUrl);
if (imageBlob) {
const croppedImageBytes = await cropImage(imageBlob, cropData);
const embeddedImage = await pdfDoc.embedJpg(croppedImageBytes);
const imageAspectRatio = embeddedImage.width / embeddedImage.height;
const photoBoxAspectRatio = photoDimsPt.width / photoDimsPt.height;
let imageWidth, imageHeight;
if (imageAspectRatio > photoBoxAspectRatio) {
imageWidth = photoDimsPt.width;
imageHeight = photoDimsPt.width / imageAspectRatio;
} else {
imageHeight = photoDimsPt.height;
imageWidth = photoDimsPt.height * imageAspectRatio;
}
const imageX = photoDimsPt.x + (photoDimsPt.width - imageWidth) / 2;
const imageY = photoDimsPt.y + (photoDimsPt.height - imageHeight) / 2;
page.drawImage(embeddedImage, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight
});
} else {
console.warn(`Image blob not found in IndexedDB for ${pictureUrl}`);
// Draw placeholder when blob not found
page.drawRectangle({
...photoDimsPt,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width
});
}
} catch (error: any) {
console.error(`Failed to embed photo for ${row.name}:`, error);
// Draw placeholder on error
page.drawRectangle({
...photoDimsPt,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width
});
}
} else {
// Draw placeholder if no photo
page.drawRectangle({
...photoDimsPt,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width
});
}
// Draw name
const name = row.name;
const namePos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.photoFields.name
);
page.drawText(`${name}`, {
...namePos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Move to next cell
currentCol++;
if (currentCol >= gridLayout.cols) {
currentCol = 0;
currentRow++;
if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font, PAGE_SETTINGS);
currentRow = 0;
}
}
}
return await pdfDoc.save();
}
function downloadFile(file: GeneratedFile) {
if (!file.url) return;
const link = document.createElement('a');
link.href = file.url;
link.download = file.downloadName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function resetAndStartOver() {
files.forEach((file) => {
if (file.url) {
URL.revokeObjectURL(file.url);
}
});
files = JSON.parse(JSON.stringify(initialFiles));
// Clear sensitive data when starting over
clearSensitiveData();
currentStep.set(0);
}
function formatFileSize(bytes: number | null): string {
if (bytes === null || bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
</script>
<div class="p-6"> <div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Generate PDFs</h2> <div class="max-w-5xl mx-auto">
<p class="text-sm text-gray-700">PDF generation functionality will be implemented here.</p> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">Generating PDFs...</h2>
<p class="text-sm text-gray-700 mb-4">
Your PDF documents are being created. Please wait a moment.
</p>
</div>
{#if files.some((f) => f.state === 'generating')}
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-blue-800"> Processing... </span>
</div>
</div>
</div>
{/if}
{#if generationStarted}
<!-- Generated Files -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
<div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Available Downloads</h3>
</div>
<div class="divide-y divide-gray-200">
{#each files as file (file.name)}
<div class="p-4 flex items-center justify-between">
<div class="flex items-center">
{#if file.displayName === 'Text PDF'}
<svg
class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="18" x2="12" y2="12"></line>
<line x1="9" y1="12" x2="15" y2="12"></line>
</svg>
{:else if file.displayName === 'Photos PDF'}
<svg
class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<circle cx="12" cy="13" r="2"></circle>
<path d="M15 17.5c-1.5-1-4.5-1-6 0"></path>
</svg>
{/if}
<div>
<h4 class="text-sm font-medium text-gray-900">{file.displayName}</h4>
{#if file.state === 'done' && file.size}
<p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
{:else if file.state === 'error'}
<p class="text-xs text-red-500">Error: {file.error}</p>
{:else if file.state === 'generating'}
<p class="text-xs text-gray-500">Generating...</p>
{:else if file.state === 'idle'}
<p class="text-xs text-gray-500">Waiting...</p>
{/if}
</div>
</div>
{#if file.state === 'idle'}
<div class="px-4 py-2 text-gray-500 text-sm">Waiting...</div>
{:else if file.state === 'generating'}
<button
disabled
aria-label="Generating..."
class="px-4 py-2 bg-gray-400 text-white rounded-md text-sm font-medium cursor-wait"
>
<div class="flex items-center justify-center">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
</button>
{:else if file.state === 'done'}
<button
onclick={() => downloadFile(file)}
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Download
</button>
{:else if file.state === 'error'}
<button
onclick={() => handleGenerate(file.name)}
class="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700"
>
Retry
</button>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Navigation -->
<div class="mt-10">
<Navigator
{currentStep}
onForward={resetAndStartOver}
canProceed={files.some((f) => f.state === 'done' || f.state === 'error')}
textBack="Back to Gallery"
textForwardEnabled="Start Over"
textForwardDisabled="Generate PDFs to Continue"
hideForwardUntilProceedable={true}
/>
</div>
</div>
</div> </div>

View File

@@ -1,4 +1,419 @@
<div class="p-6"> <script lang="ts">
<h2 class="text-xl font-semibold text-gray-900">Filter Rows</h2> import {
<p class="text-sm text-gray-700">Row filtering functionality will be implemented here.</p> sheetData,
columnMapping,
selectedSheet,
currentStep,
} 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 { parseAndFormatDate } from '$lib/utils/date';
let isLoading = $state(true);
let error = $state<string | null>(null);
let rows = $state<RowData[]>([]);
let sortColumn = $state<keyof RowData | null>(null);
let sortDirection = $state<'asc' | 'desc'>('asc');
let lastCheckedId: string | null = $state(null);
const ROW_LIMIT = 200;
// Fetch and process data from the Google Sheet
async function fetchAndProcessData() {
isLoading = true;
error = null;
try {
const sheet = $selectedSheet;
const mapping = $columnMapping;
if (!sheet || !mapping || !mapping.sheetName) {
error = 'Sheet information or column mapping is missing.';
isLoading = false;
rows = [];
return;
}
const range = `${mapping.sheetName}!A:Z`;
const rawData = await getSheetData(sheet.id, range);
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
}
const alreadyPrinted =
mapping.alreadyPrinted !== -1
? (row[mapping.alreadyPrinted] || '').toLowerCase() === 'true'
: false;
const isValid = !!(name && pictureUrl);
return {
id: uuid(),
name,
nationality: mapping.nationality !== -1 ? row[mapping.nationality] || '' : '',
birthday,
pictureUrl,
alreadyPrinted,
_rowIndex: index + 1,
_valid: isValid,
_checked: false
};
})
.filter((row): row is RowData => row !== null);
rows = processedData;
} catch (e: any) {
error = e.message || 'An unknown error occurred while fetching data.';
console.error(e);
rows = [];
} finally {
isLoading = false;
}
}
function handleRowClick(event: MouseEvent, clickedId: string) {
const clickedRow = rows.find((r) => r.id === clickedId);
if (!clickedRow || !clickedRow._valid) return;
// Handle shift-clicking for range selection
if (event.shiftKey && lastCheckedId) {
const lastIndex = displayData.findIndex((r) => r.id === lastCheckedId);
const currentIndex = displayData.findIndex((r) => r.id === clickedId);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const isChecked = !clickedRow._checked; // The state to apply to the range
for (let i = start; i <= end; i++) {
const rowToSelect = displayData[i];
if (rowToSelect && rowToSelect._valid) {
// Prevent checking more than the limit
if (isChecked && selectedCount >= ROW_LIMIT && !rowToSelect._checked) {
continue;
}
rowToSelect._checked = isChecked;
}
}
}
} else {
// Normal click, just toggle the state
if (!clickedRow._checked && selectedCount >= ROW_LIMIT) {
// Do not allow checking more than the limit
} else {
clickedRow._checked = !clickedRow._checked;
}
}
// Update the last checked ID for the next shift-click
lastCheckedId = clickedId;
}
// Run on component mount
onMount(() => {
ensureToken();
fetchAndProcessData();
});
// Function to toggle select-all: selects first 200 eligible items in current view
function toggleSelectAll(event: Event) {
const target = event.target as HTMLInputElement;
const shouldCheck = target.checked;
// Determine eligible rows in the current display order
const eligible = displayData.filter((r) => r._valid && !r.alreadyPrinted);
const firstBatch = eligible.slice(0, ROW_LIMIT);
if (shouldCheck) {
// Check only the first batch, uncheck the rest
rows.forEach((row) => (row._checked = false));
firstBatch.forEach((row) => (row._checked = true));
} else {
// Uncheck all
rows.forEach((row) => (row._checked = false));
}
}
// Function to handle sorting
function sortBy(column: keyof RowData) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
}
// Derived state for sorted data to be displayed
const displayData = $derived.by(() => {
if (!sortColumn) return rows;
return [...rows].sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
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() {
$sheetData = rows.filter((row) => row._checked);
}
</script>
<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>
<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={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}
>
{#if isLoading}
<svg
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"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Refreshing...
{:else}
Refresh Data
{/if}
</button>
</div>
</div>
{#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>
</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>
<th class="px-4 py-3 text-left">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
onchange={toggleSelectAll}
checked={allValidRowsSelected}
/>
</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('_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
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#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>
</div>
{/if}
<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> </div>

View File

@@ -1,18 +1,28 @@
<script lang="ts"> <script lang="ts">
import { availableSheets, selectedSheet, currentStep } from '$lib/stores'; import { selectedSheet, currentStep } from '$lib/stores';
import { searchSheets } from '$lib/google'; import type { SheetInfoType } from '$lib/stores';
import { searchSheets, ensureToken, userEmail } from '$lib/google';
import { hashString } from '$lib/utils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte';
let searchQuery = ''; let searchQuery = $state('');
let isLoading = false; let isLoading = $state(false);
let error = ''; let error = $state('');
let searchResults: any[] = []; let searchResults = $state<any[]>([]);
let hasSearched = false; let hasSearched = $state(false);
let recentSheets: any[] = []; let recentSheets = $state<any[]>([]);
const RECENT_SHEETS_KEY = 'esn-recent-sheets'; async function getRecentSheetsKey() {
const email = $userEmail;
if (email) {
return `recentSheets_${await hashString(email)}`;
}
return 'recentSheets_anonymous';
}
onMount(() => { onMount(() => {
ensureToken();
loadRecentSheets(); loadRecentSheets();
}); });
@@ -24,97 +34,56 @@
try { try {
searchResults = await searchSheets(searchQuery); searchResults = await searchSheets(searchQuery);
availableSheets.set(
searchResults.map(sheet => ({
id: sheet.id,
name: sheet.name,
url: sheet.webViewLink
}))
);
hasSearched = true; hasSearched = true;
} catch (err) { } catch (err) {
console.error('Error searching sheets:', err); console.error('Error searching sheets:', err);
error = 'Failed to search sheets. Please check your connection and try again.'; error = 'Failed to search sheets. Please check your connection and try again.';
searchResults = []; searchResults = [];
availableSheets.set([]);
} finally { } finally {
isLoading = false; isLoading = false;
} }
} }
function loadRecentSheets() { async function loadRecentSheets() {
try { try {
const saved = localStorage.getItem(RECENT_SHEETS_KEY); const key = await getRecentSheetsKey();
const saved = localStorage.getItem(key);
if (saved) { if (saved) {
recentSheets = JSON.parse(saved); recentSheets = JSON.parse(saved);
} }
} catch (err) { } catch (err) {
console.error('Error loading recent sheets:', err); console.error('Error loading recent sheets:', err);
// If there's an error, clear the stored value // If there's an error, clear the stored value
localStorage.removeItem(RECENT_SHEETS_KEY); const key = await getRecentSheetsKey();
localStorage.removeItem(key);
recentSheets = []; recentSheets = [];
} }
} }
function saveToRecentSheets(sheet) {
// Create a copy of the sheet object with just the properties we need
const sheetToSave = {
id: sheet.id,
name: sheet.name,
url: sheet.webViewLink || sheet.url,
iconLink: sheet.iconLink
};
// Remove this sheet if it already exists in the list
recentSheets = recentSheets.filter(s => s.id !== sheetToSave.id);
// Add the sheet to the beginning of the list
recentSheets = [sheetToSave, ...recentSheets];
// Keep only up to 3 recent sheets
if (recentSheets.length > 3) {
recentSheets = recentSheets.slice(0, 3);
}
// Save to localStorage
try {
localStorage.setItem(RECENT_SHEETS_KEY, JSON.stringify(recentSheets));
} catch (err) {
console.error('Error saving recent sheets:', err);
}
}
function handleSelectSheet(sheet) { function handleSelectSheet(sheet) {
const sheetData = { const sheetData: SheetInfoType = {
id: sheet.id, id: sheet.id,
name: sheet.name, name: sheet.name,
url: sheet.webViewLink || sheet.url webViewLink: sheet.webViewLink
}; };
selectedSheet.set(sheetData); selectedSheet.set(sheetData);
saveToRecentSheets(sheet);
} }
function handleContinue() { let canProceed = $derived($selectedSheet.id !== '');
currentStep.set(3); // Move to the column mapping step
}
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-2xl mx-auto">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2"> <h2 class="mb-2 text-xl font-semibold text-gray-900">Select Google Sheet</h2>
Select Google Sheet
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="mb-4 text-sm text-gray-700">
Search for and select the Google Sheet containing your member data. Search for and select the Google Sheet containing your member data.
</p> </p>
</div> </div>
<!-- Search input --> <!-- Search input -->
<div class="mb-6"> <div class="mb-6">
<label for="sheet-search" class="block text-sm font-medium text-gray-700 mb-2"> <label for="sheet-search" class="mb-2 block text-sm font-medium text-gray-700">
Search sheets Search sheets
</label> </label>
@@ -124,16 +93,21 @@
type="text" type="text"
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Type sheet name..." placeholder="Type sheet name..."
class="flex-grow px-4 py-2 border border-gray-300 rounded-l-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent" class="flex-grow 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();
}}
/> />
<button <button
on:click={handleSearch} onclick={handleSearch}
disabled={isLoading || !searchQuery.trim()} disabled={isLoading || !searchQuery.trim()}
class="px-4 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed" class="rounded-r-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
> >
{#if isLoading} {#if isLoading}
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div> <div
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
{:else} {:else}
Search Search
{/if} {/if}
@@ -142,7 +116,7 @@
</div> </div>
{#if error} {#if error}
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6"> <div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p> <p class="text-sm text-red-800">{error}</p>
</div> </div>
{/if} {/if}
@@ -150,7 +124,7 @@
<!-- Results --> <!-- Results -->
{#if hasSearched} {#if hasSearched}
<div class="mb-6"> <div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3"> <h3 class="mb-3 text-sm font-medium text-gray-700">
{searchResults.length {searchResults.length
? `Found ${searchResults.length} matching sheets` ? `Found ${searchResults.length} matching sheets`
: 'No matching sheets found'} : 'No matching sheets found'}
@@ -160,24 +134,35 @@
<div class="space-y-3"> <div class="space-y-3">
{#each searchResults as sheet} {#each searchResults as sheet}
<div <div
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50 class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.id ===
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" (sheet.id || sheet.id)
on:click={() => handleSelectSheet(sheet)} ? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
tabindex="0"
role="button"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
}}
> >
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-center justify-between">
<div> <div>
<p class="font-medium text-gray-900">{sheet.name}</p> <p class="font-medium text-gray-900">{sheet.name}</p>
<p class="text-xs text-gray-500 mt-1">ID: {sheet.id}</p> <p class="mt-1 text-xs text-gray-500 break-all whitespace-normal" title={sheet.id}>ID: {sheet.id}</p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
{#if sheet.iconLink} {#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" /> <img src={sheet.iconLink} alt="Sheet icon" class="my-2 mr-2 h-5 w-5" />
{/if} {/if}
{#if $selectedSheet?.id === sheet.id} {#if $selectedSheet?.id === (sheet.id || sheet.id)}
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5 text-blue-600 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" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg> </svg>
{/if} {/if}
</div> </div>
@@ -186,9 +171,19 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="text-center py-8 bg-gray-50 rounded-lg border border-gray-200"> <div class="rounded-lg border border-gray-200 bg-gray-50 py-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<p class="mt-2 text-sm text-gray-500">Try a different search term</p> <p class="mt-2 text-sm text-gray-500">Try a different search term</p>
</div> </div>
@@ -198,31 +193,40 @@
<!-- If we have recent sheets and haven't searched yet, show them --> <!-- If we have recent sheets and haven't searched yet, show them -->
{#if recentSheets.length > 0 && !hasSearched} {#if recentSheets.length > 0 && !hasSearched}
<div class="mb-6"> <div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3"> <h3 class="mb-3 text-sm font-medium text-gray-700">Recent sheets</h3>
Recent sheets
</h3>
<div class="space-y-3"> <div class="space-y-3">
{#each recentSheets as sheet} {#each recentSheets as sheet}
<div <div
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50 class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.id ===
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}" (sheet.id || sheet.id)
on:click={() => handleSelectSheet(sheet)} ? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
tabindex="0"
role="button"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
}}
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-gray-900">{sheet.name}</p> <p class="font-medium text-gray-900">{sheet.name}</p>
<p class="text-xs text-gray-500 mt-1">Recently used</p> <p class="mt-1 text-xs text-gray-500">Recently used</p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
{#if sheet.iconLink} {#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" /> <img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
{/if} {/if}
{#if $selectedSheet?.id === sheet.id} {#if $selectedSheet.id === sheet.id}
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg> </svg>
{/if} {/if}
</div> </div>
@@ -231,35 +235,37 @@
{/each} {/each}
</div> </div>
<div class="border-t border-gray-200 mt-4 pt-4"> <div class="mt-4 border-t border-gray-200 pt-4">
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">Or search for a different sheet above</p>
Or search for a different sheet above
</p>
</div> </div>
</div> </div>
{:else} {:else}
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200 mb-6"> <div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 py-12 text-center">
<svg class="mx-auto h-16 w-16 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> class="mx-auto h-16 w-16 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg> </svg>
<h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3> <h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">Enter a name or keyword to find your Google Sheets</p>
Enter a name or keyword to find your Google Sheets
</p>
</div> </div>
{/if} {/if}
{/if} {/if}
<!-- Continue button --> <!-- Navigation -->
{#if $selectedSheet} <Navigator
<div class="mt-6 flex justify-end"> {canProceed}
<button {currentStep}
on:click={handleContinue} textBack="Back to Auth"
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700" textForwardDisabled="Select a sheet"
> textForwardEnabled="Continue to Column Mapping"
Continue → />
</button>
</div>
{/if}
</div>
</div> </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

@@ -0,0 +1,188 @@
<script lang="ts">
import type { PhotoDimensions } from '$lib/cards/types';
import PhotoCrop from './PhotoCrop.svelte';
let { photo, onCropUpdated, onRetry, photoDimensions } = $props<{
photo: {
name: string;
url: string;
status: 'loading' | 'success' | 'error';
objectUrl?: string;
retryCount: number;
cropData?: { x: number; y: number; width: number; height: number };
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
};
onCropUpdated: (detail: any) => void;
onRetry: () => void;
photoDimensions: PhotoDimensions;
}>();
let showCropper = $state(false);
let imageDimensions = $state<{ w: number; h: number } | null>(null);
let imageContainer = $state<HTMLDivElement | undefined>();
const cropBoxStyle = $derived(() => {
if (!photo.cropData || !imageDimensions || !imageContainer) {
return 'display: none;';
}
const { w: naturalW, h: naturalH } = imageDimensions;
const { x, y, width, height } = photo.cropData;
const { clientWidth: containerW, clientHeight: containerH } = imageContainer;
const containerAspect = containerW / containerH;
const naturalAspect = naturalW / naturalH;
let imgW, imgH;
if (naturalAspect > containerAspect) {
// Image is wider than container, so it's letterboxed top/bottom
imgW = containerW;
imgH = containerW / naturalAspect;
} else {
// Image is taller than container, so it's letterboxed left/right
imgH = containerH;
imgW = containerH * naturalAspect;
}
const offsetX = (containerW - imgW) / 2;
const offsetY = (containerH - imgH) / 2;
const scaleX = imgW / naturalW;
const scaleY = imgH / naturalH;
const left = x * scaleX + offsetX;
const top = y * scaleY + offsetY;
const boxWidth = width * scaleX;
const boxHeight = height * scaleY;
return `
position: absolute;
left: ${left}px;
top: ${top}px;
width: ${boxWidth}px;
height: ${boxHeight}px;
border: 2px solid #3b82f6; /* blue-500 */
box-shadow: 0 0 0 9999px rgba(229, 231, 235, 0.75); /* gray-200 with opacity */
transition: all 0.3s;
`;
});
function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement;
imageDimensions = { w: img.naturalWidth, h: img.naturalHeight };
}
function handleCropUpdated(detail: any) {
onCropUpdated(detail);
showCropper = false;
}
</script>
{#if photo.status === 'loading'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-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"
></div>
<span class="text-xs text-gray-600">Loading...</span>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-blue-600">Processing photo...</span>
</div>
</div>
{:else if photo.status === 'success' && photo.objectUrl}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative">
<div
class="h-48 bg-gray-200 flex items-center justify-center relative overflow-hidden"
bind:this={imageContainer}
>
<img
src={photo.objectUrl}
alt={`Photo of ${photo.name}`}
class="max-w-full max-h-full object-contain"
onload={handleImageLoad}
/>
{#if photo.cropData}
<div style={cropBoxStyle()}></div>
{/if}
</div>
<div class="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>
{:else if photo.faceDetectionStatus === 'failed'}
<span class="text-xs text-orange-600">Face not found</span>
{:else if photo.faceDetectionStatus === 'processing'}
<span class="text-xs text-blue-600">Detecting face...</span>
{:else if photo.faceDetectionStatus === 'manual'}
<span class="text-xs text-purple-600">Manual crop</span>
{:else if photo.faceDetectionStatus === 'pending'}
<span class="text-xs text-gray-500">Queued...</span>
{/if}
</div>
<button
onclick={() => (showCropper = true)}
class="p-1 text-gray-500 hover:text-blue-600 shrink-0"
title="Edit Crop"
aria-label="Edit Crop"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L16.732 3.732z"
/>
</svg>
</button>
</div>
{#if showCropper}
<PhotoCrop
imageUrl={photo.objectUrl}
personName={photo.name}
initialCropData={photo.cropData}
{photoDimensions}
onClose={() => (showCropper = false)}
onCropUpdated={handleCropUpdated}
/>
{/if}
</div>
{:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-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"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs text-red-600 mb-2">Failed to load</span>
<button
class="text-xs text-blue-600 hover:text-blue-800 underline"
onclick={onRetry}
disabled={photo.retryCount >= 3}
>
{photo.retryCount >= 3 ? 'Max retries' : 'Retry'}
</button>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-red-600">Failed to load</span>
</div>
</div>
{/if}

View File

@@ -0,0 +1,334 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PhotoDimensions } from '$lib/cards/types';
let {
imageUrl,
personName,
initialCropData,
onCropUpdated,
onClose,
photoDimensions
} = $props<{
imageUrl: string;
personName: string;
initialCropData?: { x: number; y: number; width: number; height: number };
onCropUpdated: (detail: {
cropData: { x: number; y: number; width: number; height: number };
}) => void;
onClose: () => void;
photoDimensions: PhotoDimensions;
}>();
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let image: HTMLImageElement;
let isImageLoaded = false;
// Crop rectangle state
let crop = {
x: 0,
y: 0,
width: 200,
height: 200
};
// Interaction state
let isDragging = false;
let dragStart = { x: 0, y: 0 };
// Canvas dimensions
let canvasWidth = 600;
let canvasHeight = 400;
// Use the photo card aspect ratio from the selected card's dimensions
const cropRatio = photoDimensions.width / photoDimensions.height;
onMount(() => {
ctx = canvas.getContext('2d')!;
loadImage();
});
async function loadImage() {
image = new Image();
image.onload = () => {
isImageLoaded = true;
// Calculate canvas size to fit image while maintaining aspect ratio
const maxWidth = 600;
const maxHeight = 400;
const imageAspect = image.width / image.height;
if (imageAspect > maxWidth / maxHeight) {
canvasWidth = maxWidth;
canvasHeight = maxWidth / imageAspect;
} else {
canvasHeight = maxHeight;
canvasWidth = maxHeight * imageAspect;
}
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Initialize crop rectangle
if (initialCropData) {
// Scale initial crop to canvas dimensions
const scaleX = canvasWidth / image.width;
const scaleY = canvasHeight / image.height;
crop = {
x: initialCropData.x * scaleX,
y: initialCropData.y * scaleY,
width: initialCropData.width * scaleX,
height: initialCropData.height * scaleY
};
} else {
// Default crop: centered with correct aspect ratio
const maxSize = Math.min(canvasWidth, canvasHeight) * 0.6;
const cropWidth = maxSize;
const cropHeight = cropWidth / cropRatio;
// If height exceeds canvas, scale down proportionally
if (cropHeight > canvasHeight * 0.8) {
const scale = (canvasHeight * 0.8) / cropHeight;
crop = {
x: (canvasWidth - (cropWidth * scale)) / 2,
y: (canvasHeight - (cropHeight * scale)) / 2,
width: cropWidth * scale,
height: cropHeight * scale
};
} else {
crop = {
x: (canvasWidth - cropWidth) / 2,
y: (canvasHeight - cropHeight) / 2,
width: cropWidth,
height: cropHeight
};
}
}
drawCanvas();
};
image.src = imageUrl;
}
function drawCanvas() {
if (!ctx || !isImageLoaded) return;
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Draw image
ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
// Draw overlay (darken non-crop area)
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(crop.x, crop.y, crop.width, crop.height);
ctx.globalCompositeOperation = 'source-over';
// Draw crop rectangle border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height);
}
function getMousePos(e: MouseEvent) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function isInCropArea(x: number, y: number) {
return x >= crop.x && x <= crop.x + crop.width && y >= crop.y && y <= crop.y + crop.height;
}
function handleMouseDown(e: MouseEvent) {
const pos = getMousePos(e);
if (isInCropArea(pos.x, pos.y)) {
isDragging = true;
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
}
}
function handleMouseMove(e: MouseEvent) {
const pos = getMousePos(e);
if (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
if (isInCropArea(pos.x, pos.y)) {
canvas.style.cursor = 'move';
} else {
canvas.style.cursor = 'default';
}
}
}
function handleMouseUp() {
isDragging = false;
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;
const scaleY = image.height / canvasHeight;
const finalCrop = {
x: Math.round(crop.x * scaleX),
y: Math.round(crop.y * scaleY),
width: Math.round(crop.width * scaleX),
height: Math.round(crop.height * scaleY)
};
onCropUpdated({ cropData: finalCrop });
onClose();
}
function handleCancel() {
onClose();
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={handleOverlayClick}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
>
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4" role="document">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="dialog-title" class="text-lg font-semibold text-gray-800">
Crop Photo: {personName}
</h3>
<button onclick={onClose} class="text-gray-400 hover:text-gray-600" aria-label="Close">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="relative mb-4 p-2 rounded-md text-center">
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
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">
<button
onclick={handleCancel}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
Cancel
</button>
<button
onclick={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
>
Save Crop
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,107 +1,205 @@
import { writable } from 'svelte/store'; import { writable, get } from 'svelte/store';
import { env } from '$env/dynamic/public';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; // Store state: undefined = not yet known, null = failed/logged out, string = token
export const accessToken = writable<string | null | undefined>(undefined);
export const isGoogleApiReady = writable(false);
export const isSignedIn = writable(false); export const isSignedIn = writable(false);
export const isGoogleApiReady = writable(false); // To track GAPI client readiness
export const userEmail = writable<string | null>(null);
let tokenClient: google.accounts.oauth2.TokenClient; let tokenClient: google.accounts.oauth2.TokenClient;
let gapiInited = false;
let gsiInited = false;
const TOKEN_KEY = 'google_oauth_token'; // This function ensures both GAPI (for Sheets/Drive APIs) and GSI (for auth) are loaded in the correct order.
export function initGoogleClient(callback: () => void) { export function initGoogleClients(callback: () => void) {
const script = document.createElement('script'); // If everything is already initialized, just run the callback.
script.src = 'https://apis.google.com/js/api.js'; if (gapiInited && gsiInited) {
script.onload = () => { isGoogleApiReady.set(true); // Ensure it's set if called again
gapi.load('client', async () => { callback();
await gapi.client.init({ return;
// NOTE: API KEY IS NOT REQUIRED FOR THIS IMPLEMENTATION }
// apiKey: 'YOUR_API_KEY',
// 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: [ discoveryDocs: [
'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest', 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
'https://www.googleapis.com/discovery/v1/apis/sheets/v4/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);
}
}
/**
* 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); isGoogleApiReady.set(true);
// Restore token from storage if available
const saved = localStorage.getItem(TOKEN_KEY);
if (saved) {
try {
const data = JSON.parse(saved);
if (data.access_token && data.expires_at && data.expires_at > Date.now()) {
gapi.client.setToken({ access_token: data.access_token });
isSignedIn.set(true);
} else {
localStorage.removeItem(TOKEN_KEY);
}
} catch {
localStorage.removeItem(TOKEN_KEY);
}
}
callback(); callback();
});
}; };
document.body.appendChild(script);
const scriptGsi = document.createElement('script');
scriptGsi.src = 'https://accounts.google.com/gsi/client';
scriptGsi.onload = () => {
tokenClient = google.accounts.oauth2.initTokenClient({
client_id: GOOGLE_CLIENT_ID,
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);
} }
export function handleSignIn() { /**
if (gapi.client.getToken() === null) { * Tries to get a token silently.
tokenClient.requestAccessToken({ prompt: 'consent' }); * This is for background tasks and on-load checks.
} else { * 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: '' }); tokenClient.requestAccessToken({ prompt: '' });
} }
} });
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);
}); });
} }
/**
* Prompts the user for consent to grant a token.
* This should be called when a user clicks a "Sign In" button.
*/
export function requestTokenFromUser() {
initGoogleClients(() => {
if (tokenClient) {
tokenClient.requestAccessToken({ prompt: 'consent' });
} else {
console.error("requestTokenFromUser called before Google client was initialized.");
}
});
}
/**
* Signs the user out, revokes the token, and clears all local state.
*/
export function handleSignOut() {
const token = get(accessToken);
if (token && gsiInited) {
google.accounts.oauth2.revoke(token, () => {
console.log('User token revoked.');
});
}
// 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) { export async function searchSheets(query: string) {
if (!gapi.client.drive) { await ensureToken(); // Ensure we are authenticated before making a call
if (!gapi.client || !gapi.client.drive) {
throw new Error('Google Drive API not loaded'); throw new Error('Google Drive API not loaded');
} }
const response = await gapi.client.drive.files.list({ const response = await gapi.client.drive.files.list({
q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`, q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`,
fields: 'files(id, name, iconLink, webViewLink)', fields: 'files(id, name, iconLink, webViewLink)',
pageSize: 20, pageSize: 20,
supportsAllDrives: true,
includeItemsFromAllDrives: true,
corpora: 'allDrives'
}); });
return response.result.files || []; return response.result.files || [];
} }
export async function getSheetNames(spreadsheetId: string) { export async function getSheetNames(spreadsheetId: string) {
if (!gapi.client.sheets) { await ensureToken();
if (!gapi.client || !gapi.client.sheets) {
throw new Error('Google Sheets API not loaded'); throw new Error('Google Sheets API not loaded');
} }
const response = await gapi.client.sheets.spreadsheets.get({ const response = await gapi.client.sheets.spreadsheets.get({
@@ -117,7 +215,8 @@ export async function getSheetNames(spreadsheetId: string) {
} }
export async function getSheetData(spreadsheetId: string, range: string) { export async function getSheetData(spreadsheetId: string, range: string) {
if (!gapi.client.sheets) { await ensureToken();
if (!gapi.client || !gapi.client.sheets) {
throw new Error('Google Sheets API not loaded'); throw new Error('Google Sheets API not loaded');
} }
const response = await gapi.client.sheets.spreadsheets.values.get({ const response = await gapi.client.sheets.spreadsheets.values.get({
@@ -126,3 +225,81 @@ export async function getSheetData(spreadsheetId: string, range: string) {
}); });
return response.result.values || []; return response.result.values || [];
} }
// Extract Google Drive file ID from various URL formats
export function extractDriveFileId(url: string): string | null {
if (!url) return null;
// Handle different Google Drive URL formats
const patterns = [
/\/file\/d\/([a-zA-Z0-9-_]+)/, // https://drive.google.com/file/d/FILE_ID/view
/id=([a-zA-Z0-9-_]+)/, // https://drive.google.com/open?id=FILE_ID
/\/d\/([a-zA-Z0-9-_]+)/, // https://drive.google.com/uc?id=FILE_ID&export=download
/^([a-zA-Z0-9-_]{25,})$/ // Direct file ID
];
for (const pattern of patterns) {
const match = pattern.exec(url);
if (match) {
return match[1];
}
}
return null;
}
// Check if URL is a Google Drive URL
export function isGoogleDriveUrl(url: string): boolean {
return url.includes('drive.google.com') || url.includes('googleapis.com');
}
// Download image from Google Drive using the API
export async function downloadDriveImage(url: string): Promise<Blob> {
await ensureToken();
const fileId = extractDriveFileId(url);
if (!fileId) {
throw new Error('Could not extract file ID from Google Drive URL');
}
if (!gapi.client || !gapi.client.drive) {
throw new Error('Google Drive API not loaded');
}
try {
// Get file metadata first to check if it exists and is accessible
const metadata = await gapi.client.drive.files.get({
fileId: fileId,
fields: 'id,name,mimeType,size'
});
if (!metadata.result.mimeType?.startsWith('image/')) {
throw new Error('File is not an image');
}
// Download the file content
const response = await gapi.client.drive.files.get({
fileId: fileId,
alt: 'media'
});
// The response body is already binary data, convert to blob
const binaryString = response.body;
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: metadata.result.mimeType });
} catch (error) {
console.error('Error downloading from Google Drive:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to download image from Google Drive: ${errorMessage}`);
}
}
// Create an object URL from image data for display
export function createImageObjectUrl(blob: Blob): string {
return URL.createObjectURL(blob);
}

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

@@ -0,0 +1,95 @@
import { get } from 'idb-keyval';
// 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; // mm
cellHeight: number; // mm
}
// Function to retrieve a blob from IndexedDB
export async function getImageBlob(url: string): Promise<Blob | undefined> {
return await get(url);
}
// Calculate how many cards can fit on a page.
export function calculateGrid(
pageWidth: number,
pageHeight: number,
margin: number,
cardWidth: number,
cardHeight: number
): GridLayout {
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
};
}
// 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 } {
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 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 } {
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, g: 0, b: 0 },
width: 0.5 // in points
};
// Text configuration
export const TEXT_CONFIG = {
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 // 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,60 +1,99 @@
import { writable, derived } from 'svelte/store'; import { writable, derived } from 'svelte/store';
// This file is holy and shall not be edited by Copilot!
// User session and authentication // User session and authentication
export const session = writable<{ export const session = writable<{
token?: string; token?: string;
user?: { name: string; email: string }; user?: { name: string; email: string };
}>({}); }>({});
// Raw sheet data after import // Data structure column mapping
export const rawSheetData = writable<string[][]>([]); export interface ColumnMappingType {
name: number;
nationality: number;
birthday: number;
pictureUrl: number;
alreadyPrinted: number;
sheetName: string;
}
// Column mapping configuration // Data structure for a row in the sheet
export const columnMapping = writable<{
name?: number;
surname?: number;
nationality?: number;
birthday?: number;
pictureUrl?: number;
}>({});
// Processed row data after mapping and validation
export interface RowData { export interface RowData {
id: string; id: string; // Unique identifier
name: string; name: string;
surname: string;
nationality: string; nationality: string;
birthday: string; birthday: string;
pictureUrl: string; pictureUrl: string;
valid: boolean; alreadyPrinted: boolean;
included: boolean; _rowIndex: number;
age?: number; _checked: boolean;
validationErrors: string[]; _valid: boolean;
} }
export const sheetData = writable<RowData[]>([]);
// Picture storage and metadata // Picture storage and metadata
export interface PictureBlobInfo { export interface PictureBlobInfoType {
id: string; id: string;
blob: Blob;
url: string; url: string;
downloaded: boolean; downloaded: boolean;
faceDetected: boolean; faceDetected: boolean;
faceCount: number; faceCount: number;
} }
export const pictures = writable<Record<string, PictureBlobInfo>>({}); // CropType rectangles for each photo
export interface CropType {
// Crop rectangles for each photo
export interface Crop {
x: number; x: number;
y: number; y: number;
width: number; width: number;
height: number; height: number;
} }
export const cropRects = writable<Record<string, Crop>>({}); // Google Sheets list for search
export interface SheetInfoType {
id: string;
name: string;
webViewLink: string;
}
// Card details type
export interface CardDetailsType {
esnSection: string;
studiesAt: string;
validityStart: string;
}
// Column mapping configuration
export const columnMapping = writable<ColumnMappingType>({
name: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1,
sheetName: ''
});
// Store to hold the processed sheet data
export const sheetData = writable<RowData[]>([]);
// Store and hold the processed picture data
export const pictures = writable<Record<string, PictureBlobInfoType>>({});
// Store and hold the crop rectangles from face detection
export const cropRects = writable<Record<string, CropType>>({});
// Store and hold the selected sheet
export const selectedSheet = writable<SheetInfoType>({ id: '', name: '', webViewLink: '' });
// Card details for generation
export const cardDetails = writable<CardDetailsType | null>(null);
// Selected card type for generation
import type { Card } from '$lib/cards/types';
export const selectedCard = writable<Card | null>(null);
// Wizard state management // Wizard state management
export const currentStep = writable<number>(0); export const currentStep = writable<number>(0);
@@ -65,6 +104,8 @@ export const steps = [
'search', 'search',
'mapping', 'mapping',
'validation', 'validation',
'card-details',
'card-select',
'gallery', 'gallery',
'generate' 'generate'
] as const; ] as const;
@@ -90,53 +131,3 @@ export const progress = writable<ProgressState>({
total: 0, total: 0,
message: '' message: ''
}); });
// Google Sheets list for search
export interface SheetInfo {
id: string;
name: string;
url: string;
}
export const availableSheets = writable<SheetInfo[]>([]);
// Selected sheet
export const selectedSheet = writable<SheetInfo | null>(null);
// Validation derived stores
export const validRowCount = derived(
sheetData,
($sheetData) => $sheetData.filter(row => row.valid && row.included).length
);
export const invalidRowCount = derived(
sheetData,
($sheetData) => $sheetData.filter(row => !row.valid).length
);
export const totalRowCount = derived(
sheetData,
($sheetData) => $sheetData.length
);
// Face detection status
export const faceDetectionProgress = writable<{
completed: number;
total: number;
currentImage: string;
}>({
completed: 0,
total: 0,
currentImage: ''
});
// PDF generation status
export const pdfGenerationStatus = writable<{
generating: boolean;
stage: 'preparing' | 'text-pdf' | 'photo-pdf' | 'complete';
progress: number;
}>({
generating: false,
stage: 'preparing',
progress: 0
});

13
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Hashes a string using the SHA-256 algorithm.
* @param input The string to hash.
* @returns A promise that resolves to the hex-encoded hash string.
*/
export async function hashString(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}

68
src/lib/utils/date.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Parses a date string from various common formats and returns it in YYYY-MM-DD format.
* Handles ISO (YYYY-MM-DD), European (DD.MM.YYYY), and US (MM/DD/YYYY) formats,
* as well as Excel-style serial numbers.
* @param value The date string to parse.
* @returns The formatted date string or the original value if parsing fails.
*/
export function parseAndFormatDate(value: string | number | undefined): string {
if (value === undefined || value === null || value === '') return '';
const trimmed = value.toString().trim();
if (!trimmed) return '';
let date: Date | null = null;
// 1. Try direct parsing (handles ISO 8601 like YYYY-MM-DD)
const directParse = new Date(trimmed);
if (!isNaN(directParse.getTime()) && trimmed.match(/^\d{4}/)) {
date = directParse;
}
// 2. Regex for MM/DD/YYYY or MM.DD.YYYY or MM-DD-YYYY (common in Google Forms)
if (!date) {
const mdyMatch = trimmed.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})$/);
if (mdyMatch) {
const [, m, d, y] = mdyMatch;
// Basic validation to avoid mixing up DMY and MDY for ambiguous dates like 01/02/2023
// If the first part is > 12, it's likely a day (DMY), so we'll let the next block handle it.
if (parseInt(m) <= 12) {
const year = y.length === 2 ? parseInt(`20${y}`) : parseInt(y);
date = new Date(year, parseInt(m) - 1, parseInt(d));
}
}
}
// 3. Regex for DD/MM/YYYY or DD.MM.YYYY or DD-MM-YYYY
if (!date) {
const dmyMatch = trimmed.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})$/);
if (dmyMatch) {
const [, d, m, y] = dmyMatch;
const year = y.length === 2 ? parseInt(`20${y}`) : parseInt(y);
// Month is 0-indexed in JS
date = new Date(year, parseInt(m) - 1, parseInt(d));
}
}
// 4. Handle Excel serial date number (days since 1900-01-01, with Excel's leap year bug)
if (!date && /^\d{5}$/.test(trimmed)) {
const serial = parseInt(trimmed, 10);
// Excel's epoch starts on day 1, which it considers 1900-01-01.
// JS Date epoch is 1970-01-01.
// Days between 1900-01-01 and 1970-01-01 is 25569.
// Excel incorrectly thinks 1900 was a leap year, so we subtract 1 for dates after Feb 1900.
const excelEpochDiff = serial > 60 ? 25567 : 25568;
const utcMilliseconds = (serial - excelEpochDiff) * 86400 * 1000;
date = new Date(utcMilliseconds);
}
// If we have a valid date, format it. Otherwise, return original.
if (date && !isNaN(date.getTime())) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
return trimmed; // Fallback
}

View File

@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { initGoogleClient } from '$lib/google'; import { initGoogleClients } from '$lib/google';
import '../app.css'; import '../app.css';
let { children } = $props(); let { children } = $props();
onMount(() => { onMount(() => {
initGoogleClient(() => { initGoogleClients(() => {
// You can add any logic here to run after the client is initialized
console.log('Google API client initialized'); console.log('Google API client initialized');
}); });
}); });

View File

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

View File

@@ -1,12 +0,0 @@
<nav class="border-b border-gray-300 bg-gray-50 p-2 text-gray-900">
<div class="container mx-auto max-w-2xl p-2">
<div class="flex items-center justify-between">
<a href="/private/home" class="text-lg font-bold" aria-label="ScanWave Home">ScanWave</a>
<ul class="flex space-x-4">
<li><a href="/private/scanner">Scanner</a></li>
<li><a href="/private/events">Events</a></li>
</ul>
</div>
</div>
</nav>

View File

@@ -16,7 +16,8 @@ self.addEventListener('install', (event) => {
await cache.addAll(ASSETS); await cache.addAll(ASSETS);
} }
event.waitUntil(addFilesToCache()); // Precache and activate this SW immediately so new versions take control
event.waitUntil(Promise.all([addFilesToCache(), self.skipWaiting()]))
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
@@ -27,7 +28,11 @@ self.addEventListener('activate', (event) => {
} }
} }
event.waitUntil(deleteOldCaches()); // Clean old caches and take control of existing clients immediately
event.waitUntil((async () => {
await deleteOldCaches();
await self.clients.claim();
})());
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
@@ -64,7 +69,9 @@ self.addEventListener('fetch', (event) => {
throw new Error('invalid response from fetch'); throw new Error('invalid response from fetch');
} }
if (response.status === 200) {
// Only cache successful same-origin GET responses at runtime
if (response.status === 200 && url.origin === self.location.origin) {
cache.put(event.request, response.clone()); cache.put(event.request, response.clone());
} }

4
src/types/heic-convert-browser.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'heic-convert/browser' {
const convert: (options: { buffer: Uint8Array; format: 'JPEG' | 'PNG'; quality?: number }) => Promise<ArrayBuffer | Uint8Array>;
export default convert;
}

BIN
static/cards/2026.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

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

Binary file not shown.

View File

@@ -3,10 +3,23 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { adapter: adapter() } kit: { adapter: adapter() },
csp: {
mode: 'hash',
directives: {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'"],
'img-src': ["'self'", 'data:'],
'connect-src': ["'self'", 'https://www.googleapis.com'],
'font-src': ["'self'"],
'object-src': ["'none'"],
'frame-ancestors': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"]
},
}
}; };
export default config; export default config;