Restructuring and navigator
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores';
|
||||
import { getSheetNames, getSheetData } from '$lib/google';
|
||||
import { onMount } from 'svelte';
|
||||
import Navigator from './subcomponents/Navigator.svelte';
|
||||
|
||||
// Type definitions for better TypeScript support
|
||||
interface ColumnMappingType {
|
||||
@@ -444,8 +445,6 @@
|
||||
} catch (err) {
|
||||
console.error('Failed to save column mapping to localStorage:', err);
|
||||
}
|
||||
|
||||
currentStep.set(4); // Move to next step
|
||||
}
|
||||
|
||||
async function handleShowEditor() {
|
||||
@@ -754,20 +753,12 @@
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
onclick={() => currentStep.set(2)}
|
||||
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
← Back to Sheet Selection
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<Navigator
|
||||
canProceed={mappingComplete}
|
||||
{currentStep}
|
||||
textBack="Back to Sheet Selection"
|
||||
textForwardDisabled="Select a column mapping"
|
||||
textForwardEnabled="Continue"
|
||||
onForward={handleContinue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { columnMapping, filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
|
||||
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 blazeface from '@tensorflow-models/blazeface';
|
||||
|
||||
@@ -144,7 +145,6 @@
|
||||
|
||||
// For non-HEIC images, proceed as normal
|
||||
await processLoadedBlob(index, blob);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to load photo for ${photo.name}:`, error);
|
||||
photo.status = 'error';
|
||||
@@ -172,7 +172,6 @@
|
||||
|
||||
// Now that it's converted, process it like any other image
|
||||
await processLoadedBlob(index, convertedBlob);
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Failed to convert HEIC image for ${photo.name}:`, e);
|
||||
photo.status = 'error';
|
||||
@@ -200,7 +199,7 @@
|
||||
console.log(`Photo loaded successfully: ${photo.name}`);
|
||||
|
||||
// Save to pictures store
|
||||
pictures.update(pics => ({
|
||||
pictures.update((pics) => ({
|
||||
...pics,
|
||||
[photo.url]: {
|
||||
id: photo.url,
|
||||
@@ -233,13 +232,19 @@
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
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);
|
||||
|
||||
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
|
||||
let [x1, y1] = face.topLeft as [number, number];
|
||||
let [x2, y2] = face.bottomRight as [number, number];
|
||||
@@ -288,13 +293,13 @@
|
||||
photos[index].faceDetectionStatus = 'completed';
|
||||
|
||||
// Save crop data to store
|
||||
cropRects.update(crops => ({
|
||||
cropRects.update((crops) => ({
|
||||
...crops,
|
||||
[photos[index].url]: crop
|
||||
}));
|
||||
|
||||
// Update pictures store with face detection info
|
||||
pictures.update(pics => ({
|
||||
pictures.update((pics) => ({
|
||||
...pics,
|
||||
[photos[index].url]: {
|
||||
...pics[photos[index].url],
|
||||
@@ -323,12 +328,15 @@
|
||||
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].faceDetectionStatus = 'manual';
|
||||
|
||||
// Save updated crop data to store
|
||||
cropRects.update(crops => ({
|
||||
cropRects.update((crops) => ({
|
||||
...crops,
|
||||
[photos[index].url]: detail.cropData
|
||||
}));
|
||||
@@ -336,23 +344,23 @@
|
||||
// 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
|
||||
function cleanupObjectUrls() {
|
||||
photos.forEach(photo => {
|
||||
photos.forEach((photo) => {
|
||||
if (photo.objectUrl && photo.objectUrl.startsWith('blob:')) {
|
||||
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
|
||||
$effect(() => {
|
||||
return () => {
|
||||
@@ -362,26 +370,24 @@
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
Review & Crop Photos
|
||||
</h2>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-900">Review & Crop Photos</h2>
|
||||
|
||||
<p class="text-sm text-gray-700 mb-4">
|
||||
Photos are automatically cropped using face detection. Click the pen icon to manually adjust the crop area.
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Processing Status -->
|
||||
{#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">
|
||||
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div>
|
||||
<span class="text-sm text-blue-800">
|
||||
Processing photos...
|
||||
</span>
|
||||
<div
|
||||
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
|
||||
></div>
|
||||
<span class="text-sm text-blue-800"> Processing photos... </span>
|
||||
</div>
|
||||
<span class="text-sm text-blue-600">
|
||||
{processedCount} / {totalCount}
|
||||
@@ -389,9 +395,9 @@
|
||||
</div>
|
||||
|
||||
{#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
|
||||
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}%"
|
||||
></div>
|
||||
</div>
|
||||
@@ -401,10 +407,10 @@
|
||||
|
||||
<!-- Summary Stats -->
|
||||
{#if !isProcessing && photos.length > 0}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Processing Summary</h3>
|
||||
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<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-2xl font-bold text-gray-900">{photos.length}</div>
|
||||
<div class="text-gray-600">Total Photos</div>
|
||||
@@ -412,45 +418,46 @@
|
||||
|
||||
<div class="text-center">
|
||||
<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 class="text-gray-600">Loaded</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<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 class="text-gray-600">Auto-cropped</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">
|
||||
{photos.filter(p => p.cropData).length}
|
||||
{photos.filter((p) => p.cropData).length}
|
||||
</div>
|
||||
<div class="text-gray-600">Ready</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<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 class="text-gray-600">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if photos.filter(p => p.status === 'error').length > 0}
|
||||
<div class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
{#if photos.filter((p) => p.status === 'error').length > 0}
|
||||
<div class="mt-4 rounded border border-yellow-200 bg-yellow-50 p-3">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>Note:</strong> Cards will only be generated for photos that load successfully.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !canProceed() && photos.filter(p => p.status === 'success').length > 0}
|
||||
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
|
||||
{#if !canProceed() && photos.filter((p) => p.status === 'success').length > 0}
|
||||
<div class="mt-4 rounded border border-blue-200 bg-blue-50 p-3">
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -458,11 +465,21 @@
|
||||
{/if}
|
||||
|
||||
<!-- 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}
|
||||
<div class="text-center py-12">
|
||||
<svg 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"/>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No photos found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
@@ -470,7 +487,7 @@
|
||||
</p>
|
||||
</div>
|
||||
{: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}
|
||||
<PhotoCard
|
||||
{photo}
|
||||
@@ -483,24 +500,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
onclick={() => currentStep.set(4)}
|
||||
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={() => 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>
|
||||
<Navigator
|
||||
canProceed={canProceed()}
|
||||
{currentStep}
|
||||
textBack="Back to Row Filter"
|
||||
textForwardDisabled="Waiting from photos"
|
||||
textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
34
src/lib/components/wizard/StepGalleryNavigation.svelte
Normal file
34
src/lib/components/wizard/StepGalleryNavigation.svelte
Normal 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>
|
||||
@@ -7,7 +7,7 @@
|
||||
currentStep,
|
||||
sheetData
|
||||
} from '$lib/stores';
|
||||
import type { RowData } from '$lib/stores';
|
||||
import Navigator from './subcomponents/Navigator.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { getSheetNames, getSheetData } from '$lib/google';
|
||||
|
||||
@@ -213,9 +213,6 @@
|
||||
|
||||
// Store the filtered data
|
||||
filteredSheetData.set(selectedData);
|
||||
|
||||
// Move to next step
|
||||
currentStep.set(5);
|
||||
}
|
||||
|
||||
$: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => {
|
||||
@@ -497,21 +494,12 @@
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
onclick={() => currentStep.set(3)}
|
||||
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
← Back to Colum Selection
|
||||
</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 with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} →`
|
||||
: 'Select rows to continue'}
|
||||
</button>
|
||||
</div>
|
||||
<Navigator
|
||||
canProceed={canProceed}
|
||||
currentStep={currentStep}
|
||||
textBack="Back to Colum Selection"
|
||||
textForwardDisabled="Select rows to continue"
|
||||
textForwardEnabled={`Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} →`}
|
||||
onForward={handleContinue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
|
||||
import { searchSheets } from '$lib/google';
|
||||
import { onMount } from 'svelte';
|
||||
import Navigator from './subcomponents/Navigator.svelte';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let isLoading = $state(false);
|
||||
@@ -66,11 +67,6 @@
|
||||
}
|
||||
|
||||
let canProceed = $derived($selectedSheet !== null);
|
||||
|
||||
function handleContinue() {
|
||||
if (!canProceed) return;
|
||||
currentStep.set(3); // Move to the column mapping step
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
@@ -262,20 +258,11 @@
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
onclick={() => currentStep.set(1)}
|
||||
class="rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
← Back to Auth
|
||||
</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>
|
||||
<Navigator
|
||||
{canProceed}
|
||||
{currentStep}
|
||||
textBack="Back to Auth"
|
||||
textForwardDisabled="Select a sheet"
|
||||
textForwardEnabled="Continue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
60
src/lib/components/wizard/subcomponents/Navigator.svelte
Normal file
60
src/lib/components/wizard/subcomponents/Navigator.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user