Improve the the cropping process, UI and UX

This commit is contained in:
Roman Krček
2025-07-18 09:11:17 +02:00
parent c77c96c1c7
commit 9bbd02dd67
8 changed files with 2146 additions and 1881 deletions

View File

@@ -7,8 +7,10 @@
- Pass fucntions as props instead od dispatching events
- Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax
- when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state.
- USe $props instead of export let!
- Use styling from ".github/styling.md" for any UI components.
- Refer to the ".github/core-instructions.md" for the overall structure of the application.
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
- Remain consistent in styling and code structure.
- Avoid unncessary iterations. If problems is mostly solved, stop.
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.

View File

@@ -1,90 +1,185 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import PhotoCrop from './PhotoCrop.svelte';
import PhotoCrop from './PhotoCrop.svelte';
export let imageUrl: string;
export let personName: string;
export let isProcessing = false;
export let cropData: { x: number; y: number; width: number; height: number } | null = null;
let { photo, onCropUpdated, onRetry } = $props<{
photo: {
name: string;
url: string;
status: 'loading' | 'success' | 'error';
objectUrl?: string;
retryCount: number;
cropData?: { x: number; y: number; width: number; height: number };
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
};
onCropUpdated: (detail: any) => void;
onRetry: () => void;
}>();
const dispatch = createEventDispatcher<{
cropUpdated: { x: number; y: number; width: number; height: number };
}>();
let showCropper = $state(false);
let imageDimensions = $state<{ w: number; h: number } | null>(null);
let imageContainer = $state<HTMLDivElement | undefined>();
let showCropEditor = false;
let currentCrop = cropData;
const cropBoxStyle = $derived(() => {
if (!photo.cropData || !imageDimensions || !imageContainer) {
return 'display: none;';
}
let photoElement: HTMLImageElement;
const { w: naturalW, h: naturalH } = imageDimensions;
const { x, y, width, height } = photo.cropData;
const { clientWidth: containerW, clientHeight: containerH } = imageContainer;
function openCropEditor() {
showCropEditor = true;
}
const containerAspect = containerW / containerH;
const naturalAspect = naturalW / naturalH;
function handleCropSave(e: CustomEvent<{ x: number; y: number; width: number; height: number }>) {
currentCrop = e.detail;
showCropEditor = false;
dispatch('cropUpdated', currentCrop!);
}
let imgW, imgH;
if (naturalAspect > containerAspect) {
// Image is wider than container, so it's letterboxed top/bottom
imgW = containerW;
imgH = containerW / naturalAspect;
} else {
// Image is taller than container, so it's letterboxed left/right
imgH = containerH;
imgW = containerH * naturalAspect;
}
function handleCropCancel() {
showCropEditor = false;
}
const offsetX = (containerW - imgW) / 2;
const offsetY = (containerH - imgH) / 2;
$: if (cropData) currentCrop = cropData;
const scaleX = imgW / naturalW;
const scaleY = imgH / naturalH;
const left = x * scaleX + offsetX;
const top = y * scaleY + offsetY;
const boxWidth = width * scaleX;
const boxHeight = height * scaleY;
return `
position: absolute;
left: ${left}px;
top: ${top}px;
width: ${boxWidth}px;
height: ${boxHeight}px;
border: 2px solid #3b82f6; /* blue-500 */
box-shadow: 0 0 0 9999px rgba(229, 231, 235, 0.75); /* gray-200 with opacity */
transition: all 0.3s;
`;
});
function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement;
imageDimensions = { w: img.naturalWidth, h: img.naturalHeight };
}
function handleCropUpdated(detail: any) {
onCropUpdated(detail);
showCropper = false;
}
</script>
<div class="relative group">
<div class="relative overflow-hidden rounded-lg border-2 border-gray-200">
<img
bind:this={photoElement}
src={imageUrl}
alt={personName}
class="w-full h-full object-cover"
/>
{#if photo.status === 'loading'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center">
<div class="flex flex-col items-center">
<div
class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"
></div>
<span class="text-xs text-gray-600">Loading...</span>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-blue-600">Processing photo...</span>
</div>
</div>
{:else if photo.status === 'success' && photo.objectUrl}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative">
<div
class="h-48 bg-gray-100 flex items-center justify-center relative overflow-hidden"
bind:this={imageContainer}
>
<img
src={photo.objectUrl}
alt={`Photo of ${photo.name}`}
class="max-w-full max-h-full object-contain"
onload={handleImageLoad}
/>
{#if photo.cropData}
<div style={cropBoxStyle()}></div>
{/if}
</div>
{#if currentCrop}
<!-- Show crop preview overlay with proper masking -->
<div class="absolute inset-0 pointer-events-none">
<div class="relative w-full h-full">
<!-- Create mask using box-shadow to darken only non-crop areas -->
<div
class="absolute border-2 border-blue-500 border-dashed"
style="left: {(currentCrop.x / photoElement?.naturalWidth) * 100}%;
top: {(currentCrop.y / photoElement?.naturalHeight) * 100}%;
width: {(currentCrop.width / photoElement?.naturalWidth) * 100}%;
height: {(currentCrop.height / photoElement?.naturalHeight) * 100}%;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.3);"
></div>
</div>
</div>
{/if}
<div class="p-3 flex items-center justify-between">
<div>
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
{#if photo.faceDetectionStatus === 'completed'}
<span class="text-xs text-green-600">Face detected</span>
{:else if photo.faceDetectionStatus === 'failed'}
<span class="text-xs text-orange-600">Face not found</span>
{:else if photo.faceDetectionStatus === 'processing'}
<span class="text-xs text-blue-600">Detecting face...</span>
{:else if photo.faceDetectionStatus === 'manual'}
<span class="text-xs text-purple-600">Manual crop</span>
{:else if photo.faceDetectionStatus === 'pending'}
<span class="text-xs text-gray-500">Queued...</span>
{/if}
</div>
<button
onclick={() => (showCropper = true)}
class="p-1 text-gray-500 hover:text-blue-600"
title="Edit Crop"
aria-label="Edit Crop"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L16.732 3.732z"
/>
</svg>
</button>
</div>
<!-- Edit crop button -->
<button
on:click={openCropEditor}
class="absolute top-2 right-2 bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full p-2 shadow-lg transition-all duration-200 opacity-0 group-hover:opacity-100"
title="Edit crop area"
>
<svg class="w-4 h-4 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
</svg>
</button>
</div>
<div class="mt-2">
<p class="text-sm font-medium text-gray-900 truncate">{personName}</p>
{#if isProcessing}
<p class="text-xs text-gray-500">Processing...</p>
{/if}
</div>
</div>
{#if showCropEditor}
<PhotoCrop
{imageUrl}
{personName}
initialCrop={currentCrop}
on:save={handleCropSave}
on:cancel={handleCropCancel}
/>
{#if showCropper}
<PhotoCrop
imageUrl={photo.objectUrl}
personName={photo.name}
initialCropData={photo.cropData}
onClose={() => (showCropper = false)}
onCropUpdated={handleCropUpdated}
/>
{/if}
</div>
{:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center">
<div class="flex flex-col items-center text-center p-4">
<svg
class="w-12 h-12 text-red-400 mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs text-red-600 mb-2">Failed to load</span>
<button
class="text-xs text-blue-600 hover:text-blue-800 underline"
onclick={onRetry}
disabled={photo.retryCount >= 3}
>
{photo.retryCount >= 3 ? 'Max retries' : 'Retry'}
</button>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-red-600">Failed to load</span>
</div>
</div>
{/if}

View File

@@ -1,360 +1,374 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { onMount } from 'svelte';
export let imageUrl: string;
export let personName: string;
export let initialCrop: { x: number; y: number; width: number; height: number } | null = null;
let { imageUrl, personName, initialCropData, onCropUpdated, onClose } = $props<{
imageUrl: string;
personName: string;
initialCropData?: { x: number; y: number; width: number; height: number };
onCropUpdated: (detail: {
cropData: { x: number; y: number; width: number; height: number };
}) => void;
onClose: () => void;
}>();
const dispatch = createEventDispatcher<{
save: { x: number; y: number; width: number; height: number };
cancel: void;
}>();
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let image: HTMLImageElement;
let isImageLoaded = false;
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let image: HTMLImageElement;
let isImageLoaded = false;
// Crop rectangle state
let crop = {
x: 0,
y: 0,
width: 200,
height: 200
};
// Crop rectangle state
let crop = {
x: 0,
y: 0,
width: 200,
height: 200
};
// Interaction state
let isDragging = false;
let isResizing = false;
let dragStart = { x: 0, y: 0 };
let resizeHandle = '';
// Interaction state
let isDragging = false;
let isResizing = false;
let dragStart = { x: 0, y: 0 };
let resizeHandle = '';
// Canvas dimensions
let canvasWidth = 600;
let canvasHeight = 400;
// Canvas dimensions
let canvasWidth = 600;
let canvasHeight = 400;
// Get crop ratio from environment
const cropRatio = parseFloat(import.meta.env.VITE_CROP_RATIO || '1.0');
// Get crop ratio from environment
const cropRatio = parseFloat(import.meta.env.VITE_CROP_RATIO || '1.0');
onMount(() => {
ctx = canvas.getContext('2d')!;
loadImage();
});
onMount(() => {
ctx = canvas.getContext('2d')!;
loadImage();
});
async function loadImage() {
image = new Image();
image.onload = () => {
isImageLoaded = true;
async function loadImage() {
image = new Image();
image.onload = () => {
isImageLoaded = true;
// Calculate canvas size to fit image while maintaining aspect ratio
const maxWidth = 600;
const maxHeight = 400;
const imageAspect = image.width / image.height;
// Calculate canvas size to fit image while maintaining aspect ratio
const maxWidth = 600;
const maxHeight = 400;
const imageAspect = image.width / image.height;
if (imageAspect > maxWidth / maxHeight) {
canvasWidth = maxWidth;
canvasHeight = maxWidth / imageAspect;
} else {
canvasHeight = maxHeight;
canvasWidth = maxHeight * imageAspect;
}
if (imageAspect > maxWidth / maxHeight) {
canvasWidth = maxWidth;
canvasHeight = maxWidth / imageAspect;
} else {
canvasHeight = maxHeight;
canvasWidth = maxHeight * imageAspect;
}
canvas.width = canvasWidth;
canvas.height = canvasHeight;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Initialize crop rectangle
if (initialCropData) {
// Scale initial crop to canvas dimensions
const scaleX = canvasWidth / image.width;
const scaleY = canvasHeight / image.height;
crop = {
x: initialCropData.x * scaleX,
y: initialCropData.y * scaleY,
width: initialCropData.width * scaleX,
height: initialCropData.height * scaleY
};
} else {
// Default crop: centered with correct aspect ratio
const maxSize = Math.min(canvasWidth, canvasHeight) * 0.6;
const cropWidth = maxSize;
const cropHeight = cropWidth / cropRatio;
// Initialize crop rectangle
if (initialCrop) {
// Scale initial crop to canvas dimensions
const scaleX = canvasWidth / image.width;
const scaleY = canvasHeight / image.height;
crop = {
x: initialCrop.x * scaleX,
y: initialCrop.y * scaleY,
width: initialCrop.width * scaleX,
height: initialCrop.height * scaleY
};
} else {
// Default crop: centered with correct aspect ratio
const maxSize = Math.min(canvasWidth, canvasHeight) * 0.6;
const cropWidth = maxSize;
const cropHeight = cropWidth / cropRatio;
// If height exceeds canvas, scale down proportionally
if (cropHeight > canvasHeight * 0.8) {
const scale = (canvasHeight * 0.8) / cropHeight;
crop = {
x: (canvasWidth - (cropWidth * scale)) / 2,
y: (canvasHeight - (cropHeight * scale)) / 2,
width: cropWidth * scale,
height: cropHeight * scale
};
} else {
crop = {
x: (canvasWidth - cropWidth) / 2,
y: (canvasHeight - cropHeight) / 2,
width: cropWidth,
height: cropHeight
};
}
}
// If height exceeds canvas, scale down proportionally
if (cropHeight > canvasHeight * 0.8) {
const scale = (canvasHeight * 0.8) / cropHeight;
crop = {
x: (canvasWidth - (cropWidth * scale)) / 2,
y: (canvasHeight - (cropHeight * scale)) / 2,
width: cropWidth * scale,
height: cropHeight * scale
};
} else {
crop = {
x: (canvasWidth - cropWidth) / 2,
y: (canvasHeight - cropHeight) / 2,
width: cropWidth,
height: cropHeight
};
}
}
drawCanvas();
};
image.src = imageUrl;
}
drawCanvas();
};
image.src = imageUrl;
}
function drawCanvas() {
if (!ctx || !isImageLoaded) return;
function drawCanvas() {
if (!ctx || !isImageLoaded) return;
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Draw image
ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
// Draw image
ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
// Draw overlay (darken non-crop area)
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Draw overlay (darken non-crop area)
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(crop.x, crop.y, crop.width, crop.height);
ctx.globalCompositeOperation = 'source-over';
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(crop.x, crop.y, crop.width, crop.height);
ctx.globalCompositeOperation = 'source-over';
// Draw crop rectangle border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height);
// Draw crop rectangle border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
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;
// 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' },
];
// 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);
});
}
handles.forEach(handle => {
ctx.fillRect(handle.x, handle.y, handleSize, handleSize);
ctx.strokeRect(handle.x, handle.y, handleSize, handleSize);
});
}
function getMousePos(e: MouseEvent) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function getMousePos(e: MouseEvent) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function isInCropArea(x: number, y: number) {
return x >= crop.x && x <= crop.x + crop.width &&
y >= crop.y && y <= crop.y + crop.height;
}
function isInCropArea(x: number, y: number) {
return x >= crop.x && x <= crop.x + crop.width &&
y >= crop.y && y <= crop.y + crop.height;
}
function getResizeHandle(x: number, y: number) {
const handleSize = 12; // Match the drawing size
const tolerance = handleSize;
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';
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 '';
}
return '';
}
function handleMouseDown(e: MouseEvent) {
const pos = getMousePos(e);
const handle = getResizeHandle(pos.x, pos.y);
function handleMouseDown(e: MouseEvent) {
const pos = getMousePos(e);
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
isResizing = true;
resizeHandle = handle;
dragStart = pos;
} else if (isInCropArea(pos.x, pos.y)) {
isDragging = true;
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
}
}
if (handle) {
isResizing = true;
resizeHandle = handle;
dragStart = pos;
} else if (isInCropArea(pos.x, pos.y)) {
isDragging = true;
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
}
}
function handleMouseMove(e: MouseEvent) {
const pos = getMousePos(e);
function handleMouseMove(e: MouseEvent) {
const pos = getMousePos(e);
if (isResizing) {
const dx = pos.x - dragStart.x;
const dy = pos.y - dragStart.y;
if (isResizing) {
const dx = pos.x - dragStart.x;
const dy = pos.y - dragStart.y;
const newCrop = { ...crop };
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;
// 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;
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;
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;
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;
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;
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;
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;
}
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;
}
// 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;
}
// 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.y = Math.max(0, Math.min(canvasHeight - crop.height, pos.y - dragStart.y));
drawCanvas();
} else {
// Update cursor based on hover state
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
canvas.style.cursor = handle + '-resize';
} else if (isInCropArea(pos.x, pos.y)) {
canvas.style.cursor = 'move';
} else {
canvas.style.cursor = 'default';
}
}
}
crop = newCrop;
drawCanvas();
} else if (isDragging) {
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));
drawCanvas();
} else {
// Update cursor based on hover state
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
canvas.style.cursor = handle + '-resize';
} else if (isInCropArea(pos.x, pos.y)) {
canvas.style.cursor = 'move';
} else {
canvas.style.cursor = 'default';
}
}
}
function handleMouseUp() {
isDragging = false;
isResizing = false;
resizeHandle = '';
canvas.style.cursor = 'default';
}
function handleMouseUp() {
isDragging = false;
isResizing = false;
resizeHandle = '';
canvas.style.cursor = 'default';
}
function handleSave() {
// Scale crop rectangle back to original image dimensions
const scaleX = image.width / canvasWidth;
const scaleY = image.height / canvasHeight;
function handleSave() {
// Convert canvas coordinates back to image coordinates
const scaleX = image.width / canvasWidth;
const scaleY = image.height / canvasHeight;
const finalCrop = {
x: Math.round(crop.x * scaleX),
y: Math.round(crop.y * scaleY),
width: Math.round(crop.width * scaleX),
height: Math.round(crop.height * scaleY)
};
const imageCrop = {
x: Math.round(crop.x * scaleX),
y: Math.round(crop.y * scaleY),
width: Math.round(crop.width * scaleX),
height: Math.round(crop.height * scaleY)
};
onCropUpdated({ cropData: finalCrop });
onClose();
}
dispatch('save', imageCrop);
}
function handleCancel() {
onClose();
}
function handleCancel() {
dispatch('cancel');
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" on:click={handleCancel}>
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4" on:click|stopPropagation>
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">
Crop Photo - {personName}
</h3>
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={handleOverlayClick}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
>
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4" role="document">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="dialog-title" class="text-lg font-semibold text-gray-800">
Crop Photo: {personName}
</h3>
<button onclick={onClose} class="text-gray-400 hover:text-gray-600" aria-label="Close">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<button
on:click={handleCancel}
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="mb-4 p-2 rounded-md text-center">
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
class="mx-auto cursor-move"
style="max-width: 100%; height: auto;"
></canvas>
</div>
<div class="flex flex-col items-center space-y-4">
<div class="border border-gray-300 rounded-lg overflow-hidden">
<canvas
bind:this={canvas}
on:mousedown={handleMouseDown}
on:mousemove={handleMouseMove}
on:mouseup={handleMouseUp}
on:mouseleave={handleMouseUp}
class="block"
></canvas>
</div>
<p class="text-sm text-gray-600 text-center max-w-lg">
Drag the crop area to move it, or drag the corner handles to resize.
The selected area will be used for the member card.
<br>
<span class="font-medium">Aspect Ratio: {cropRatio.toFixed(1)}:1 {cropRatio === 1.0 ? '(Square)' : cropRatio === 1.5 ? '(3:2)' : ''}</span>
</p>
<div class="flex space-x-3">
<button
on:click={handleCancel}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
Cancel
</button>
<button
on:click={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
>
Save Crop
</button>
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
onclick={handleCancel}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
Cancel
</button>
<button
onclick={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
>
Save Crop
</button>
</div>
</div>
</div>
</div>

View File

@@ -48,14 +48,14 @@
<div class="flex space-x-3 justify-center">
<button
on:click={proceed}
onclick={proceed}
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700"
>
Continue →
</button>
<button
on:click={handleSignOut}
onclick={handleSignOut}
class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium"
>
Sign Out
@@ -65,7 +65,7 @@
{:else}
<!-- Unauthenticated state -->
<button
on:click={handleSignIn}
onclick={handleSignIn}
disabled={!$isGoogleApiReady}
class="w-full bg-blue-600 text-white px-4 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,8 @@
let isProcessing = $state(false);
let processedCount = $state(0);
let totalCount = $state(0);
let detector: blazeface.BlazeFaceModel;
let detector: blazeface.BlazeFaceModel | undefined;
let detectorPromise: Promise<void> | undefined;
interface PhotoInfo {
name: string;
@@ -19,72 +20,93 @@
objectUrl?: string;
retryCount: number;
cropData?: { x: number; y: number; width: number; height: number };
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed';
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
}
// Initialize detector and process photos
onMount(async () => {
console.log('StepGallery mounted, initializing face detector...');
await tf.setBackend('webgl');
await tf.ready();
detector = await blazeface.load();
console.log('BlazeFace model loaded');
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
console.log('Processing photos for gallery step');
processPhotos();
} else {
console.log('No data to process:', { dataLength: $filteredSheetData.length, pictureUrlMapping: $columnMapping.pictureUrl });
function initializeDetector() {
if (!detectorPromise) {
detectorPromise = (async () => {
console.log('Initializing face detector...');
await tf.setBackend('webgl');
await tf.ready();
detector = await blazeface.load();
console.log('BlazeFace model loaded');
})();
}
});
return detectorPromise;
}
async function processPhotos() {
async function processPhotosInParallel() {
if (isProcessing) return;
console.log('Starting processPhotos...');
console.log('Starting processPhotos in parallel...');
isProcessing = true;
processedCount = 0;
// Get valid and included rows from filteredSheetData
const validRows = $filteredSheetData.filter(row => row._isValid);
console.log(`Found ${validRows.length} valid rows`);
// Get unique photos to process
const validRows = $filteredSheetData.filter((row) => row._isValid);
const photoUrls = new Set<string>();
const photoMap = new Map<string, any[]>(); // url -> row data
const photoMap = new Map<string, any[]>();
validRows.forEach((row: any) => {
const photoUrl = row.pictureUrl;
if (photoUrl && photoUrl.trim()) {
photoUrls.add(photoUrl.trim());
if (!photoMap.has(photoUrl.trim())) {
photoMap.set(photoUrl.trim(), []);
const trimmedUrl = photoUrl.trim();
photoUrls.add(trimmedUrl);
if (!photoMap.has(trimmedUrl)) {
photoMap.set(trimmedUrl, []);
}
photoMap.get(photoUrl.trim())!.push(row);
photoMap.get(trimmedUrl)!.push(row);
}
});
console.log(`Found ${photoUrls.size} unique photo URLs`);
totalCount = photoUrls.size;
console.log(`Found ${totalCount} unique photo URLs`);
// Initialize photos array
photos = Array.from(photoUrls).map(url => ({
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, // Use first person's name for display
photos = Array.from(photoUrls).map((url) => ({
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname,
url,
status: 'loading' as const,
retryCount: 0,
faceDetectionStatus: 'pending' as const
}));
// Process each photo
const concurrencyLimit = 5;
const promises = [];
for (let i = 0; i < photos.length; i++) {
await loadPhoto(i);
await detectFaceForPhoto(i);
processedCount++;
const promise = (async () => {
await loadPhoto(i);
processedCount++;
})();
promises.push(promise);
if (promises.length >= concurrencyLimit) {
await Promise.all(promises);
promises.length = 0;
}
}
await Promise.all(promises);
isProcessing = false;
console.log('All photos processed.');
}
// Initialize detector and process photos
onMount(() => {
console.log('StepGallery mounted');
initializeDetector(); // Start loading model
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
console.log('Processing photos for gallery step');
processPhotosInParallel();
} else {
console.log('No data to process:', {
dataLength: $filteredSheetData.length,
pictureUrlMapping: $columnMapping.pictureUrl
});
}
});
async function loadPhoto(index: number, isRetry = false) {
const photo = photos[index];
@@ -165,17 +187,27 @@
async function detectFaceForPhoto(index: number) {
try {
await initializeDetector(); // Ensure detector is loaded
if (!detector) {
photos[index].faceDetectionStatus = 'failed';
console.error('Face detector not available.');
return;
}
photos[index].faceDetectionStatus = 'processing';
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = photos[index].objectUrl!;
await new Promise((r, e) => { img.onload = r; img.onerror = e; });
const predictions = await detector.estimateFaces(img, false);
if (predictions.length > 0) {
const face = predictions.sort((a,b) => (b.probability?.[0]||0) - (a.probability?.[0]||0))[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];
// Coordinates in displayed image space
let [x1,y1] = face.topLeft;
let [x2,y2] = face.bottomRight;
let [x1,y1] = face.topLeft as [number, number];
let [x2,y2] = face.bottomRight as [number, number];
// Scale to natural image size
const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height;
@@ -225,7 +257,8 @@
} else {
photos[index].faceDetectionStatus = 'failed';
}
} catch {
} catch (error) {
console.error(`Face detection failed for ${photos[index].name}:`, error);
photos[index].faceDetectionStatus = 'failed';
}
// No need to reassign photos array with $state reactivity
@@ -242,13 +275,14 @@
await loadPhoto(index, true);
}
function handleCropUpdate(index: number, cropData: { x: number; y: number; width: number; height: number }) {
photos[index].cropData = cropData;
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 => ({
...crops,
[photos[index].url]: cropData
[photos[index].url]: detail.cropData
}));
// No need to reassign photos array with $state reactivity
@@ -376,7 +410,7 @@
{/if}
<!-- Photo Grid -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
<div class="bg-white overflow-hidden mb-6">
{#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">
@@ -388,55 +422,14 @@
</p>
</div>
{:else}
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each photos as photo, index}
{#if photo.status === 'loading'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="aspect-square bg-gray-100 flex items-center justify-center">
<div class="flex flex-col items-center">
<div class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"></div>
<span class="text-xs text-gray-600">Loading...</span>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-blue-600">Processing photo...</span>
</div>
</div>
{:else if photo.status === 'success' && photo.objectUrl}
<PhotoCard
imageUrl={photo.objectUrl}
personName={photo.name}
isProcessing={photo.faceDetectionStatus === 'processing'}
cropData={photo.cropData}
on:cropUpdated={(e) => handleCropUpdate(index, e.detail)}
/>
{:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="aspect-square bg-gray-100 flex items-center justify-center">
<div class="flex flex-col items-center text-center p-4">
<svg class="w-12 h-12 text-red-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-xs text-red-600 mb-2">Failed to load</span>
<button
class="text-xs text-blue-600 hover:text-blue-800 underline"
onclick={() => retryPhoto(index)}
disabled={photo.retryCount >= 3}
>
{photo.retryCount >= 3 ? 'Max retries' : 'Retry'}
</button>
</div>
</div>
<div class="p-3">
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<span class="text-xs text-red-600">Failed to load</span>
</div>
</div>
{/if}
{/each}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each photos as photo, index}
<PhotoCard
{photo}
onCropUpdated={(e) => handleCropUpdate(index, e)}
onRetry={() => retryPhoto(index)}
/>
{/each}
</div>
{/if}
</div>

View File

@@ -1,441 +1,517 @@
<script lang="ts">
import { selectedSheet, columnMapping, rawSheetData, filteredSheetData, currentStep, sheetData } from '$lib/stores';
import type { RowData } from '$lib/stores';
import { onMount } from 'svelte';
import { getSheetNames, getSheetData } from '$lib/google';
import {
selectedSheet,
columnMapping,
rawSheetData,
filteredSheetData,
currentStep,
sheetData
} from '$lib/stores';
import type { RowData } from '$lib/stores';
import { onMount } from 'svelte';
import { getSheetNames, getSheetData } from '$lib/google';
let searchTerm = '';
let sortColumn = '';
let sortDirection: 'asc' | 'desc' = 'asc';
let selectedRows = new Set<number>();
let selectAll = false;
let processedData: any[] = [];
let filteredData: any[] = [];
let headers: string[] = [];
let isLoading = false;
let searchTerm = '';
let sortColumn = '';
let sortDirection: 'asc' | 'desc' = 'asc';
let selectedRows = new Set<number>();
let selectAll = false;
let processedData: any[] = [];
let filteredData: any[] = [];
let headers: string[] = [];
let isLoading = false;
$: {
// Filter data based on search term
if (searchTerm.trim()) {
filteredData = processedData.filter(row =>
Object.values(row).some(value =>
String(value).toLowerCase().includes(searchTerm.toLowerCase())
)
);
} else {
filteredData = processedData;
}
}
$: {
// Filter data based on search term
if (searchTerm.trim()) {
filteredData = processedData.filter((row) =>
Object.values(row).some((value) =>
String(value).toLowerCase().includes(searchTerm.toLowerCase())
)
);
} else {
filteredData = processedData;
}
}
$: {
// Sort data if sort column is selected
if (sortColumn && filteredData.length > 0) {
filteredData = [...filteredData].sort((a, b) => {
const aVal = String(a[sortColumn]).toLowerCase();
const bVal = String(b[sortColumn]).toLowerCase();
$: {
// Sort data if sort column is selected
if (sortColumn && filteredData.length > 0) {
filteredData = [...filteredData].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);
}
});
}
}
if (sortDirection === 'asc') {
return aVal.localeCompare(bVal);
} else {
return bVal.localeCompare(aVal);
}
});
}
}
onMount(() => {
console.log('StepRowFilter mounted');
processSheetData();
});
onMount(() => {
console.log('StepRowFilter mounted');
processSheetData();
});
// Fetch raw sheet data from Google Sheets if not already loaded
async function fetchRawSheetData() {
if (!$rawSheetData || $rawSheetData.length === 0) {
if (!$selectedSheet) 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);
rawSheetData.set(data);
}
}
// Fetch raw sheet data from Google Sheets if not already loaded
async function fetchRawSheetData() {
console.log("Fetching raw sheet data...");
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);
rawSheetData.set(data);
}
async function processSheetData() {
isLoading = true;
try {
await fetchRawSheetData();
if (!$rawSheetData || $rawSheetData.length === 0 || !$columnMapping) {
return;
}
async function processSheetData() {
isLoading = true;
try {
// Get headers from the mapping
headers = Object.keys($columnMapping);
// Get headers from the mapping
headers = Object.keys($columnMapping);
await fetchRawSheetData();
// Process the data starting from row 2 (skip header row)
processedData = $rawSheetData.slice(1).map((row, index) => {
const processedRow: any = {
_rowIndex: index + 1, // Store original row index
_isValid: true
};
// Process the data starting from row 2 (skip header row)
processedData = $rawSheetData.slice(1).map((row, index) => {
const processedRow: any = {
_rowIndex: index + 1, // Store original row index
_isValid: true
};
// Map each column according to the column mapping
for (const [field, columnIndex] of Object.entries($columnMapping)) {
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;
}
}
}
// Map each column according to the column mapping
for (const [field, columnIndex] of Object.entries($columnMapping)) {
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 requiredFields = ['name', 'surname', 'nationality', 'birthday', 'pictureUrl'];
const hasAllRequiredFields = requiredFields.every(field =>
processedRow[field] && String(processedRow[field]).trim() !== ''
);
// Check if all required fields have values (excluding alreadyPrinted)
const requiredFields = ['name', 'surname', 'nationality', 'birthday', 'pictureUrl'];
const hasAllRequiredFields = requiredFields.every(
(field) => processedRow[field] && String(processedRow[field]).trim() !== ''
);
if (!hasAllRequiredFields) {
processedRow._isValid = false;
}
if (!hasAllRequiredFields) {
processedRow._isValid = false;
}
return processedRow;
});
return processedRow;
});
// Initially select rows based on validity and "Already Printed" status
selectedRows = new Set(
processedData
.filter(row => {
if (!row._isValid) return false;
// Initially select rows based on validity and "Already Printed" status
selectedRows = new Set(
processedData
.filter((row) => {
if (!row._isValid) return false;
// Check "Already Printed" column value
const alreadyPrinted = row.alreadyPrinted;
if (alreadyPrinted) {
const value = String(alreadyPrinted).toLowerCase().trim();
// If the value is "true", "yes", "1", or any truthy value, don't select
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
}
// Check "Already Printed" column value
const alreadyPrinted = row.alreadyPrinted;
if (alreadyPrinted) {
const value = String(alreadyPrinted).toLowerCase().trim();
// If the value is "true", "yes", "1", or any truthy value, don't select
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
}
// If empty or falsy, select the row
return true;
})
.map(row => row._rowIndex)
);
// If empty or falsy, select the row
return true;
})
.map((row) => row._rowIndex)
);
updateSelectAllState();
} finally {
isLoading = false;
}
}
updateSelectAllState();
} finally {
isLoading = false;
}
}
function toggleRowSelection(rowIndex: number) {
if (selectedRows.has(rowIndex)) {
selectedRows.delete(rowIndex);
} else {
selectedRows.add(rowIndex);
}
selectedRows = new Set(selectedRows); // Trigger reactivity
updateSelectAllState();
}
function toggleRowSelection(rowIndex: number) {
if (selectedRows.has(rowIndex)) {
selectedRows.delete(rowIndex);
} else {
selectedRows.add(rowIndex);
}
selectedRows = new Set(selectedRows); // Trigger reactivity
updateSelectAllState();
}
function toggleSelectAll() {
if (selectAll) {
// Deselect all visible valid rows that aren't already printed
filteredData.forEach(row => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.delete(row._rowIndex);
}
});
} else {
// Select all visible valid rows that aren't already printed
filteredData.forEach(row => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.add(row._rowIndex);
}
});
}
selectedRows = new Set(selectedRows);
updateSelectAllState();
}
function toggleSelectAll() {
if (selectAll) {
// Deselect all visible valid rows that aren't already printed
filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.delete(row._rowIndex);
}
});
} else {
// Select all visible valid rows that aren't already printed
filteredData.forEach((row) => {
if (row._isValid && !isRowAlreadyPrinted(row)) {
selectedRows.add(row._rowIndex);
}
});
}
selectedRows = new Set(selectedRows);
updateSelectAllState();
}
function updateSelectAllState() {
const visibleValidUnprintedRows = filteredData.filter(row => row._isValid && !isRowAlreadyPrinted(row));
const selectedVisibleValidUnprintedRows = visibleValidUnprintedRows.filter(row => selectedRows.has(row._rowIndex));
function updateSelectAllState() {
const visibleValidUnprintedRows = filteredData.filter(
(row) => row._isValid && !isRowAlreadyPrinted(row)
);
const selectedVisibleValidUnprintedRows = visibleValidUnprintedRows.filter((row) =>
selectedRows.has(row._rowIndex)
);
selectAll = visibleValidUnprintedRows.length > 0 && selectedVisibleValidUnprintedRows.length === visibleValidUnprintedRows.length;
}
selectAll =
visibleValidUnprintedRows.length > 0 &&
selectedVisibleValidUnprintedRows.length === visibleValidUnprintedRows.length;
}
function handleSort(column: string) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
}
function handleSort(column: string) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
}
function getFieldLabel(field: string): string {
const labels = {
name: 'First Name',
surname: 'Last Name',
nationality: 'Nationality',
birthday: 'Birthday',
pictureUrl: 'Photo URL',
alreadyPrinted: 'Already Printed'
};
return labels[field] || field;
}
function getFieldLabel(field: string): string {
const labels: { [key: string]: string } = {
name: 'First Name',
surname: 'Last Name',
nationality: 'Nationality',
birthday: 'Birthday',
pictureUrl: 'Photo URL',
alreadyPrinted: 'Already Printed'
};
return labels[field] || field;
}
function isRowAlreadyPrinted(row: any): boolean {
const alreadyPrinted = row.alreadyPrinted;
if (!alreadyPrinted) return false;
function isRowAlreadyPrinted(row: any): boolean {
const alreadyPrinted = row.alreadyPrinted;
if (!alreadyPrinted) return false;
const value = String(alreadyPrinted).toLowerCase().trim();
return value === 'true' || value === 'yes' || value === '1' || value === 'x';
}
const value = String(alreadyPrinted).toLowerCase().trim();
return value === 'true' || value === 'yes' || value === '1' || value === 'x';
}
function handleContinue() {
// Filter the data to only include selected rows
const selectedData = processedData.filter(row =>
selectedRows.has(row._rowIndex) && row._isValid
);
function handleContinue() {
// Filter the data to only include selected rows
const selectedData = processedData.filter(
(row) => selectedRows.has(row._rowIndex) && row._isValid
);
// Store the filtered data
filteredSheetData.set(selectedData);
// Store the filtered data
filteredSheetData.set(selectedData);
// Move to next step
currentStep.set(5);
}
// Move to next step
currentStep.set(5);
}
$: selectedValidCount = Array.from(selectedRows).filter(rowIndex => {
const row = processedData.find(r => r._rowIndex === rowIndex);
return row && row._isValid;
}).length;
// Allow proceeding only if at least one valid row is selected
$: canProceed = selectedValidCount > 0;
$: selectedValidCount = Array.from(selectedRows).filter((rowIndex) => {
const row = processedData.find((r) => r._rowIndex === rowIndex);
return row && row._isValid;
}).length;
// Allow proceeding only if at least one valid row is selected
$: canProceed = selectedValidCount > 0;
</script>
<div class="p-6">
<div class="max-w-4xl mx-auto">
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Filter and Select Rows
</h2>
<div class="mb-6">
<h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
<p class="text-sm text-gray-700 mb-4">
Review your data and select which rows you want to include in the card generation.
Only rows with all required fields will be available for selection.
</p>
</div>
<p class="mb-4 text-sm text-gray-700">
Review your data and select which rows you want to include in the card generation. Only rows
with all required fields will be available for selection.
</p>
</div>
<!-- Search and Filter Controls -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<div class="flex flex-col sm:flex-row gap-4">
<!-- Search -->
<div class="flex-grow">
<label for="search" class="block text-sm font-medium text-gray-700 mb-2">
Search rows
</label>
<input
id="search"
type="text"
bind:value={searchTerm}
placeholder="Search in any field..."
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<!-- Search and Filter Controls -->
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex flex-col gap-4 sm:flex-row">
<!-- Search -->
<div class="flex-grow">
<label for="search" class="mb-2 block text-sm font-medium text-gray-700">
Search rows
</label>
<input
id="search"
type="text"
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="block text-sm font-medium text-gray-700 mb-2">
Sort by
</label>
<select
id="sort"
bind:value={sortColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">No sorting</option>
{#each headers as header}
<option value={header}>{getFieldLabel(header)}</option>
{/each}
</select>
</div>
</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>
<!-- Stats -->
<div class="mt-4 flex items-center flex-wrap gap-4 text-sm text-gray-600">
<span>Total rows: {processedData.length}</span>
<span>Valid rows: {processedData.filter(row => row._isValid).length}</span>
<span class="text-orange-600">Printed: {processedData.filter(row => isRowAlreadyPrinted(row)).length}</span>
<span>Filtered rows: {filteredData.length}</span>
<span class="font-medium text-blue-600">Selected: {selectedValidCount}</span>
<button
onclick={processSheetData}
disabled={isLoading}
class="ml-auto inline-flex items-center px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-wait"
>
{#if isLoading}
<svg class="h-4 w-4 mr-2 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>
<!-- Stats -->
<div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
<span>Total rows: {processedData.length}</span>
<span>Valid rows: {processedData.filter((row) => row._isValid).length}</span>
<span class="text-orange-600"
>Printed: {processedData.filter((row) => isRowAlreadyPrinted(row)).length}</span
>
<span>Filtered rows: {filteredData.length}</span>
<span class="font-medium text-blue-600">Selected: {selectedValidCount}</span>
<button
onclick={processSheetData}
disabled={isLoading}
class="ml-auto 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>
<!-- Data Table -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6 relative">
{#if isLoading}
<div class="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 z-10">
<svg class="h-10 w-10 text-blue-600 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>
</div>
{/if}
{#if filteredData.length === 0}
<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="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"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3>
<p class="mt-1 text-sm text-gray-500">
{searchTerm ? 'No rows match your search criteria.' : 'No data available to display.'}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<!-- Select All Checkbox -->
<th class="px-3 py-3 text-left">
<input
type="checkbox"
bind:checked={selectAll}
onchange={toggleSelectAll}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</th>
<!-- Data Table -->
<div class="relative mb-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
{#if filteredData.length === 0 && !isLoading}
<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="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"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3>
<p class="mt-1 text-sm text-gray-500">
{searchTerm ? 'No rows match your search criteria.' : 'No data available to display.'}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<!-- Select All Checkbox -->
<th class="px-3 py-3 text-left">
<input
type="checkbox"
bind:checked={selectAll}
onchange={toggleSelectAll}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
disabled={isLoading}
/>
</th>
<!-- Column Headers -->
{#each headers.filter(h => h !== 'alreadyPrinted') as header}
<th
class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onclick={() => handleSort(header)}
>
<div class="flex items-center space-x-1">
<span>{getFieldLabel(header)}</span>
{#if sortColumn === header}
<svg class="w-4 h-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}
<!-- 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 class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each filteredData as row}
<tr class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(row) ? 'bg-orange-50' : ''}">
<!-- Selection Checkbox -->
<td class="px-3 py-4">
{#if row._isValid}
<input
type="checkbox"
checked={selectedRows.has(row._rowIndex)}
onchange={() => toggleRowSelection(row._rowIndex)}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
{:else}
<div class="w-4 h-4 bg-gray-200 rounded"></div>
{/if}
</td>
<!-- Status Column -->
<th
class="px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<!-- Loading skeleton rows -->
{#each Array(5) as _, index}
<tr class="hover:bg-gray-50">
<!-- Selection Checkbox Skeleton -->
<td class="px-3 py-4">
<div class="h-4 w-4 animate-pulse rounded bg-gray-200"></div>
</td>
<!-- Data Columns -->
{#each headers.filter(h => h !== 'alreadyPrinted') as header}
<td class="px-3 py-4 text-sm text-gray-900 max-w-xs truncate">
{row[header] || ''}
</td>
{/each}
<!-- Data Columns Skeletons -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<td class="px-3 py-4">
<div
class="h-4 animate-pulse rounded bg-gray-200"
style="width: {Math.random() * 40 + 60}%"
></div>
</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 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
Valid
</span>
{:else}
<span class="inline-flex px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
Missing data
</span>
{/if}
<!-- Status Column Skeleton -->
<td class="px-3 py-4">
<div class="flex flex-col space-y-1">
<div class="h-6 w-16 animate-pulse rounded-full bg-gray-200"></div>
</div>
</td>
</tr>
{/each}
{:else}
<!-- Actual data rows -->
{#each filteredData as row}
<tr
class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(
row
)
? 'bg-orange-50'
: ''}"
>
<!-- Selection Checkbox -->
<td class="px-3 py-4">
{#if row._isValid}
<input
type="checkbox"
checked={selectedRows.has(row._rowIndex)}
onchange={() => toggleRowSelection(row._rowIndex)}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{:else}
<div class="h-4 w-4 rounded bg-gray-200"></div>
{/if}
</td>
{#if isRowAlreadyPrinted(row)}
<span class="inline-flex px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded-full">
Already Printed
</span>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Data Columns -->
{#each headers.filter((h) => h !== 'alreadyPrinted') as header}
<td class="max-w-xs truncate px-3 py-4 text-sm text-gray-900">
{row[header] || ''}
</td>
{/each}
<!-- Selection Summary -->
{#if selectedValidCount > 0}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" 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>
{/if}
<!-- 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}
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(3)}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
← Back to Colum Selection
</button>
<button
onclick={handleContinue}
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
? `Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} `
: 'Select rows to continue'}
</button>
</div>
</div>
{#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>
</table>
</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>
{/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>
</div>

View File

@@ -1,251 +1,281 @@
<script lang="ts">
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
import { searchSheets } from '$lib/google';
import { onMount } from 'svelte';
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
import { searchSheets } from '$lib/google';
import { onMount } from 'svelte';
let searchQuery = $state('');
let isLoading = $state(false);
let error = $state('');
let searchResults = $state<any[]>([]);
let hasSearched = $state(false);
let recentSheets = $state<any[]>([]);
let searchQuery = $state('');
let isLoading = $state(false);
let error = $state('');
let searchResults = $state<any[]>([]);
let hasSearched = $state(false);
let recentSheets = $state<any[]>([]);
const RECENT_SHEETS_KEY = 'esn-recent-sheets';
const RECENT_SHEETS_KEY = 'esn-recent-sheets';
onMount(() => {
loadRecentSheets();
});
onMount(() => {
loadRecentSheets();
});
async function handleSearch() {
if (!searchQuery.trim()) return;
async function handleSearch() {
if (!searchQuery.trim()) return;
isLoading = true;
error = '';
isLoading = true;
error = '';
try {
searchResults = await searchSheets(searchQuery);
availableSheets.set(
searchResults.map(sheet => ({
spreadsheetId: sheet.spreadsheetId || sheet.id,
name: sheet.name,
url: sheet.webViewLink
}))
);
hasSearched = true;
} catch (err) {
console.error('Error searching sheets:', err);
error = 'Failed to search sheets. Please check your connection and try again.';
searchResults = [];
availableSheets.set([]);
} finally {
isLoading = false;
}
}
try {
searchResults = await searchSheets(searchQuery);
availableSheets.set(
searchResults.map((sheet) => ({
spreadsheetId: sheet.spreadsheetId || sheet.id,
name: sheet.name,
url: sheet.webViewLink
}))
);
hasSearched = true;
} catch (err) {
console.error('Error searching sheets:', err);
error = 'Failed to search sheets. Please check your connection and try again.';
searchResults = [];
availableSheets.set([]);
} finally {
isLoading = false;
}
}
function loadRecentSheets() {
try {
const saved = localStorage.getItem(RECENT_SHEETS_KEY);
if (saved) {
recentSheets = JSON.parse(saved);
}
} catch (err) {
console.error('Error loading recent sheets:', err);
// If there's an error, clear the stored value
localStorage.removeItem(RECENT_SHEETS_KEY);
recentSheets = [];
}
}
function loadRecentSheets() {
try {
const saved = localStorage.getItem(RECENT_SHEETS_KEY);
if (saved) {
recentSheets = JSON.parse(saved);
}
} catch (err) {
console.error('Error loading recent sheets:', err);
// If there's an error, clear the stored value
localStorage.removeItem(RECENT_SHEETS_KEY);
recentSheets = [];
}
}
function handleSelectSheet(sheet) {
const sheetData = {
spreadsheetId: sheet.spreadsheetId || sheet.id,
name: sheet.name,
url: sheet.webViewLink || sheet.url
};
selectedSheet.set(sheetData);
}
function handleSelectSheet(sheet) {
const sheetData = {
spreadsheetId: sheet.spreadsheetId || sheet.id,
name: sheet.name,
url: sheet.webViewLink || sheet.url
};
selectedSheet.set(sheetData);
}
let canProceed = $derived($selectedSheet !== null);
let canProceed = $derived($selectedSheet !== null);
function handleContinue() {
if (!canProceed) return;
currentStep.set(3); // Move to the column mapping step
}
function handleContinue() {
if (!canProceed) return;
currentStep.set(3); // Move to the column mapping step
}
</script>
<div class="p-6">
<div class="max-w-2xl mx-auto">
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Select Google Sheet
</h2>
<div class="mb-6">
<h2 class="mb-2 text-xl font-semibold text-gray-900">Select Google Sheet</h2>
<p class="text-sm text-gray-700 mb-4">
Search for and select the Google Sheet containing your member data.
</p>
</div>
<p class="mb-4 text-sm text-gray-700">
Search for and select the Google Sheet containing your member data.
</p>
</div>
<!-- Search input -->
<div class="mb-6">
<label for="sheet-search" class="block text-sm font-medium text-gray-700 mb-2">
Search sheets
</label>
<!-- Search input -->
<div class="mb-6">
<label for="sheet-search" class="mb-2 block text-sm font-medium text-gray-700">
Search sheets
</label>
<div class="flex">
<input
id="sheet-search"
type="text"
bind:value={searchQuery}
placeholder="Type sheet name..."
class="flex-grow px-4 py-2 border border-gray-300 rounded-l-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent"
onkeydown={e => { if (e.key === 'Enter') handleSearch(); }}
/>
<div class="flex">
<input
id="sheet-search"
type="text"
bind:value={searchQuery}
placeholder="Type sheet name..."
class="flex-grow rounded-l-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-600"
onkeydown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
/>
<button
onclick={handleSearch}
disabled={isLoading || !searchQuery.trim()}
class="px-4 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{#if isLoading}
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
{:else}
Search
{/if}
</button>
</div>
</div>
<button
onclick={handleSearch}
disabled={isLoading || !searchQuery.trim()}
class="rounded-r-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{#if isLoading}
<div
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
{:else}
Search
{/if}
</button>
</div>
</div>
{#if error}
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
{#if error}
<div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<!-- Results -->
{#if hasSearched}
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">
{searchResults.length
? `Found ${searchResults.length} matching sheets`
: 'No matching sheets found'}
</h3>
<!-- Results -->
{#if hasSearched}
<div class="mb-6">
<h3 class="mb-3 text-sm font-medium text-gray-700">
{searchResults.length
? `Found ${searchResults.length} matching sheets`
: 'No matching sheets found'}
</h3>
{#if searchResults.length}
<div class="space-y-3">
{#each searchResults as sheet}
<div
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
tabindex="0"
role="button"
onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); }}
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900">{sheet.name}</p>
<p class="text-xs text-gray-500 mt-1">ID: {sheet.id}</p>
</div>
{#if searchResults.length}
<div class="space-y-3">
{#each searchResults as sheet}
<div
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
(sheet.spreadsheetId || sheet.id)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
tabindex="0"
role="button"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
}}
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900">{sheet.name}</p>
<p class="mt-1 text-xs text-gray-500">ID: {sheet.id}</p>
</div>
<div class="flex items-center">
{#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" />
{/if}
<div class="flex items-center">
{#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
{/if}
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
<svg class="w-5 h-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>
{/if}
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-center py-8 bg-gray-50 rounded-lg border border-gray-200">
<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="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"/>
</svg>
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
</div>
{/if}
</div>
{:else}
<!-- If we have recent sheets and haven't searched yet, show them -->
{#if recentSheets.length > 0 && !hasSearched}
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">
Recent sheets
</h3>
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
<svg class="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>
{/if}
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="rounded-lg border border-gray-200 bg-gray-50 py-8 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="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"
/>
</svg>
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
</div>
{/if}
</div>
{:else}
<!-- If we have recent sheets and haven't searched yet, show them -->
{#if recentSheets.length > 0 && !hasSearched}
<div class="mb-6">
<h3 class="mb-3 text-sm font-medium text-gray-700">Recent sheets</h3>
<div class="space-y-3">
{#each recentSheets as sheet}
<div
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
tabindex="0"
role="button"
onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); }}
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900">{sheet.name}</p>
<p class="text-xs text-gray-500 mt-1">Recently used</p>
</div>
<div class="space-y-3">
{#each recentSheets as sheet}
<div
class="cursor-pointer rounded-lg border p-4 transition-colors hover:bg-gray-50 {$selectedSheet?.spreadsheetId ===
(sheet.spreadsheetId || sheet.id)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSelectSheet(sheet)}
tabindex="0"
role="button"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
}}
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900">{sheet.name}</p>
<p class="mt-1 text-xs text-gray-500">Recently used</p>
</div>
<div class="flex items-center">
{#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" />
{/if}
<div class="flex items-center">
{#if sheet.iconLink}
<img src={sheet.iconLink} alt="Sheet icon" class="mr-2 h-5 w-5" />
{/if}
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
<svg class="w-5 h-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>
{/if}
</div>
</div>
</div>
{/each}
</div>
{#if $selectedSheet?.spreadsheetId === (sheet.spreadsheetId || sheet.id)}
<svg class="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>
{/if}
</div>
</div>
</div>
{/each}
</div>
<div class="border-t border-gray-200 mt-4 pt-4">
<p class="text-xs text-gray-500">
Or search for a different sheet above
</p>
</div>
</div>
{:else}
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200 mb-6">
<svg class="mx-auto h-16 w-16 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3>
<p class="mt-1 text-sm text-gray-500">
Enter a name or keyword to find your Google Sheets
</p>
</div>
{/if}
{/if}
<div class="mt-4 border-t border-gray-200 pt-4">
<p class="text-xs text-gray-500">Or search for a different sheet above</p>
</div>
</div>
{:else}
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 py-12 text-center">
<svg
class="mx-auto h-16 w-16 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3>
<p class="mt-1 text-sm text-gray-500">Enter a name or keyword to find your Google Sheets</p>
</div>
{/if}
{/if}
<!-- Navigation -->
<div class="flex justify-between">
<button
onclick={() => currentStep.set(1)}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
← Back to Auth
</button>
<!-- 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="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
? 'Continue →'
: 'Select a sheet to continue'}
</button>
</div>
</div>
<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>