Fixed sheet local storage

This commit is contained in:
Roman Krček
2025-08-06 14:35:12 +02:00
parent 7fb72c7d75
commit c6cc9c6658
7 changed files with 309 additions and 579 deletions

View File

@@ -14,4 +14,5 @@
- Remain consistent in styling and code structure. - Remain consistent in styling and code structure.
- Avoid unncessary iterations. If problems is mostly solved, stop. - Avoid unncessary iterations. If problems is mostly solved, stop.
- Split big components into subcomponents. Always create smaller subcomponents for better context management later. - Split big components into subcomponents. Always create smaller subcomponents for better context management later.
- Do not do what you're not being asked. Stick to scope of my request. - Do not do what you're not being asked. Stick to scope of my request.
- Do not edit stores.ts ! Unless is explicitly allow you to.

7
package-lock.json generated
View File

@@ -15,7 +15,6 @@
"@types/gapi.client.drive-v3": "^0.0.5", "@types/gapi.client.drive-v3": "^0.0.5",
"@types/gapi.client.sheets-v4": "^0.0.4", "@types/gapi.client.sheets-v4": "^0.0.4",
"@types/google.accounts": "^0.0.17", "@types/google.accounts": "^0.0.17",
"@types/uuid": "^10.0.0",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
"idb": "^8.0.3", "idb": "^8.0.3",
@@ -29,6 +28,7 @@
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/uuid": "^10.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
@@ -631,6 +631,9 @@
}, },
"node_modules/@types/uuid": { "node_modules/@types/uuid": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@webgpu/types": { "node_modules/@webgpu/types": {
@@ -2019,6 +2022,8 @@
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"

View File

@@ -18,6 +18,7 @@
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/uuid": "^10.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
@@ -36,7 +37,6 @@
"@types/gapi.client.drive-v3": "^0.0.5", "@types/gapi.client.drive-v3": "^0.0.5",
"@types/gapi.client.sheets-v4": "^0.0.4", "@types/gapi.client.sheets-v4": "^0.0.4",
"@types/google.accounts": "^0.0.17", "@types/google.accounts": "^0.0.17",
"@types/uuid": "^10.0.0",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
"idb": "^8.0.3", "idb": "^8.0.3",

View File

@@ -5,17 +5,15 @@
import StepColumnMap from './wizard/StepColumnMap.svelte'; import StepColumnMap from './wizard/StepColumnMap.svelte';
import StepRowFilter from './wizard/StepRowFilter.svelte'; import StepRowFilter from './wizard/StepRowFilter.svelte';
import StepCardDetails from './wizard/StepCardDetails.svelte'; import StepCardDetails from './wizard/StepCardDetails.svelte';
import StepGallery from './wizard/StepGallery.svelte'; // import StepGallery from './wizard/StepGallery.svelte';
import StepGenerate from './wizard/StepGenerate.svelte'; // import StepGenerate from './wizard/StepGenerate.svelte';
const steps = [ const steps = [
StepAuth, StepAuth,
StepSheetSearch, StepSheetSearch,
StepColumnMap, StepColumnMap,
StepRowFilter, StepRowFilter,
StepCardDetails, StepCardDetails
StepGallery,
StepGenerate
]; ];
const stepTitles = [ const stepTitles = [
@@ -23,9 +21,7 @@
'Select Sheet', 'Select Sheet',
'Map Columns', 'Map Columns',
'Filter Rows', 'Filter Rows',
'Card Details', 'Enter Card Details'
'Review Photos',
'Generate PDFs'
]; ];
</script> </script>

View File

@@ -1,251 +1,118 @@
<script lang="ts"> <script lang="ts">
import { import {
selectedSheet, sheetData,
columnMapping, columnMapping,
rawSheetData, selectedSheet,
filteredSheetData, currentStep,
currentStep
} from '$lib/stores'; } from '$lib/stores';
import type { RowData } from '$lib/stores';
import { getSheetData } from '$lib/google';
import { v4 as uuid } from 'uuid';
import Navigator from './subcomponents/Navigator.svelte'; import Navigator from './subcomponents/Navigator.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSheetNames, getSheetData } from '$lib/google';
let searchTerm = ''; let isLoading = $state(true);
let sortColumn = ''; let error = $state<string | null>(null);
let sortDirection: 'asc' | 'desc' = 'asc'; let rows = $state<RowData[]>([]);
let selectedRows = new Set<number>();
let selectAll = false;
let processedData = $state<any[]>([]);
let headers: string[] = [];
let isLoading = $state(false);
let showAlreadyPrinted = $state(false);
// Use $state for displayData instead of $derived to avoid TypeScript errors let sortColumn = $state<keyof RowData | null>(null);
let displayData = $state<any[]>([]); let sortDirection = $state<'asc' | 'desc'>('asc');
// Update displayData whenever relevant values change
$effect(() => {
// Debug log at the start
console.log('Updating displayData from processedData:', processedData);
// If processedData is empty, return empty array
if (!processedData || !processedData.length) {
displayData = [];
return;
}
// Clone array to avoid mutations
let data = [...processedData];
console.log('Initial data length:', data.length);
// 1. Filter by search term // Fetch and process data from the Google Sheet
if (searchTerm.trim()) { async function fetchAndProcessData() {
data = data.filter((row) => isLoading = true;
Object.values(row).some((value) => error = null;
String(value)
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
);
console.log('After search term filter:', data.length);
}
// 2. Filter out already printed rows if collapsed
if (!showAlreadyPrinted) {
data = data.filter((row) => !isRowAlreadyPrinted(row));
console.log('After already printed filter:', data.length);
}
// 3. Sort the data
if (sortColumn) {
data = [...data].sort((a, b) => {
const aVal = String(a[sortColumn] || '').toLowerCase();
const bVal = String(b[sortColumn] || '').toLowerCase();
if (sortDirection === 'asc') {
return aVal.localeCompare(bVal);
} else {
return bVal.localeCompare(aVal);
}
});
}
console.log('Final filtered data:', data);
displayData = data;
});
onMount(() => {
console.log('StepRowFilter mounted');
processSheetData();
});
// Fetch raw sheet data from Google Sheets if not already loaded
async function fetchRawSheetData() {
console.log('Fetching raw sheet data...');
try { try {
if (!$selectedSheet) { const sheet = $selectedSheet;
console.error('No sheet selected'); const mapping = $columnMapping;
if (!sheet || !mapping || !mapping.sheetName) {
error = 'Sheet information or column mapping is missing.';
isLoading = false;
rows = [];
return; return;
} }
const sheetNames = await getSheetNames($selectedSheet.spreadsheetId);
if (sheetNames.length === 0) return;
const sheetName = sheetNames[0];
const range = `${sheetName}!A:Z`;
const data = await getSheetData($selectedSheet.spreadsheetId, range);
console.log('Fetched data:', data);
rawSheetData.set(data);
} catch (e) {
console.error('Error fetching raw sheet data:', e);
// Re-throw the error to be caught by the caller
throw e;
}
}
async function processSheetData() { const range = `${mapping.sheetName}!A:Z`;
isLoading = true; const rawData = await getSheetData(sheet.id, range);
try {
// Get headers from the mapping
headers = Object.keys($columnMapping);
await fetchRawSheetData(); if (!rawData || rawData.length < 2) {
// Handle case with no data or only headers
rows = [];
isLoading = false;
return;
}
// Debug logging const dataRows = rawData.slice(1);
console.log('Raw sheet data:', $rawSheetData);
console.log('Column mapping:', $columnMapping);
// Clear existing state before processing new data const processedData = dataRows
selectedRows = new Set(); .map((row, index): RowData | null => {
const name = mapping.name !== -1 ? row[mapping.name] || '' : '';
// Process the data starting from row 2 (skip header row) const surname = mapping.surname !== -1 ? row[mapping.surname] || '' : '';
processedData = $rawSheetData.slice(1).map((row, index) => { const pictureUrl = mapping.pictureUrl !== -1 ? row[mapping.pictureUrl] || '' : '';
const processedRow: any = {
_rowIndex: index + 1, // Store original row index
_isValid: true
};
// Map each column according to the column mapping if (!name && !surname && !pictureUrl) {
for (const [field, columnIndex] of Object.entries($columnMapping)) { return null; // Skip entirely empty rows
if (columnIndex !== -1 && columnIndex !== undefined && columnIndex < row.length) {
processedRow[field] = row[columnIndex] || '';
} else {
processedRow[field] = '';
// Only mark as invalid if it's a required field
if (field !== 'alreadyPrinted') {
processedRow._isValid = false;
}
} }
}
// Check if all required fields have values (excluding alreadyPrinted) const alreadyPrinted =
const requiredFields = ['name', 'surname', 'nationality', 'birthday', 'pictureUrl']; mapping.alreadyPrinted !== -1
const hasAllRequiredFields = requiredFields.every( ? (row[mapping.alreadyPrinted] || '').toLowerCase() === 'true'
(field) => processedRow[field] && String(processedRow[field]).trim() !== '' : false;
);
if (!hasAllRequiredFields) { const isValid = !!(name && surname && pictureUrl);
processedRow._isValid = false;
}
return processedRow; return {
}); id: uuid(),
name,
surname,
nationality: mapping.nationality !== -1 ? row[mapping.nationality] || '' : '',
birthday: mapping.birthday !== -1 ? row[mapping.birthday] || '' : '',
pictureUrl,
alreadyPrinted,
_rowIndex: index + 2, // Sheet rows are 1-based, plus header
_valid: isValid,
_checked: isValid && !alreadyPrinted
};
})
.filter((row): row is RowData => row !== null);
// Initially select rows based on validity and "Already Printed" status rows = processedData;
const rowsToConsider = processedData.filter((row) => { } catch (e: any) {
if (!row._isValid) return false; error = e.message || 'An unknown error occurred while fetching data.';
const alreadyPrinted = row.alreadyPrinted; console.error(e);
if (alreadyPrinted) { rows = [];
const value = String(alreadyPrinted).toLowerCase().trim();
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
}
return true;
});
const initialSelection = rowsToConsider.map((row) => row._rowIndex);
// Make sure we create a completely new Set for reactivity
selectedRows = new Set([...initialSelection]);
// Update UI state
updateSelectAllState();
updateSelectedValidCount();
// Debug logging
console.log('Processed data:', processedData);
console.log('Display data:', displayData);
console.log('Selected rows:', selectedRows);
console.log('Selected valid count after initialization:', selectedValidCount);
} catch (e) {
console.error('Error processing sheet data:', e);
} finally { } finally {
isLoading = false; isLoading = false;
} }
} } // Run on component mount
onMount(() => {
fetchAndProcessData();
});
function toggleRowSelection(rowIndex: number) { // Function to toggle a single row's checked state
if (selectedRows.has(rowIndex)) { function toggleRow(id: string) {
selectedRows.delete(rowIndex); const row = rows.find((r) => r.id === id);
} else { if (row && row._valid) {
selectedRows.add(rowIndex); row._checked = !row._checked;
} }
// Force reactivity with new Set console.log("toggleRow", id, row?._checked);
selectedRows = new Set([...selectedRows]);
console.log('Toggled row selection, new selectedRows size:', selectedRows.size);
updateSelectAllState();
updateSelectedValidCount();
console.log('After toggle - canProceed:', canProceed);
console.log('After toggle - selectedValidCount > 0:', selectedValidCount > 0);
console.log('After toggle - selectedValidCount:', selectedValidCount);
} }
function toggleSelectAll() { // Function to toggle all valid rows
// Toggle selectAll state first function toggleSelectAll(event: Event) {
selectAll = !selectAll; const target = event.target as HTMLInputElement;
console.log('Toggle select all clicked, new state:', selectAll); const shouldCheck = target.checked;
if (!selectAll) { rows.forEach((row) => {
// If now unchecked, deselect all visible valid rows if (row._valid && !row.alreadyPrinted) {
displayData.forEach((row) => { row._checked = shouldCheck;
if (row._isValid) { }
selectedRows.delete(row._rowIndex); });
}
});
} else {
// If now checked, select all visible valid rows
displayData.forEach((row) => {
if (row._isValid) {
selectedRows.add(row._rowIndex);
}
});
}
// Force reactivity with new Set
selectedRows = new Set([...selectedRows]);
console.log('Toggled select all, new selectedRows size:', selectedRows.size);
updateSelectedValidCount();
console.log('Selected valid count after toggleSelectAll:', selectedValidCount);
} }
function updateSelectAllState() { // Function to handle sorting
// Find all valid rows that are currently visible function sortBy(column: keyof RowData) {
const visibleValidRows = displayData.filter(row => row._isValid);
if (visibleValidRows.length === 0) {
// No valid rows to select
selectAll = false;
return;
}
// Check if all visible valid rows are selected
const allSelected = visibleValidRows.every(row => selectedRows.has(row._rowIndex));
// Update selectAll state
selectAll = allSelected;
console.log('updateSelectAllState: selectAll =', selectAll,
', visibleValidRows =', visibleValidRows.length,
', selectedRows size =', selectedRows.size);
}
function handleSort(column: string) {
if (sortColumn === column) { if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else { } else {
@@ -254,374 +121,251 @@
} }
} }
function getFieldLabel(field: string): string { // Derived state for sorted data to be displayed
const labels: { [key: string]: string } = { const displayData = $derived.by(() => {
name: 'First Name', if (!sortColumn) return rows;
surname: 'Last Name',
nationality: 'Nationality',
birthday: 'Birthday',
pictureUrl: 'Photo URL',
alreadyPrinted: 'Already Printed'
};
return labels[field] || field;
}
function isRowAlreadyPrinted(row: any): boolean { return [...rows].sort((a, b) => {
const alreadyPrinted = row.alreadyPrinted; const aValue = a[sortColumn];
if (!alreadyPrinted) return false; const bValue = b[sortColumn];
const value = String(alreadyPrinted).toLowerCase().trim(); if (aValue === bValue) return 0;
return value === 'true' || value === 'yes' || value === '1' || value === 'x';
} let comparison = 0;
if (aValue > bValue) {
comparison = 1;
} else {
comparison = -1;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
});
// Derived state to determine if the "Select All" checkbox should be checked
const allValidRowsSelected = $derived.by(() => {
const validRows = rows.filter((row) => row._valid && !row.alreadyPrinted);
if (validRows.length === 0) return false;
return validRows.every((row) => row._checked);
});
const selectedCount = $derived(rows.filter((row) => row._checked).length);
function handleContinue() { function handleContinue() {
// Filter the data to only include selected rows $sheetData = rows.filter((row) => row._checked);
const selectedData = processedData.filter(
(row) => selectedRows.has(row._rowIndex) && row._isValid
);
// Store the filtered data
filteredSheetData.set(selectedData);
} }
// Use $state for selectedValidCount
let selectedValidCount = $state(0);
// Create a dedicated function to recalculate selectedValidCount
function updateSelectedValidCount() {
// Get array of row indices
const rowIndices = Array.from(selectedRows);
console.log('Selected row indices:', rowIndices);
// Count valid rows
let count = 0;
for (const rowIndex of rowIndices) {
// Find the row in processedData
const row = processedData.find(r => r._rowIndex === rowIndex);
if (row && row._isValid) {
count++;
}
}
console.log('Setting selectedValidCount to:', count, 'from', selectedValidCount);
selectedValidCount = count;
}
// Update count when selectedRows or processedData changes
$effect(() => {
// Track dependencies explicitly
const rowsSize = selectedRows.size;
const dataSize = processedData.length;
console.log('Effect triggered - selectedRows size:', rowsSize, 'processedData size:', dataSize);
updateSelectedValidCount();
});
// Allow proceeding only if at least one valid row is selected
let canProceed = $derived(selectedValidCount > 0);
</script> </script>
<div class="p-6"> <div class="container max-w-none p-6">
<div class="mb-6"> <div class="mb-4 flex items-center justify-between">
<h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2> <div>
<h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
<p class="mb-4 text-sm text-gray-700"> <p class="text-sm text-gray-700">
Review your data and select which rows you want to include in the card generation. Only rows Review your data and select which rows to include. Invalid or already printed rows are
with all required fields will be available for selection. disabled.
</p> </p>
</div> {#if $selectedSheet?.id}
<p class="mt-1 text-sm text-gray-500">
<!-- Search and Filter Controls --> Need to make changes?
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4"> <a
<div class="flex flex-col gap-4 sm:flex-row"> href={`https://docs.google.com/spreadsheets/d/${$selectedSheet.id}/edit`}
<!-- Search --> target="_blank"
<div class="flex-grow"> rel="noopener noreferrer"
<label for="search" class="mb-2 block text-sm font-medium text-gray-700"> class="text-blue-600 underline hover:text-blue-800"
Search rows >
</label> Open Google Sheet
<input </a>
id="search" </p>
type="text" {/if}
bind:value={searchTerm}
placeholder="Search in any field..."
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<!-- Sort -->
<div class="sm:w-48">
<label for="sort" class="mb-2 block text-sm font-medium text-gray-700"> Sort by </label>
<select
id="sort"
bind:value={sortColumn}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="">No sorting</option>
{#each headers as header}
<option value={header}>{getFieldLabel(header)}</option>
{/each}
</select>
</div>
</div> </div>
<button
<!-- Stats --> onclick={fetchAndProcessData}
<div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600"> 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"
<span>Total rows: {processedData.length}</span> disabled={isLoading}
<span>Valid rows: {processedData.filter((row) => row._isValid).length}</span> >
<span class="text-orange-600" {#if isLoading}
>Printed: {processedData.filter((row) => isRowAlreadyPrinted(row)).length}</span
>
<span>Filtered rows: {displayData.length}</span>
<span class="font-medium text-blue-600">Selected: {selectedValidCount}</span>
<div class="ml-auto flex items-center gap-4">
<button
class="text-sm text-gray-600 hover:text-gray-900"
onclick={() => (showAlreadyPrinted = !showAlreadyPrinted)}
>
{showAlreadyPrinted ? 'Hide' : 'Show'} Printed ({processedData.filter((row) =>
isRowAlreadyPrinted(row)
).length})
</button>
<button
onclick={processSheetData}
disabled={isLoading}
class="inline-flex items-center rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-wait disabled:opacity-50"
>
{#if isLoading}
<svg
class="mr-2 h-4 w-4 animate-spin"
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"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
Refreshing...
{:else}
Refresh Data
{/if}
</button>
</div>
</div>
</div>
<!-- Data Table -->
<div class="relative mb-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
{#if displayData.length === 0 && !isLoading}
<div class="py-12 text-center">
<svg <svg
class="mx-auto h-12 w-12 text-gray-400" class="-ml-1 mr-2 h-5 w-5 animate-spin text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"
> >
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path <path
stroke-linecap="round" class="opacity-75"
stroke-linejoin="round" fill="currentColor"
stroke-width="2" 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"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" ></path>
/>
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3> Refreshing...
<p class="mt-1 text-sm text-gray-500"> {:else}
{searchTerm ? 'No rows match your search criteria.' : 'No data available to display.'} Refresh Data
</p> {/if}
</button>
</div>
{#if isLoading}
<div class="py-12 text-center">
<p class="text-lg">Loading data from Google Sheet...</p>
<p class="text-gray-500">Please wait a moment.</p>
</div>
{:else if error}
<div
class="rounded-md border border-red-400 bg-red-50 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">An Error Occurred</h3>
<div class="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
</div>
</div> </div>
{:else} </div>
{:else if rows.length === 0}
<div class="py-12 text-center">
<h3 class="text-lg font-medium text-gray-900">No Data Found</h3>
<p class="mt-1 text-sm text-gray-500">
The selected sheet appears to be empty or could not be read.
</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<!-- Select All Checkbox --> <th class="px-4 py-3 text-left">
<th class="px-3 py-3 text-left">
<input <input
type="checkbox" type="checkbox"
checked={selectAll}
onclick={(e) => {
// Use event.preventDefault() to avoid default checkbox behavior
e.preventDefault();
toggleSelectAll();
}}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
disabled={isLoading} onchange={toggleSelectAll}
checked={allValidRowsSelected}
/> />
</th> </th>
<!-- Column Headers -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<th
class="cursor-pointer px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:bg-gray-100"
onclick={() => !isLoading && handleSort(header)}
>
<div class="flex items-center space-x-1">
<span>{getFieldLabel(header)}</span>
{#if sortColumn === header}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
{#if sortDirection === 'asc'}
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
{:else}
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
{/if}
</svg>
{/if}
</div>
</th>
{/each}
<!-- Status Column -->
<th <th
class="px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase" 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('_rowIndex')}>#</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('name')}>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
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('nationality')}>Nationality</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('birthday')}>Birthday</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('pictureUrl')}>Picture URL</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('alreadyPrinted')}>Printed</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('_valid')}>Status</th
> >
Status
</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading} {#each displayData as row (row.id)}
<!-- Loading skeleton rows --> <tr
{#each Array(5) as _, index} class="hover:bg-gray-50"
<tr class="hover:bg-gray-50"> class:bg-gray-100={!row._valid}
<!-- Selection Checkbox Skeleton --> class:text-gray-400={!row._valid || row.alreadyPrinted}
<td class="px-3 py-4"> class:bg-orange-50={row.alreadyPrinted}
<div class="h-4 w-4 animate-pulse rounded bg-gray-200"></div> >
</td> <td class="px-4 py-3">
<input
<!-- Data Columns Skeletons --> type="checkbox"
{#each headers.filter((h) => h !== 'alreadyPrinted') as header} class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-200"
<td class="px-3 py-4"> checked={row._checked}
<div disabled={!row._valid}
class="h-4 animate-pulse rounded bg-gray-200" onchange={() => toggleRow(row.id)}
style="width: {Math.random() * 40 + 60}%" />
></div> </td>
</td> <td class="whitespace-nowrap px-4 py-3 text-sm">{row._rowIndex}</td>
{/each} <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>
<!-- Status Column Skeleton --> <td class="whitespace-nowrap px-4 py-3 text-sm">{row.nationality}</td>
<td class="px-3 py-4"> <td class="whitespace-nowrap px-4 py-3 text-sm">{row.birthday}</td>
<div class="flex flex-col space-y-1"> <td class="whitespace-nowrap px-4 py-3 text-sm">
<div class="h-6 w-16 animate-pulse rounded-full bg-gray-200"></div> <a
</div> href={row.pictureUrl}
</td> target="_blank"
</tr> rel="noopener noreferrer"
{/each} class="text-blue-600 hover:underline"
{:else} title={row.pictureUrl}>link</a
<!-- Actual data rows --> >
{#each displayData as row} </td>
<tr <td class="whitespace-nowrap px-4 py-3 text-sm">
class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted( {#if row.alreadyPrinted}
row <span
) class="inline-flex rounded-full bg-orange-100 px-2 text-xs font-semibold leading-5 text-orange-800"
? 'bg-orange-50' >Yes</span
: ''}" >
> {:else}
<!-- Selection Checkbox --> <span
<td class="px-3 py-4"> class="inline-flex rounded-full bg-gray-100 px-2 text-xs font-semibold leading-5 text-gray-800"
{#if row._isValid} >No</span
<input >
type="checkbox" {/if}
checked={selectedRows.has(row._rowIndex)} </td>
onclick={(e) => { <td class="whitespace-nowrap px-4 py-3 text-sm">
// Use event.preventDefault() to avoid default checkbox behavior {#if row._valid}
e.preventDefault(); <span
toggleRowSelection(row._rowIndex); class="inline-flex rounded-full bg-green-100 px-2 text-xs font-semibold leading-5 text-green-800"
}} >Valid</span
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" >
/> {:else}
{:else} <span
<div class="h-4 w-4 rounded bg-gray-200"></div> class="inline-flex rounded-full bg-red-100 px-2 text-xs font-semibold leading-5 text-red-800"
{/if} >Invalid</span
</td> >
{/if}
<!-- Data Columns --> </td>
{#each headers.filter((h) => h !== 'alreadyPrinted') as header} </tr>
<td class="max-w-xs truncate px-3 py-4 text-sm text-gray-900"> {/each}
{row[header] || ''}
</td>
{/each}
<!-- Status Column -->
<td class="px-3 py-4 text-sm">
<div class="flex flex-col space-y-1">
{#if row._isValid}
<span
class="inline-flex rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800"
>
Valid
</span>
{:else}
<span
class="inline-flex rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800"
>
Missing data
</span>
{/if}
{#if isRowAlreadyPrinted(row)}
<span
class="inline-flex rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800"
>
Already Printed
</span>
{/if}
</div>
</td>
</tr>
{/each}
{/if}
</tbody> </tbody>
</table> </table>
</div> </div>
{/if}
</div>
<!-- Selection Summary -->
{#if selectedValidCount > 0}
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center">
<svg class="mr-2 h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<span class="text-sm text-blue-800">
<strong>{selectedValidCount}</strong>
{selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation
</span>
</div>
</div> </div>
{/if} {/if}
<!-- Navigation --> <div class="mt-6">
<Navigator <Navigator
canProceed={canProceed} canProceed={selectedCount > 0}
currentStep={currentStep} currentStep={currentStep}
textBack="Back to Colum Selection" onForward={handleContinue}
textForwardDisabled="Select rows to continue" textBack="Back to Column Mapping"
textForwardEnabled={`Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} →`} textForwardEnabled="Continue ({selectedCount} selected)"
onForward={handleContinue} textForwardDisabled="Select at least one valid row"
/> />
</div>
</div> </div>

View File

@@ -50,19 +50,6 @@
} }
} }
function saveRecentSheet(sheet) {
// Remove duplicates
recentSheets = recentSheets.filter(s => s.id !== sheet.id);
recentSheets.unshift(sheet);
// Limit to 5 recent sheets
if (recentSheets.length > 5) {
recentSheets.pop();
}
localStorage.setItem(RECENT_SHEETS_KEY, JSON.stringify(recentSheets));
}
function handleSelectSheet(sheet) { function handleSelectSheet(sheet) {
const sheetData: SheetInfoType = { const sheetData: SheetInfoType = {
id: sheet.id, id: sheet.id,
@@ -270,6 +257,5 @@
textBack="Back to Auth" textBack="Back to Auth"
textForwardDisabled="Select a sheet" textForwardDisabled="Select a sheet"
textForwardEnabled="Continue" textForwardEnabled="Continue"
onForward={() => saveRecentSheet($selectedSheet)}
/> />
</div> </div>

View File

@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import { on } from 'svelte/events';
let { let {
canProceed, canProceed,
currentStep, currentStep,