Compare commits
56 Commits
c6ea10e1d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90265110f | ||
|
|
97460c018c | ||
|
|
74910e3346 | ||
|
|
20b21de69e | ||
|
|
68e4d0b77b | ||
|
|
e43101648b | ||
|
|
c6c3bbc024 | ||
|
|
d845021f7e | ||
|
|
dcba02260a | ||
|
|
9de5646519 | ||
|
|
2b3371e67f | ||
|
|
a9dc5888e6 | ||
|
|
1a2329b6c1 | ||
|
|
82395afa6e | ||
|
|
be7bdc551a | ||
|
|
44de5d9ad6 | ||
|
|
f5c2063586 | ||
|
|
667c18a746 | ||
|
|
7276e9ff89 | ||
|
|
99ab5cfb4f | ||
|
|
6f7843405c | ||
|
|
c95f96594f | ||
|
|
6ed1f985e0 | ||
|
|
c6cc9c6658 | ||
|
|
7fb72c7d75 | ||
|
|
ebb14e9e1a | ||
|
|
3af8c116a4 | ||
|
|
e9987009c7 | ||
|
|
d8b4eea3ef | ||
|
|
2f730fdbbb | ||
|
|
b5814ed552 | ||
|
|
052e5975fd | ||
|
|
1e96668e48 | ||
|
|
923300e49b | ||
|
|
1aa6cd53fa | ||
|
|
dc1edaae84 | ||
|
|
1fde370890 | ||
|
|
39b15f1314 | ||
|
|
be47b096d5 | ||
|
|
e587d1099b | ||
|
|
8e41c6d78f | ||
|
|
1a8ce546d4 | ||
|
|
fa6b8312c6 | ||
|
|
94e34fbc75 | ||
|
|
162a158a85 | ||
|
|
e8dcb700b5 | ||
|
|
ceececfd99 | ||
|
|
2e228126be | ||
|
|
9bbd02dd67 | ||
|
|
c77c96c1c7 | ||
|
|
2072e57585 | ||
|
|
c695664784 | ||
|
|
4f119dc121 | ||
|
|
ffa427d42c | ||
|
|
735e13731c | ||
|
|
3ea48272b2 |
13
.env.example
13
.env.example
@@ -1,2 +1,13 @@
|
||||
# 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
|
||||
|
||||
78
.gitea/workflows/release.yml
Normal file
78
.gitea/workflows/release.yml
Normal 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'
|
||||
13
.github/copilot-instructions.md
vendored
13
.github/copilot-instructions.md
vendored
@@ -2,7 +2,18 @@
|
||||
- You are a helpful AI assistant that helps developers write code.
|
||||
- This code is written in Svelte 5
|
||||
- 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.
|
||||
- Refer to the ".github/core-instructions.md" for the overall structure of the application.
|
||||
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
|
||||
- Remain consistent in styling and code structure.
|
||||
- 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
4
.github/styling.md
vendored
@@ -89,7 +89,7 @@ This document outlines the design system and styling conventions used in the app
|
||||
|
||||
### Container Pattern
|
||||
```html
|
||||
<div class="container mx-auto max-w-2xl bg-white p-4">
|
||||
<div class="container mx-auto max-w-5xl bg-white p-4">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
```
|
||||
@@ -225,7 +225,7 @@ This document outlines the design system and styling conventions used in the app
|
||||
### Top Navigation
|
||||
```html
|
||||
<nav class="border-b border-gray-300 bg-gray-50 p-4 text-gray-900">
|
||||
<div class="container mx-auto max-w-2xl">
|
||||
<div class="container mx-auto max-w-5xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-lg font-bold">App Name</a>
|
||||
<ul class="flex space-x-4">
|
||||
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal 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
12
docker-compose-prod.yml
Normal 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
7
docker-compose.yml
Normal 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
1597
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "esn-card-generator",
|
||||
"name": "card-forge",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
@@ -29,14 +30,18 @@
|
||||
"vite-plugin-devtools-json": "^0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensorflow-models/blazeface": "^0.1.0",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/gapi.client.drive": "^3.0.15",
|
||||
"@types/gapi.client.sheets": "^4.0.20201031",
|
||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||
"@types/gapi.client.sheets-v4": "^0.0.4",
|
||||
"@types/google.accounts": "^0.0.17",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"heic-convert": "^2.1.0",
|
||||
"idb": "^8.0.3",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"p-queue": "^8.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Card Forge</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
1
src/fontkit.d.ts
vendored
Normal file
1
src/fontkit.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module 'fontkit';
|
||||
31
src/lib/cards/esncard_2026.ts
Normal file
31
src/lib/cards/esncard_2026.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Card } from './types';
|
||||
|
||||
// User-configurable settings for PDF generation
|
||||
export const ESNCard2026: Card = {
|
||||
name: 'ESNcard 2026',
|
||||
image: '/cards/2026.webp',
|
||||
textCard: {
|
||||
width: 50, // mm
|
||||
height: 35 // mm
|
||||
},
|
||||
photoCard: {
|
||||
width: 32, // mm
|
||||
height: 45 // mm
|
||||
},
|
||||
photo: {
|
||||
width: 28, // mm
|
||||
height: 38 // mm
|
||||
},
|
||||
textFields: {
|
||||
name: { x: 3, y: 5, size: 9 },
|
||||
nationality: { x: 3, y: 14, size: 9 },
|
||||
birthday: { x: 33, y: 14, size: 9 },
|
||||
studiesAt: { x: 3, y: 23, size: 9 },
|
||||
esnSection: { x: 3, y: 32, size: 9 },
|
||||
validityStart: { x: 33, y: 32, size: 9 }
|
||||
},
|
||||
photoFields: {
|
||||
photo: { x: 2, y: 2, width: 28, height: 38 },
|
||||
name: { x: 2, y: 42, size: 7 }
|
||||
}
|
||||
};
|
||||
31
src/lib/cards/esncard_anniversary.ts
Normal file
31
src/lib/cards/esncard_anniversary.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Card } from './types';
|
||||
|
||||
// User-configurable settings for PDF generation
|
||||
export const ESNCardAnniversary: Card = {
|
||||
name: 'ESNcard Anniversary',
|
||||
image: '/cards/esncard_anniversary.png',
|
||||
textCard: {
|
||||
width: 45, // mm
|
||||
height: 30 // mm
|
||||
},
|
||||
photoCard: {
|
||||
width: 29, // mm
|
||||
height: 41 // mm
|
||||
},
|
||||
photo: {
|
||||
width: 27, // mm
|
||||
height: 37 // mm
|
||||
},
|
||||
textFields: {
|
||||
name: { x: 2, y: 4, size: 8 },
|
||||
nationality: { x: 2, y: 12, size: 8 },
|
||||
birthday: { x: 30, y: 12, size: 8 },
|
||||
studiesAt: { x: 2, y: 20, size: 8 },
|
||||
esnSection: { x: 2, y: 28, size: 8 },
|
||||
validityStart: { x: 30, y: 28, size: 8 }
|
||||
},
|
||||
photoFields: {
|
||||
photo: { x: 2, y: 2, width: 26, height: 36 },
|
||||
name: { x: 2, y: 40, size: 6 }
|
||||
}
|
||||
};
|
||||
5
src/lib/cards/index.ts
Normal file
5
src/lib/cards/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ESNCardAnniversary } from './esncard_anniversary';
|
||||
// import { ESNCard2026 } from './esncard_2026';
|
||||
import type { Card } from './types';
|
||||
|
||||
export const cardTypes: Card[] = [ESNCardAnniversary];
|
||||
46
src/lib/cards/types/index.ts
Normal file
46
src/lib/cards/types/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface CardDimensions {
|
||||
width: number; // mm
|
||||
height: number; // mm
|
||||
}
|
||||
|
||||
export interface PhotoDimensions {
|
||||
width: number; // mm
|
||||
height: number; // mm
|
||||
}
|
||||
|
||||
export interface TextPosition {
|
||||
x: number; // mm, relative to cell top-left
|
||||
y: number; // mm, relative to cell top-left
|
||||
size: number; // font size in points
|
||||
}
|
||||
|
||||
export interface PhotoPosition {
|
||||
x: number; // mm, relative to cell top-left
|
||||
y: number; // mm, relative to cell top-left
|
||||
width: number; // mm
|
||||
height: number; // mm
|
||||
}
|
||||
|
||||
export interface TextFieldLayout {
|
||||
name: TextPosition;
|
||||
nationality: TextPosition;
|
||||
birthday: TextPosition;
|
||||
studiesAt: TextPosition;
|
||||
esnSection: TextPosition;
|
||||
validityStart: TextPosition;
|
||||
}
|
||||
|
||||
export interface PhotoFieldLayout {
|
||||
photo: PhotoPosition;
|
||||
name: TextPosition;
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
name: string;
|
||||
image: string;
|
||||
textCard: CardDimensions;
|
||||
photoCard: CardDimensions;
|
||||
photo: PhotoDimensions;
|
||||
textFields: TextFieldLayout;
|
||||
photoFields: PhotoFieldLayout;
|
||||
}
|
||||
@@ -1,41 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { currentStep } from '$lib/stores.js';
|
||||
import { currentStep } from '$lib/stores.js';
|
||||
import FeatureList from './splash/FeatureList.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
function startWizard() {
|
||||
currentStep.set(1); // Move to auth step
|
||||
}
|
||||
const buildDate = env.PUBLIC_BUILD_DATE;
|
||||
const gitRef = env.PUBLIC_GIT_REF ? env.PUBLIC_GIT_REF.substring(0, 7) : '';
|
||||
|
||||
|
||||
function startWizard() {
|
||||
currentStep.set(1); // Move to auth step
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div class="container mx-auto max-w-2xl bg-white p-8 rounded-lg shadow-lg text-center">
|
||||
<div class="mb-8">
|
||||
<!-- ESN Logo placeholder -->
|
||||
<div class="mx-auto mb-6 w-24 h-24 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<span class="text-white text-2xl font-bold">ESN</span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-6 text-3xl font-bold text-gray-800">
|
||||
ESN Card Generator
|
||||
</h1>
|
||||
|
||||
<p class="text-lg text-gray-700 leading-relaxed mb-6">
|
||||
Transform your Google Sheets into professional ESN membership cards with photos.
|
||||
Privacy-first: all processing happens in your browser.
|
||||
</p>
|
||||
|
||||
<div class="text-sm text-gray-500 mb-8">
|
||||
<p class="mb-2">✓ Import data from Google Sheets</p>
|
||||
<p class="mb-2">✓ Automatic face detection and cropping</p>
|
||||
<p class="mb-2">✓ Generate text and photo PDFs</p>
|
||||
<p>✓ No data stored on our servers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={startWizard}
|
||||
class="bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Start Creating Cards
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
|
||||
<div
|
||||
class="container mx-auto max-w-5xl rounded-lg border border-gray-200 bg-white/90 p-10 text-center shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex flex-col items-center">
|
||||
<!-- Animated ESN Logo -->
|
||||
<div
|
||||
class="mx-auto mb-6 flex h-40 w-40 items-center justify-center rounded-full bg-gradient-to-tr from-blue-400 via-purple-400 to-pink-400"
|
||||
>
|
||||
<img src="/favicon.svg" alt="ESN Logo" class="h-28 w-28 drop-shadow-lg" />
|
||||
</div>
|
||||
<h1
|
||||
class="mb-2 pb-4 bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-6xl font-extrabold tracking-tight text-transparent"
|
||||
>
|
||||
Card Forge
|
||||
</h1>
|
||||
<p class="mb-4 text-xl font-medium leading-relaxed text-gray-700">
|
||||
Transform your Google Sheets into professional ESNcards with photos.
|
||||
</p>
|
||||
<p class="mb-4 text-lg leading-relaxed text-gray-600">
|
||||
<span class="font-semibold text-black-800">Privacy-first</span>: all processing happens in
|
||||
your browser.
|
||||
</p>
|
||||
<FeatureList class="mb-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href="https://youtube.com"
|
||||
target="_blank"
|
||||
class="flex w-64 items-center justify-center gap-2 rounded-lg bg-pink-400 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-pink-400"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
d="M23.498 6.186a2.998 2.998 0 0 0-2.115-2.117C19.073 3.5 12 3.5 12 3.5s-7.073 0-9.383.569A2.998 2.998 0 0 0 .502 6.186C0 8.497 0 12 0 12s0 3.503.502 5.814a2.998 2.998 0 0 0 2.115 2.117C4.927 20.5 12 20.5 12 20.5s7.073 0 9.383-.569a2.998 2.998 0 0 0 2.115-2.117C24 15.503 24 12 24 12s0-3.503-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||
/></svg
|
||||
>
|
||||
Watch Tutorial
|
||||
</a>
|
||||
<button
|
||||
onclick={startWizard}
|
||||
class="w-64 rounded-lg bg-blue-600 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-blue-700"
|
||||
>
|
||||
Start Creating Cards
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="mt-4 text-center">
|
||||
{#if buildDate && gitRef}
|
||||
<p class="text-xs text-gray-400">
|
||||
Build: {gitRef} {buildDate}
|
||||
</p>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { currentStep } from '$lib/stores.js';
|
||||
import StepAuth from './wizard/StepAuth.svelte';
|
||||
import StepSheetSearch from './wizard/StepSheetSearch.svelte';
|
||||
import StepColumnMap from './wizard/StepColumnMap.svelte';
|
||||
// Additional steps to be added as they are implemented
|
||||
import { currentStep, steps as stepNames, currentStepName } from '$lib/stores';
|
||||
import Splash from './Splash.svelte';
|
||||
import StepAuth from './wizard/StepAuth.svelte';
|
||||
import StepSheetSearch from './wizard/StepSheetSearch.svelte';
|
||||
import StepColumnMap from './wizard/StepColumnMap.svelte';
|
||||
import StepRowFilter from './wizard/StepRowFilter.svelte';
|
||||
import StepCardDetails from './wizard/StepCardDetails.svelte';
|
||||
import StepCardSelect from './wizard/StepCardSelect.svelte';
|
||||
import StepGallery from './wizard/StepGallery.svelte';
|
||||
import StepGenerate from './wizard/StepGenerate.svelte';
|
||||
|
||||
const steps = [
|
||||
StepAuth,
|
||||
StepSheetSearch,
|
||||
StepColumnMap
|
||||
];
|
||||
const stepTitles = {
|
||||
splash: 'Welcome',
|
||||
auth: 'Authenticate',
|
||||
search: 'Select Sheet',
|
||||
mapping: 'Map Columns',
|
||||
validation: 'Filter Rows',
|
||||
'card-details': 'Enter Card Details',
|
||||
'card-select': 'Select Card Type',
|
||||
gallery: 'Preview Gallery',
|
||||
generate: 'Generate Cards'
|
||||
};
|
||||
|
||||
const stepTitles = [
|
||||
'Authenticate',
|
||||
'Select Sheet',
|
||||
'Map Columns'
|
||||
];
|
||||
|
||||
function goToPreviousStep() {
|
||||
if ($currentStep > 1) {
|
||||
currentStep.update(n => n - 1);
|
||||
}
|
||||
}
|
||||
let currentTitle = $derived(stepTitles[$currentStepName]);
|
||||
let currentStepIndex = $derived(stepNames.indexOf($currentStepName));
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="container mx-auto max-w-4xl p-4">
|
||||
<!-- Progress indicator -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold text-gray-800">
|
||||
{stepTitles[$currentStep - 1]}
|
||||
</h1>
|
||||
<span class="text-sm text-gray-500">
|
||||
Step {$currentStep} of {steps.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style="width: {($currentStep / steps.length) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 min-h-screen p-4">
|
||||
<div class="container mx-auto max-w-5xl pb-10">
|
||||
{#if $currentStepName !== 'splash'}
|
||||
<!-- Progress indicator -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold text-gray-800">
|
||||
{currentTitle}
|
||||
</h1>
|
||||
<span class="text-sm text-gray-500">
|
||||
Step {currentStepIndex} of {stepNames.length - 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<svelte:component this={steps[$currentStep - 1]} />
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style="width: {(currentStepIndex / (stepNames.length - 1)) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex justify-between mt-6">
|
||||
<button
|
||||
on:click={goToPreviousStep}
|
||||
disabled={$currentStep <= 1}
|
||||
class="px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step content -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
{#if $currentStepName === 'splash'}
|
||||
<Splash />
|
||||
{:else if $currentStepName === 'auth'}
|
||||
<StepAuth />
|
||||
{:else if $currentStepName === 'search'}
|
||||
<StepSheetSearch />
|
||||
{:else if $currentStepName === 'mapping'}
|
||||
<StepColumnMap />
|
||||
{:else if $currentStepName === 'validation'}
|
||||
<StepRowFilter />
|
||||
{:else if $currentStepName === 'card-details'}
|
||||
<StepCardDetails />
|
||||
{:else if $currentStepName === 'card-select'}
|
||||
<StepCardSelect />
|
||||
{:else if $currentStepName === 'gallery'}
|
||||
<StepGallery />
|
||||
{:else if $currentStepName === 'generate'}
|
||||
<StepGenerate />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
10
src/lib/components/splash/FeatureList.svelte
Normal file
10
src/lib/components/splash/FeatureList.svelte
Normal 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>
|
||||
@@ -1,80 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { currentStep } from '$lib/stores.js';
|
||||
import { isSignedIn, handleSignIn, handleSignOut, isGoogleApiReady } from '$lib/google';
|
||||
import { onMount } from 'svelte';
|
||||
import { currentStep } from '$lib/stores.js';
|
||||
import {
|
||||
isSignedIn,
|
||||
handleSignOut,
|
||||
requestTokenFromUser,
|
||||
isGoogleApiReady,
|
||||
initGoogleClients
|
||||
} from '$lib/google';
|
||||
import Navigator from './subcomponents/Navigator.svelte';
|
||||
|
||||
function proceed() {
|
||||
currentStep.set(2);
|
||||
}
|
||||
onMount(() => {
|
||||
if (!$isGoogleApiReady) {
|
||||
initGoogleClients(() => {
|
||||
// This callback is called when the Google clients are ready.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function handleSignIn() {
|
||||
requestTokenFromUser();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="max-w-md mx-auto text-center">
|
||||
<div class="mb-6">
|
||||
<div class="mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12.017 11.215c-3.573-2.775-9.317-.362-9.317 4.686C2.7 21.833 6.943 24 12.017 24c5.076 0 9.319-2.167 9.319-8.099 0-5.048-5.744-7.461-9.319-4.686z"/>
|
||||
<path d="M20.791 5.016c-1.395-1.395-3.61-1.428-5.024-.033l-1.984 1.984v-.002L12.017 8.73 10.25 6.965l-1.984-1.984c-1.414-1.395-3.629-1.362-5.024.033L1.498 6.758c-1.438 1.438-1.438 3.77 0 5.208l1.744 1.744c1.395 1.395 3.61 1.428 5.024.033l1.984-1.984v.002L12.017 9.996l1.767 1.765 1.984 1.984c1.414 1.395 3.629 1.362 5.024-.033l1.744-1.744c1.438-1.438 1.438-3.77 0-5.208L20.791 5.016z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
Connect to Google
|
||||
</h2>
|
||||
|
||||
<p class="text-sm text-gray-700 leading-relaxed mb-6">
|
||||
Sign in with your Google account to access your Google Sheets and Google Drive for photo downloads.
|
||||
</p>
|
||||
|
||||
<div class="text-xs text-gray-500 mb-6 space-y-1">
|
||||
<p>Required permissions:</p>
|
||||
<p>• View your Google Spreadsheets</p>
|
||||
<p>• View and manage the files in your Google Drive</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-900">Connect to Google</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
Sign in with your Google account to access your Google Sheets and Google Drive for photo
|
||||
downloads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if $isSignedIn}
|
||||
<!-- Authenticated state -->
|
||||
<div class="bg-green-50 border border-green-300 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-green-800">Authenticated</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-green-800 mb-3">
|
||||
You are signed in.
|
||||
</p>
|
||||
|
||||
<div class="flex space-x-3 justify-center">
|
||||
<button
|
||||
on:click={proceed}
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700"
|
||||
>
|
||||
Continue →
|
||||
</button>
|
||||
|
||||
<button
|
||||
on:click={handleSignOut}
|
||||
class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Unauthenticated state -->
|
||||
<button
|
||||
on:click={handleSignIn}
|
||||
disabled={!$isGoogleApiReady}
|
||||
class="w-full bg-blue-600 text-white px-4 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if $isGoogleApiReady}
|
||||
Sign In with Google
|
||||
{:else}
|
||||
Loading Google API...
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid gap-8 md:grid-cols-2">
|
||||
<!-- Left Column: Information -->
|
||||
<div class="space-y-6 text-gray-700">
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900">Google Sheets Integration</h4>
|
||||
<p class="text-sm">
|
||||
Seamlessly import your data without the hassle of manual copy-pasting.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900">Google Drive Access</h4>
|
||||
<p class="text-sm">
|
||||
Automatically download photos for your cards directly from your Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900">Privacy & Security</h4>
|
||||
<p class="text-sm">
|
||||
Your data is processed entirely in your browser. Nothing is ever uploaded to or stored on
|
||||
our servers. We only request read-only access. All of the data is then removed from your browser
|
||||
when the work is finished.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Action -->
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-8"
|
||||
>
|
||||
{#if !$isGoogleApiReady}
|
||||
<!-- Loading state -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div
|
||||
class="h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
|
||||
></div>
|
||||
<span class="text-sm text-gray-600">Loading Google services...</span>
|
||||
</div>
|
||||
{:else if $isSignedIn}
|
||||
<!-- Authenticated state -->
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900">Successfully Connected</h3>
|
||||
</div>
|
||||
<p class="mb-6 mt-2 text-sm text-gray-600">You are signed in and ready to proceed.</p>
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Unauthenticated state -->
|
||||
<div class="w-full text-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Ready to Connect?</h3>
|
||||
<p class="mb-6 text-sm text-gray-600">
|
||||
Click the button below to sign in with your Google account.
|
||||
</p>
|
||||
<button
|
||||
onclick={handleSignIn}
|
||||
class="flex w-full items-center justify-center rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
>
|
||||
Sign In with Google
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div> <div class="mt-8">
|
||||
<Navigator
|
||||
canProceed={$isSignedIn}
|
||||
{currentStep}
|
||||
textBack="Back to Splash"
|
||||
textForwardDisabled="Sign in to continue"
|
||||
textForwardEnabled="Continue to Sheet Search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
102
src/lib/components/wizard/StepCardDetails.svelte
Normal file
102
src/lib/components/wizard/StepCardDetails.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { currentStep, cardDetails } from '$lib/stores';
|
||||
import Navigator from './subcomponents/Navigator.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let esnSection = $state('');
|
||||
let studiesAt = $state('');
|
||||
let validityStart = $state('');
|
||||
|
||||
onMount(() => {
|
||||
validityStart = new Date().toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const savedesnSection = localStorage.getItem('esnSection');
|
||||
if (savedesnSection) {
|
||||
esnSection = savedesnSection;
|
||||
}
|
||||
const savedStudiesAt = localStorage.getItem('studiesAt');
|
||||
if (savedStudiesAt) {
|
||||
studiesAt = savedStudiesAt;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to access localStorage on mount:', error);
|
||||
}
|
||||
});
|
||||
|
||||
let canProceed = $derived(esnSection.trim() !== '' && studiesAt.trim() !== '' && validityStart.trim() !== '');
|
||||
|
||||
function handleContinue() {
|
||||
try {
|
||||
localStorage.setItem('esnSection', esnSection);
|
||||
localStorage.setItem('studiesAt', studiesAt);
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error);
|
||||
}
|
||||
// Include new field; spread in case store has more fields defined elsewhere
|
||||
$cardDetails = { ...$cardDetails, esnSection, studiesAt, validityStart } as any;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-900">Enter Card Details</h2>
|
||||
<p class="mb-4 text-sm text-gray-700">
|
||||
Please provide the following details to be printed on the cards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="esnSection" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
ESN Section
|
||||
</label>
|
||||
<input
|
||||
id="esnSection"
|
||||
type="text"
|
||||
bind:value={esnSection}
|
||||
placeholder="e.g., ESN VUT Brno"
|
||||
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="studiesAt" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
Studies At
|
||||
</label>
|
||||
<input
|
||||
id="studiesAt"
|
||||
type="text"
|
||||
bind:value={studiesAt}
|
||||
placeholder="e.g., Brno University of Technology"
|
||||
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="validityStart" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
Card Validity Start Date
|
||||
</label>
|
||||
<input
|
||||
id="validityStart"
|
||||
type="date"
|
||||
bind:value={validityStart}
|
||||
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
Default date is today, but future date can be selected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<Navigator
|
||||
{canProceed}
|
||||
{currentStep}
|
||||
onForward={handleContinue}
|
||||
textBack="Back to Row Selection"
|
||||
textForwardEnabled="Continue to Card Selection"
|
||||
textForwardDisabled="Please fill out all fields"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
79
src/lib/components/wizard/StepCardSelect.svelte
Normal file
79
src/lib/components/wizard/StepCardSelect.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { currentStep, selectedCard } from '$lib/stores';
|
||||
import { cardTypes } from '$lib/cards';
|
||||
import type { Card } from '$lib/cards/types';
|
||||
import Navigator from './subcomponents/Navigator.svelte';
|
||||
|
||||
let selected: Card | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
const savedCardName = localStorage.getItem('selectedCardName');
|
||||
if (savedCardName) {
|
||||
const foundCard = cardTypes.find((c) => c.name === savedCardName);
|
||||
if (foundCard) {
|
||||
selected = foundCard;
|
||||
selectedCard.set(foundCard);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function selectCard(card: Card) {
|
||||
selected = card;
|
||||
selectedCard.set(card);
|
||||
localStorage.setItem('selectedCardName', card.name);
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
if (selected) {
|
||||
currentStep.set($currentStep + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
currentStep.set($currentStep - 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">Select Card Type</h2>
|
||||
<p class="text-sm text-gray-700 mb-4">
|
||||
Choose the type of card you want to generate. This will determine the layout and dimensions of the final PDFs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card Type Selector -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
|
||||
{#each cardTypes as card (card.name)}
|
||||
<button
|
||||
class="relative rounded-lg border-2 p-2 transition-all duration-200"
|
||||
class:border-blue-600={selected?.name === card.name}
|
||||
class:border-gray-200={selected?.name !== card.name}
|
||||
class:shadow-lg={selected?.name === card.name}
|
||||
onclick={() => selectCard(card)}
|
||||
>
|
||||
<img src={card.image} alt={card.name} class="w-full h-auto rounded-md mb-2" />
|
||||
<p class="text-sm font-medium text-center text-gray-800">{card.name}</p>
|
||||
{#if selected?.name === card.name}
|
||||
<div
|
||||
class="absolute top-2 right-2 bg-blue-600 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Navigator onForward={onNext} onBack={onBack} nextDisabled={!selected} />
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Review Photos</h2>
|
||||
<p class="text-sm text-gray-700">Photo gallery and review functionality will be implemented here.</p>
|
||||
<div class="mb-6">
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Generate PDFs</h2>
|
||||
<p class="text-sm text-gray-700">PDF generation functionality will be implemented here.</p>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,419 @@
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Filter Rows</h2>
|
||||
<p class="text-sm text-gray-700">Row filtering functionality will be implemented here.</p>
|
||||
<script lang="ts">
|
||||
import {
|
||||
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>
|
||||
|
||||
@@ -1,265 +1,271 @@
|
||||
<script lang="ts">
|
||||
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
|
||||
import { searchSheets } from '$lib/google';
|
||||
import { onMount } from 'svelte';
|
||||
import { selectedSheet, currentStep } from '$lib/stores';
|
||||
import type { SheetInfoType } from '$lib/stores';
|
||||
import { searchSheets, ensureToken, userEmail } from '$lib/google';
|
||||
import { hashString } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import Navigator from './subcomponents/Navigator.svelte';
|
||||
|
||||
let searchQuery = '';
|
||||
let isLoading = false;
|
||||
let error = '';
|
||||
let searchResults: any[] = [];
|
||||
let hasSearched = false;
|
||||
let recentSheets: any[] = [];
|
||||
|
||||
const RECENT_SHEETS_KEY = 'esn-recent-sheets';
|
||||
|
||||
onMount(() => {
|
||||
loadRecentSheets();
|
||||
});
|
||||
let searchQuery = $state('');
|
||||
let isLoading = $state(false);
|
||||
let error = $state('');
|
||||
let searchResults = $state<any[]>([]);
|
||||
let hasSearched = $state(false);
|
||||
let recentSheets = $state<any[]>([]);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
isLoading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
searchResults = await searchSheets(searchQuery);
|
||||
availableSheets.set(
|
||||
searchResults.map(sheet => ({
|
||||
id: sheet.id,
|
||||
name: sheet.name,
|
||||
url: sheet.webViewLink
|
||||
}))
|
||||
);
|
||||
hasSearched = true;
|
||||
} catch (err) {
|
||||
console.error('Error searching sheets:', err);
|
||||
error = 'Failed to search sheets. Please check your connection and try again.';
|
||||
searchResults = [];
|
||||
availableSheets.set([]);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function getRecentSheetsKey() {
|
||||
const email = $userEmail;
|
||||
if (email) {
|
||||
return `recentSheets_${await hashString(email)}`;
|
||||
}
|
||||
return 'recentSheets_anonymous';
|
||||
}
|
||||
|
||||
function loadRecentSheets() {
|
||||
try {
|
||||
const saved = localStorage.getItem(RECENT_SHEETS_KEY);
|
||||
if (saved) {
|
||||
recentSheets = JSON.parse(saved);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading recent sheets:', err);
|
||||
// If there's an error, clear the stored value
|
||||
localStorage.removeItem(RECENT_SHEETS_KEY);
|
||||
recentSheets = [];
|
||||
}
|
||||
}
|
||||
|
||||
function 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);
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
ensureToken();
|
||||
loadRecentSheets();
|
||||
});
|
||||
|
||||
function handleSelectSheet(sheet) {
|
||||
const sheetData = {
|
||||
id: sheet.id,
|
||||
name: sheet.name,
|
||||
url: sheet.webViewLink || sheet.url
|
||||
};
|
||||
|
||||
selectedSheet.set(sheetData);
|
||||
saveToRecentSheets(sheet);
|
||||
}
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
function handleContinue() {
|
||||
currentStep.set(3); // Move to the column mapping step
|
||||
}
|
||||
isLoading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
searchResults = await searchSheets(searchQuery);
|
||||
hasSearched = true;
|
||||
} catch (err) {
|
||||
console.error('Error searching sheets:', err);
|
||||
error = 'Failed to search sheets. Please check your connection and try again.';
|
||||
searchResults = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentSheets() {
|
||||
try {
|
||||
const key = await getRecentSheetsKey();
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
recentSheets = JSON.parse(saved);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading recent sheets:', err);
|
||||
// If there's an error, clear the stored value
|
||||
const key = await getRecentSheetsKey();
|
||||
localStorage.removeItem(key);
|
||||
recentSheets = [];
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectSheet(sheet) {
|
||||
const sheetData: SheetInfoType = {
|
||||
id: sheet.id,
|
||||
name: sheet.name,
|
||||
webViewLink: sheet.webViewLink
|
||||
};
|
||||
selectedSheet.set(sheetData);
|
||||
}
|
||||
|
||||
let canProceed = $derived($selectedSheet.id !== '');
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
Select Google Sheet
|
||||
</h2>
|
||||
|
||||
<p class="text-sm text-gray-700 mb-4">
|
||||
Search for and select the Google Sheet containing your member data.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-900">Select Google Sheet</h2>
|
||||
|
||||
<!-- Search input -->
|
||||
<div class="mb-6">
|
||||
<label for="sheet-search" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Search sheets
|
||||
</label>
|
||||
|
||||
<div class="flex">
|
||||
<input
|
||||
id="sheet-search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type sheet name..."
|
||||
class="flex-grow px-4 py-2 border border-gray-300 rounded-l-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent"
|
||||
/>
|
||||
|
||||
<button
|
||||
on:click={handleSearch}
|
||||
disabled={isLoading || !searchQuery.trim()}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if isLoading}
|
||||
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
{:else}
|
||||
Search
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-4 text-sm text-gray-700">
|
||||
Search for and select the Google Sheet containing your member data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6">
|
||||
<p class="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Search input -->
|
||||
<div class="mb-6">
|
||||
<label for="sheet-search" class="mb-2 block text-sm font-medium text-gray-700">
|
||||
Search sheets
|
||||
</label>
|
||||
|
||||
<!-- Results -->
|
||||
{#if hasSearched}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||
{searchResults.length
|
||||
? `Found ${searchResults.length} matching sheets`
|
||||
: 'No matching sheets found'}
|
||||
</h3>
|
||||
|
||||
{#if searchResults.length}
|
||||
<div class="space-y-3">
|
||||
{#each searchResults as sheet}
|
||||
<div
|
||||
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50
|
||||
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
|
||||
on:click={() => handleSelectSheet(sheet)}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">ID: {sheet.id}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{#if sheet.iconLink}
|
||||
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" />
|
||||
{/if}
|
||||
|
||||
{#if $selectedSheet?.id === sheet.id}
|
||||
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- If we have recent sheets and haven't searched yet, show them -->
|
||||
{#if recentSheets.length > 0 && !hasSearched}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||
Recent sheets
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each recentSheets as sheet}
|
||||
<div
|
||||
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50
|
||||
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
|
||||
on:click={() => handleSelectSheet(sheet)}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Recently used</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{#if sheet.iconLink}
|
||||
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" />
|
||||
{/if}
|
||||
|
||||
{#if $selectedSheet?.id === sheet.id}
|
||||
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 mt-4 pt-4">
|
||||
<p class="text-xs text-gray-500">
|
||||
Or search for a different sheet above
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200 mb-6">
|
||||
<svg class="mx-auto h-16 w-16 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Enter a name or keyword to find your Google Sheets
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex">
|
||||
<input
|
||||
id="sheet-search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type sheet name..."
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Continue button -->
|
||||
{#if $selectedSheet}
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
on:click={handleContinue}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
|
||||
>
|
||||
Continue →
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={handleSearch}
|
||||
disabled={isLoading || !searchQuery.trim()}
|
||||
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}
|
||||
<div
|
||||
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
></div>
|
||||
{:else}
|
||||
Search
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
|
||||
<p class="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results -->
|
||||
{#if hasSearched}
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-3 text-sm font-medium text-gray-700">
|
||||
{searchResults.length
|
||||
? `Found ${searchResults.length} matching sheets`
|
||||
: 'No matching sheets found'}
|
||||
</h3>
|
||||
|
||||
{#if searchResults.length}
|
||||
<div class="space-y-3">
|
||||
{#each searchResults as sheet}
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.id ===
|
||||
(sheet.id || sheet.id)
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200'}"
|
||||
onclick={() => handleSelectSheet(sheet)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 break-all whitespace-normal" title={sheet.id}>ID: {sheet.id}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{#if sheet.iconLink}
|
||||
<img src={sheet.iconLink} alt="Sheet icon" class="my-2 mr-2 h-5 w-5" />
|
||||
{/if}
|
||||
|
||||
{#if $selectedSheet?.id === (sheet.id || sheet.id)}
|
||||
<svg class="h-5 w-5 text-blue-600 my-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 py-8 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- If we have recent sheets and haven't searched yet, show them -->
|
||||
{#if recentSheets.length > 0 && !hasSearched}
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-3 text-sm font-medium text-gray-700">Recent sheets</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each recentSheets as sheet}
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.id ===
|
||||
(sheet.id || sheet.id)
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200'}"
|
||||
onclick={() => handleSelectSheet(sheet)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Recently used</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{#if sheet.iconLink}
|
||||
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
|
||||
{/if}
|
||||
|
||||
{#if $selectedSheet.id === sheet.id}
|
||||
<svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border-t border-gray-200 pt-4">
|
||||
<p class="text-xs text-gray-500">Or search for a different sheet above</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 py-12 text-center">
|
||||
<svg
|
||||
class="mx-auto h-16 w-16 text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Enter a name or keyword to find your Google Sheets</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<Navigator
|
||||
{canProceed}
|
||||
{currentStep}
|
||||
textBack="Back to Auth"
|
||||
textForwardDisabled="Select a sheet"
|
||||
textForwardEnabled="Continue to Column Mapping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
63
src/lib/components/wizard/subcomponents/Navigator.svelte
Normal file
63
src/lib/components/wizard/subcomponents/Navigator.svelte
Normal 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>
|
||||
188
src/lib/components/wizard/subcomponents/PhotoCard.svelte
Normal file
188
src/lib/components/wizard/subcomponents/PhotoCard.svelte
Normal 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}
|
||||
334
src/lib/components/wizard/subcomponents/PhotoCrop.svelte
Normal file
334
src/lib/components/wizard/subcomponents/PhotoCrop.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
export const isGoogleApiReady = writable(false);
|
||||
// Store state: undefined = not yet known, null = failed/logged out, string = token
|
||||
export const accessToken = writable<string | null | undefined>(undefined);
|
||||
export const isSignedIn = writable(false);
|
||||
export const isGoogleApiReady = writable(false); // To track GAPI client readiness
|
||||
export const userEmail = writable<string | null>(null);
|
||||
|
||||
let tokenClient: google.accounts.oauth2.TokenClient;
|
||||
let gapiInited = false;
|
||||
let gsiInited = false;
|
||||
|
||||
const TOKEN_KEY = 'google_oauth_token';
|
||||
export function initGoogleClient(callback: () => void) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://apis.google.com/js/api.js';
|
||||
script.onload = () => {
|
||||
gapi.load('client', async () => {
|
||||
await gapi.client.init({
|
||||
// NOTE: API KEY IS NOT REQUIRED FOR THIS IMPLEMENTATION
|
||||
// apiKey: 'YOUR_API_KEY',
|
||||
discoveryDocs: [
|
||||
'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
|
||||
'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest',
|
||||
],
|
||||
});
|
||||
isGoogleApiReady.set(true);
|
||||
// Restore token from storage if available
|
||||
const saved = localStorage.getItem(TOKEN_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const data = JSON.parse(saved);
|
||||
if (data.access_token && data.expires_at && data.expires_at > Date.now()) {
|
||||
gapi.client.setToken({ access_token: data.access_token });
|
||||
isSignedIn.set(true);
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
callback();
|
||||
});
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
// This function ensures both GAPI (for Sheets/Drive APIs) and GSI (for auth) are loaded in the correct order.
|
||||
export function initGoogleClients(callback: () => void) {
|
||||
// If everything is already initialized, just run the callback.
|
||||
if (gapiInited && gsiInited) {
|
||||
isGoogleApiReady.set(true); // Ensure it's set if called again
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptGsi = document.createElement('script');
|
||||
scriptGsi.src = 'https://accounts.google.com/gsi/client';
|
||||
scriptGsi.onload = () => {
|
||||
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);
|
||||
// 1. Load GAPI script for Sheets/Drive APIs first.
|
||||
if (!gapiInited) {
|
||||
const gapiScript = document.createElement('script');
|
||||
gapiScript.src = 'https://apis.google.com/js/api.js';
|
||||
gapiScript.async = true;
|
||||
gapiScript.defer = true;
|
||||
document.head.appendChild(gapiScript);
|
||||
gapiScript.onload = () => {
|
||||
gapi.load('client', () => {
|
||||
gapi.client
|
||||
.init({
|
||||
discoveryDocs: [
|
||||
'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
|
||||
'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest'
|
||||
]
|
||||
})
|
||||
.then(() => {
|
||||
gapiInited = true;
|
||||
// Now that GAPI is ready, initialize the GSI client.
|
||||
initGsiClient(callback);
|
||||
});
|
||||
});
|
||||
};
|
||||
} else {
|
||||
// GAPI is already ready, just ensure GSI is initialized.
|
||||
initGsiClient(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleSignIn() {
|
||||
if (gapi.client.getToken() === null) {
|
||||
tokenClient.requestAccessToken({ prompt: 'consent' });
|
||||
} else {
|
||||
tokenClient.requestAccessToken({ prompt: '' });
|
||||
}
|
||||
/**
|
||||
* Fetches user's email and stores it.
|
||||
*/
|
||||
async function fetchUserInfo(token: string) {
|
||||
try {
|
||||
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch user info');
|
||||
}
|
||||
const profile = await response.json();
|
||||
userEmail.set(profile.email);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user info:', error);
|
||||
userEmail.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load GSI script for Auth. This should only be called after GAPI is ready.
|
||||
function initGsiClient(callback: () => void) {
|
||||
if (gsiInited) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const gsiScript = document.createElement('script');
|
||||
gsiScript.src = 'https://accounts.google.com/gsi/client';
|
||||
gsiScript.async = true;
|
||||
gsiScript.defer = true;
|
||||
document.head.appendChild(gsiScript);
|
||||
gsiScript.onload = () => {
|
||||
gsiInited = true;
|
||||
tokenClient = google.accounts.oauth2.initTokenClient({
|
||||
client_id: env.PUBLIC_GOOGLE_CLIENT_ID,
|
||||
scope:
|
||||
'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/userinfo.email',
|
||||
callback: (tokenResponse) => {
|
||||
// This callback handles responses from all token requests.
|
||||
if (tokenResponse.error) {
|
||||
console.error('Google token error:', tokenResponse.error);
|
||||
accessToken.set(null);
|
||||
isSignedIn.set(false);
|
||||
if (gapiInited) gapi.client.setToken(null);
|
||||
} else if (tokenResponse.access_token) {
|
||||
const token = tokenResponse.access_token;
|
||||
accessToken.set(token);
|
||||
isSignedIn.set(true);
|
||||
// Also set the token for the GAPI client
|
||||
if (gapiInited) gapi.client.setToken({ access_token: token });
|
||||
fetchUserInfo(token);
|
||||
}
|
||||
}
|
||||
});
|
||||
isGoogleApiReady.set(true);
|
||||
callback();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to get a token silently.
|
||||
* This is for background tasks and on-load checks.
|
||||
* It will not show a consent prompt to the user.
|
||||
*/
|
||||
export function ensureToken(): Promise<string> {
|
||||
return new Promise((res, rej) => {
|
||||
initGoogleClients(() => {
|
||||
const currentToken = get(accessToken);
|
||||
// If we already have a valid token, resolve immediately.
|
||||
if (currentToken) {
|
||||
res(currentToken);
|
||||
return;
|
||||
}
|
||||
|
||||
let unsubscribe: () => void;
|
||||
unsubscribe = accessToken.subscribe((t) => {
|
||||
// undefined means we are still waiting for the initial token request.
|
||||
if (t) { // Got a token.
|
||||
if (unsubscribe) unsubscribe();
|
||||
res(t);
|
||||
} else if (t === null) { // Got an explicit null, meaning auth failed.
|
||||
if (unsubscribe) unsubscribe();
|
||||
rej(new Error('Failed to retrieve access token. The user may need to sign in.'));
|
||||
}
|
||||
});
|
||||
|
||||
// If no token, request one silently.
|
||||
// The result is handled by the callback in initGsiClient, which updates the store and resolves the promise.
|
||||
if (get(accessToken) === undefined) {
|
||||
tokenClient.requestAccessToken({ prompt: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user for consent to grant a token.
|
||||
* This should be called when a user clicks a "Sign In" button.
|
||||
*/
|
||||
export function requestTokenFromUser() {
|
||||
initGoogleClients(() => {
|
||||
if (tokenClient) {
|
||||
tokenClient.requestAccessToken({ prompt: 'consent' });
|
||||
} else {
|
||||
console.error("requestTokenFromUser called before Google client was initialized.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the user out, revokes the token, and clears all local state.
|
||||
*/
|
||||
export function handleSignOut() {
|
||||
const token = gapi.client.getToken();
|
||||
if (token !== null) {
|
||||
google.accounts.oauth2.revoke(token.access_token, () => {
|
||||
gapi.client.setToken(null);
|
||||
isSignedIn.set(false);
|
||||
});
|
||||
}
|
||||
const token = get(accessToken);
|
||||
if (token && gsiInited) {
|
||||
google.accounts.oauth2.revoke(token, () => {
|
||||
console.log('User token revoked.');
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all tokens and states
|
||||
if (gapiInited) {
|
||||
gapi.client.setToken(null);
|
||||
}
|
||||
accessToken.set(null);
|
||||
isSignedIn.set(false);
|
||||
userEmail.set(null);
|
||||
|
||||
console.log('User signed out.');
|
||||
}
|
||||
|
||||
export async function searchSheets(query: string) {
|
||||
if (!gapi.client.drive) {
|
||||
await ensureToken(); // Ensure we are authenticated before making a call
|
||||
if (!gapi.client || !gapi.client.drive) {
|
||||
throw new Error('Google Drive API not loaded');
|
||||
}
|
||||
const response = await gapi.client.drive.files.list({
|
||||
q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`,
|
||||
fields: 'files(id, name, iconLink, webViewLink)',
|
||||
pageSize: 20,
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
corpora: 'allDrives'
|
||||
});
|
||||
return response.result.files || [];
|
||||
}
|
||||
|
||||
export async function getSheetNames(spreadsheetId: string) {
|
||||
if (!gapi.client.sheets) {
|
||||
await ensureToken();
|
||||
if (!gapi.client || !gapi.client.sheets) {
|
||||
throw new Error('Google Sheets API not loaded');
|
||||
}
|
||||
const response = await gapi.client.sheets.spreadsheets.get({
|
||||
@@ -117,7 +215,8 @@ export async function getSheetNames(spreadsheetId: string) {
|
||||
}
|
||||
|
||||
export async function getSheetData(spreadsheetId: string, range: string) {
|
||||
if (!gapi.client.sheets) {
|
||||
await ensureToken();
|
||||
if (!gapi.client || !gapi.client.sheets) {
|
||||
throw new Error('Google Sheets API not loaded');
|
||||
}
|
||||
const response = await gapi.client.sheets.spreadsheets.values.get({
|
||||
@@ -126,3 +225,81 @@ export async function getSheetData(spreadsheetId: string, range: string) {
|
||||
});
|
||||
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
95
src/lib/pdfLayout.ts
Normal 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
14
src/lib/pdfSettings.ts
Normal 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
|
||||
};
|
||||
@@ -1,142 +1,133 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
|
||||
// This file is holy and shall not be edited by Copilot!
|
||||
|
||||
|
||||
// User session and authentication
|
||||
export const session = writable<{
|
||||
token?: string;
|
||||
user?: { name: string; email: string };
|
||||
}>({});
|
||||
|
||||
// Raw sheet data after import
|
||||
export const rawSheetData = writable<string[][]>([]);
|
||||
|
||||
// Column mapping configuration
|
||||
export const columnMapping = writable<{
|
||||
name?: number;
|
||||
surname?: number;
|
||||
nationality?: number;
|
||||
birthday?: number;
|
||||
pictureUrl?: number;
|
||||
}>({});
|
||||
|
||||
// Processed row data after mapping and validation
|
||||
export interface RowData {
|
||||
id: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
nationality: string;
|
||||
birthday: string;
|
||||
pictureUrl: string;
|
||||
valid: boolean;
|
||||
included: boolean;
|
||||
age?: number;
|
||||
validationErrors: string[];
|
||||
// Data structure column mapping
|
||||
export interface ColumnMappingType {
|
||||
name: number;
|
||||
nationality: number;
|
||||
birthday: number;
|
||||
pictureUrl: number;
|
||||
alreadyPrinted: number;
|
||||
sheetName: string;
|
||||
}
|
||||
|
||||
export const sheetData = writable<RowData[]>([]);
|
||||
// Data structure for a row in the sheet
|
||||
export interface RowData {
|
||||
id: string; // Unique identifier
|
||||
name: string;
|
||||
nationality: string;
|
||||
birthday: string;
|
||||
pictureUrl: string;
|
||||
alreadyPrinted: boolean;
|
||||
_rowIndex: number;
|
||||
_checked: boolean;
|
||||
_valid: boolean;
|
||||
}
|
||||
|
||||
// Picture storage and metadata
|
||||
export interface PictureBlobInfo {
|
||||
id: string;
|
||||
blob: Blob;
|
||||
url: string;
|
||||
downloaded: boolean;
|
||||
faceDetected: boolean;
|
||||
faceCount: number;
|
||||
export interface PictureBlobInfoType {
|
||||
id: string;
|
||||
url: string;
|
||||
downloaded: boolean;
|
||||
faceDetected: boolean;
|
||||
faceCount: number;
|
||||
}
|
||||
|
||||
export const pictures = writable<Record<string, PictureBlobInfo>>({});
|
||||
|
||||
// Crop rectangles for each photo
|
||||
export interface Crop {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// CropType rectangles for each photo
|
||||
export interface CropType {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const cropRects = writable<Record<string, Crop>>({});
|
||||
// Google Sheets list for search
|
||||
export interface SheetInfoType {
|
||||
id: string;
|
||||
name: string;
|
||||
webViewLink: string;
|
||||
}
|
||||
|
||||
// Card details type
|
||||
export interface CardDetailsType {
|
||||
esnSection: string;
|
||||
studiesAt: string;
|
||||
validityStart: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Column mapping configuration
|
||||
export const columnMapping = writable<ColumnMappingType>({
|
||||
name: -1,
|
||||
nationality: -1,
|
||||
birthday: -1,
|
||||
pictureUrl: -1,
|
||||
alreadyPrinted: -1,
|
||||
sheetName: ''
|
||||
});
|
||||
|
||||
// Store to hold the processed sheet data
|
||||
export const sheetData = writable<RowData[]>([]);
|
||||
|
||||
// Store and hold the processed picture data
|
||||
export const pictures = writable<Record<string, PictureBlobInfoType>>({});
|
||||
|
||||
// Store and hold the crop rectangles from face detection
|
||||
export const cropRects = writable<Record<string, CropType>>({});
|
||||
|
||||
// Store and hold the selected sheet
|
||||
export const selectedSheet = writable<SheetInfoType>({ id: '', name: '', webViewLink: '' });
|
||||
|
||||
// Card details for generation
|
||||
export const cardDetails = writable<CardDetailsType | null>(null);
|
||||
|
||||
// Selected card type for generation
|
||||
import type { Card } from '$lib/cards/types';
|
||||
export const selectedCard = writable<Card | null>(null);
|
||||
|
||||
// Wizard state management
|
||||
export const currentStep = writable<number>(0);
|
||||
|
||||
export const steps = [
|
||||
'splash',
|
||||
'auth',
|
||||
'search',
|
||||
'mapping',
|
||||
'validation',
|
||||
'gallery',
|
||||
'generate'
|
||||
'splash',
|
||||
'auth',
|
||||
'search',
|
||||
'mapping',
|
||||
'validation',
|
||||
'card-details',
|
||||
'card-select',
|
||||
'gallery',
|
||||
'generate'
|
||||
] as const;
|
||||
|
||||
export type WizardStep = typeof steps[number];
|
||||
|
||||
export const currentStepName = derived(
|
||||
currentStep,
|
||||
($currentStep) => steps[$currentStep]
|
||||
currentStep,
|
||||
($currentStep) => steps[$currentStep]
|
||||
);
|
||||
|
||||
// Progress tracking
|
||||
export interface ProgressState {
|
||||
stage: string;
|
||||
current: number;
|
||||
total: number;
|
||||
message: string;
|
||||
stage: string;
|
||||
current: number;
|
||||
total: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const progress = writable<ProgressState>({
|
||||
stage: '',
|
||||
current: 0,
|
||||
total: 0,
|
||||
message: ''
|
||||
});
|
||||
|
||||
// Google Sheets list for search
|
||||
export interface SheetInfo {
|
||||
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
|
||||
stage: '',
|
||||
current: 0,
|
||||
total: 0,
|
||||
message: ''
|
||||
});
|
||||
|
||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Hashes a string using the SHA-256 algorithm.
|
||||
* @param input The string to hash.
|
||||
* @returns A promise that resolves to the hex-encoded hash string.
|
||||
*/
|
||||
export async function hashString(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return hashHex;
|
||||
}
|
||||
68
src/lib/utils/date.ts
Normal file
68
src/lib/utils/date.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Parses a date string from various common formats and returns it in YYYY-MM-DD format.
|
||||
* Handles ISO (YYYY-MM-DD), European (DD.MM.YYYY), and US (MM/DD/YYYY) formats,
|
||||
* as well as Excel-style serial numbers.
|
||||
* @param value The date string to parse.
|
||||
* @returns The formatted date string or the original value if parsing fails.
|
||||
*/
|
||||
export function parseAndFormatDate(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
|
||||
const trimmed = value.toString().trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
let date: Date | null = null;
|
||||
|
||||
// 1. Try direct parsing (handles ISO 8601 like YYYY-MM-DD)
|
||||
const directParse = new Date(trimmed);
|
||||
if (!isNaN(directParse.getTime()) && trimmed.match(/^\d{4}/)) {
|
||||
date = directParse;
|
||||
}
|
||||
|
||||
// 2. Regex for MM/DD/YYYY or MM.DD.YYYY or MM-DD-YYYY (common in Google Forms)
|
||||
if (!date) {
|
||||
const mdyMatch = trimmed.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})$/);
|
||||
if (mdyMatch) {
|
||||
const [, m, d, y] = mdyMatch;
|
||||
// Basic validation to avoid mixing up DMY and MDY for ambiguous dates like 01/02/2023
|
||||
// If the first part is > 12, it's likely a day (DMY), so we'll let the next block handle it.
|
||||
if (parseInt(m) <= 12) {
|
||||
const year = y.length === 2 ? parseInt(`20${y}`) : parseInt(y);
|
||||
date = new Date(year, parseInt(m) - 1, parseInt(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Regex for DD/MM/YYYY or DD.MM.YYYY or DD-MM-YYYY
|
||||
if (!date) {
|
||||
const dmyMatch = trimmed.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})$/);
|
||||
if (dmyMatch) {
|
||||
const [, d, m, y] = dmyMatch;
|
||||
const year = y.length === 2 ? parseInt(`20${y}`) : parseInt(y);
|
||||
// Month is 0-indexed in JS
|
||||
date = new Date(year, parseInt(m) - 1, parseInt(d));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle Excel serial date number (days since 1900-01-01, with Excel's leap year bug)
|
||||
if (!date && /^\d{5}$/.test(trimmed)) {
|
||||
const serial = parseInt(trimmed, 10);
|
||||
// Excel's epoch starts on day 1, which it considers 1900-01-01.
|
||||
// JS Date epoch is 1970-01-01.
|
||||
// Days between 1900-01-01 and 1970-01-01 is 25569.
|
||||
// Excel incorrectly thinks 1900 was a leap year, so we subtract 1 for dates after Feb 1900.
|
||||
const excelEpochDiff = serial > 60 ? 25567 : 25568;
|
||||
const utcMilliseconds = (serial - excelEpochDiff) * 86400 * 1000;
|
||||
date = new Date(utcMilliseconds);
|
||||
}
|
||||
|
||||
// If we have a valid date, format it. Otherwise, return original.
|
||||
if (date && !isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
return trimmed; // Fallback
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { initGoogleClient } from '$lib/google';
|
||||
import { initGoogleClients } from '$lib/google';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
initGoogleClient(() => {
|
||||
// You can add any logic here to run after the client is initialized
|
||||
initGoogleClients(() => {
|
||||
console.log('Google API client initialized');
|
||||
});
|
||||
});
|
||||
|
||||
1
src/routes/.layout.server.ts
Normal file
1
src/routes/.layout.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -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>
|
||||
@@ -16,7 +16,8 @@ self.addEventListener('install', (event) => {
|
||||
await cache.addAll(ASSETS);
|
||||
}
|
||||
|
||||
event.waitUntil(addFilesToCache());
|
||||
// Precache and activate this SW immediately so new versions take control
|
||||
event.waitUntil(Promise.all([addFilesToCache(), self.skipWaiting()]))
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
@@ -27,7 +28,11 @@ self.addEventListener('activate', (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(deleteOldCaches());
|
||||
// Clean old caches and take control of existing clients immediately
|
||||
event.waitUntil((async () => {
|
||||
await deleteOldCaches();
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
@@ -64,7 +69,9 @@ self.addEventListener('fetch', (event) => {
|
||||
throw new Error('invalid response from fetch');
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
|
||||
// Only cache successful same-origin GET responses at runtime
|
||||
if (response.status === 200 && url.origin === self.location.origin) {
|
||||
cache.put(event.request, response.clone());
|
||||
}
|
||||
|
||||
|
||||
4
src/types/heic-convert-browser.d.ts
vendored
Normal file
4
src/types/heic-convert-browser.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'heic-convert/browser' {
|
||||
const convert: (options: { buffer: Uint8Array; format: 'JPEG' | 'PNG'; quality?: number }) => Promise<ArrayBuffer | Uint8Array>;
|
||||
export default convert;
|
||||
}
|
||||
BIN
static/cards/2026.webp
Normal file
BIN
static/cards/2026.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
static/cards/esncard_anniversary.png
Normal file
BIN
static/cards/esncard_anniversary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 426 KiB |
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
@@ -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 |
BIN
static/fonts/Roboto-Regular.ttf
Normal file
BIN
static/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
@@ -3,10 +3,23 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
kit: { adapter: adapter() }
|
||||
kit: { adapter: adapter() },
|
||||
csp: {
|
||||
mode: 'hash',
|
||||
directives: {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'"],
|
||||
'style-src': ["'self'"],
|
||||
'img-src': ["'self'", 'data:'],
|
||||
'connect-src': ["'self'", 'https://www.googleapis.com'],
|
||||
'font-src': ["'self'"],
|
||||
'object-src': ["'none'"],
|
||||
'frame-ancestors': ["'none'"],
|
||||
'base-uri': ["'self'"],
|
||||
'form-action': ["'self'"]
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user