Compare commits

..

2 Commits

Author SHA1 Message Date
Roman Krček
8e41c6d78f Restructuring and navigator
All checks were successful
Build Docker image / build (push) Successful in 2m0s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 27s
2025-07-18 13:59:28 +02:00
Roman Krček
1a8ce546d4 Dependency updates 2025-07-18 13:45:55 +02:00
11 changed files with 584 additions and 524 deletions

22
package-lock.json generated
View File

@@ -12,8 +12,8 @@
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0", "@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
"@types/gapi.client.drive": "^3.0.15", "@types/gapi.client.drive-v3": "^0.0.5",
"@types/gapi.client.sheets": "^4.0.20201031", "@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", "@types/uuid": "^10.0.0",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",
@@ -1519,21 +1519,19 @@
"@maxim_mazurok/gapi.client.discovery-v1": "latest" "@maxim_mazurok/gapi.client.discovery-v1": "latest"
} }
}, },
"node_modules/@types/gapi.client.drive": { "node_modules/@types/gapi.client.drive-v3": {
"version": "3.0.15", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/@types/gapi.client.drive/-/gapi.client.drive-3.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz",
"integrity": "sha512-qEfI0LxUBadOLmym4FkaNGpI4ibBCBPJHiUFWKIv0GIp7yKT2d+wztJYKr9giIRecErUCF+jGSDw1fzTZ6hPVQ==", "integrity": "sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==",
"deprecated": "use @types/gapi.client.drive-v3 instead; see https://github.com/Maxim-Mazurok/google-api-typings-generator/issues/652 for details",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@maxim_mazurok/gapi.client.drive-v3": "latest" "@maxim_mazurok/gapi.client.drive-v3": "latest"
} }
}, },
"node_modules/@types/gapi.client.sheets": { "node_modules/@types/gapi.client.sheets-v4": {
"version": "4.0.20201031", "version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/gapi.client.sheets/-/gapi.client.sheets-4.0.20201031.tgz", "resolved": "https://registry.npmjs.org/@types/gapi.client.sheets-v4/-/gapi.client.sheets-v4-0.0.4.tgz",
"integrity": "sha512-1Aiu11rNNoyPDHW6v8TVcSmlDN+MkxSuafwiawaK5YqZ+uYA+O63vjUvkK+3qNduSLh7D9qBJc/8GGwgN6gsTw==", "integrity": "sha512-6kTJ7aDMAElfdQV1XzVJmZWjgbibpa84DMuKuaN8Cwqci/dkglPyHXKvsGrRugmuYvgFYr35AQqwz6j3q8R0dw==",
"deprecated": "use @types/gapi.client.sheets-v4 instead; see https://github.com/Maxim-Mazurok/google-api-typings-generator/issues/652 for details",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@maxim_mazurok/gapi.client.sheets-v4": "latest" "@maxim_mazurok/gapi.client.sheets-v4": "latest"

View File

@@ -33,8 +33,8 @@
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0", "@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
"@types/gapi.client.drive": "^3.0.15", "@types/gapi.client.drive-v3": "^0.0.5",
"@types/gapi.client.sheets": "^4.0.20201031", "@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", "@types/uuid": "^10.0.0",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",

View File

@@ -2,6 +2,7 @@
import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores'; import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores';
import { getSheetNames, getSheetData } from '$lib/google'; import { getSheetNames, getSheetData } from '$lib/google';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte';
// Type definitions for better TypeScript support // Type definitions for better TypeScript support
interface ColumnMappingType { interface ColumnMappingType {
@@ -444,8 +445,6 @@
} catch (err) { } catch (err) {
console.error('Failed to save column mapping to localStorage:', err); console.error('Failed to save column mapping to localStorage:', err);
} }
currentStep.set(4); // Move to next step
} }
async function handleShowEditor() { async function handleShowEditor() {
@@ -754,20 +753,12 @@
{/if} {/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <Navigator
<button canProceed={mappingComplete}
onclick={() => currentStep.set(2)} {currentStep}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300" textBack="Back to Sheet Selection"
> textForwardDisabled="Select a column mapping"
← Back to Sheet Selection textForwardEnabled="Continue"
</button> onForward={handleContinue}
/>
<button
onclick={handleContinue}
disabled={!mappingComplete}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{mappingComplete ? 'Continue →' : 'Select a column mapping'}
</button>
</div>
</div> </div>

View File

@@ -3,7 +3,8 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { columnMapping, filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores'; import { columnMapping, filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl } from '$lib/google'; import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl } from '$lib/google';
import PhotoCard from '../PhotoCard.svelte'; import Navigator from './subcomponents/Navigator.svelte';
import PhotoCard from './subcomponents/PhotoCard.svelte';
import * as tf from '@tensorflow/tfjs'; import * as tf from '@tensorflow/tfjs';
import * as blazeface from '@tensorflow-models/blazeface'; import * as blazeface from '@tensorflow-models/blazeface';
@@ -144,7 +145,6 @@
// For non-HEIC images, proceed as normal // For non-HEIC images, proceed as normal
await processLoadedBlob(index, blob); await processLoadedBlob(index, blob);
} catch (error) { } catch (error) {
console.error(`Failed to load photo for ${photo.name}:`, error); console.error(`Failed to load photo for ${photo.name}:`, error);
photo.status = 'error'; photo.status = 'error';
@@ -172,7 +172,6 @@
// Now that it's converted, process it like any other image // Now that it's converted, process it like any other image
await processLoadedBlob(index, convertedBlob); await processLoadedBlob(index, convertedBlob);
} catch (e) { } catch (e) {
console.error(`Failed to convert HEIC image for ${photo.name}:`, e); console.error(`Failed to convert HEIC image for ${photo.name}:`, e);
photo.status = 'error'; photo.status = 'error';
@@ -200,7 +199,7 @@
console.log(`Photo loaded successfully: ${photo.name}`); console.log(`Photo loaded successfully: ${photo.name}`);
// Save to pictures store // Save to pictures store
pictures.update(pics => ({ pictures.update((pics) => ({
...pics, ...pics,
[photo.url]: { [photo.url]: {
id: photo.url, id: photo.url,
@@ -233,13 +232,19 @@
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
img.src = photos[index].objectUrl!; img.src = photos[index].objectUrl!;
await new Promise((r, e) => { img.onload = r; img.onerror = e; }); await new Promise((r, e) => {
img.onload = r;
img.onerror = e;
});
const predictions = await detector.estimateFaces(img, false); const predictions = await detector.estimateFaces(img, false);
if (predictions.length > 0) { if (predictions.length > 0) {
const getProbability = (p: number | tf.Tensor) => (typeof p === 'number' ? p : p.dataSync()[0]); const getProbability = (p: number | tf.Tensor) =>
typeof p === 'number' ? p : p.dataSync()[0];
const face = predictions.sort((a,b) => getProbability(b.probability!) - getProbability(a.probability!))[0]; const face = predictions.sort(
(a, b) => getProbability(b.probability!) - getProbability(a.probability!)
)[0];
// Coordinates in displayed image space // Coordinates in displayed image space
let [x1, y1] = face.topLeft as [number, number]; let [x1, y1] = face.topLeft as [number, number];
let [x2, y2] = face.bottomRight as [number, number]; let [x2, y2] = face.bottomRight as [number, number];
@@ -288,13 +293,13 @@
photos[index].faceDetectionStatus = 'completed'; photos[index].faceDetectionStatus = 'completed';
// Save crop data to store // Save crop data to store
cropRects.update(crops => ({ cropRects.update((crops) => ({
...crops, ...crops,
[photos[index].url]: crop [photos[index].url]: crop
})); }));
// Update pictures store with face detection info // Update pictures store with face detection info
pictures.update(pics => ({ pictures.update((pics) => ({
...pics, ...pics,
[photos[index].url]: { [photos[index].url]: {
...pics[photos[index].url], ...pics[photos[index].url],
@@ -323,12 +328,15 @@
await loadPhoto(index, true); await loadPhoto(index, true);
} }
function handleCropUpdate(index: number, detail: { cropData: { x: number; y: number; width: number; height: number } }) { function handleCropUpdate(
index: number,
detail: { cropData: { x: number; y: number; width: number; height: number } }
) {
photos[index].cropData = detail.cropData; photos[index].cropData = detail.cropData;
photos[index].faceDetectionStatus = 'manual'; photos[index].faceDetectionStatus = 'manual';
// Save updated crop data to store // Save updated crop data to store
cropRects.update(crops => ({ cropRects.update((crops) => ({
...crops, ...crops,
[photos[index].url]: detail.cropData [photos[index].url]: detail.cropData
})); }));
@@ -336,23 +344,23 @@
// No need to reassign photos array with $state reactivity // No need to reassign photos array with $state reactivity
} }
const canProceed = $derived(() => {
const hasPhotos = photos.length > 0;
const allLoaded = photos.every(photo => photo.status === 'success');
const allCropped = photos.every(photo => photo.cropData);
return hasPhotos && allLoaded && allCropped;
});
// Cleanup object URLs when component is destroyed // Cleanup object URLs when component is destroyed
function cleanupObjectUrls() { function cleanupObjectUrls() {
photos.forEach(photo => { photos.forEach((photo) => {
if (photo.objectUrl && photo.objectUrl.startsWith('blob:')) { if (photo.objectUrl && photo.objectUrl.startsWith('blob:')) {
URL.revokeObjectURL(photo.objectUrl); URL.revokeObjectURL(photo.objectUrl);
} }
}); });
} }
const canProceed = $derived(() => {
const hasPhotos = photos.length > 0;
const allLoaded = photos.every((photo) => photo.status === 'success');
const allCropped = photos.every((photo) => photo.cropData);
return hasPhotos && allLoaded && allCropped;
});
// Cleanup on unmount using $effect // Cleanup on unmount using $effect
$effect(() => { $effect(() => {
return () => { return () => {
@@ -362,26 +370,24 @@
</script> </script>
<div class="p-6"> <div class="p-6">
<div class="max-w-6xl mx-auto">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2"> <h2 class="mb-2 text-xl font-semibold text-gray-900">Review & Crop Photos</h2>
Review & Crop Photos
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="mb-4 text-sm text-gray-700">
Photos are automatically cropped using face detection. Click the pen icon to manually adjust the crop area. Photos are automatically cropped using face detection. Click the pen icon to manually adjust
the crop area.
</p> </p>
</div> </div>
<!-- Processing Status --> <!-- Processing Status -->
{#if isProcessing} {#if isProcessing}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6"> <div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div> <div
<span class="text-sm text-blue-800"> class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
Processing photos... ></div>
</span> <span class="text-sm text-blue-800"> Processing photos... </span>
</div> </div>
<span class="text-sm text-blue-600"> <span class="text-sm text-blue-600">
{processedCount} / {totalCount} {processedCount} / {totalCount}
@@ -389,9 +395,9 @@
</div> </div>
{#if totalCount > 0} {#if totalCount > 0}
<div class="mt-3 w-full bg-blue-200 rounded-full h-2"> <div class="mt-3 h-2 w-full rounded-full bg-blue-200">
<div <div
class="bg-blue-600 h-2 rounded-full transition-all duration-300" class="h-2 rounded-full bg-blue-600 transition-all duration-300"
style="width: {(processedCount / totalCount) * 100}%" style="width: {(processedCount / totalCount) * 100}%"
></div> ></div>
</div> </div>
@@ -401,10 +407,10 @@
<!-- Summary Stats --> <!-- Summary Stats -->
{#if !isProcessing && photos.length > 0} {#if !isProcessing && photos.length > 0}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6"> <div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Processing Summary</h3> <h3 class="mb-3 text-sm font-medium text-gray-700">Processing Summary</h3>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm md:grid-cols-5">
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-gray-900">{photos.length}</div> <div class="text-2xl font-bold text-gray-900">{photos.length}</div>
<div class="text-gray-600">Total Photos</div> <div class="text-gray-600">Total Photos</div>
@@ -412,45 +418,46 @@
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-green-600"> <div class="text-2xl font-bold text-green-600">
{photos.filter(p => p.status === 'success').length} {photos.filter((p) => p.status === 'success').length}
</div> </div>
<div class="text-gray-600">Loaded</div> <div class="text-gray-600">Loaded</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-blue-600"> <div class="text-2xl font-bold text-blue-600">
{photos.filter(p => p.faceDetectionStatus === 'completed').length} {photos.filter((p) => p.faceDetectionStatus === 'completed').length}
</div> </div>
<div class="text-gray-600">Auto-cropped</div> <div class="text-gray-600">Auto-cropped</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-purple-600"> <div class="text-2xl font-bold text-purple-600">
{photos.filter(p => p.cropData).length} {photos.filter((p) => p.cropData).length}
</div> </div>
<div class="text-gray-600">Ready</div> <div class="text-gray-600">Ready</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-red-600"> <div class="text-2xl font-bold text-red-600">
{photos.filter(p => p.status === 'error').length} {photos.filter((p) => p.status === 'error').length}
</div> </div>
<div class="text-gray-600">Failed</div> <div class="text-gray-600">Failed</div>
</div> </div>
</div> </div>
{#if photos.filter(p => p.status === 'error').length > 0} {#if photos.filter((p) => p.status === 'error').length > 0}
<div class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded"> <div class="mt-4 rounded border border-yellow-200 bg-yellow-50 p-3">
<p class="text-sm text-yellow-800"> <p class="text-sm text-yellow-800">
<strong>Note:</strong> Cards will only be generated for photos that load successfully. <strong>Note:</strong> Cards will only be generated for photos that load successfully.
</p> </p>
</div> </div>
{/if} {/if}
{#if !canProceed() && photos.filter(p => p.status === 'success').length > 0} {#if !canProceed() && photos.filter((p) => p.status === 'success').length > 0}
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded"> <div class="mt-4 rounded border border-blue-200 bg-blue-50 p-3">
<p class="text-sm text-blue-800"> <p class="text-sm text-blue-800">
<strong>Tip:</strong> All photos need to be cropped before proceeding. Face detection runs automatically. <strong>Tip:</strong> All photos need to be cropped before proceeding. Face detection runs
automatically.
</p> </p>
</div> </div>
{/if} {/if}
@@ -458,11 +465,21 @@
{/if} {/if}
<!-- Photo Grid --> <!-- Photo Grid -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6"> <div class="mb-6 overflow-hidden rounded-lg bg-white">
{#if photos.length === 0 && !isProcessing} {#if photos.length === 0 && !isProcessing}
<div class="text-center py-12"> <div class="py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 002 2z"/> class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 002 2z"
/>
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No photos found</h3> <h3 class="mt-2 text-sm font-medium text-gray-900">No photos found</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
@@ -470,7 +487,7 @@
</p> </p>
</div> </div>
{:else} {:else}
<div class="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3">
{#each photos as photo, index} {#each photos as photo, index}
<PhotoCard <PhotoCard
{photo} {photo}
@@ -483,24 +500,11 @@
</div> </div>
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <Navigator
<button canProceed={canProceed()}
onclick={() => currentStep.set(4)} {currentStep}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300" textBack="Back to Row Filter"
> textForwardDisabled="Waiting from photos"
← Back to Row Filter textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
</button> />
<button
onclick={() => currentStep.set(6)}
disabled={!canProceed()}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{canProceed()
? `Generate ${photos.filter(p => p.status === 'success' && p.cropData).length} Cards `
: 'Waiting for photos to load and crop'}
</button>
</div> </div>
</div>
</div>

View File

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

View File

@@ -7,7 +7,7 @@
currentStep, currentStep,
sheetData sheetData
} from '$lib/stores'; } from '$lib/stores';
import type { RowData } from '$lib/stores'; import Navigator from './subcomponents/Navigator.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSheetNames, getSheetData } from '$lib/google'; import { getSheetNames, getSheetData } from '$lib/google';
@@ -213,9 +213,6 @@
// Store the filtered data // Store the filtered data
filteredSheetData.set(selectedData); filteredSheetData.set(selectedData);
// Move to next step
currentStep.set(5);
} }
$: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => { $: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => {
@@ -497,21 +494,12 @@
{/if} {/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <Navigator
<button canProceed={canProceed}
onclick={() => currentStep.set(3)} currentStep={currentStep}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300" textBack="Back to Colum Selection"
> textForwardDisabled="Select rows to continue"
← Back to Colum Selection textForwardEnabled={`Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} →`}
</button> onForward={handleContinue}
<button />
onclick={handleContinue}
disabled={!canProceed}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{canProceed
? `Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} `
: 'Select rows to continue'}
</button>
</div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { availableSheets, selectedSheet, currentStep } from '$lib/stores'; import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
import { searchSheets } from '$lib/google'; import { searchSheets } from '$lib/google';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte';
let searchQuery = $state(''); let searchQuery = $state('');
let isLoading = $state(false); let isLoading = $state(false);
@@ -66,11 +67,6 @@
} }
let canProceed = $derived($selectedSheet !== null); let canProceed = $derived($selectedSheet !== null);
function handleContinue() {
if (!canProceed) return;
currentStep.set(3); // Move to the column mapping step
}
</script> </script>
<div class="p-6"> <div class="p-6">
@@ -262,20 +258,11 @@
{/if} {/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <Navigator
<button {canProceed}
onclick={() => currentStep.set(1)} {currentStep}
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300" textBack="Back to Auth"
> textForwardDisabled="Select a sheet"
← Back to Auth textForwardEnabled="Continue"
</button> />
<button
onclick={handleContinue}
disabled={!canProceed}
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{canProceed ? 'Continue →' : 'Select a sheet to continue'}
</button>
</div>
</div> </div>

View File

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

View File

@@ -13,8 +13,6 @@ export function initGoogleClient(callback: () => void) {
script.onload = () => { script.onload = () => {
gapi.load('client', async () => { gapi.load('client', async () => {
await gapi.client.init({ await gapi.client.init({
// NOTE: API KEY IS NOT REQUIRED FOR THIS IMPLEMENTATION
// apiKey: 'YOUR_API_KEY',
discoveryDocs: [ discoveryDocs: [
'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest', 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest', 'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest',