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

This commit is contained in:
Roman Krček
2025-08-11 18:30:07 +02:00
parent 1a2329b6c1
commit a9dc5888e6
17 changed files with 484 additions and 382 deletions

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: 26, // mm
height: 36 // 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, ESNCard2026];

View File

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

View File

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

View File

@@ -95,7 +95,7 @@
{currentStep}
onForward={handleContinue}
textBack="Back to Row Selection"
textForwardEnabled="Continue to Photo Review"
textForwardEnabled="Continue to Card Selection"
textForwardDisabled="Please fill out all fields"
/>
</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-4xl 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,8 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
import { PHOTO_DIMENSIONS } from '$lib/pdfSettings';
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 Navigator from './subcomponents/Navigator.svelte';
import PhotoCard from './subcomponents/PhotoCard.svelte';
@@ -340,8 +339,9 @@ async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85)
const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX;
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
// Use the photo card aspect ratio from PDF settings (width / height)
const cropRatio = PHOTO_DIMENSIONS.width / PHOTO_DIMENSIONS.height;
// Use the photo card aspect ratio from the selected card
const photoDimensions = $selectedCard!.photo;
const cropRatio = photoDimensions.width / photoDimensions.height;
const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0');
const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0');
const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5');
@@ -581,6 +581,7 @@ async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85)
{#each photos as photo, index}
<PhotoCard
{photo}
photoDimensions={$selectedCard!.photo}
onCropUpdated={(e) => handleCropUpdate(index, e)}
onRetry={() => retryPhoto(index)}
/>
@@ -593,7 +594,7 @@ async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85)
<Navigator
canProceed={canProceed()}
{currentStep}
textBack="Back to Card Details"
textBack="Back to Card Selection"
textForwardDisabled="Waiting from photos"
textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
/>

View File

@@ -1,25 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import { sheetData, currentStep, pictures, cropRects, cardDetails } from '$lib/stores';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { sheetData, currentStep, pictures, cropRects, cardDetails, selectedCard } from '$lib/stores';
import type { Card } from '$lib/cards/types';
import { PDFDocument, StandardFonts, rgb, type PDFPage } from 'pdf-lib';
import * as fontkit from 'fontkit';
import { clear } from 'idb-keyval';
import {
BORDER_CONFIG,
TEXT_CONFIG,
calculateGrid,
getAbsolutePositionPt,
getAbsolutePhotoDimensionsPt,
getImageBlob,
MM_TO_PT
} from '$lib/pdfLayout';
import {
PAGE_SETTINGS,
TEXT_CARD_DIMENSIONS,
PHOTO_CARD_DIMENSIONS,
TEXT_FIELD_LAYOUT,
PHOTO_FIELD_LAYOUT
} from '$lib/pdfSettings';
BORDER_CONFIG,
TEXT_CONFIG,
calculateGrid,
getAbsolutePositionPt,
getAbsolutePhotoDimensionsPt,
getImageBlob,
MM_TO_PT
} from '$lib/pdfLayout';
import { PAGE_SETTINGS } from '$lib/pdfSettings';
import type { PageSettings } from '$lib/pdfSettings';
import Navigator from './subcomponents/Navigator.svelte';
type FileGenerationState = 'idle' | 'generating' | 'done' | 'error';
@@ -70,13 +67,10 @@
}
onMount(() => {
handleGenerateAll();
// Add event listener for page unload
window.addEventListener('beforeunload', handleBeforeUnload);
// Start generation automatically when the component mounts
handleGenerate('esncards_text.pdf');
handleGenerate('esncards_photos.pdf');
// Cleanup function when component unmounts
return () => {
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
async function loadRobotoFont() {
try {
@@ -110,15 +120,15 @@
}
// Draw a very wide 'H' (10 cm length) at the top and left margins as registration marks
function drawHMarks(page: any, font: any) {
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 = (PAGE_SETTINGS.pageWidth / 2) * MM_TO_PT;
const centerTopY = (PAGE_SETTINGS.pageHeight - PAGE_SETTINGS.margin / 2) * MM_TO_PT;
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;
@@ -147,7 +157,6 @@
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;
@@ -163,8 +172,8 @@
});
// Left margin center (vertical bar)
const centerLeftX = (PAGE_SETTINGS.margin / 2) * MM_TO_PT;
const centerLeftY = (PAGE_SETTINGS.pageHeight / 2) * MM_TO_PT;
const centerLeftX = (pageSettings.margin / 2) * MM_TO_PT;
const centerLeftY = (pageSettings.pageHeight / 2) * MM_TO_PT;
// Vertical bar (left)
page.drawRectangle({
@@ -328,13 +337,6 @@
const timestamp = getTimestamp();
const baseName = fileName.replace('.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) {
console.error(`PDF generation failed for ${fileName}:`, error);
fileToUpdate.state = 'error';
@@ -343,6 +345,9 @@
}
async function generateTextPDF() {
const card = $selectedCard;
if (!card) throw new Error('No card type selected');
const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit);
@@ -355,8 +360,8 @@
PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin,
TEXT_CARD_DIMENSIONS.width,
TEXT_CARD_DIMENSIONS.height
card.textCard.width,
card.textCard.height
);
const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
@@ -364,7 +369,7 @@
};
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font);
drawHMarks(page, font, PAGE_SETTINGS);
let currentRow = 0;
let currentCol = 0;
@@ -392,14 +397,12 @@
const birthdayFmt = formatDateDDMMYY(birthday);
const validityStartFmt = formatDateDDMMYY(validityStart);
console.log(birthday, validityStart)
// Row 1: Name
const namePos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.name
card.textFields.name
);
page.drawText(`${name} ${surname}`, {
...namePos,
@@ -412,7 +415,7 @@
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.nationality
card.textFields.nationality
);
page.drawText(`${nationality}`, {
...natPos,
@@ -425,16 +428,16 @@
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.birthday
card.textFields.birthday
);
// Row 3: Studies at
const studiesPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.studiesAt
card.textFields.studiesAt
);
page.drawText(`${studiesAt}` , {
page.drawText(`${studiesAt}`, {
...studiesPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
@@ -445,9 +448,9 @@
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.esnSection
card.textFields.esnSection
);
page.drawText(`${esnSection}` , {
page.drawText(`${esnSection}`, {
...sectionPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
@@ -458,9 +461,9 @@
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.validityStart
card.textFields.validityStart
);
page.drawText(`${validityStartFmt}` , {
page.drawText(`${validityStartFmt}`, {
...validPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
@@ -488,7 +491,7 @@
currentRow++;
if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font);
drawHMarks(page, font, PAGE_SETTINGS);
currentRow = 0;
}
}
@@ -498,6 +501,9 @@
}
async function generatePhotoPDF() {
const card = $selectedCard;
if (!card) throw new Error('No card type selected');
const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit);
@@ -510,8 +516,8 @@
PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin,
PHOTO_CARD_DIMENSIONS.width,
PHOTO_CARD_DIMENSIONS.height
card.photoCard.width,
card.photoCard.height
);
const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
@@ -519,7 +525,7 @@
};
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font);
drawHMarks(page, font, PAGE_SETTINGS);
let currentRow = 0;
let currentCol = 0;
@@ -537,7 +543,7 @@
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
PHOTO_FIELD_LAYOUT.photo
card.photoFields.photo
);
const pictureUrl = row.pictureUrl;
@@ -607,7 +613,7 @@
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
PHOTO_FIELD_LAYOUT.name
card.photoFields.name
);
page.drawText(`${name} ${surname}`, {
...namePos,
@@ -622,7 +628,7 @@
currentRow++;
if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font);
drawHMarks(page, font, PAGE_SETTINGS);
currentRow = 0;
}
}
@@ -667,148 +673,127 @@
<div class="p-6">
<div class="max-w-4xl mx-auto">
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Generating PDFs
</h2>
<h2 class="text-xl font-semibold text-gray-900 mb-2">Generating PDFs...</h2>
<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>
</div>
<!-- Summary -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Generation Summary</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="text-center">
<div class="text-2xl font-bold text-gray-900">
{$sheetData.filter((row) => row._valid).length}
{#if files.some((f) => f.state === 'generating')}
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-blue-800"> Processing... </span>
</div>
<div class="text-gray-600">Records to Process</div>
</div>
<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}
<!-- Generated Files -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
<div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Available Downloads</h3>
</div>
{#if generationStarted}
<!-- Generated Files -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
<div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Available Downloads</h3>
</div>
<div class="divide-y divide-gray-200">
{#each files as file (file.name)}
<div class="p-4 flex items-center justify-between">
<div class="flex items-center">
{#if file.displayName === 'Text PDF'}
<svg
class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="18" x2="12" y2="12"></line>
<line x1="9" y1="12" x2="15" y2="12"></line>
</svg>
{:else if file.displayName === 'Photos PDF'}
<svg
class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<circle cx="12" cy="13" r="2"></circle>
<path d="M15 17.5c-1.5-1-4.5-1-6 0"></path>
</svg>
{/if}
<div>
<h4 class="text-sm font-medium text-gray-900">{file.displayName}</h4>
{#if file.state === 'done' && file.size}
<p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
{:else if file.state === 'error'}
<p class="text-xs text-red-500">Error: {file.error}</p>
<div class="divide-y divide-gray-200">
{#each files as file (file.name)}
<div class="p-4 flex items-center justify-between">
<div class="flex items-center">
{#if file.displayName === 'Text PDF'}
<svg
class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="18" x2="12" y2="12"></line>
<line x1="9" y1="12" x2="15" y2="12"></line>
</svg>
{:else if file.displayName === 'Photos PDF'}
<svg
class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<circle cx="12" cy="13" r="2"></circle>
<path d="M15 17.5c-1.5-1-4.5-1-6 0"></path>
</svg>
{/if}
</div>
</div>
{#if file.state === 'idle'}
<button
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'}
<button
disabled
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="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
></div>
Generating...
<div>
<h4 class="text-sm font-medium text-gray-900">{file.displayName}</h4>
{#if file.state === 'done' && file.size}
<p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
{:else if file.state === 'error'}
<p class="text-xs text-red-500">Error: {file.error}</p>
{:else if file.state === 'generating'}
<p class="text-xs text-gray-500">Generating...</p>
{:else if file.state === 'idle'}
<p class="text-xs text-gray-500">Waiting...</p>
{/if}
</div>
</button>
{:else if file.state === 'done'}
<button
onclick={() => downloadFile(file)}
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Download
</button>
{:else if file.state === 'error'}
<button
onclick={() => handleGenerate(file.name)}
class="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700"
>
Retry
</button>
{/if}
</div>
{/each}
</div>
{#if file.state === 'idle'}
<div class="px-4 py-2 text-gray-500 text-sm">Waiting...</div>
{:else if file.state === 'generating'}
<button
disabled
aria-label="Generating..."
class="px-4 py-2 bg-gray-400 text-white rounded-md text-sm font-medium cursor-wait"
>
<div class="flex items-center justify-center">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
</button>
{:else if file.state === 'done'}
<button
onclick={() => downloadFile(file)}
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Download
</button>
{:else if file.state === 'error'}
<button
onclick={() => handleGenerate(file.name)}
class="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700"
>
Retry
</button>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(6)}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
Back to Gallery
</button>
{#if files.some((f) => f.state === 'done' || f.state === 'error')}
<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 class="mt-10">
<Navigator
{currentStep}
onForward={resetAndStartOver}
canProceed={files.some((f) => f.state === 'done' || f.state === 'error')}
textBack="Back to Gallery"
textForwardEnabled="Start Over"
textForwardDisabled="Generate PDFs to Continue"
hideForwardUntilProceedable={true}
/>
</div>
</div>
</div>

View File

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

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import type { PhotoDimensions } from '$lib/cards/types';
import PhotoCrop from './PhotoCrop.svelte';
let { photo, onCropUpdated, onRetry } = $props<{
let { photo, onCropUpdated, onRetry, photoDimensions } = $props<{
photo: {
name: string;
url: string;
@@ -13,6 +14,7 @@
};
onCropUpdated: (detail: any) => void;
onRetry: () => void;
photoDimensions: PhotoDimensions;
}>();
let showCropper = $state(false);
@@ -145,6 +147,7 @@
imageUrl={photo.objectUrl}
personName={photo.name}
initialCropData={photo.cropData}
{photoDimensions}
onClose={() => (showCropper = false)}
onCropUpdated={handleCropUpdated}
/>

View File

@@ -1,8 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { PHOTO_DIMENSIONS } from '$lib/pdfSettings';
import type { PhotoDimensions } from '$lib/cards/types';
let { imageUrl, personName, initialCropData, onCropUpdated, onClose } = $props<{
let {
imageUrl,
personName,
initialCropData,
onCropUpdated,
onClose,
photoDimensions
} = $props<{
imageUrl: string;
personName: string;
initialCropData?: { x: number; y: number; width: number; height: number };
@@ -10,6 +17,7 @@
cropData: { x: number; y: number; width: number; height: number };
}) => void;
onClose: () => void;
photoDimensions: PhotoDimensions;
}>();
let canvas: HTMLCanvasElement;
@@ -35,8 +43,8 @@
let canvasWidth = 600;
let canvasHeight = 400;
// Use the photo card aspect ratio from PDF settings (width / height)
const cropRatio = PHOTO_DIMENSIONS.width / PHOTO_DIMENSIONS.height;
// Use the photo card aspect ratio from the selected card's dimensions
const cropRatio = photoDimensions.width / photoDimensions.height;
onMount(() => {
ctx = canvas.getContext('2d')!;

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';
// Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm)

View File

@@ -6,111 +6,9 @@ export interface PageSettings {
margin: number; // mm
}
export interface CardDimensions {
width: number; // mm
height: number; // mm
}
// A4 Page dimensions in millimeters
export const PAGE_SETTINGS: PageSettings = {
pageWidth: 210,
pageHeight: 297,
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: 45,
height: 30
};
// Dimensions for a single card in the photo PDF.
export const PHOTO_CARD_DIMENSIONS: CardDimensions = {
width: 29,
height: 41
};
// Photo dimensions within the photo card
export const PHOTO_DIMENSIONS = {
width: 26, // mm
height: 36 // 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;
}
const FONT_SIZE = 8; // pt
// Text PDF Field Positions (in mm, relative to cell top-left)
export const TEXT_FIELD_LAYOUT: TextFieldLayout = {
name: {
x: 2,
y: 4,
size: FONT_SIZE
},
nationality: {
x: 2,
y: 12,
size: FONT_SIZE
},
birthday: {
x: 30,
y: 12,
size: FONT_SIZE
},
studiesAt: {
x: 2,
y: 20,
size: FONT_SIZE
},
esnSection: {
x: 2,
y: 28,
size: FONT_SIZE
},
validityStart: {
x: 30,
y: 28,
size: FONT_SIZE
}
};
// Photo PDF Field Positions (in mm, relative to cell top-left)
export const PHOTO_FIELD_LAYOUT: PhotoFieldLayout = {
photo: {
x: 2,
y: 2,
width: PHOTO_DIMENSIONS.width,
height: PHOTO_DIMENSIONS.height
},
name: {
x: 2,
y: PHOTO_DIMENSIONS.height + 4,
size: 6
}
};

View File

@@ -95,6 +95,10 @@ export const selectedSheet = writable<SheetInfoType>({ id: '', name: '', webView
// Card details for generation
export const cardDetails = writable<CardDetailsType | null>(null);
// Selected card type for generation
import type { Card } from '$lib/cards/types';
export const selectedCard = writable<Card | null>(null);
// Wizard state management
export const currentStep = writable<number>(0);
@@ -104,6 +108,8 @@ export const steps = [
'search',
'mapping',
'validation',
'card-details',
'card-select',
'gallery',
'generate'
] as const;

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