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 - 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 - 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. - 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. - Use styling from ".github/styling.md" for any UI components.
- Refer to the ".github/core-instructions.md" for the overall structure of the application. - 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. - 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. - Remain consistent in styling and code structure.
- Avoid unncessary iterations. If problems is mostly solved, stop. - Avoid unncessary iterations. If problems is mostly solved, stop.
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.

View File

@@ -1,90 +1,185 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import PhotoCrop from './PhotoCrop.svelte';
import PhotoCrop from './PhotoCrop.svelte';
export let imageUrl: string; let { photo, onCropUpdated, onRetry } = $props<{
export let personName: string; photo: {
export let isProcessing = false; name: string;
export let cropData: { x: number; y: number; width: number; height: number } | null = null; 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<{ let showCropper = $state(false);
cropUpdated: { x: number; y: number; width: number; height: number }; let imageDimensions = $state<{ w: number; h: number } | null>(null);
}>(); let imageContainer = $state<HTMLDivElement | undefined>();
let showCropEditor = false; const cropBoxStyle = $derived(() => {
let currentCrop = cropData; 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() { const containerAspect = containerW / containerH;
showCropEditor = true; const naturalAspect = naturalW / naturalH;
}
function handleCropSave(e: CustomEvent<{ x: number; y: number; width: number; height: number }>) { let imgW, imgH;
currentCrop = e.detail; if (naturalAspect > containerAspect) {
showCropEditor = false; // Image is wider than container, so it's letterboxed top/bottom
dispatch('cropUpdated', currentCrop!); imgW = containerW;
} imgH = containerW / naturalAspect;
} else {
// Image is taller than container, so it's letterboxed left/right
imgH = containerH;
imgW = containerH * naturalAspect;
}
function handleCropCancel() { const offsetX = (containerW - imgW) / 2;
showCropEditor = false; 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> </script>
<div class="relative group"> {#if photo.status === 'loading'}
<div class="relative overflow-hidden rounded-lg border-2 border-gray-200"> <div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<img <div class="h-48 bg-gray-100 flex items-center justify-center">
bind:this={photoElement} <div class="flex flex-col items-center">
src={imageUrl} <div
alt={personName} class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"
class="w-full h-full object-cover" ></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} <div class="p-3 flex items-center justify-between">
<!-- Show crop preview overlay with proper masking --> <div>
<div class="absolute inset-0 pointer-events-none"> <h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
<div class="relative w-full h-full"> {#if photo.faceDetectionStatus === 'completed'}
<!-- Create mask using box-shadow to darken only non-crop areas --> <span class="text-xs text-green-600">Face detected</span>
<div {:else if photo.faceDetectionStatus === 'failed'}
class="absolute border-2 border-blue-500 border-dashed" <span class="text-xs text-orange-600">Face not found</span>
style="left: {(currentCrop.x / photoElement?.naturalWidth) * 100}%; {:else if photo.faceDetectionStatus === 'processing'}
top: {(currentCrop.y / photoElement?.naturalHeight) * 100}%; <span class="text-xs text-blue-600">Detecting face...</span>
width: {(currentCrop.width / photoElement?.naturalWidth) * 100}%; {:else if photo.faceDetectionStatus === 'manual'}
height: {(currentCrop.height / photoElement?.naturalHeight) * 100}%; <span class="text-xs text-purple-600">Manual crop</span>
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.3);" {:else if photo.faceDetectionStatus === 'pending'}
></div> <span class="text-xs text-gray-500">Queued...</span>
</div> {/if}
</div> </div>
{/if} <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 --> {#if showCropper}
<button <PhotoCrop
on:click={openCropEditor} imageUrl={photo.objectUrl}
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" personName={photo.name}
title="Edit crop area" initialCropData={photo.cropData}
> onClose={() => (showCropper = false)}
<svg class="w-4 h-4 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor"> onCropUpdated={handleCropUpdated}
<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> {/if}
</button> </div>
</div> {:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="mt-2"> <div class="h-48 bg-gray-100 flex items-center justify-center">
<p class="text-sm font-medium text-gray-900 truncate">{personName}</p> <div class="flex flex-col items-center text-center p-4">
{#if isProcessing} <svg
<p class="text-xs text-gray-500">Processing...</p> class="w-12 h-12 text-red-400 mb-2"
{/if} fill="none"
</div> viewBox="0 0 24 24"
</div> stroke="currentColor"
>
{#if showCropEditor} <path
<PhotoCrop stroke-linecap="round"
{imageUrl} stroke-linejoin="round"
{personName} stroke-width="2"
initialCrop={currentCrop} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
on:save={handleCropSave} />
on:cancel={handleCropCancel} </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} {/if}

View File

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

View File

@@ -48,14 +48,14 @@
<div class="flex space-x-3 justify-center"> <div class="flex space-x-3 justify-center">
<button <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" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700"
> >
Continue → Continue →
</button> </button>
<button <button
on:click={handleSignOut} onclick={handleSignOut}
class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium" class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium"
> >
Sign Out Sign Out
@@ -65,7 +65,7 @@
{:else} {:else}
<!-- Unauthenticated state --> <!-- Unauthenticated state -->
<button <button
on:click={handleSignIn} onclick={handleSignIn}
disabled={!$isGoogleApiReady} 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" 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 isProcessing = $state(false);
let processedCount = $state(0); let processedCount = $state(0);
let totalCount = $state(0); let totalCount = $state(0);
let detector: blazeface.BlazeFaceModel; let detector: blazeface.BlazeFaceModel | undefined;
let detectorPromise: Promise<void> | undefined;
interface PhotoInfo { interface PhotoInfo {
name: string; name: string;
@@ -19,72 +20,93 @@
objectUrl?: string; objectUrl?: string;
retryCount: number; retryCount: number;
cropData?: { x: number; y: number; width: number; height: 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 function initializeDetector() {
onMount(async () => { if (!detectorPromise) {
console.log('StepGallery mounted, initializing face detector...'); detectorPromise = (async () => {
await tf.setBackend('webgl'); console.log('Initializing face detector...');
await tf.ready(); await tf.setBackend('webgl');
detector = await blazeface.load(); await tf.ready();
console.log('BlazeFace model loaded'); detector = await blazeface.load();
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) { console.log('BlazeFace model loaded');
console.log('Processing photos for gallery step'); })();
processPhotos();
} else {
console.log('No data to process:', { dataLength: $filteredSheetData.length, pictureUrlMapping: $columnMapping.pictureUrl });
} }
}); return detectorPromise;
}
async function processPhotos() { async function processPhotosInParallel() {
if (isProcessing) return; if (isProcessing) return;
console.log('Starting processPhotos...'); console.log('Starting processPhotos in parallel...');
isProcessing = true; isProcessing = true;
processedCount = 0; processedCount = 0;
// Get valid and included rows from filteredSheetData const validRows = $filteredSheetData.filter((row) => row._isValid);
const validRows = $filteredSheetData.filter(row => row._isValid);
console.log(`Found ${validRows.length} valid rows`);
// Get unique photos to process
const photoUrls = new Set<string>(); const photoUrls = new Set<string>();
const photoMap = new Map<string, any[]>(); // url -> row data const photoMap = new Map<string, any[]>();
validRows.forEach((row: any) => { validRows.forEach((row: any) => {
const photoUrl = row.pictureUrl; const photoUrl = row.pictureUrl;
if (photoUrl && photoUrl.trim()) { if (photoUrl && photoUrl.trim()) {
photoUrls.add(photoUrl.trim()); const trimmedUrl = photoUrl.trim();
if (!photoMap.has(photoUrl.trim())) { photoUrls.add(trimmedUrl);
photoMap.set(photoUrl.trim(), []); 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; totalCount = photoUrls.size;
console.log(`Found ${totalCount} unique photo URLs`);
// Initialize photos array photos = Array.from(photoUrls).map((url) => ({
photos = Array.from(photoUrls).map(url => ({ name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname,
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, // Use first person's name for display
url, url,
status: 'loading' as const, status: 'loading' as const,
retryCount: 0, retryCount: 0,
faceDetectionStatus: 'pending' as const faceDetectionStatus: 'pending' as const
})); }));
// Process each photo const concurrencyLimit = 5;
const promises = [];
for (let i = 0; i < photos.length; i++) { for (let i = 0; i < photos.length; i++) {
await loadPhoto(i); const promise = (async () => {
await detectFaceForPhoto(i); await loadPhoto(i);
processedCount++; processedCount++;
})();
promises.push(promise);
if (promises.length >= concurrencyLimit) {
await Promise.all(promises);
promises.length = 0;
}
} }
await Promise.all(promises);
isProcessing = false; 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) { async function loadPhoto(index: number, isRetry = false) {
const photo = photos[index]; const photo = photos[index];
@@ -165,17 +187,27 @@
async function detectFaceForPhoto(index: number) { async function detectFaceForPhoto(index: number) {
try { try {
await initializeDetector(); // Ensure detector is loaded
if (!detector) {
photos[index].faceDetectionStatus = 'failed';
console.error('Face detector not available.');
return;
}
photos[index].faceDetectionStatus = 'processing'; photos[index].faceDetectionStatus = 'processing';
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
img.src = photos[index].objectUrl!; img.src = photos[index].objectUrl!;
await new Promise((r, e) => { img.onload = r; img.onerror = e; }); await new Promise((r, e) => { img.onload = r; img.onerror = e; });
const predictions = await detector.estimateFaces(img, false); const predictions = await detector.estimateFaces(img, false);
if (predictions.length > 0) { if (predictions.length > 0) {
const 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 // Coordinates in displayed image space
let [x1,y1] = face.topLeft; let [x1,y1] = face.topLeft as [number, number];
let [x2,y2] = face.bottomRight; let [x2,y2] = face.bottomRight as [number, number];
// Scale to natural image size // Scale to natural image size
const scaleX = img.naturalWidth / img.width; const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height; const scaleY = img.naturalHeight / img.height;
@@ -225,7 +257,8 @@
} else { } else {
photos[index].faceDetectionStatus = 'failed'; photos[index].faceDetectionStatus = 'failed';
} }
} catch { } catch (error) {
console.error(`Face detection failed for ${photos[index].name}:`, error);
photos[index].faceDetectionStatus = 'failed'; photos[index].faceDetectionStatus = 'failed';
} }
// No need to reassign photos array with $state reactivity // No need to reassign photos array with $state reactivity
@@ -242,13 +275,14 @@
await loadPhoto(index, true); await loadPhoto(index, true);
} }
function handleCropUpdate(index: number, 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 = cropData; photos[index].cropData = detail.cropData;
photos[index].faceDetectionStatus = 'manual';
// Save updated crop data to store // Save updated crop data to store
cropRects.update(crops => ({ cropRects.update(crops => ({
...crops, ...crops,
[photos[index].url]: cropData [photos[index].url]: detail.cropData
})); }));
// No need to reassign photos array with $state reactivity // No need to reassign photos array with $state reactivity
@@ -376,7 +410,7 @@
{/if} {/if}
<!-- Photo Grid --> <!-- 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} {#if photos.length === 0 && !isProcessing}
<div class="text-center py-12"> <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"> <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> </p>
</div> </div>
{:else} {: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">
<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}
{#each photos as photo, index} <PhotoCard
{#if photo.status === 'loading'} {photo}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm"> onCropUpdated={(e) => handleCropUpdate(index, e)}
<div class="aspect-square bg-gray-100 flex items-center justify-center"> onRetry={() => retryPhoto(index)}
<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> {/each}
<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> </div>
{/if} {/if}
</div> </div>

View File

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

View File

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