Compare commits
6 Commits
2b3371e67f
...
68e4d0b77b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e4d0b77b | ||
|
|
e43101648b | ||
|
|
c6c3bbc024 | ||
|
|
d845021f7e | ||
|
|
dcba02260a | ||
|
|
9de5646519 |
4
.github/styling.md
vendored
4
.github/styling.md
vendored
@@ -89,7 +89,7 @@ This document outlines the design system and styling conventions used in the app
|
|||||||
|
|
||||||
### Container Pattern
|
### 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">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<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-10 flex flex-col items-center">
|
||||||
<!-- Animated ESN Logo -->
|
<!-- Animated ESN Logo -->
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</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'}
|
{#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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</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">Select Card Type</h2>
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">Select Card Type</h2>
|
||||||
<p class="text-sm text-gray-700 mb-4">
|
<p class="text-sm text-gray-700 mb-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85)
|
|||||||
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,
|
||||||
|
|||||||
@@ -388,7 +388,6 @@
|
|||||||
|
|
||||||
// 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 studiesAt = studiesAtAll;
|
||||||
@@ -404,7 +403,7 @@
|
|||||||
PAGE_SETTINGS.pageHeight,
|
PAGE_SETTINGS.pageHeight,
|
||||||
card.textFields.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)
|
||||||
@@ -608,14 +607,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,
|
||||||
card.photoFields.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)
|
||||||
@@ -671,7 +669,7 @@
|
|||||||
</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">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">
|
<p class="text-sm text-gray-700 mb-4">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
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;
|
const ROW_LIMIT = 200;
|
||||||
|
|
||||||
@@ -50,10 +51,9 @@
|
|||||||
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) {
|
if (!name && !pictureUrl) {
|
||||||
return null; // Skip entirely empty rows
|
return null; // Skip entirely empty rows
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,12 +62,11 @@
|
|||||||
? (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: mapping.birthday !== -1 ? row[mapping.birthday] || '' : '',
|
||||||
pictureUrl,
|
pictureUrl,
|
||||||
@@ -87,20 +86,52 @@
|
|||||||
} 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 toggleRow(id: string) {
|
|
||||||
const row = rows.find((r) => r.id === id);
|
|
||||||
if (row && row._valid) {
|
|
||||||
row._checked = !row._checked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to toggle select-all: selects first 200 eligible items in current view
|
// Function to toggle select-all: selects first 200 eligible items in current view
|
||||||
function toggleSelectAll(event: Event) {
|
function toggleSelectAll(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
@@ -171,56 +202,62 @@
|
|||||||
<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. Select a batch of max 200 items by using the top checkbox.
|
Review your data and select which rows to include. Select a batch of max 200 items by using
|
||||||
|
the top checkbox.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-700">
|
<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.
|
Already printed or invalid data is marked in the status column.
|
||||||
</p>
|
</p>
|
||||||
{#if $selectedSheet?.id}
|
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
|
||||||
Need to make changes?
|
|
||||||
<a
|
|
||||||
href={`https://docs.google.com/spreadsheets/d/${$selectedSheet.id}/edit`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-blue-600 underline hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Open the spreadsheet
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex flex-col space-y-2">
|
||||||
onclick={fetchAndProcessData}
|
{#if $selectedSheet?.id}
|
||||||
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"
|
<a
|
||||||
disabled={isLoading}
|
href={`https://docs.google.com/spreadsheets/d/${$selectedSheet.id}/edit`}
|
||||||
>
|
target="_blank"
|
||||||
{#if isLoading}
|
rel="noopener noreferrer"
|
||||||
<svg
|
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"
|
||||||
class="-ml-1 mr-2 h-5 w-5 animate-spin text-gray-500"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<circle
|
Open Sheet
|
||||||
class="opacity-25"
|
</a>
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Refreshing...
|
|
||||||
{:else}
|
|
||||||
Refresh Data
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
<button
|
||||||
|
onclick={fetchAndProcessData}
|
||||||
|
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-wait disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{#if isLoading}
|
||||||
|
<svg
|
||||||
|
class="-ml-1 mr-2 h-5 w-5 animate-spin text-gray-500"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Refreshing...
|
||||||
|
{:else}
|
||||||
|
Refresh Data
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
@@ -277,11 +314,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"
|
||||||
@@ -319,12 +352,11 @@
|
|||||||
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 || (selectedCount >= ROW_LIMIT && !row._checked)}
|
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">
|
||||||
|
|||||||
@@ -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 = 'recentSheets';
|
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 = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,7 @@
|
|||||||
|
|
||||||
// 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;
|
||||||
@@ -135,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) {
|
||||||
@@ -165,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 };
|
||||||
}
|
}
|
||||||
@@ -197,87 +157,14 @@
|
|||||||
|
|
||||||
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';
|
||||||
@@ -287,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;
|
||||||
@@ -352,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">
|
||||||
|
|||||||
@@ -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 || [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,73 +12,69 @@ 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;
|
alreadyPrinted: number;
|
||||||
alreadyPrinted: number;
|
sheetName: string;
|
||||||
sheetName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data structure for a row in the sheet
|
// Data structure for a row in the sheet
|
||||||
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;
|
alreadyPrinted: boolean;
|
||||||
alreadyPrinted: boolean;
|
_rowIndex: number;
|
||||||
_rowIndex: number;
|
_checked: boolean;
|
||||||
_checked: boolean;
|
_valid: boolean;
|
||||||
_valid: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picture storage and metadata
|
// Picture storage and metadata
|
||||||
export interface PictureBlobInfoType {
|
export interface PictureBlobInfoType {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
faceDetected: boolean;
|
faceDetected: boolean;
|
||||||
faceCount: number;
|
faceCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CropType rectangles for each photo
|
// CropType rectangles for each photo
|
||||||
export interface CropType {
|
export interface CropType {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google Sheets list for search
|
// Google Sheets list for search
|
||||||
export interface SheetInfoType {
|
export interface SheetInfoType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
webViewLink: string;
|
webViewLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card details type
|
// Card details type
|
||||||
export interface CardDetailsType {
|
export interface CardDetailsType {
|
||||||
esnSection: string;
|
esnSection: string;
|
||||||
studiesAt: 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,
|
nationality: -1,
|
||||||
surname: -1,
|
birthday: -1,
|
||||||
nationality: -1,
|
pictureUrl: -1,
|
||||||
birthday: -1,
|
alreadyPrinted: -1,
|
||||||
pictureUrl: -1,
|
sheetName: ''
|
||||||
alreadyPrinted: -1,
|
});
|
||||||
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[]>([]);
|
||||||
@@ -103,35 +99,35 @@ export const selectedCard = writable<Card | null>(null);
|
|||||||
export const currentStep = writable<number>(0);
|
export const currentStep = writable<number>(0);
|
||||||
|
|
||||||
export const steps = [
|
export const steps = [
|
||||||
'splash',
|
'splash',
|
||||||
'auth',
|
'auth',
|
||||||
'search',
|
'search',
|
||||||
'mapping',
|
'mapping',
|
||||||
'validation',
|
'validation',
|
||||||
'card-details',
|
'card-details',
|
||||||
'card-select',
|
'card-select',
|
||||||
'gallery',
|
'gallery',
|
||||||
'generate'
|
'generate'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WizardStep = typeof steps[number];
|
export type WizardStep = typeof steps[number];
|
||||||
|
|
||||||
export const currentStepName = derived(
|
export const currentStepName = derived(
|
||||||
currentStep,
|
currentStep,
|
||||||
($currentStep) => steps[$currentStep]
|
($currentStep) => steps[$currentStep]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Progress tracking
|
// Progress tracking
|
||||||
export interface ProgressState {
|
export interface ProgressState {
|
||||||
stage: string;
|
stage: string;
|
||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const progress = writable<ProgressState>({
|
export const progress = writable<ProgressState>({
|
||||||
stage: '',
|
stage: '',
|
||||||
current: 0,
|
current: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
message: ''
|
message: ''
|
||||||
});
|
});
|
||||||
|
|||||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Hashes a string using the SHA-256 algorithm.
|
||||||
|
* @param input The string to hash.
|
||||||
|
* @returns A promise that resolves to the hex-encoded hash string.
|
||||||
|
*/
|
||||||
|
export async function hashString(input: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user