Compare commits

...

5 Commits

Author SHA1 Message Date
Roman Krček
1e96668e48 Fix signout and redesign auth step
All checks were successful
Build Docker image / build (push) Successful in 3m14s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 1m8s
2025-07-30 16:54:17 +02:00
Roman Krček
923300e49b Memory leak fixes 2025-07-30 16:36:06 +02:00
Roman Krček
1aa6cd53fa p-q and limitations to processing only 200 items 2025-07-30 16:06:44 +02:00
Roman Krček
dc1edaae84 Remove unused files 2025-07-29 15:57:33 +02:00
Roman Krček
1fde370890 Rework generation page and settings 2025-07-29 15:56:42 +02:00
13 changed files with 1097 additions and 823 deletions

View File

@@ -13,4 +13,5 @@
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
- Remain consistent in styling and code structure.
- Avoid unncessary iterations. If problems is mostly solved, stop.
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.
- Do not do what you're not being asked. Stick to scope of my request.

42
package-lock.json generated
View File

@@ -19,6 +19,8 @@
"fontkit": "^2.0.4",
"heic-convert": "^2.1.0",
"idb": "^8.0.3",
"idb-keyval": "^6.2.2",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
"uuid": "^11.1.0"
},
@@ -1013,6 +1015,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"license": "MIT"
@@ -1187,6 +1195,12 @@
"version": "8.0.3",
"license": "ISC"
},
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/is-core-module": {
"version": "2.16.1",
"dev": true,
@@ -1443,6 +1457,34 @@
}
}
},
"node_modules/p-queue": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.0.tgz",
"integrity": "sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
"p-timeout": "^6.1.2"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz",
"integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"license": "(MIT AND Zlib)"

View File

@@ -40,6 +40,8 @@
"fontkit": "^2.0.4",
"heic-convert": "^2.1.0",
"idb": "^8.0.3",
"idb-keyval": "^6.2.2",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
"uuid": "^11.1.0"
}

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

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

View File

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

View File

@@ -7,6 +7,8 @@
import PhotoCard from './subcomponents/PhotoCard.svelte';
import * as tf from '@tensorflow/tfjs';
import * as blazeface from '@tensorflow-models/blazeface';
import PQueue from 'p-queue';
import { set, clear } from 'idb-keyval';
let photos = $state<PhotoInfo[]>([]);
let isProcessing = $state(false);
@@ -14,6 +16,8 @@
let totalCount = $state(0);
let detector: blazeface.BlazeFaceModel | undefined;
let detectorPromise: Promise<void> | undefined;
let downloadQueue: PQueue;
let faceDetectionQueue: PQueue;
interface PhotoInfo {
name: string;
@@ -38,13 +42,59 @@
return detectorPromise;
}
// Force memory cleanup
async function forceMemoryCleanup() {
await tf.nextFrame(); // Wait for any pending GPU operations
// Log memory state without aggressive cleanup
const memInfo = tf.memory();
console.log('Memory status:', {
tensors: memInfo.numTensors,
dataBuffers: memInfo.numDataBuffers,
bytes: memInfo.numBytes
});
// Only run garbage collection if available, don't dispose variables
if (typeof window !== 'undefined' && 'gc' in window) {
(window as any).gc();
}
}
async function processPhotosInParallel() {
if (isProcessing) return;
console.log('Starting processPhotos in parallel...');
console.log('Starting processPhotos with queues...');
isProcessing = true;
processedCount = 0;
try {
// Clear previous session's images from IndexedDB
await clear();
console.log('Cleared IndexedDB.');
} catch (e) {
console.error('Could not clear IndexedDB:', e);
}
// Initialize queues with more conservative concurrency
downloadQueue = new PQueue({ concurrency: 3 }); // Reduced from 5
faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety
// When both queues are idle, we're done
downloadQueue.on('idle', async () => {
if (faceDetectionQueue.size === 0 && faceDetectionQueue.pending === 0) {
await forceMemoryCleanup(); // Clean up memory when processing is complete
isProcessing = false;
console.log('All queues are idle. Processing complete.');
}
});
faceDetectionQueue.on('idle', async () => {
if (downloadQueue.size === 0 && downloadQueue.pending === 0) {
await forceMemoryCleanup(); // Clean up memory when processing is complete
isProcessing = false;
console.log('All queues are idle. Processing complete.');
}
});
const validRows = $filteredSheetData.filter((row) => row._isValid);
const photoUrls = new Set<string>();
const photoMap = new Map<string, any[]>();
@@ -62,7 +112,7 @@
});
totalCount = photoUrls.size;
console.log(`Found ${totalCount} unique photo URLs`);
console.log(`Found ${totalCount} unique photo URLs to process.`);
photos = Array.from(photoUrls).map((url) => ({
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname,
@@ -72,26 +122,10 @@
faceDetectionStatus: 'pending' as const
}));
const concurrencyLimit = 5;
const promises = [];
// Add all photos to the download queue
for (let i = 0; i < photos.length; i++) {
const promise = (async () => {
await loadPhoto(i);
processedCount++;
})();
promises.push(promise);
if (promises.length >= concurrencyLimit) {
await Promise.all(promises);
promises.length = 0;
}
downloadQueue.add(() => loadPhoto(i));
}
await Promise.all(promises);
isProcessing = false;
console.log('All photos processed.');
}
// Initialize detector and process photos
@@ -148,6 +182,8 @@
} catch (error) {
console.error(`Failed to load photo for ${photo.name}:`, error);
photo.status = 'error';
// Since this step failed, we still count it as "processed" to not stall the progress bar
processedCount++;
}
}
@@ -175,6 +211,8 @@
} catch (e) {
console.error(`Failed to convert HEIC image for ${photo.name}:`, e);
photo.status = 'error';
// Since this step failed, we still count it as "processed" to not stall the progress bar
processedCount++;
}
}
@@ -198,12 +236,14 @@
photo.status = 'success';
console.log(`Photo loaded successfully: ${photo.name}`);
// Save to pictures store
// 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) => ({
...pics,
[photo.url]: {
id: photo.url,
blob: blob,
url: objectUrl,
downloaded: true,
faceDetected: false,
@@ -211,32 +251,47 @@
}
}));
// Automatically run face detection to generate crop
await detectFaceForPhoto(index);
// Add face detection to its queue
faceDetectionQueue.add(() => detectFaceForPhoto(index));
} catch (error) {
console.error(`Failed to process blob for ${photo.name}:`, error);
photo.status = 'error';
// Since this step failed, we still count it as "processed" to not stall the progress bar
processedCount++;
}
}
async function detectFaceForPhoto(index: number) {
const photo = photos[index];
let imageTensor;
try {
await initializeDetector(); // Ensure detector is loaded
if (!detector) {
photos[index].faceDetectionStatus = 'failed';
photo.faceDetectionStatus = 'failed';
console.error('Face detector not available.');
return;
}
photos[index].faceDetectionStatus = 'processing';
photo.faceDetectionStatus = 'processing';
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = photos[index].objectUrl!;
img.src = photo.objectUrl!;
await new Promise((r, e) => {
img.onload = r;
img.onerror = e;
});
const predictions = await detector.estimateFaces(img, false);
// Create tensor and manually dispose it after use
imageTensor = tf.browser.fromPixels(img);
const predictions = await detector.estimateFaces(imageTensor, false);
// Log memory usage for debugging
const memInfo = tf.memory();
console.log(`TensorFlow.js memory after face detection for ${photo.name}:`, {
numTensors: memInfo.numTensors,
numDataBuffers: memInfo.numDataBuffers,
numBytes: memInfo.numBytes
});
if (predictions.length > 0) {
const getProbability = (p: number | tf.Tensor) =>
@@ -245,26 +300,27 @@
const face = predictions.sort(
(a, b) => getProbability(b.probability!) - getProbability(a.probability!)
)[0];
// Coordinates in displayed image space
let [x1, y1] = face.topLeft as [number, number];
let [x2, y2] = face.bottomRight as [number, number];
// Scale to natural image size
const topLeft = face.topLeft as [number, number];
const bottomRight = face.bottomRight as [number, number];
let [x1, y1] = topLeft;
let [x2, y2] = bottomRight;
const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height;
const faceWidth = (x2 - x1) * scaleX;
const faceHeight = (y2 - y1) * scaleY;
const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX;
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
// Load crop config from env
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0');
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');
// Compute crop size and center
let cropWidth = faceWidth * cropScale;
let cropHeight = cropWidth / cropRatio;
// If crop is larger than image, scale it down while maintaining aspect ratio
if (cropWidth > img.naturalWidth || cropHeight > img.naturalHeight) {
const widthRatio = img.naturalWidth / cropWidth;
const heightRatio = img.naturalHeight / cropHeight;
@@ -276,9 +332,11 @@
let centerX = faceCenterX + cropWidth * offsetX;
let centerY = faceCenterY + cropHeight * offsetY;
// Clamp center to ensure crop fits
centerX = Math.max(cropWidth / 2, Math.min(centerX, img.naturalWidth - cropWidth / 2));
centerY = Math.max(cropHeight / 2, Math.min(centerY, img.naturalHeight - cropHeight / 2));
centerY = Math.max(
cropHeight / 2,
Math.min(centerY, img.naturalHeight - cropHeight / 2)
);
const cropX = centerX - cropWidth / 2;
const cropY = centerY - cropHeight / 2;
@@ -289,32 +347,40 @@
width: Math.round(cropWidth),
height: Math.round(cropHeight)
};
photos[index].cropData = crop;
photos[index].faceDetectionStatus = 'completed';
photo.cropData = crop;
photo.faceDetectionStatus = 'completed';
// Save crop data to store
cropRects.update((crops) => ({
...crops,
[photos[index].url]: crop
[photo.url]: crop
}));
// Update pictures store with face detection info
pictures.update((pics) => ({
...pics,
[photos[index].url]: {
...pics[photos[index].url],
[photo.url]: {
...pics[photo.url],
faceDetected: true,
faceCount: predictions.length
}
}));
} else {
photos[index].faceDetectionStatus = 'failed';
photo.faceDetectionStatus = 'failed';
}
} catch (error) {
console.error(`Face detection failed for ${photos[index].name}:`, error);
photos[index].faceDetectionStatus = 'failed';
console.error(`Face detection failed for ${photo.name}:`, error);
photo.faceDetectionStatus = 'failed';
} finally {
// Manually dispose of the input tensor to prevent memory leaks
if (imageTensor) {
imageTensor.dispose();
}
// Add a small delay to allow GPU memory to be freed before next operation
await new Promise(resolve => setTimeout(resolve, 100));
// This is the final step for a photo, so we increment the processed count here.
processedCount++;
}
// No need to reassign photos array with $state reactivity
}
async function retryPhoto(index: number) {
@@ -325,7 +391,8 @@
}
photo.retryCount++;
await loadPhoto(index, true);
// Add the retry attempt back to the download queue
downloadQueue.add(() => loadPhoto(index, true));
}
function handleCropUpdate(
@@ -364,6 +431,13 @@
// Cleanup on unmount using $effect
$effect(() => {
return () => {
// Clear queues on component unmount to stop any ongoing processing
if (downloadQueue) {
downloadQueue.clear();
}
if (faceDetectionQueue) {
faceDetectionQueue.clear();
}
cleanupObjectUrls();
};
});

View File

@@ -1,34 +0,0 @@
<script lang="ts">
let { canProceed, photos, currentStep } = $props<{
canProceed: () => boolean;
photos: any[];
currentStep: any;
}>();
function handleBack() {
currentStep.set(4);
}
function handleNext() {
currentStep.set(6);
}
</script>
<div class="flex justify-between">
<button
onclick={handleBack}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
← Back to Row Filter
</button>
<button
onclick={handleNext}
disabled={!canProceed()}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{canProceed()
? `Generate ${photos.filter(p => p.status === 'success' && p.cropData).length} Cards `
: 'Waiting for photos to load and crop'}
</button>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -108,24 +108,18 @@
});
// Initially select rows based on validity and "Already Printed" status
selectedRows = new Set(
processedData
.filter((row) => {
if (!row._isValid) return false;
const rowsToConsider = processedData.filter((row) => {
if (!row._isValid) return false;
const alreadyPrinted = row.alreadyPrinted;
if (alreadyPrinted) {
const value = String(alreadyPrinted).toLowerCase().trim();
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
}
return true;
});
// Check "Already Printed" column value
const alreadyPrinted = row.alreadyPrinted;
if (alreadyPrinted) {
const value = String(alreadyPrinted).toLowerCase().trim();
// If the value is "true", "yes", "1", or any truthy value, don't select
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
}
// If empty or falsy, select the row
return true;
})
.map((row) => row._rowIndex)
);
const initialSelection = rowsToConsider.map((row) => row._rowIndex);
selectedRows = new Set(initialSelection);
updateSelectAllState();
} finally {
@@ -145,19 +139,21 @@
function toggleSelectAll() {
if (selectAll) {
// Deselect all visible valid rows that aren't already printed
// Deselect all visible valid rows
filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
if (row._isValid) {
selectedRows.delete(row._rowIndex);
}
});
} else {
// Select all visible valid rows that aren't already printed
filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.add(row._rowIndex);
}
});
const rowsToSelect = filteredData.filter(
(row) => row._isValid && !isRowAlreadyPrinted(row) && !selectedRows.has(row._rowIndex)
);
for (const row of rowsToSelect) {
selectedRows.add(row._rowIndex);
}
}
selectedRows = new Set(selectedRows);
updateSelectAllState();

View File

@@ -78,7 +78,7 @@
{#if photo.status === 'loading'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center">
<div class="h-48 bg-gray-200 flex items-center justify-center">
<div class="flex flex-col items-center">
<div
class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"
@@ -94,7 +94,7 @@
{:else if photo.status === 'success' && photo.objectUrl}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative">
<div
class="h-48 bg-gray-100 flex items-center justify-center relative overflow-hidden"
class="h-48 bg-gray-200 flex items-center justify-center relative overflow-hidden"
bind:this={imageContainer}
>
<img
@@ -152,7 +152,7 @@
</div>
{:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center">
<div class="h-48 bg-gray-200 flex items-center justify-center">
<div class="flex flex-col items-center text-center p-4">
<svg
class="w-12 h-12 text-red-400 mb-2"

View File

@@ -81,13 +81,33 @@ export function handleSignIn() {
}
export function handleSignOut() {
const token = gapi.client.getToken();
if (token !== null) {
google.accounts.oauth2.revoke(token.access_token, () => {
gapi.client.setToken(null);
isSignedIn.set(false);
});
}
const savedToken = localStorage.getItem(TOKEN_KEY);
if (savedToken) {
try {
const tokenData = JSON.parse(savedToken);
if (tokenData.access_token) {
google.accounts.oauth2.revoke(tokenData.access_token, () => {
console.log('User token revoked.');
});
}
} catch (e) {
console.error('Error parsing token from localStorage', e);
}
}
// Disables automatic sign-in on the next page load.
google.accounts.id.disableAutoSelect();
// Clear gapi client token
gapi.client.setToken(null);
// Clear token from localStorage
localStorage.removeItem(TOKEN_KEY);
// Update signed in state
isSignedIn.set(false);
console.log('User signed out.');
}
export async function searchSheets(query: string) {

View File

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

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

@@ -0,0 +1,96 @@
// User-configurable settings for PDF generation
export interface PageSettings {
pageWidth: number; // mm
pageHeight: number; // mm
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: 10
};
// 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
}
};