Compare commits

...

6 Commits

Author SHA1 Message Date
Roman Krček
68e4d0b77b Add loader for google services
All checks were successful
Build Docker image / build (push) Successful in 8m8s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 30s
2025-09-08 16:04:50 +02:00
Roman Krček
e43101648b Recent sheets per user 2025-09-08 15:45:36 +02:00
Roman Krček
c6c3bbc024 Fix manual resizing 2025-09-08 15:19:05 +02:00
Roman Krček
d845021f7e Allow to search all drives 2025-09-07 22:37:10 +02:00
Roman Krček
dcba02260a Add button to sheet and shift selector 2025-09-07 22:15:03 +02:00
Roman Krček
9de5646519 Change to just full name 2025-09-07 22:03:21 +02:00
14 changed files with 344 additions and 293 deletions

4
.github/styling.md vendored
View File

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

View File

@@ -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 -->

View File

@@ -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">

View File

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

View File

@@ -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">

View File

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

View File

@@ -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,

View File

@@ -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">

View File

@@ -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">

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { selectedSheet, currentStep } from '$lib/stores'; import { selectedSheet, currentStep } from '$lib/stores';
import type { SheetInfoType } from '$lib/stores'; import type { SheetInfoType } from '$lib/stores';
import { searchSheets, ensureToken } from '$lib/google'; import { searchSheets, ensureToken, userEmail } from '$lib/google';
import { hashString } from '$lib/utils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte'; import Navigator from './subcomponents/Navigator.svelte';
@@ -12,7 +13,13 @@
let hasSearched = $state(false); let hasSearched = $state(false);
let recentSheets = $state<any[]>([]); let recentSheets = $state<any[]>([]);
const RECENT_SHEETS_KEY = '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 = [];
} }
} }

View File

@@ -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">

View File

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

View File

@@ -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
View File

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