Compare commits

..

18 Commits

Author SHA1 Message Date
Roman Krček
b90265110f Update splash
All checks were successful
Build Docker image / build (push) Successful in 1m43s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 30s
2025-11-18 13:37:32 +01:00
Roman Krček
97460c018c Fix date issues
All checks were successful
Build Docker image / build (push) Successful in 3m2s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 43s
2025-11-18 13:32:55 +01:00
Roman Krček
74910e3346 Resize photos and make thicker borders on text
All checks were successful
Build Docker image / build (push) Successful in 3m53s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 29s
2025-10-28 18:19:38 +01:00
Roman Krček
20b21de69e Fix deprecated methods 2025-09-17 21:47:37 +02:00
Roman Krček
68e4d0b77b Add loader for google services
All checks were successful
Build Docker image / build (push) Successful in 8m8s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 30s
2025-09-08 16:04:50 +02:00
Roman Krček
e43101648b Recent sheets per user 2025-09-08 15:45:36 +02:00
Roman Krček
c6c3bbc024 Fix manual resizing 2025-09-08 15:19:05 +02:00
Roman Krček
d845021f7e Allow to search all drives 2025-09-07 22:37:10 +02:00
Roman Krček
dcba02260a Add button to sheet and shift selector 2025-09-07 22:15:03 +02:00
Roman Krček
9de5646519 Change to just full name 2025-09-07 22:03:21 +02:00
Roman Krček
2b3371e67f Remove 2026 made-up card to not confuse people
All checks were successful
Build Docker image / build (push) Successful in 4m37s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 53s
2025-08-12 08:51:30 +02:00
Roman Krček
a9dc5888e6 Added card types
All checks were successful
Build Docker image / build (push) Successful in 2m5s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 46s
2025-08-11 18:30:07 +02:00
Roman Krček
1a2329b6c1 Fine-tuning the layout
All checks were successful
Build Docker image / build (push) Successful in 1m43s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 1m32s
2025-08-11 17:28:32 +02:00
Roman Krček
82395afa6e Better caching
All checks were successful
Build Docker image / build (push) Successful in 2m20s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 36s
2025-08-11 16:47:08 +02:00
Roman Krček
be7bdc551a Performace optimization
All checks were successful
Build Docker image / build (push) Successful in 1m40s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 35s
2025-08-11 16:42:55 +02:00
Roman Krček
44de5d9ad6 Proper sizing in the layout
All checks were successful
Build Docker image / build (push) Successful in 3m19s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 49s
2025-08-11 16:13:53 +02:00
Roman Krček
f5c2063586 Added row limiting
All checks were successful
Build Docker image / build (push) Successful in 3m22s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 48s
2025-08-08 09:25:25 +02:00
Roman Krček
667c18a746 Fixed mistaken git ref
All checks were successful
Build Docker image / build (push) Successful in 1m35s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 34s
2025-08-07 16:48:12 +02:00
33 changed files with 1234 additions and 721 deletions

View File

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

View File

@@ -7,7 +7,7 @@
- Pass fucntions as props instead od dispatching events - Pass fucntions as props instead od dispatching events
- Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax - Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax
- when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state. - when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state.
- USe $props instead of export let! - USe $props instead of "export let"!
- Use styling from ".github/styling.md" for any UI components. - Use styling from ".github/styling.md" for any UI components.
- Refer to the ".github/core-instructions.md" for the overall structure of the application. - Refer to the ".github/core-instructions.md" for the overall structure of the application.
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task. - Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
@@ -16,3 +16,4 @@
- Split big components into subcomponents. Always create smaller subcomponents for better context management later. - Split big components into subcomponents. Always create smaller subcomponents for better context management later.
- Do not do what you're not being asked. Stick to scope of my request. - Do not do what you're not being asked. Stick to scope of my request.
- Do not edit stores.ts ! Unless is explicitly allow you to. - Do not edit stores.ts ! Unless is explicitly allow you to.
- Focus only on files that are relevant. Do not venture to fix other things.

4
.github/styling.md vendored
View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "card-forge", "name": "card-forge",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View File

@@ -0,0 +1,31 @@
import type { Card } from './types';
// User-configurable settings for PDF generation
export const ESNCard2026: Card = {
name: 'ESNcard 2026',
image: '/cards/2026.webp',
textCard: {
width: 50, // mm
height: 35 // mm
},
photoCard: {
width: 32, // mm
height: 45 // mm
},
photo: {
width: 28, // mm
height: 38 // mm
},
textFields: {
name: { x: 3, y: 5, size: 9 },
nationality: { x: 3, y: 14, size: 9 },
birthday: { x: 33, y: 14, size: 9 },
studiesAt: { x: 3, y: 23, size: 9 },
esnSection: { x: 3, y: 32, size: 9 },
validityStart: { x: 33, y: 32, size: 9 }
},
photoFields: {
photo: { x: 2, y: 2, width: 28, height: 38 },
name: { x: 2, y: 42, size: 7 }
}
};

View File

@@ -0,0 +1,31 @@
import type { Card } from './types';
// User-configurable settings for PDF generation
export const ESNCardAnniversary: Card = {
name: 'ESNcard Anniversary',
image: '/cards/esncard_anniversary.png',
textCard: {
width: 45, // mm
height: 30 // mm
},
photoCard: {
width: 29, // mm
height: 41 // mm
},
photo: {
width: 27, // mm
height: 37 // mm
},
textFields: {
name: { x: 2, y: 4, size: 8 },
nationality: { x: 2, y: 12, size: 8 },
birthday: { x: 30, y: 12, size: 8 },
studiesAt: { x: 2, y: 20, size: 8 },
esnSection: { x: 2, y: 28, size: 8 },
validityStart: { x: 30, y: 28, size: 8 }
},
photoFields: {
photo: { x: 2, y: 2, width: 26, height: 36 },
name: { x: 2, y: 40, size: 6 }
}
};

5
src/lib/cards/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import { ESNCardAnniversary } from './esncard_anniversary';
// import { ESNCard2026 } from './esncard_2026';
import type { Card } from './types';
export const cardTypes: Card[] = [ESNCardAnniversary];

View File

@@ -0,0 +1,46 @@
export interface CardDimensions {
width: number; // mm
height: number; // mm
}
export interface PhotoDimensions {
width: number; // mm
height: number; // mm
}
export interface TextPosition {
x: number; // mm, relative to cell top-left
y: number; // mm, relative to cell top-left
size: number; // font size in points
}
export interface PhotoPosition {
x: number; // mm, relative to cell top-left
y: number; // mm, relative to cell top-left
width: number; // mm
height: number; // mm
}
export interface TextFieldLayout {
name: TextPosition;
nationality: TextPosition;
birthday: TextPosition;
studiesAt: TextPosition;
esnSection: TextPosition;
validityStart: TextPosition;
}
export interface PhotoFieldLayout {
photo: PhotoPosition;
name: TextPosition;
}
export interface Card {
name: string;
image: string;
textCard: CardDimensions;
photoCard: CardDimensions;
photo: PhotoDimensions;
textFields: TextFieldLayout;
photoFields: PhotoFieldLayout;
}

View File

@@ -3,8 +3,8 @@
import FeatureList from './splash/FeatureList.svelte'; import FeatureList from './splash/FeatureList.svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
const buildDate = env.PUBLIC_BUILD_DATE || '2025-01-01'; const buildDate = env.PUBLIC_BUILD_DATE;
const vcsRef = env.PUBLIC_VCS_REF ? env.PUBLIC_VCS_REF.substring(0, 7) : 'abcdef'; const gitRef = env.PUBLIC_GIT_REF ? env.PUBLIC_GIT_REF.substring(0, 7) : '';
function startWizard() { function startWizard() {
@@ -14,9 +14,9 @@
<div class="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4"> <div class="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
<div <div
class="container mx-auto max-w-4xl rounded-lg border border-gray-200 bg-white/90 p-10 text-center shadow-xl" class="container mx-auto max-w-5xl rounded-lg border border-gray-200 bg-white/90 p-10 text-center shadow-xl"
> >
<div class="mb-10 flex flex-col items-center"> <div class="mb-4 flex flex-col items-center">
<!-- Animated ESN Logo --> <!-- Animated ESN Logo -->
<div <div
class="mx-auto mb-6 flex h-40 w-40 items-center justify-center rounded-full bg-gradient-to-tr from-blue-400 via-purple-400 to-pink-400" class="mx-auto mb-6 flex h-40 w-40 items-center justify-center rounded-full bg-gradient-to-tr from-blue-400 via-purple-400 to-pink-400"
@@ -35,37 +35,37 @@
<span class="font-semibold text-black-800">Privacy-first</span>: all processing happens in <span class="font-semibold text-black-800">Privacy-first</span>: all processing happens in
your browser. your browser.
</p> </p>
<div class="mb-6"> <FeatureList class="mb-6" />
</div>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a <a
href="https://youtube.com" href="https://youtube.com"
target="_blank" target="_blank"
class="inline-flex items-center gap-2 rounded-lg bg-pink-100 px-4 py-2 font-semibold text-pink-700 transition-colors hover:bg-pink-200" class="flex w-64 items-center justify-center gap-2 rounded-lg bg-pink-400 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-pink-400"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5" class="h-6 w-6"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
><path ><path
d="M23.498 6.186a2.998 2.998 0 0 0-2.115-2.117C19.073 3.5 12 3.5 12 3.5s-7.073 0-9.383.569A2.998 2.998 0 0 0 .502 6.186C0 8.497 0 12 0 12s0 3.503.502 5.814a2.998 2.998 0 0 0 2.115 2.117C4.927 20.5 12 20.5 12 20.5s7.073 0 9.383-.569a2.998 2.998 0 0 0 2.115-2.117C24 15.503 24 12 24 12s0-3.503-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" d="M23.498 6.186a2.998 2.998 0 0 0-2.115-2.117C19.073 3.5 12 3.5 12 3.5s-7.073 0-9.383.569A2.998 2.998 0 0 0 .502 6.186C0 8.497 0 12 0 12s0 3.503.502 5.814a2.998 2.998 0 0 0 2.115 2.117C4.927 20.5 12 20.5 12 20.5s7.073 0 9.383-.569a2.998 2.998 0 0 0 2.115-2.117C24 15.503 24 12 24 12s0-3.503-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
/></svg /></svg
> >
Watch how Card Forge works Watch Tutorial
</a> </a>
</div>
<FeatureList class="mb-8" />
</div>
<button <button
onclick={startWizard} onclick={startWizard}
class="rounded-lg bg-blue-600 bg-gradient-to-r px-10 py-4 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105" class="w-64 rounded-lg bg-blue-600 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-blue-700"
> >
Start Creating Cards Start Creating Cards
</button> </button>
</div> </div>
</div>
<footer class="mt-4 text-center"> <footer class="mt-4 text-center">
{#if buildDate && vcsRef} {#if buildDate && gitRef}
<p class="text-xs text-gray-400"> <p class="text-xs text-gray-400">
Build: {vcsRef} {buildDate} Build: {gitRef} {buildDate}
</p> </p>
{/if} {/if}
</footer> </footer>

View File

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

View File

@@ -1,8 +1,23 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { currentStep } from '$lib/stores.js'; import { currentStep } from '$lib/stores.js';
import { isSignedIn, handleSignOut, requestTokenFromUser } from '$lib/google'; import {
isSignedIn,
handleSignOut,
requestTokenFromUser,
isGoogleApiReady,
initGoogleClients
} from '$lib/google';
import Navigator from './subcomponents/Navigator.svelte'; import Navigator from './subcomponents/Navigator.svelte';
onMount(() => {
if (!$isGoogleApiReady) {
initGoogleClients(() => {
// This callback is called when the Google clients are ready.
});
}
});
function handleSignIn() { function handleSignIn() {
requestTokenFromUser(); requestTokenFromUser();
} }
@@ -46,7 +61,15 @@
<div <div
class="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-8" class="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-8"
> >
{#if $isSignedIn} {#if !$isGoogleApiReady}
<!-- Loading state -->
<div class="flex items-center justify-center gap-2">
<div
class="h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-gray-600">Loading Google services...</span>
</div>
{:else if $isSignedIn}
<!-- Authenticated state --> <!-- Authenticated state -->
<div class="text-center"> <div class="text-center">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">

View File

@@ -3,31 +3,38 @@
import Navigator from './subcomponents/Navigator.svelte'; import Navigator from './subcomponents/Navigator.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let homeSection = $state(''); let esnSection = $state('');
let studiesAt = $state('');
let validityStart = $state(''); let validityStart = $state('');
onMount(() => { onMount(() => {
validityStart = new Date().toISOString().split('T')[0]; validityStart = new Date().toISOString().split('T')[0];
try { try {
const savedHomeSection = localStorage.getItem('homeSection'); const savedesnSection = localStorage.getItem('esnSection');
if (savedHomeSection) { if (savedesnSection) {
homeSection = savedHomeSection; esnSection = savedesnSection;
}
const savedStudiesAt = localStorage.getItem('studiesAt');
if (savedStudiesAt) {
studiesAt = savedStudiesAt;
} }
} catch (error) { } catch (error) {
console.error('Failed to access localStorage on mount:', error); console.error('Failed to access localStorage on mount:', error);
} }
}); });
let canProceed = $derived(homeSection.trim() !== '' && validityStart.trim() !== ''); let canProceed = $derived(esnSection.trim() !== '' && studiesAt.trim() !== '' && validityStart.trim() !== '');
function handleContinue() { function handleContinue() {
try { try {
localStorage.setItem('homeSection', homeSection); localStorage.setItem('esnSection', esnSection);
localStorage.setItem('studiesAt', studiesAt);
} catch (error) { } catch (error) {
console.error('Failed to save to localStorage:', error); console.error('Failed to save to localStorage:', error);
} }
$cardDetails = { homeSection, validityStart }; // Include new field; spread in case store has more fields defined elsewhere
$cardDetails = { ...$cardDetails, esnSection, studiesAt, validityStart } as any;
} }
</script> </script>
@@ -41,18 +48,31 @@
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<label for="homeSection" class="mb-2 block text-sm font-medium text-gray-700"> <label for="esnSection" class="mb-2 block text-sm font-medium text-gray-700">
Home Section ESN Section
</label> </label>
<input <input
id="homeSection" id="esnSection"
type="text" type="text"
bind:value={homeSection} bind:value={esnSection}
placeholder="e.g., ESN VUT Brno" 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" 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>
<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> <div>
<label for="validityStart" class="mb-2 block text-sm font-medium text-gray-700"> <label for="validityStart" class="mb-2 block text-sm font-medium text-gray-700">
Card Validity Start Date Card Validity Start Date
@@ -75,7 +95,7 @@
{currentStep} {currentStep}
onForward={handleContinue} onForward={handleContinue}
textBack="Back to Row Selection" textBack="Back to Row Selection"
textForwardEnabled="Continue to Photo Review" textForwardEnabled="Continue to Card Selection"
textForwardDisabled="Please fill out all fields" textForwardDisabled="Please fill out all fields"
/> />
</div> </div>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { onMount } from 'svelte';
import { currentStep, selectedCard } from '$lib/stores';
import { cardTypes } from '$lib/cards';
import type { Card } from '$lib/cards/types';
import Navigator from './subcomponents/Navigator.svelte';
let selected: Card | null = $state(null);
onMount(() => {
const savedCardName = localStorage.getItem('selectedCardName');
if (savedCardName) {
const foundCard = cardTypes.find((c) => c.name === savedCardName);
if (foundCard) {
selected = foundCard;
selectedCard.set(foundCard);
}
}
});
function selectCard(card: Card) {
selected = card;
selectedCard.set(card);
localStorage.setItem('selectedCardName', card.name);
}
function onNext() {
if (selected) {
currentStep.set($currentStep + 1);
}
}
function onBack() {
currentStep.set($currentStep - 1);
}
</script>
<div class="p-6">
<div class="max-w-5xl mx-auto">
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">Select Card Type</h2>
<p class="text-sm text-gray-700 mb-4">
Choose the type of card you want to generate. This will determine the layout and dimensions of the final PDFs.
</p>
</div>
<!-- Card Type Selector -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
{#each cardTypes as card (card.name)}
<button
class="relative rounded-lg border-2 p-2 transition-all duration-200"
class:border-blue-600={selected?.name === card.name}
class:border-gray-200={selected?.name !== card.name}
class:shadow-lg={selected?.name === card.name}
onclick={() => selectCard(card)}
>
<img src={card.image} alt={card.name} class="w-full h-auto rounded-md mb-2" />
<p class="text-sm font-medium text-center text-gray-800">{card.name}</p>
{#if selected?.name === card.name}
<div
class="absolute top-2 right-2 bg-blue-600 text-white rounded-full w-6 h-6 flex items-center justify-center"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
{/if}
</button>
{/each}
</div>
<Navigator onForward={onNext} onBack={onBack} nextDisabled={!selected} />
</div>
</div>

View File

@@ -1,5 +1,11 @@
<script lang="ts"> <script lang="ts">
import { selectedSheet, currentStep, columnMapping } from '$lib/stores'; import {
selectedSheet,
currentStep,
columnMapping,
} from '$lib/stores';
import { userEmail } from '$lib/google';
import { hashString } from '$lib/utils';
import type { ColumnMappingType, SheetInfoType } from '$lib/stores'; import type { ColumnMappingType, SheetInfoType } from '$lib/stores';
import { getSheetNames, getSheetData, ensureToken } from '$lib/google'; import { getSheetNames, getSheetData, ensureToken } from '$lib/google';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -18,7 +24,6 @@
let savedSheetInfo = $state<SheetInfoType | null>(null); let savedSheetInfo = $state<SheetInfoType | null>(null);
let mappedIndices = $state<ColumnMappingType>({ let mappedIndices = $state<ColumnMappingType>({
name: -1, name: -1,
surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1, pictureUrl: -1,
@@ -27,15 +32,20 @@
}); });
const requiredFields = [ const requiredFields = [
{ key: 'name', label: 'First Name', required: true }, { key: 'name', label: 'Full Name', required: true },
{ key: 'surname', label: 'Last Name', required: true },
{ key: 'nationality', label: 'Nationality', required: true }, { key: 'nationality', label: 'Nationality', required: true },
{ key: 'birthday', label: 'Birthday', required: true }, { key: 'birthday', label: 'Birthday', required: true },
{ key: 'pictureUrl', label: 'Photo URL', required: true }, { key: 'pictureUrl', label: 'Photo URL', required: true },
{ key: 'alreadyPrinted', label: 'Already Printed', required: false } { key: 'alreadyPrinted', label: 'Already Printed', required: false }
]; ];
const RECENT_SHEETS_KEY = 'recentSheets'; async function getRecentSheetsKey() {
const email = $userEmail;
if (email) {
return `recentSheets_${await hashString(email)}`;
}
return 'recentSheets_anonymous';
}
// Load available sheets when component mounts // Load available sheets when component mounts
onMount(async () => { onMount(async () => {
@@ -44,7 +54,8 @@
console.log('Selected sheet on mount:', $selectedSheet); console.log('Selected sheet on mount:', $selectedSheet);
// Check if we already have saved mapping data // Check if we already have saved mapping data
const recentSheetsData = localStorage.getItem(RECENT_SHEETS_KEY); const key = await getRecentSheetsKey();
const recentSheetsData = localStorage.getItem(key);
if (recentSheetsData) { if (recentSheetsData) {
try { try {
@@ -65,7 +76,6 @@
// Set the mapped indices from saved data // Set the mapped indices from saved data
mappedIndices = { mappedIndices = {
name: savedSheet.columnMapping.name, name: savedSheet.columnMapping.name,
surname: savedSheet.columnMapping.surname,
nationality: savedSheet.columnMapping.nationality, nationality: savedSheet.columnMapping.nationality,
birthday: savedSheet.columnMapping.birthday, birthday: savedSheet.columnMapping.birthday,
pictureUrl: savedSheet.columnMapping.pictureUrl, pictureUrl: savedSheet.columnMapping.pictureUrl,
@@ -123,7 +133,6 @@
previewData = []; previewData = [];
mappedIndices = { mappedIndices = {
name: -1, name: -1,
surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1, pictureUrl: -1,
@@ -165,7 +174,7 @@
autoMapColumns(); autoMapColumns();
// Check if we have saved column mapping for this sheet // Check if we have saved column mapping for this sheet
loadSavedColumnMapping(); await loadSavedColumnMapping();
} else { } else {
error = 'The selected sheet appears to be empty.'; error = 'The selected sheet appears to be empty.';
console.warn('Sheet is empty'); console.warn('Sheet is empty');
@@ -182,7 +191,6 @@
// Reset mappings // Reset mappings
mappedIndices = { mappedIndices = {
name: -1, name: -1,
surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1, pictureUrl: -1,
@@ -192,8 +200,7 @@
// Auto-mapping patterns // Auto-mapping patterns
const patterns: Record<keyof Omit<ColumnMappingType, 'sheetName'>, RegExp> = { const patterns: Record<keyof Omit<ColumnMappingType, 'sheetName'>, RegExp> = {
name: /first[\s_-]*name|name|given[\s_-]*name|vorname/i, name: /full[\s_-]*name|name/i,
surname: /last[\s_-]*name|surname|family[\s_-]*name|nachname/i,
nationality: /nationality|country|nation/i, nationality: /nationality|country|nation/i,
birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i, birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i,
pictureUrl: /photo|picture|image|url|avatar/i, pictureUrl: /photo|picture|image|url|avatar/i,
@@ -241,14 +248,15 @@
updateMappingStatus(); updateMappingStatus();
} }
function loadSavedColumnMapping() { async function loadSavedColumnMapping() {
if (!$selectedSheet || !selectedSheetName) { if (!$selectedSheet || !selectedSheetName) {
console.log('Cannot load saved column mapping: missing selectedSheet or selectedSheetName'); console.log('Cannot load saved column mapping: missing selectedSheet or selectedSheetName');
return; return;
} }
try { try {
const existingData = localStorage.getItem(RECENT_SHEETS_KEY); const key = await getRecentSheetsKey();
const existingData = localStorage.getItem(key);
if (existingData) { if (existingData) {
const recentSheets = JSON.parse(existingData); const recentSheets = JSON.parse(existingData);
@@ -262,7 +270,6 @@
// Override auto-mapping with saved mapping // Override auto-mapping with saved mapping
mappedIndices = { mappedIndices = {
name: savedSheet.columnMapping.name ?? -1, name: savedSheet.columnMapping.name ?? -1,
surname: savedSheet.columnMapping.surname ?? -1,
nationality: savedSheet.columnMapping.nationality ?? -1, nationality: savedSheet.columnMapping.nationality ?? -1,
birthday: savedSheet.columnMapping.birthday ?? -1, birthday: savedSheet.columnMapping.birthday ?? -1,
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1, pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
@@ -286,7 +293,6 @@
if (!mappedIndices) { if (!mappedIndices) {
mappedIndices = { mappedIndices = {
name: -1, name: -1,
surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1, pictureUrl: -1,
@@ -306,7 +312,6 @@
// Only check required fields for completion // Only check required fields for completion
const requiredIndices = { const requiredIndices = {
name: mappedIndices.name, name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality, nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday, birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl, pictureUrl: mappedIndices.pictureUrl,
@@ -319,7 +324,6 @@
// Update the column mapping store // Update the column mapping store
columnMapping.set({ columnMapping.set({
name: mappedIndices.name, name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality, nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday, birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl, pictureUrl: mappedIndices.pictureUrl,
@@ -328,12 +332,13 @@
}); });
} }
function handleContinue() { async function handleContinue() {
if (!mappingComplete || !$selectedSheet || !selectedSheetName) return; if (!mappingComplete || !$selectedSheet || !selectedSheetName) return;
// Save column mapping to localStorage for the selected sheet // Save column mapping to localStorage for the selected sheet
try { try {
const existingData = localStorage.getItem(RECENT_SHEETS_KEY); const key = await getRecentSheetsKey();
const existingData = localStorage.getItem(key);
let recentSheets = existingData ? JSON.parse(existingData) : []; let recentSheets = existingData ? JSON.parse(existingData) : [];
// Find the current sheet in recent sheets and update its column mapping // Find the current sheet in recent sheets and update its column mapping
@@ -343,7 +348,6 @@
const columnMappingData = { const columnMappingData = {
name: mappedIndices.name, name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality, nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday, birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl, pictureUrl: mappedIndices.pictureUrl,
@@ -355,9 +359,6 @@
// Update existing entry // Update existing entry
recentSheets[sheetIndex].columnMapping = columnMappingData; recentSheets[sheetIndex].columnMapping = columnMappingData;
recentSheets[sheetIndex].lastUsed = new Date().toISOString(); recentSheets[sheetIndex].lastUsed = new Date().toISOString();
// Ensure we have consistent property names
recentSheets[sheetIndex].id = recentSheets[sheetIndex].id || recentSheets[sheetIndex].id;
} else { } else {
// Add new entry // Add new entry
const newEntry = { const newEntry = {
@@ -375,7 +376,7 @@
} }
} }
localStorage.setItem(RECENT_SHEETS_KEY, JSON.stringify(recentSheets)); localStorage.setItem(key, JSON.stringify(recentSheets));
} catch (err) { } catch (err) {
console.error('Failed to save column mapping to localStorage:', err); console.error('Failed to save column mapping to localStorage:', err);
} }
@@ -434,7 +435,7 @@
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zm-4 4a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { columnMapping, sheetData, currentStep, pictures, cropRects } from '$lib/stores'; import { columnMapping, sheetData, currentStep, pictures, cropRects, selectedCard } from '$lib/stores';
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl, ensureToken } from '$lib/google'; import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl, ensureToken } from '$lib/google';
import Navigator from './subcomponents/Navigator.svelte'; import Navigator from './subcomponents/Navigator.svelte';
import PhotoCard from './subcomponents/PhotoCard.svelte'; import PhotoCard from './subcomponents/PhotoCard.svelte';
@@ -39,9 +39,43 @@
console.log('BlazeFace model loaded'); console.log('BlazeFace model loaded');
})(); })();
} }
return detectorPromise; return detectorPromise;
} }
// Create a downscaled JPEG preview to reduce memory usage for UI rendering
async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85): Promise<Blob> {
try {
const bitmap = await createImageBitmap(original);
let { width, height } = bitmap;
const maxDim = Math.max(width, height);
// If image is already at or below the threshold, keep it as-is
if (maxDim <= maxSide) {
bitmap.close();
return original;
}
const scale = Math.min(1, maxSide / maxDim);
const targetW = Math.max(1, Math.round(width * scale));
const targetH = Math.max(1, Math.round(height * scale));
const canvas = document.createElement('canvas');
canvas.width = targetW;
canvas.height = targetH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context unavailable');
ctx.drawImage(bitmap, 0, 0, targetW, targetH);
bitmap.close();
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', quality)
);
return blob;
} catch (e) {
// Fallback to original if downscale fails
return original;
}
}
// Force memory cleanup // Force memory cleanup
async function forceMemoryCleanup() { async function forceMemoryCleanup() {
await tf.nextFrame(); // Wait for any pending GPU operations await tf.nextFrame(); // Wait for any pending GPU operations
@@ -67,7 +101,7 @@
} }
// Initialize queues with more conservative concurrency // Initialize queues with more conservative concurrency
downloadQueue = new PQueue({ concurrency: 3 }); // Reduced from 5 downloadQueue = new PQueue({ concurrency: 4 }); // Reduced from 5
faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety
// When both queues are idle, we're done // When both queues are idle, we're done
@@ -106,7 +140,7 @@
console.log(`Found ${totalCount} unique photo URLs to process.`); console.log(`Found ${totalCount} unique photo URLs to process.`);
photos = Array.from(photoUrls).map((url) => ({ photos = Array.from(photoUrls).map((url) => ({
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, name: photoMap.get(url)![0].name,
url, url,
status: 'loading' as const, status: 'loading' as const,
retryCount: 0, retryCount: 0,
@@ -185,7 +219,10 @@
quality: 0.9 quality: 0.9
}); });
const convertedBlob = new Blob([outputBuffer], { type: 'image/jpeg' }); const buffer = outputBuffer instanceof Uint8Array
? outputBuffer.buffer.slice(outputBuffer.byteOffset, outputBuffer.byteOffset + outputBuffer.byteLength)
: outputBuffer;
const convertedBlob = new Blob([buffer as ArrayBuffer], { type: 'image/jpeg' });
// Now that it's converted, process it like any other image // Now that it's converted, process it like any other image
await processLoadedBlob(index, convertedBlob); await processLoadedBlob(index, convertedBlob);
@@ -200,26 +237,30 @@
async function processLoadedBlob(index: number, blob: Blob) { async function processLoadedBlob(index: number, blob: Blob) {
const photo = photos[index]; const photo = photos[index];
try { try {
const objectUrl = createImageObjectUrl(blob); // Downsize once and use this for storage, preview, and detection
const resizedBlob = await createPreviewBlob(blob, 1600, 0.85);
await set(photo.url, resizedBlob);
const objectUrl = createImageObjectUrl(resizedBlob);
// Test if image loads properly // Test if downsized image loads properly
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(); img.onload = () => resolve();
img.onerror = (error) => { img.onerror = (error) => {
console.error(`Failed to load image for ${photo.name}:`, error); console.error(`Failed to load downsized image for ${photo.name}:`, error);
reject(new Error('Failed to load image')); reject(new Error('Failed to load image'));
}; };
img.src = objectUrl; img.src = objectUrl;
}); });
// Revoke any previous preview URL to avoid leaks
if (photo.objectUrl && photo.objectUrl.startsWith('blob:') && photo.objectUrl !== objectUrl) {
URL.revokeObjectURL(photo.objectUrl);
}
photo.objectUrl = objectUrl; photo.objectUrl = objectUrl;
photo.status = 'success'; photo.status = 'success';
// Save blob to IndexedDB instead of the store
await set(photo.url, blob);
// Save to pictures store, but without the blob to save memory
pictures.update((pics) => ({ pictures.update((pics) => ({
...pics, ...pics,
[photo.url]: { [photo.url]: {
@@ -231,7 +272,7 @@
} }
})); }));
// Add face detection to its queue // Add face detection to its queue using the downsized image shown in UI
faceDetectionQueue.add(() => detectFaceForPhoto(index)); faceDetectionQueue.add(() => detectFaceForPhoto(index));
} catch (error) { } catch (error) {
console.error(`Failed to process blob for ${photo.name}:`, error); console.error(`Failed to process blob for ${photo.name}:`, error);
@@ -255,37 +296,52 @@
photo.faceDetectionStatus = 'processing'; photo.faceDetectionStatus = 'processing';
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
// Use the downsized UI image to keep coordinates aligned
img.src = photo.objectUrl!; img.src = photo.objectUrl!;
await new Promise((r, e) => { await new Promise((r, e) => {
img.onload = r; img.onload = r;
img.onerror = e; img.onerror = e;
}); });
// Create tensor and manually dispose it after use // Create tensor; run estimation (avoid tf.tidy here to not dispose returned tensors prematurely)
imageTensor = tf.browser.fromPixels(img); imageTensor = tf.browser.fromPixels(img);
const predictions = await detector.estimateFaces(imageTensor, false); const predictions: any[] = await detector.estimateFaces(imageTensor, false);
if (predictions.length > 0) { if (predictions.length > 0) {
const getProbability = (p: number | tf.Tensor) => const tensorToNumArray = (v: any): number[] => {
typeof p === 'number' ? p : p.dataSync()[0]; if (Array.isArray(v)) return v as number[];
if (typeof v === 'number') return [v];
if (v && typeof v.dataSync === 'function') {
const arr = Array.from(v.dataSync() as Float32Array);
if (typeof v.dispose === 'function') v.dispose();
return arr as number[];
}
return [];
};
const getProbability = (p: any) => tensorToNumArray(p)[0] ?? 0;
const face = predictions.sort( const face = predictions.sort(
(a, b) => getProbability(b.probability!) - getProbability(a.probability!) (a, b) => getProbability(b.probability!) - getProbability(a.probability!)
)[0]; )[0];
const topLeft = face.topLeft as [number, number]; const topLeftArr = tensorToNumArray(face.topLeft);
const bottomRight = face.bottomRight as [number, number]; const bottomRightArr = tensorToNumArray(face.bottomRight);
const topLeft = [topLeftArr[0], topLeftArr[1]] as [number, number];
const bottomRight = [bottomRightArr[0], bottomRightArr[1]] as [number, number];
let [x1, y1] = topLeft; let [x1, y1] = topLeft;
let [x2, y2] = bottomRight; let [x2, y2] = bottomRight;
const scaleX = img.naturalWidth / img.width; // Use natural sizes; detection ran on original if provided
const scaleY = img.naturalHeight / img.height; const scaleX = 1;
const scaleY = 1;
const faceWidth = (x2 - x1) * scaleX; const faceWidth = (x2 - x1) * scaleX;
const faceHeight = (y2 - y1) * scaleY; const faceHeight = (y2 - y1) * scaleY;
const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX; const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX;
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY; const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0'); // Use the photo card aspect ratio from the selected card
const photoDimensions = $selectedCard!.photo;
const cropRatio = photoDimensions.width / photoDimensions.height;
const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0'); const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0');
const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0'); const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0');
const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5'); const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5');
@@ -411,6 +467,11 @@
faceDetectionQueue.clear(); faceDetectionQueue.clear();
} }
cleanupObjectUrls(); cleanupObjectUrls();
// Dispose the detector model if possible to release GPU/CPU memory
if (detector && typeof (detector as any).dispose === 'function') {
(detector as any).dispose();
}
detector = undefined;
}; };
}); });
</script> </script>
@@ -520,6 +581,7 @@
{#each photos as photo, index} {#each photos as photo, index}
<PhotoCard <PhotoCard
{photo} {photo}
photoDimensions={$selectedCard!.photo}
onCropUpdated={(e) => handleCropUpdate(index, e)} onCropUpdated={(e) => handleCropUpdate(index, e)}
onRetry={() => retryPhoto(index)} onRetry={() => retryPhoto(index)}
/> />
@@ -532,8 +594,8 @@
<Navigator <Navigator
canProceed={canProceed()} canProceed={canProceed()}
{currentStep} {currentStep}
textBack="Back to Card Details" textBack="Back to Card Selection"
textForwardDisabled="Waiting from photos" textForwardDisabled="Waiting for photos"
textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`} textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
/> />
</div> </div>

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { sheetData, currentStep, pictures, cropRects } from '$lib/stores'; import { sheetData, currentStep, pictures, cropRects, cardDetails, selectedCard } from '$lib/stores';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; import type { Card } from '$lib/cards/types';
import { PDFDocument, StandardFonts, rgb, type PDFPage } from 'pdf-lib';
import * as fontkit from 'fontkit'; import * as fontkit from 'fontkit';
import { clear } from 'idb-keyval'; import { clear } from 'idb-keyval';
import { import {
@@ -13,13 +14,9 @@
getImageBlob, getImageBlob,
MM_TO_PT MM_TO_PT
} from '$lib/pdfLayout'; } from '$lib/pdfLayout';
import { import { PAGE_SETTINGS } from '$lib/pdfSettings';
PAGE_SETTINGS, import type { PageSettings } from '$lib/pdfSettings';
TEXT_CARD_DIMENSIONS, import Navigator from './subcomponents/Navigator.svelte';
PHOTO_CARD_DIMENSIONS,
TEXT_FIELD_LAYOUT,
PHOTO_FIELD_LAYOUT
} from '$lib/pdfSettings';
type FileGenerationState = 'idle' | 'generating' | 'done' | 'error'; type FileGenerationState = 'idle' | 'generating' | 'done' | 'error';
@@ -70,13 +67,10 @@
} }
onMount(() => { onMount(() => {
handleGenerateAll();
// Add event listener for page unload // Add event listener for page unload
window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('beforeunload', handleBeforeUnload);
// Start generation automatically when the component mounts
handleGenerate('esncards_text.pdf');
handleGenerate('esncards_photos.pdf');
// Cleanup function when component unmounts // Cleanup function when component unmounts
return () => { return () => {
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
@@ -84,6 +78,22 @@
}; };
}); });
async function handleGenerateAll() {
if (!$selectedCard) return;
// Reset file states and revoke old URLs
files.forEach((f) => {
if (f.url) URL.revokeObjectURL(f.url);
});
files = JSON.parse(JSON.stringify(initialFiles));
// Generate both
await handleGenerate('esncards_text.pdf');
await handleGenerate('esncards_photos.pdf');
}
const generationStarted = $derived(files.some((f) => f.state !== 'idle'));
// Load Roboto font // Load Roboto font
async function loadRobotoFont() { async function loadRobotoFont() {
try { try {
@@ -109,6 +119,107 @@
return `${year}-${month}-${day}-${hours}-${minutes}`; return `${year}-${month}-${day}-${hours}-${minutes}`;
} }
// Draw a very wide 'H' (10 cm length) at the top and left margins as registration marks
function drawHMarks(page: PDFPage, font: any, pageSettings: PageSettings) {
const color = rgb(0, 0, 0); // pure black
const lengthPt = 100 * MM_TO_PT; // 10 cm
const tickLenPt = 2 * MM_TO_PT; // 2 mm tick
const strokePt = 0.7; // visual thickness
// Top margin center
const centerTopX = (pageSettings.pageWidth / 2) * MM_TO_PT;
const centerTopY = (pageSettings.pageHeight - pageSettings.margin / 2) * MM_TO_PT;
const leftTopX = centerTopX - lengthPt / 2;
const rightTopX = centerTopX + lengthPt / 2;
// Horizontal bar (top)
page.drawRectangle({
x: leftTopX,
y: centerTopY - strokePt / 2,
width: lengthPt,
height: strokePt,
color
});
// Left vertical tick (top)
page.drawRectangle({
x: leftTopX - strokePt / 2,
y: centerTopY - tickLenPt / 2,
width: strokePt,
height: tickLenPt,
color
});
// Right vertical tick (top)
page.drawRectangle({
x: rightTopX - strokePt / 2,
y: centerTopY - tickLenPt / 2,
width: strokePt,
height: tickLenPt,
color
});
// Label under the top bar, centered
const label = 'Print gauge - if not 10 cm long, the page is not printed correctly!';
const labelSize = 7;
const labelWidth = font.widthOfTextAtSize(label, labelSize);
const labelX = centerTopX - labelWidth / 2; // center horizontally
const labelY = centerTopY - 3 * MM_TO_PT; // ~3mm below the bar
page.drawText(label, {
x: labelX,
y: labelY,
size: labelSize,
font,
color
});
// Left margin center (vertical bar)
const centerLeftX = (pageSettings.margin / 2) * MM_TO_PT;
const centerLeftY = (pageSettings.pageHeight / 2) * MM_TO_PT;
// Vertical bar (left)
page.drawRectangle({
x: centerLeftX - strokePt / 2,
y: centerLeftY - lengthPt / 2,
width: strokePt,
height: lengthPt,
color
});
// Top horizontal tick (left)
page.drawRectangle({
x: centerLeftX - tickLenPt / 2,
y: centerLeftY + lengthPt / 2 - strokePt / 2,
width: tickLenPt,
height: strokePt,
color
});
// Bottom horizontal tick (left)
page.drawRectangle({
x: centerLeftX - tickLenPt / 2,
y: centerLeftY - lengthPt / 2 - strokePt / 2,
width: tickLenPt,
height: strokePt,
color
});
}
// Format a YYYY-MM-DD string into "DD MM YY"
function formatDateDDMMYY(value: string): string {
if (!value) return '';
const trimmed = value.trim();
// Expects YYYY-MM-DD and splits it
const parts = trimmed.split('-');
if (parts.length === 3) {
const [y, mo, d] = parts;
if (y.length === 4 && mo.length === 2 && d.length === 2) {
return `${d} ${mo} ${y.slice(-2)}`;
}
}
// Fallback for any other format that might slip through
console.warn(`Unexpected date format received in formatDateDDMMYY: "${trimmed}"`);
return trimmed;
}
// Crop image using canvas // Crop image using canvas
async function cropImage( async function cropImage(
imageBlob: Blob, imageBlob: Blob,
@@ -177,7 +288,12 @@
const pdfBytes = const pdfBytes =
fileName === 'esncards_text.pdf' ? await generateTextPDF() : await generatePhotoPDF(); fileName === 'esncards_text.pdf' ? await generateTextPDF() : await generatePhotoPDF();
const blob = new Blob([pdfBytes], { type: 'application/pdf' }); // Convert Uint8Array to ArrayBuffer slice to satisfy BlobPart typing
const arrayBuffer = pdfBytes.buffer.slice(
pdfBytes.byteOffset,
pdfBytes.byteOffset + pdfBytes.byteLength
);
const blob = new Blob([arrayBuffer as ArrayBuffer], { type: 'application/pdf' });
// Revoke old URL if it exists // Revoke old URL if it exists
if (fileToUpdate.url) { if (fileToUpdate.url) {
@@ -191,13 +307,6 @@
const timestamp = getTimestamp(); const timestamp = getTimestamp();
const baseName = fileName.replace('.pdf', ''); const baseName = fileName.replace('.pdf', '');
fileToUpdate.downloadName = `${baseName}_${timestamp}.pdf`; fileToUpdate.downloadName = `${baseName}_${timestamp}.pdf`;
// Check if both PDFs are done, then clear sensitive data
const allDone = files.every((f) => f.state === 'done' || f.state === 'error');
if (allDone) {
console.log('All PDFs generated, clearing sensitive data...');
await clearSensitiveData();
}
} catch (error: any) { } catch (error: any) {
console.error(`PDF generation failed for ${fileName}:`, error); console.error(`PDF generation failed for ${fileName}:`, error);
fileToUpdate.state = 'error'; fileToUpdate.state = 'error';
@@ -206,6 +315,9 @@
} }
async function generateTextPDF() { async function generateTextPDF() {
const card = $selectedCard;
if (!card) throw new Error('No card type selected');
const pdfDoc = await PDFDocument.create(); const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit); pdfDoc.registerFontkit(fontkit);
@@ -218,8 +330,8 @@
PAGE_SETTINGS.pageWidth, PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin, PAGE_SETTINGS.margin,
TEXT_CARD_DIMENSIONS.width, card.textCard.width,
TEXT_CARD_DIMENSIONS.height card.textCard.height
); );
const pageDimsPt = { const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT, width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
@@ -227,10 +339,15 @@
}; };
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]); let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font, PAGE_SETTINGS);
let currentRow = 0; let currentRow = 0;
let currentCol = 0; let currentCol = 0;
const validRows = $sheetData.filter((row) => row._valid); const validRows = $sheetData.filter((row) => row._valid);
const details = $cardDetails;
const studiesAtAll = details?.studiesAt ?? '';
const esnSectionAll = details?.esnSection ?? '';
const validityStartAll = details?.validityStart ?? '';
for (let i = 0; i < validRows.length; i++) { for (let i = 0; i < validRows.length; i++) {
const row = validRows[i]; const row = validRows[i];
@@ -241,44 +358,86 @@
// Get field values // Get field values
const name = row.name; const name = row.name;
const surname = row.surname;
const nationality = row.nationality; const nationality = row.nationality;
const birthday = row.birthday; const birthday = row.birthday;
const studiesAt = studiesAtAll;
const esnSection = esnSectionAll;
const validityStart = validityStartAll;
const birthdayFmt = formatDateDDMMYY(birthday);
const validityStartFmt = formatDateDDMMYY(validityStart);
// Draw name // Row 1: Name
const namePos = getAbsolutePositionPt( const namePos = getAbsolutePositionPt(
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.name card.textFields.name
); );
page.drawText(`${name} ${surname}`, { page.drawText(`${name}`, {
...namePos, ...namePos,
font, font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b) color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
}); });
// Draw nationality // Row 2 left: Nationality
const natPos = getAbsolutePositionPt( const natPos = getAbsolutePositionPt(
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.nationality card.textFields.nationality
); );
page.drawText(`Nationality: ${nationality}`, { page.drawText(`${nationality}`, {
...natPos, ...natPos,
font, font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b) color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
}); });
// Draw birthday // Row 2 right: Date of birth
const bdayPos = getAbsolutePositionPt( const bdayPos = getAbsolutePositionPt(
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.birthday card.textFields.birthday
); );
page.drawText(`Birthday: ${birthday}`, { // Row 3: Studies at
const studiesPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.studiesAt
);
page.drawText(`${studiesAt}`, {
...studiesPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Row 4 left: ESN Section
const sectionPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.esnSection
);
page.drawText(`${esnSection}`, {
...sectionPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Row 4 right: Valid from
const validPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
card.textFields.validityStart
);
page.drawText(`${validityStartFmt}`, {
...validPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
page.drawText(`${birthdayFmt}`, {
...bdayPos, ...bdayPos,
font, font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b) color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
@@ -301,6 +460,7 @@
currentRow++; currentRow++;
if (currentRow >= gridLayout.rows) { if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]); page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font, PAGE_SETTINGS);
currentRow = 0; currentRow = 0;
} }
} }
@@ -310,6 +470,9 @@
} }
async function generatePhotoPDF() { async function generatePhotoPDF() {
const card = $selectedCard;
if (!card) throw new Error('No card type selected');
const pdfDoc = await PDFDocument.create(); const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit); pdfDoc.registerFontkit(fontkit);
@@ -322,8 +485,8 @@
PAGE_SETTINGS.pageWidth, PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin, PAGE_SETTINGS.margin,
PHOTO_CARD_DIMENSIONS.width, card.photoCard.width,
PHOTO_CARD_DIMENSIONS.height card.photoCard.height
); );
const pageDimsPt = { const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT, width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
@@ -331,6 +494,7 @@
}; };
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]); let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font, PAGE_SETTINGS);
let currentRow = 0; let currentRow = 0;
let currentCol = 0; let currentCol = 0;
@@ -348,7 +512,7 @@
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
PHOTO_FIELD_LAYOUT.photo card.photoFields.photo
); );
const pictureUrl = row.pictureUrl; const pictureUrl = row.pictureUrl;
@@ -413,14 +577,13 @@
// Draw name // Draw name
const name = row.name; const name = row.name;
const surname = row.surname;
const namePos = getAbsolutePositionPt( const namePos = getAbsolutePositionPt(
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
PHOTO_FIELD_LAYOUT.name card.photoFields.name
); );
page.drawText(`${name} ${surname}`, { page.drawText(`${name}`, {
...namePos, ...namePos,
font, font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b) color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
@@ -433,6 +596,7 @@
currentRow++; currentRow++;
if (currentRow >= gridLayout.rows) { if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]); page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font, PAGE_SETTINGS);
currentRow = 0; currentRow = 0;
} }
} }
@@ -475,43 +639,28 @@
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-4xl mx-auto"> <div class="max-w-5xl mx-auto">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2"> <h2 class="text-xl font-semibold text-gray-900 mb-2">Generating PDFs...</h2>
Generating PDFs
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="text-sm text-gray-700 mb-4">
Create two PDF documents: one with text data and one with photos. Your PDF documents are being created. Please wait a moment.
</p> </p>
</div> </div>
<!-- Summary --> {#if files.some((f) => f.state === 'generating')}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4"> <div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Generation Summary</h3> <div class="flex items-center justify-between">
<div class="flex items-center">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <div
<div class="text-center"> class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
<div class="text-2xl font-bold text-gray-900"> ></div>
{$sheetData.filter((row) => row._valid).length} <span class="text-sm text-blue-800"> Processing... </span>
</div> </div>
<div class="text-gray-600">Records to Process</div> </div>
</div> </div>
{/if}
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">{files.length}</div>
<div class="text-gray-600">PDFs to Generate</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">
{files.filter((f) => f.state === 'done').length}
</div>
<div class="text-gray-600">Files Ready</div>
</div>
</div>
</div>
{#if generationStarted}
<!-- Generated Files --> <!-- Generated Files -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6"> <div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
<div class="p-4 border-b border-gray-200"> <div class="p-4 border-b border-gray-200">
@@ -559,27 +708,26 @@
<p class="text-xs text-gray-500">{formatFileSize(file.size)}</p> <p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
{:else if file.state === 'error'} {:else if file.state === 'error'}
<p class="text-xs text-red-500">Error: {file.error}</p> <p class="text-xs text-red-500">Error: {file.error}</p>
{:else if file.state === 'generating'}
<p class="text-xs text-gray-500">Generating...</p>
{:else if file.state === 'idle'}
<p class="text-xs text-gray-500">Waiting...</p>
{/if} {/if}
</div> </div>
</div> </div>
{#if file.state === 'idle'} {#if file.state === 'idle'}
<button <div class="px-4 py-2 text-gray-500 text-sm">Waiting...</div>
onclick={() => handleGenerate(file.name)}
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Generate
</button>
{:else if file.state === 'generating'} {:else if file.state === 'generating'}
<button <button
disabled disabled
aria-label="Generating..."
class="px-4 py-2 bg-gray-400 text-white rounded-md text-sm font-medium cursor-wait" class="px-4 py-2 bg-gray-400 text-white rounded-md text-sm font-medium cursor-wait"
> >
<div class="flex items-center"> <div class="flex items-center justify-center">
<div <div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div> ></div>
Generating...
</div> </div>
</button> </button>
{:else if file.state === 'done'} {:else if file.state === 'done'}
@@ -601,24 +749,19 @@
{/each} {/each}
</div> </div>
</div> </div>
{/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <div class="mt-10">
<button <Navigator
onclick={() => currentStep.set(6)} {currentStep}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300" onForward={resetAndStartOver}
> canProceed={files.some((f) => f.state === 'done' || f.state === 'error')}
Back to Gallery textBack="Back to Gallery"
</button> textForwardEnabled="Start Over"
textForwardDisabled="Generate PDFs to Continue"
{#if files.some((f) => f.state === 'done' || f.state === 'error')} hideForwardUntilProceedable={true}
<button />
onclick={resetAndStartOver}
class="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700"
>
Start Over
</button>
{/if}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,7 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import Navigator from './subcomponents/Navigator.svelte'; import Navigator from './subcomponents/Navigator.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { parseAndFormatDate } from '$lib/utils/date';
let isLoading = $state(true); let isLoading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@@ -17,6 +18,9 @@
let sortColumn = $state<keyof RowData | null>(null); let sortColumn = $state<keyof RowData | null>(null);
let sortDirection = $state<'asc' | 'desc'>('asc'); let sortDirection = $state<'asc' | 'desc'>('asc');
let lastCheckedId: string | null = $state(null);
const ROW_LIMIT = 200;
// Fetch and process data from the Google Sheet // Fetch and process data from the Google Sheet
async function fetchAndProcessData() { async function fetchAndProcessData() {
@@ -48,10 +52,12 @@
const processedData = dataRows const processedData = dataRows
.map((row, index): RowData | null => { .map((row, index): RowData | null => {
const name = mapping.name !== -1 ? row[mapping.name] || '' : ''; const name = mapping.name !== -1 ? row[mapping.name] || '' : '';
const surname = mapping.surname !== -1 ? row[mapping.surname] || '' : '';
const pictureUrl = mapping.pictureUrl !== -1 ? row[mapping.pictureUrl] || '' : ''; const pictureUrl = mapping.pictureUrl !== -1 ? row[mapping.pictureUrl] || '' : '';
if (!name && !surname && !pictureUrl) { const birthdayRaw = mapping.birthday !== -1 ? row[mapping.birthday] : '';
const birthday = parseAndFormatDate(birthdayRaw);
if (!name && !pictureUrl) {
return null; // Skip entirely empty rows return null; // Skip entirely empty rows
} }
@@ -60,19 +66,18 @@
? (row[mapping.alreadyPrinted] || '').toLowerCase() === 'true' ? (row[mapping.alreadyPrinted] || '').toLowerCase() === 'true'
: false; : false;
const isValid = !!(name && surname && pictureUrl); const isValid = !!(name && pictureUrl);
return { return {
id: uuid(), id: uuid(),
name, name,
surname,
nationality: mapping.nationality !== -1 ? row[mapping.nationality] || '' : '', nationality: mapping.nationality !== -1 ? row[mapping.nationality] || '' : '',
birthday: mapping.birthday !== -1 ? row[mapping.birthday] || '' : '', birthday,
pictureUrl, pictureUrl,
alreadyPrinted, alreadyPrinted,
_rowIndex: index + 2, // Sheet rows are 1-based, plus header _rowIndex: index + 1,
_valid: isValid, _valid: isValid,
_checked: isValid && !alreadyPrinted _checked: false
}; };
}) })
.filter((row): row is RowData => row !== null); .filter((row): row is RowData => row !== null);
@@ -85,31 +90,69 @@
} finally { } finally {
isLoading = false; isLoading = false;
} }
} // Run on component mount }
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(() => { onMount(() => {
ensureToken(); ensureToken();
fetchAndProcessData(); fetchAndProcessData();
}); });
// Function to toggle a single row's checked state // Function to toggle select-all: selects first 200 eligible items in current view
function toggleRow(id: string) {
const row = rows.find((r) => r.id === id);
if (row && row._valid) {
row._checked = !row._checked;
}
console.log("toggleRow", id, row?._checked);
}
// Function to toggle all valid rows
function toggleSelectAll(event: Event) { function toggleSelectAll(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const shouldCheck = target.checked; const shouldCheck = target.checked;
rows.forEach((row) => { // Determine eligible rows in the current display order
if (row._valid && !row.alreadyPrinted) { const eligible = displayData.filter((r) => r._valid && !r.alreadyPrinted);
row._checked = shouldCheck; 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 to handle sorting
@@ -143,11 +186,12 @@
}); });
}); });
// Derived state to determine if the "Select All" checkbox should be checked // Derived state: master checkbox reflects if first 200 eligible items in current view are selected
const allValidRowsSelected = $derived.by(() => { const allValidRowsSelected = $derived.by(() => {
const validRows = rows.filter((row) => row._valid && !row.alreadyPrinted); const eligible = displayData.filter((r) => r._valid && !r.alreadyPrinted);
if (validRows.length === 0) return false; const firstBatch = eligible.slice(0, ROW_LIMIT);
return validRows.every((row) => row._checked); if (firstBatch.length === 0) return false;
return firstBatch.every((row) => row._checked);
}); });
const selectedCount = $derived(rows.filter((row) => row._checked).length); const selectedCount = $derived(rows.filter((row) => row._checked).length);
@@ -162,26 +206,33 @@
<div> <div>
<h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2> <h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
<p class="text-sm text-gray-700"> <p class="text-sm text-gray-700">
Review your data and select which rows to include. Invalid or already printed rows are Review your data and select which rows to include. Select a batch of max 200 items by using
disabled. the top checkbox.
</p> </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} {#if $selectedSheet?.id}
<p class="mt-1 text-sm text-gray-500">
Need to make changes?
<a <a
href={`https://docs.google.com/spreadsheets/d/${$selectedSheet.id}/edit`} href={`https://docs.google.com/spreadsheets/d/${$selectedSheet.id}/edit`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-blue-600 underline hover:text-blue-800" 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 Google Sheet Open Sheet
</a> </a>
</p>
{/if} {/if}
</div>
<button <button
onclick={fetchAndProcessData} onclick={fetchAndProcessData}
class="inline-flex items-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" class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-wait disabled:opacity-50"
disabled={isLoading} disabled={isLoading}
> >
{#if isLoading} {#if isLoading}
@@ -211,6 +262,7 @@
{/if} {/if}
</button> </button>
</div> </div>
</div>
{#if isLoading} {#if isLoading}
<div class="py-12 text-center"> <div class="py-12 text-center">
@@ -266,11 +318,7 @@
> >
<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" 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')}>Name</th 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('surname')}>Surname</th
> >
<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" class="cursor-pointer px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600 hover:bg-gray-100"
@@ -307,13 +355,12 @@
type="checkbox" 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" 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} checked={row._checked}
disabled={!row._valid} disabled={!row._valid || (selectedCount >= ROW_LIMIT && !row._checked)}
onchange={() => toggleRow(row.id)} onclick={(e) => handleRowClick(e, row.id)}
/> />
</td> </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._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.name}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">{row.surname}</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.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">{row.birthday}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm"> <td class="whitespace-nowrap px-4 py-3 text-sm">

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { selectedSheet, currentStep } from '$lib/stores'; import { selectedSheet, currentStep } from '$lib/stores';
import type { SheetInfoType } from '$lib/stores'; import type { SheetInfoType } from '$lib/stores';
import { searchSheets, ensureToken } from '$lib/google'; import { searchSheets, ensureToken, userEmail } from '$lib/google';
import { hashString } from '$lib/utils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte'; import Navigator from './subcomponents/Navigator.svelte';
@@ -12,7 +13,13 @@
let hasSearched = $state(false); let hasSearched = $state(false);
let recentSheets = $state<any[]>([]); let recentSheets = $state<any[]>([]);
const RECENT_SHEETS_KEY = 'recent-sheets'; async function getRecentSheetsKey() {
const email = $userEmail;
if (email) {
return `recentSheets_${await hashString(email)}`;
}
return 'recentSheets_anonymous';
}
onMount(() => { onMount(() => {
ensureToken(); ensureToken();
@@ -37,16 +44,18 @@
} }
} }
function loadRecentSheets() { async function loadRecentSheets() {
try { try {
const saved = localStorage.getItem(RECENT_SHEETS_KEY); const key = await getRecentSheetsKey();
const saved = localStorage.getItem(key);
if (saved) { if (saved) {
recentSheets = JSON.parse(saved); recentSheets = JSON.parse(saved);
} }
} catch (err) { } catch (err) {
console.error('Error loading recent sheets:', err); console.error('Error loading recent sheets:', err);
// If there's an error, clear the stored value // If there's an error, clear the stored value
localStorage.removeItem(RECENT_SHEETS_KEY); const key = await getRecentSheetsKey();
localStorage.removeItem(key);
recentSheets = []; recentSheets = [];
} }
} }

View File

@@ -1,40 +1,44 @@
<script lang="ts"> <script lang="ts">
let { let {
canProceed, canProceed = true,
currentStep, currentStep,
textBack, textBack = 'Back',
textForwardDisabled, textForwardDisabled = 'Next',
textForwardEnabled, textForwardEnabled = 'Next',
onBack, onBack,
onForward onForward,
nextDisabled = false
} = $props<{ } = $props<{
canProceed: boolean; canProceed?: boolean;
currentStep: any; currentStep?: any;
textBack: string; textBack?: string;
textForwardDisabled: string; textForwardDisabled?: string;
textForwardEnabled: string; textForwardEnabled?: string;
onBack?: () => void | null; onBack?: () => void;
onForward?: () => void | null; onForward?: () => void;
nextDisabled?: boolean;
}>(); }>();
async function handleBack() { async function handleBack() {
if (onBack) { if (onBack) {
// Allow custom back logic if provided
await onBack(); await onBack();
} } else if (currentStep) {
currentStep.set($currentStep - 1); currentStep.set($currentStep - 1);
} }
}
async function handleForward() { async function handleForward() {
if (onForward) { if (onForward) {
// Allow custom forward logic if provided
await onForward(); await onForward();
} }
if (currentStep) {
currentStep.set($currentStep + 1); currentStep.set($currentStep + 1);
} }
}
</script> </script>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:justify-between">
{#if onBack || currentStep}
<button <button
onclick={handleBack} onclick={handleBack}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300 sm:w-auto" class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300 sm:w-auto"
@@ -44,13 +48,14 @@
</svg> </svg>
<span>{textBack}</span> <span>{textBack}</span>
</button> </button>
{/if}
<button <button
onclick={handleForward} onclick={handleForward}
disabled={!canProceed} disabled={!canProceed || nextDisabled}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400 sm:w-auto" class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400 sm:w-auto"
> >
<span>{canProceed ? textForwardEnabled : textForwardDisabled}</span> <span>{canProceed && !nextDisabled ? textForwardEnabled : textForwardDisabled}</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PhotoDimensions } from '$lib/cards/types';
import PhotoCrop from './PhotoCrop.svelte'; import PhotoCrop from './PhotoCrop.svelte';
let { photo, onCropUpdated, onRetry } = $props<{ let { photo, onCropUpdated, onRetry, photoDimensions } = $props<{
photo: { photo: {
name: string; name: string;
url: string; url: string;
@@ -13,6 +14,7 @@
}; };
onCropUpdated: (detail: any) => void; onCropUpdated: (detail: any) => void;
onRetry: () => void; onRetry: () => void;
photoDimensions: PhotoDimensions;
}>(); }>();
let showCropper = $state(false); let showCropper = $state(false);
@@ -108,8 +110,8 @@
{/if} {/if}
</div> </div>
<div class="p-3 flex items-center justify-between"> <div class="esnSection p-3 flex items-center justify-between gap-2">
<div> <div class="min-w-0 flex-1">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4> <h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
{#if photo.faceDetectionStatus === 'completed'} {#if photo.faceDetectionStatus === 'completed'}
<span class="text-xs text-green-600">Face detected</span> <span class="text-xs text-green-600">Face detected</span>
@@ -125,7 +127,7 @@
</div> </div>
<button <button
onclick={() => (showCropper = true)} onclick={() => (showCropper = true)}
class="p-1 text-gray-500 hover:text-blue-600" class="p-1 text-gray-500 hover:text-blue-600 shrink-0"
title="Edit Crop" title="Edit Crop"
aria-label="Edit Crop" aria-label="Edit Crop"
> >
@@ -145,6 +147,7 @@
imageUrl={photo.objectUrl} imageUrl={photo.objectUrl}
personName={photo.name} personName={photo.name}
initialCropData={photo.cropData} initialCropData={photo.cropData}
{photoDimensions}
onClose={() => (showCropper = false)} onClose={() => (showCropper = false)}
onCropUpdated={handleCropUpdated} onCropUpdated={handleCropUpdated}
/> />

View File

@@ -1,8 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { env } from '$env/dynamic/public'; import type { PhotoDimensions } from '$lib/cards/types';
let { imageUrl, personName, initialCropData, onCropUpdated, onClose } = $props<{ let {
imageUrl,
personName,
initialCropData,
onCropUpdated,
onClose,
photoDimensions
} = $props<{
imageUrl: string; imageUrl: string;
personName: string; personName: string;
initialCropData?: { x: number; y: number; width: number; height: number }; initialCropData?: { x: number; y: number; width: number; height: number };
@@ -10,6 +17,7 @@
cropData: { x: number; y: number; width: number; height: number }; cropData: { x: number; y: number; width: number; height: number };
}) => void; }) => void;
onClose: () => void; onClose: () => void;
photoDimensions: PhotoDimensions;
}>(); }>();
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
@@ -27,16 +35,14 @@
// Interaction state // Interaction state
let isDragging = false; let isDragging = false;
let isResizing = false;
let dragStart = { x: 0, y: 0 }; let dragStart = { x: 0, y: 0 };
let resizeHandle = '';
// Canvas dimensions // Canvas dimensions
let canvasWidth = 600; let canvasWidth = 600;
let canvasHeight = 400; let canvasHeight = 400;
// Get crop ratio from environment // Use the photo card aspect ratio from the selected card's dimensions
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0'); const cropRatio = photoDimensions.width / photoDimensions.height;
onMount(() => { onMount(() => {
ctx = canvas.getContext('2d')!; ctx = canvas.getContext('2d')!;
@@ -127,25 +133,6 @@
ctx.strokeStyle = '#3b82f6'; ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height); ctx.strokeRect(crop.x, crop.y, crop.width, crop.height);
// Draw resize handles
const handleSize = 12; // Increased from 8 for easier grabbing
ctx.fillStyle = '#3b82f6';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
// Corner handles with white borders for better visibility
const handles = [
{ x: crop.x - handleSize/2, y: crop.y - handleSize/2, cursor: 'nw-resize' },
{ x: crop.x + crop.width - handleSize/2, y: crop.y - handleSize/2, cursor: 'ne-resize' },
{ x: crop.x - handleSize/2, y: crop.y + crop.height - handleSize/2, cursor: 'sw-resize' },
{ x: crop.x + crop.width - handleSize/2, y: crop.y + crop.height - handleSize/2, cursor: 'se-resize' },
];
handles.forEach(handle => {
ctx.fillRect(handle.x, handle.y, handleSize, handleSize);
ctx.strokeRect(handle.x, handle.y, handleSize, handleSize);
});
} }
function getMousePos(e: MouseEvent) { function getMousePos(e: MouseEvent) {
@@ -157,31 +144,12 @@
} }
function isInCropArea(x: number, y: number) { function isInCropArea(x: number, y: number) {
return x >= crop.x && x <= crop.x + crop.width && return x >= crop.x && x <= crop.x + crop.width && y >= crop.y && y <= crop.y + crop.height;
y >= crop.y && y <= crop.y + crop.height;
}
function getResizeHandle(x: number, y: number) {
const handleSize = 12; // Match the drawing size
const tolerance = handleSize;
if (Math.abs(x - crop.x) <= tolerance && Math.abs(y - crop.y) <= tolerance) return 'nw';
if (Math.abs(x - (crop.x + crop.width)) <= tolerance && Math.abs(y - crop.y) <= tolerance) return 'ne';
if (Math.abs(x - crop.x) <= tolerance && Math.abs(y - (crop.y + crop.height)) <= tolerance) return 'sw';
if (Math.abs(x - (crop.x + crop.width)) <= tolerance && Math.abs(y - (crop.y + crop.height)) <= tolerance) return 'se';
return '';
} }
function handleMouseDown(e: MouseEvent) { function handleMouseDown(e: MouseEvent) {
const pos = getMousePos(e); const pos = getMousePos(e);
const handle = getResizeHandle(pos.x, pos.y); if (isInCropArea(pos.x, pos.y)) {
if (handle) {
isResizing = true;
resizeHandle = handle;
dragStart = pos;
} else if (isInCropArea(pos.x, pos.y)) {
isDragging = true; isDragging = true;
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y }; dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
} }
@@ -190,86 +158,13 @@
function handleMouseMove(e: MouseEvent) { function handleMouseMove(e: MouseEvent) {
const pos = getMousePos(e); const pos = getMousePos(e);
if (isResizing) { if (isDragging) {
const dx = pos.x - dragStart.x;
const dy = pos.y - dragStart.y;
const newCrop = { ...crop };
// Use primary axis movement for more predictable resizing
switch (resizeHandle) {
case 'nw':
// Use the dominant movement direction
const primaryDelta = Math.abs(dx) > Math.abs(dy) ? dx : dy * cropRatio;
const newWidth = Math.max(20, crop.width - primaryDelta);
const newHeight = newWidth / cropRatio;
newCrop.x = Math.max(0, crop.x + crop.width - newWidth);
newCrop.y = Math.max(0, crop.y + crop.height - newHeight);
newCrop.width = newWidth;
newCrop.height = newHeight;
break;
case 'ne':
// For NE, primarily follow horizontal movement
const newWidthNE = Math.max(20, crop.width + dx);
const newHeightNE = newWidthNE / cropRatio;
newCrop.width = newWidthNE;
newCrop.height = newHeightNE;
newCrop.y = Math.max(0, crop.y + crop.height - newHeightNE);
break;
case 'sw':
// For SW, primarily follow horizontal movement
const newWidthSW = Math.max(20, crop.width - dx);
const newHeightSW = newWidthSW / cropRatio;
newCrop.x = Math.max(0, crop.x + crop.width - newWidthSW);
newCrop.width = newWidthSW;
newCrop.height = newHeightSW;
break;
case 'se':
// For SE, primarily follow horizontal movement
const newWidthSE = Math.max(20, crop.width + dx);
const newHeightSE = newWidthSE / cropRatio;
newCrop.width = newWidthSE;
newCrop.height = newHeightSE;
break;
}
// Ensure crop stays within canvas bounds
if (newCrop.x + newCrop.width > canvasWidth) {
newCrop.width = canvasWidth - newCrop.x;
newCrop.height = newCrop.width / cropRatio;
}
if (newCrop.y + newCrop.height > canvasHeight) {
newCrop.height = canvasHeight - newCrop.y;
newCrop.width = newCrop.height * cropRatio;
}
// Adjust position if crop extends beyond bounds after resizing
if (newCrop.x + newCrop.width > canvasWidth) {
newCrop.x = canvasWidth - newCrop.width;
}
if (newCrop.y + newCrop.height > canvasHeight) {
newCrop.y = canvasHeight - newCrop.height;
}
crop = newCrop;
drawCanvas();
} else if (isDragging) {
crop.x = Math.max(0, Math.min(canvasWidth - crop.width, pos.x - dragStart.x)); crop.x = Math.max(0, Math.min(canvasWidth - crop.width, pos.x - dragStart.x));
crop.y = Math.max(0, Math.min(canvasHeight - crop.height, pos.y - dragStart.y)); crop.y = Math.max(0, Math.min(canvasHeight - crop.height, pos.y - dragStart.y));
drawCanvas(); drawCanvas();
} else { } else {
// Update cursor based on hover state // Update cursor based on hover state
const handle = getResizeHandle(pos.x, pos.y); if (isInCropArea(pos.x, pos.y)) {
if (handle) {
canvas.style.cursor = handle + '-resize';
} else if (isInCropArea(pos.x, pos.y)) {
canvas.style.cursor = 'move'; canvas.style.cursor = 'move';
} else { } else {
canvas.style.cursor = 'default'; canvas.style.cursor = 'default';
@@ -279,11 +174,39 @@
function handleMouseUp() { function handleMouseUp() {
isDragging = false; isDragging = false;
isResizing = false;
resizeHandle = '';
canvas.style.cursor = 'default'; canvas.style.cursor = 'default';
} }
function zoom(factor: number) {
const center = {
x: crop.x + crop.width / 2,
y: crop.y + crop.height / 2
};
let newWidth = crop.width * factor;
let newHeight = newWidth / cropRatio;
// Clamp to min/max size
newWidth = Math.max(20, Math.min(canvasWidth, newWidth));
newHeight = newWidth / cropRatio;
if (newHeight > canvasHeight) {
newHeight = canvasHeight;
newWidth = newHeight * cropRatio;
}
crop.width = newWidth;
crop.height = newHeight;
crop.x = center.x - newWidth / 2;
crop.y = center.y - newHeight / 2;
// Ensure it stays within bounds after zooming
crop.x = Math.max(0, Math.min(canvasWidth - crop.width, crop.x));
crop.y = Math.max(0, Math.min(canvasHeight - crop.height, crop.y));
drawCanvas();
}
function handleSave() { function handleSave() {
// Scale crop rectangle back to original image dimensions // Scale crop rectangle back to original image dimensions
const scaleX = image.width / canvasWidth; const scaleX = image.width / canvasWidth;
@@ -344,16 +267,52 @@
</button> </button>
</div> </div>
<div class="mb-4 p-2 rounded-md text-center"> <div class="relative mb-4 p-2 rounded-md text-center">
<canvas <canvas
bind:this={canvas} bind:this={canvas}
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
onmousemove={handleMouseMove} onmousemove={handleMouseMove}
onmouseup={handleMouseUp} onmouseup={handleMouseUp}
onmouseleave={handleMouseUp} onmouseleave={handleMouseUp}
class="mx-auto cursor-move" class="mx-auto"
style="max-width: 100%; height: auto;" style="max-width: 100%; height: auto;"
></canvas> ></canvas>
<div class="absolute bottom-4 right-4 flex space-x-2">
<button
onclick={() => zoom(1 / 1.1)}
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-700 bg-opacity-50 text-white hover:bg-opacity-75"
aria-label="Zoom out"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-6 w-6"
>
<path
fill-rule="evenodd"
d="M4 10a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H4.75A.75.75 0 014 10z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
onclick={() => zoom(1.1)}
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-700 bg-opacity-50 text-white hover:bg-opacity-75"
aria-label="Zoom in"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-6 w-6"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">

View File

@@ -5,6 +5,7 @@ import { env } from '$env/dynamic/public';
export const accessToken = writable<string | null | undefined>(undefined); export const accessToken = writable<string | null | undefined>(undefined);
export const isSignedIn = writable(false); export const isSignedIn = writable(false);
export const isGoogleApiReady = writable(false); // To track GAPI client readiness export const isGoogleApiReady = writable(false); // To track GAPI client readiness
export const userEmail = writable<string | null>(null);
let tokenClient: google.accounts.oauth2.TokenClient; let tokenClient: google.accounts.oauth2.TokenClient;
let gapiInited = false; let gapiInited = false;
@@ -14,6 +15,7 @@ let gsiInited = false;
export function initGoogleClients(callback: () => void) { export function initGoogleClients(callback: () => void) {
// If everything is already initialized, just run the callback. // If everything is already initialized, just run the callback.
if (gapiInited && gsiInited) { if (gapiInited && gsiInited) {
isGoogleApiReady.set(true); // Ensure it's set if called again
callback(); callback();
return; return;
} }
@@ -36,7 +38,6 @@ export function initGoogleClients(callback: () => void) {
}) })
.then(() => { .then(() => {
gapiInited = true; gapiInited = true;
isGoogleApiReady.set(true);
// Now that GAPI is ready, initialize the GSI client. // Now that GAPI is ready, initialize the GSI client.
initGsiClient(callback); initGsiClient(callback);
}); });
@@ -48,6 +49,27 @@ export function initGoogleClients(callback: () => void) {
} }
} }
/**
* 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. // 2. Load GSI script for Auth. This should only be called after GAPI is ready.
function initGsiClient(callback: () => void) { function initGsiClient(callback: () => void) {
if (gsiInited) { if (gsiInited) {
@@ -64,7 +86,7 @@ function initGsiClient(callback: () => void) {
tokenClient = google.accounts.oauth2.initTokenClient({ tokenClient = google.accounts.oauth2.initTokenClient({
client_id: env.PUBLIC_GOOGLE_CLIENT_ID, client_id: env.PUBLIC_GOOGLE_CLIENT_ID,
scope: scope:
'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets.readonly', 'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/userinfo.email',
callback: (tokenResponse) => { callback: (tokenResponse) => {
// This callback handles responses from all token requests. // This callback handles responses from all token requests.
if (tokenResponse.error) { if (tokenResponse.error) {
@@ -78,9 +100,11 @@ function initGsiClient(callback: () => void) {
isSignedIn.set(true); isSignedIn.set(true);
// Also set the token for the GAPI client // Also set the token for the GAPI client
if (gapiInited) gapi.client.setToken({ access_token: token }); if (gapiInited) gapi.client.setToken({ access_token: token });
fetchUserInfo(token);
} }
} }
}); });
isGoogleApiReady.set(true);
callback(); callback();
}; };
} }
@@ -152,6 +176,7 @@ export function handleSignOut() {
} }
accessToken.set(null); accessToken.set(null);
isSignedIn.set(false); isSignedIn.set(false);
userEmail.set(null);
console.log('User signed out.'); console.log('User signed out.');
} }
@@ -165,6 +190,9 @@ export async function searchSheets(query: string) {
q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`, q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`,
fields: 'files(id, name, iconLink, webViewLink)', fields: 'files(id, name, iconLink, webViewLink)',
pageSize: 20, pageSize: 20,
supportsAllDrives: true,
includeItemsFromAllDrives: true,
corpora: 'allDrives'
}); });
return response.result.files || []; return response.result.files || [];
} }

View File

@@ -1,11 +1,3 @@
// PDF Layout Configuration Module
// Centralized configuration for PDF generation layouts, using millimeters.
import {
PHOTO_DIMENSIONS,
TEXT_FIELD_LAYOUT,
PHOTO_FIELD_LAYOUT
} from './pdfSettings';
import { get } from 'idb-keyval'; import { get } from 'idb-keyval';
// Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm) // Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm)
@@ -84,8 +76,8 @@ export function getAbsolutePhotoDimensionsPt(
// Border configuration // Border configuration
export const BORDER_CONFIG = { export const BORDER_CONFIG = {
color: { r: 0.8, g: 0.8, b: 0.8 }, color: { r: 0, g: 0, b: 0 },
width: 1 // in points width: 0.5 // in points
}; };
// Text configuration // Text configuration

View File

@@ -6,91 +6,9 @@ export interface PageSettings {
margin: number; // mm margin: number; // mm
} }
export interface CardDimensions {
width: number; // mm
height: number; // mm
}
// A4 Page dimensions in millimeters // A4 Page dimensions in millimeters
export const PAGE_SETTINGS: PageSettings = { export const PAGE_SETTINGS: PageSettings = {
pageWidth: 210, pageWidth: 210,
pageHeight: 297, pageHeight: 297,
margin: 10 margin: 15
};
// Dimensions for a single card in the text PDF.
// These dimensions will be used to calculate how many cards can fit on a page.
export const TEXT_CARD_DIMENSIONS: CardDimensions = {
width: 63,
height: 40
};
// Dimensions for a single card in the photo PDF.
export const PHOTO_CARD_DIMENSIONS: CardDimensions = {
width: 25,
height: 35
};
// Photo dimensions within the photo card
export const PHOTO_DIMENSIONS = {
width: 20, // mm
height: 35 // mm
};
export interface TextPosition {
x: number; // mm, relative to cell top-left
y: number; // mm, relative to cell top-left
size: number; // font size in points
}
export interface PhotoPosition {
x: number; // mm, relative to cell top-left
y: number; // mm, relative to cell top-left
width: number; // mm
height: number; // mm
}
export interface TextFieldLayout {
name: TextPosition;
nationality: TextPosition;
birthday: TextPosition;
}
export interface PhotoFieldLayout {
photo: PhotoPosition;
name: TextPosition;
}
// Text PDF Field Positions (in mm, relative to cell top-left)
export const TEXT_FIELD_LAYOUT: TextFieldLayout = {
name: {
x: 2,
y: 5,
size: 10 // font size in points
},
nationality: {
x: 2,
y: 10,
size: 10
},
birthday: {
x: 2,
y: 15,
size: 10
}
};
// Photo PDF Field Positions (in mm, relative to cell top-left)
export const PHOTO_FIELD_LAYOUT: PhotoFieldLayout = {
photo: {
x: 2, // 2mm from left of cell
y: 2, // 2mm from top of cell
width: PHOTO_DIMENSIONS.width,
height: PHOTO_DIMENSIONS.height
},
name: {
x: 2, // 2mm from left of cell
y: PHOTO_DIMENSIONS.height + 0, // Below the photo + 5mm gap
size: 5 // font size in points
}
}; };

View File

@@ -13,7 +13,6 @@ export const session = writable<{
// Data structure column mapping // Data structure column mapping
export interface ColumnMappingType { export interface ColumnMappingType {
name: number; name: number;
surname: number;
nationality: number; nationality: number;
birthday: number; birthday: number;
pictureUrl: number; pictureUrl: number;
@@ -25,7 +24,6 @@ export interface ColumnMappingType {
export interface RowData { export interface RowData {
id: string; // Unique identifier id: string; // Unique identifier
name: string; name: string;
surname: string;
nationality: string; nationality: string;
birthday: string; birthday: string;
pictureUrl: string; pictureUrl: string;
@@ -61,23 +59,22 @@ export interface SheetInfoType {
// Card details type // Card details type
export interface CardDetailsType { export interface CardDetailsType {
homeSection: string; esnSection: string;
studiesAt: string;
validityStart: string; validityStart: string;
} }
// Column mapping configuration // Column mapping configuration
export const columnMapping = writable<ColumnMappingType>( export const columnMapping = writable<ColumnMappingType>({
{
name: -1, name: -1,
surname: -1,
nationality: -1, nationality: -1,
birthday: -1, birthday: -1,
pictureUrl: -1, pictureUrl: -1,
alreadyPrinted: -1, alreadyPrinted: -1,
sheetName: '' sheetName: ''
}); });
// Store to hold the processed sheet data // Store to hold the processed sheet data
export const sheetData = writable<RowData[]>([]); export const sheetData = writable<RowData[]>([]);
@@ -94,6 +91,10 @@ export const selectedSheet = writable<SheetInfoType>({ id: '', name: '', webView
// Card details for generation // Card details for generation
export const cardDetails = writable<CardDetailsType | null>(null); export const cardDetails = writable<CardDetailsType | null>(null);
// Selected card type for generation
import type { Card } from '$lib/cards/types';
export const selectedCard = writable<Card | null>(null);
// Wizard state management // Wizard state management
export const currentStep = writable<number>(0); export const currentStep = writable<number>(0);
@@ -103,6 +104,8 @@ export const steps = [
'search', 'search',
'mapping', 'mapping',
'validation', 'validation',
'card-details',
'card-select',
'gallery', 'gallery',
'generate' 'generate'
] as const; ] as const;

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

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

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

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

View File

@@ -7,7 +7,6 @@
onMount(() => { onMount(() => {
initGoogleClients(() => { initGoogleClients(() => {
// You can add any logic here to run after the client is initialized
console.log('Google API client initialized'); console.log('Google API client initialized');
}); });
}); });

View File

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

View File

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

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

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

BIN
static/cards/2026.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB