Improve the the cropping process, UI and UX
This commit is contained in:
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -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.
|
||||||
@@ -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';
|
||||||
const dispatch = createEventDispatcher<{
|
objectUrl?: string;
|
||||||
cropUpdated: { x: number; y: number; width: number; height: number };
|
retryCount: number;
|
||||||
|
cropData?: { x: number; y: number; width: number; height: number };
|
||||||
|
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
|
||||||
|
};
|
||||||
|
onCropUpdated: (detail: any) => void;
|
||||||
|
onRetry: () => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let showCropEditor = false;
|
let showCropper = $state(false);
|
||||||
let currentCrop = cropData;
|
let imageDimensions = $state<{ w: number; h: number } | null>(null);
|
||||||
|
let imageContainer = $state<HTMLDivElement | undefined>();
|
||||||
|
|
||||||
let photoElement: HTMLImageElement;
|
const cropBoxStyle = $derived(() => {
|
||||||
|
if (!photo.cropData || !imageDimensions || !imageContainer) {
|
||||||
function openCropEditor() {
|
return 'display: none;';
|
||||||
showCropEditor = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCropSave(e: CustomEvent<{ x: number; y: number; width: number; height: number }>) {
|
const { w: naturalW, h: naturalH } = imageDimensions;
|
||||||
currentCrop = e.detail;
|
const { x, y, width, height } = photo.cropData;
|
||||||
showCropEditor = false;
|
const { clientWidth: containerW, clientHeight: containerH } = imageContainer;
|
||||||
dispatch('cropUpdated', currentCrop!);
|
|
||||||
|
const containerAspect = containerW / containerH;
|
||||||
|
const naturalAspect = naturalW / naturalH;
|
||||||
|
|
||||||
|
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() {
|
const offsetX = (containerW - imgW) / 2;
|
||||||
showCropEditor = false;
|
const offsetY = (containerH - imgH) / 2;
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (cropData) currentCrop = cropData;
|
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}
|
|
||||||
alt={personName}
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#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
|
<div
|
||||||
class="absolute border-2 border-blue-500 border-dashed"
|
class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"
|
||||||
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>
|
||||||
|
<span class="text-xs text-gray-600">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="p-3">
|
||||||
|
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
|
||||||
<!-- Edit crop button -->
|
<span class="text-xs text-blue-600">Processing photo...</span>
|
||||||
<button
|
</div>
|
||||||
on:click={openCropEditor}
|
</div>
|
||||||
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"
|
{:else if photo.status === 'success' && photo.objectUrl}
|
||||||
title="Edit crop area"
|
<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}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<img
|
||||||
<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"/>
|
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>
|
||||||
|
|
||||||
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
{#if showCropper}
|
||||||
<p class="text-sm font-medium text-gray-900 truncate">{personName}</p>
|
<PhotoCrop
|
||||||
{#if isProcessing}
|
imageUrl={photo.objectUrl}
|
||||||
<p class="text-xs text-gray-500">Processing...</p>
|
personName={photo.name}
|
||||||
|
initialCropData={photo.cropData}
|
||||||
|
onClose={() => (showCropper = false)}
|
||||||
|
onCropUpdated={handleCropUpdated}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else if photo.status === 'error'}
|
||||||
|
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
|
||||||
{#if showCropEditor}
|
<div class="h-48 bg-gray-100 flex items-center justify-center">
|
||||||
<PhotoCrop
|
<div class="flex flex-col items-center text-center p-4">
|
||||||
{imageUrl}
|
<svg
|
||||||
{personName}
|
class="w-12 h-12 text-red-400 mb-2"
|
||||||
initialCrop={currentCrop}
|
fill="none"
|
||||||
on:save={handleCropSave}
|
viewBox="0 0 24 24"
|
||||||
on:cancel={handleCropCancel}
|
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}
|
{/if}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<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 };
|
||||||
const dispatch = createEventDispatcher<{
|
onCropUpdated: (detail: {
|
||||||
save: { x: number; y: number; width: number; height: number };
|
cropData: { x: number; y: number; width: number; height: number };
|
||||||
cancel: void;
|
}) => void;
|
||||||
|
onClose: () => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
@@ -63,15 +64,15 @@
|
|||||||
canvas.height = canvasHeight;
|
canvas.height = canvasHeight;
|
||||||
|
|
||||||
// Initialize crop rectangle
|
// Initialize crop rectangle
|
||||||
if (initialCrop) {
|
if (initialCropData) {
|
||||||
// Scale initial crop to canvas dimensions
|
// Scale initial crop to canvas dimensions
|
||||||
const scaleX = canvasWidth / image.width;
|
const scaleX = canvasWidth / image.width;
|
||||||
const scaleY = canvasHeight / image.height;
|
const scaleY = canvasHeight / image.height;
|
||||||
crop = {
|
crop = {
|
||||||
x: initialCrop.x * scaleX,
|
x: initialCropData.x * scaleX,
|
||||||
y: initialCrop.y * scaleY,
|
y: initialCropData.y * scaleY,
|
||||||
width: initialCrop.width * scaleX,
|
width: initialCropData.width * scaleX,
|
||||||
height: initialCrop.height * scaleY
|
height: initialCropData.height * scaleY
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Default crop: centered with correct aspect ratio
|
// Default crop: centered with correct aspect ratio
|
||||||
@@ -283,72 +284,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
// Convert canvas coordinates back to image coordinates
|
// Scale crop rectangle back to original image dimensions
|
||||||
const scaleX = image.width / canvasWidth;
|
const scaleX = image.width / canvasWidth;
|
||||||
const scaleY = image.height / canvasHeight;
|
const scaleY = image.height / canvasHeight;
|
||||||
|
|
||||||
const imageCrop = {
|
const finalCrop = {
|
||||||
x: Math.round(crop.x * scaleX),
|
x: Math.round(crop.x * scaleX),
|
||||||
y: Math.round(crop.y * scaleY),
|
y: Math.round(crop.y * scaleY),
|
||||||
width: Math.round(crop.width * scaleX),
|
width: Math.round(crop.width * scaleX),
|
||||||
height: Math.round(crop.height * scaleY)
|
height: Math.round(crop.height * scaleY)
|
||||||
};
|
};
|
||||||
|
|
||||||
dispatch('save', imageCrop);
|
onCropUpdated({ cropData: finalCrop });
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
dispatch('cancel');
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event: MouseEvent) {
|
||||||
|
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"
|
||||||
|
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="p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">
|
<h3 id="dialog-title" class="text-lg font-semibold text-gray-800">
|
||||||
Crop Photo - {personName}
|
Crop Photo: {personName}
|
||||||
</h3>
|
</h3>
|
||||||
|
<button onclick={onClose} class="text-gray-400 hover:text-gray-600" aria-label="Close">
|
||||||
<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">
|
<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"/>
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
<div class="mb-4 p-2 rounded-md text-center">
|
||||||
<div class="border border-gray-300 rounded-lg overflow-hidden">
|
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={canvas}
|
bind:this={canvas}
|
||||||
on:mousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
on:mousemove={handleMouseMove}
|
onmousemove={handleMouseMove}
|
||||||
on:mouseup={handleMouseUp}
|
onmouseup={handleMouseUp}
|
||||||
on:mouseleave={handleMouseUp}
|
onmouseleave={handleMouseUp}
|
||||||
class="block"
|
class="mx-auto cursor-move"
|
||||||
|
style="max-width: 100%; height: auto;"
|
||||||
></canvas>
|
></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 text-center max-w-lg">
|
<div class="flex justify-end space-x-3">
|
||||||
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
|
<button
|
||||||
on:click={handleCancel}
|
onclick={handleCancel}
|
||||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
|
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
on:click={handleSave}
|
onclick={handleSave}
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Save Crop
|
Save Crop
|
||||||
@@ -356,5 +371,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -67,8 +67,10 @@
|
|||||||
const recentSheets = JSON.parse(recentSheetsData);
|
const recentSheets = JSON.parse(recentSheetsData);
|
||||||
if (recentSheets && recentSheets.length > 0) {
|
if (recentSheets && recentSheets.length > 0) {
|
||||||
// Find a sheet that matches the current spreadsheet
|
// Find a sheet that matches the current spreadsheet
|
||||||
const savedSheet = recentSheets.find((sheet: SheetInfoType) =>
|
const savedSheet = recentSheets.find(
|
||||||
(sheet.id === $selectedSheet.spreadsheetId || sheet.spreadsheetId === $selectedSheet.spreadsheetId)
|
(sheet: SheetInfoType) =>
|
||||||
|
sheet.id === $selectedSheet.spreadsheetId ||
|
||||||
|
sheet.spreadsheetId === $selectedSheet.spreadsheetId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (savedSheet) {
|
if (savedSheet) {
|
||||||
@@ -122,7 +124,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Loading sheet data quietly for spreadsheet:', $selectedSheet.spreadsheetId, 'sheet:', sheetName);
|
console.log(
|
||||||
|
'Loading sheet data quietly for spreadsheet:',
|
||||||
|
$selectedSheet.spreadsheetId,
|
||||||
|
'sheet:',
|
||||||
|
sheetName
|
||||||
|
);
|
||||||
|
|
||||||
// Make sure we verify the sheet exists before trying to load it
|
// Make sure we verify the sheet exists before trying to load it
|
||||||
if (availableSheets.length === 0) {
|
if (availableSheets.length === 0) {
|
||||||
@@ -212,7 +219,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Loading sheet data for spreadsheet:', $selectedSheet.spreadsheetId, 'sheet:', sheetName);
|
console.log(
|
||||||
|
'Loading sheet data for spreadsheet:',
|
||||||
|
$selectedSheet.spreadsheetId,
|
||||||
|
'sheet:',
|
||||||
|
sheetName
|
||||||
|
);
|
||||||
isLoadingData = true;
|
isLoadingData = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
@@ -281,11 +293,13 @@
|
|||||||
|
|
||||||
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
|
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
|
||||||
// Check if this column is empty (all preview rows are empty for this column)
|
// Check if this column is empty (all preview rows are empty for this column)
|
||||||
const isEmpty = previewData.every(row => !row[colIndex] || String(row[colIndex]).trim() === '');
|
const isEmpty = previewData.every(
|
||||||
|
(row) => !row[colIndex] || String(row[colIndex]).trim() === ''
|
||||||
|
);
|
||||||
|
|
||||||
// Also check if this column isn't already mapped to another field
|
// Also check if this column isn't already mapped to another field
|
||||||
const isAlreadyMapped = Object.entries(mappedIndices).some(([field, index]) =>
|
const isAlreadyMapped = Object.entries(mappedIndices).some(
|
||||||
field !== 'alreadyPrinted' && index === colIndex
|
([field, index]) => field !== 'alreadyPrinted' && index === colIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isEmpty && !isAlreadyMapped) {
|
if (isEmpty && !isAlreadyMapped) {
|
||||||
@@ -311,8 +325,10 @@
|
|||||||
|
|
||||||
if (existingData) {
|
if (existingData) {
|
||||||
const recentSheets = JSON.parse(existingData);
|
const recentSheets = JSON.parse(existingData);
|
||||||
const savedSheet = recentSheets.find((sheet: SheetInfoType) =>
|
const savedSheet = recentSheets.find(
|
||||||
(sheet.id === $selectedSheet.spreadsheetId || sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
|
(sheet: SheetInfoType) =>
|
||||||
|
(sheet.id === $selectedSheet.spreadsheetId ||
|
||||||
|
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
|
||||||
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
|
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -356,7 +372,7 @@
|
|||||||
pictureUrl: mappedIndices.pictureUrl
|
pictureUrl: mappedIndices.pictureUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
mappingComplete = Object.values(requiredIndices).every(index => index !== -1);
|
mappingComplete = Object.values(requiredIndices).every((index) => index !== -1);
|
||||||
console.log('Mapping complete:', mappingComplete);
|
console.log('Mapping complete:', mappingComplete);
|
||||||
|
|
||||||
// Update the column mapping store
|
// Update the column mapping store
|
||||||
@@ -380,8 +396,10 @@
|
|||||||
let recentSheets = existingData ? JSON.parse(existingData) : [];
|
let recentSheets = existingData ? JSON.parse(existingData) : [];
|
||||||
|
|
||||||
// Find the current sheet in recent sheets and update its column mapping
|
// Find the current sheet in recent sheets and update its column mapping
|
||||||
const sheetIndex = recentSheets.findIndex((sheet: SheetInfoType) =>
|
const sheetIndex = recentSheets.findIndex(
|
||||||
(sheet.id === $selectedSheet.spreadsheetId || sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
|
(sheet: SheetInfoType) =>
|
||||||
|
(sheet.id === $selectedSheet.spreadsheetId ||
|
||||||
|
sheet.spreadsheetId === $selectedSheet.spreadsheetId) &&
|
||||||
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
|
(sheet.sheetName === selectedSheetName || sheet.sheetMapping === selectedSheetName)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -400,8 +418,10 @@
|
|||||||
recentSheets[sheetIndex].lastUsed = new Date().toISOString();
|
recentSheets[sheetIndex].lastUsed = new Date().toISOString();
|
||||||
|
|
||||||
// Ensure we have consistent property names
|
// Ensure we have consistent property names
|
||||||
recentSheets[sheetIndex].spreadsheetId = recentSheets[sheetIndex].spreadsheetId || recentSheets[sheetIndex].id;
|
recentSheets[sheetIndex].spreadsheetId =
|
||||||
recentSheets[sheetIndex].sheetMapping = recentSheets[sheetIndex].sheetMapping || recentSheets[sheetIndex].sheetName;
|
recentSheets[sheetIndex].spreadsheetId || recentSheets[sheetIndex].id;
|
||||||
|
recentSheets[sheetIndex].sheetMapping =
|
||||||
|
recentSheets[sheetIndex].sheetMapping || recentSheets[sheetIndex].sheetName;
|
||||||
} else {
|
} else {
|
||||||
// Add new entry
|
// Add new entry
|
||||||
const newEntry = {
|
const newEntry = {
|
||||||
@@ -458,41 +478,50 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="max-w-3xl mx-auto">
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
<h2 class="mb-2 text-xl font-semibold text-gray-900">Select Sheet and Map Columns</h2>
|
||||||
Select Sheet and Map Columns
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-700 mb-4">
|
<p class="mb-4 text-sm text-gray-700">
|
||||||
First, select which sheet contains your member data, then map the columns to the required fields.
|
First, select which sheet contains your member data, then map the columns to the required
|
||||||
|
fields.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if hasSavedMapping && !showMappingEditor}
|
{#if hasSavedMapping && !showMappingEditor}
|
||||||
<!-- Simplified view when we have saved mapping -->
|
<!-- Simplified view when we have saved mapping -->
|
||||||
<div class="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
|
<div class="mb-6 rounded-lg border border-green-200 bg-green-50 p-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<svg class="mx-auto h-16 w-16 text-green-600 mb-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="mx-auto mb-4 h-16 w-16 text-green-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
|
||||||
|
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>
|
</svg>
|
||||||
<h3 class="text-xl font-semibold text-green-800 mb-3">Configuration Complete</h3>
|
<h3 class="mb-3 text-xl font-semibold text-green-800">Configuration Complete</h3>
|
||||||
<p class="text-green-700 mb-2">
|
<p class="mb-2 text-green-700">
|
||||||
<span class="font-medium">Spreadsheet:</span> {savedSheetInfo?.name}
|
<span class="font-medium">Spreadsheet:</span>
|
||||||
|
{savedSheetInfo?.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-green-700 mb-2">
|
<p class="mb-2 text-green-700">
|
||||||
<span class="font-medium">Sheet:</span> {selectedSheetName}
|
<span class="font-medium">Sheet:</span>
|
||||||
|
{selectedSheetName}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-green-700 mb-6">
|
<p class="mb-6 text-green-700">
|
||||||
Column mapping loaded from your previous session.<br>
|
Column mapping loaded from your previous session.<br />
|
||||||
Everything is ready to proceed to the next step.
|
Everything is ready to proceed to the next step.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={handleShowEditor}
|
onclick={handleShowEditor}
|
||||||
class="inline-flex items-center px-4 py-2 text-sm text-green-700 hover:text-green-900 font-medium border border-green-300 rounded-lg hover:bg-green-100 transition-colors"
|
class="inline-flex items-center rounded-lg border border-green-300 px-4 py-2 text-sm font-medium text-green-700 transition-colors hover:bg-green-100 hover:text-green-900"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.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"/>
|
<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>
|
</svg>
|
||||||
Make changes if needed
|
Make changes if needed
|
||||||
</button>
|
</button>
|
||||||
@@ -500,18 +529,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Sheet Selection -->
|
<!-- Sheet Selection -->
|
||||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
<h3 class="mb-3 text-sm font-medium text-gray-700">Step 1: Select Sheet</h3>
|
||||||
Step 1: Select Sheet
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{#if isLoadingSheets}
|
{#if isLoadingSheets}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div>
|
<div
|
||||||
|
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
|
||||||
|
></div>
|
||||||
<span class="text-sm text-gray-600">Loading sheets...</span>
|
<span class="text-sm text-gray-600">Loading sheets...</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="bg-red-50 border border-red-300 rounded-lg p-3 mb-3">
|
<div class="mb-3 rounded-lg border border-red-300 bg-red-50 p-3">
|
||||||
<p class="text-sm text-red-800">{error}</p>
|
<p class="text-sm text-red-800">{error}</p>
|
||||||
<button
|
<button
|
||||||
class="mt-2 text-sm text-blue-600 hover:text-blue-800"
|
class="mt-2 text-sm text-blue-600 hover:text-blue-800"
|
||||||
@@ -521,9 +550,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if availableSheets.length === 0}
|
{:else if availableSheets.length === 0}
|
||||||
<div class="text-center py-6 bg-white rounded border border-gray-200">
|
<div class="rounded border border-gray-200 bg-white py-6 text-center">
|
||||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
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>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No sheets found</h3>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No sheets found</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
@@ -537,26 +576,30 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="block text-sm font-medium text-gray-700 mb-3">
|
<p class="mb-3 block text-sm font-medium text-gray-700">Choose sheet:</p>
|
||||||
Choose sheet:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each availableSheets as sheetName}
|
{#each availableSheets as sheetName}
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="border rounded-lg p-3 cursor-pointer transition-colors hover:bg-gray-50
|
class="cursor-pointer rounded-lg border p-3 transition-colors hover:bg-gray-50
|
||||||
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
|
{selectedSheetName === sheetName
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200'}"
|
||||||
onclick={() => handleSheetSelect(sheetName)}
|
onclick={() => handleSheetSelect(sheetName)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleSheetSelect(sheetName)}
|
onkeydown={(e) => e.key === 'Enter' && handleSheetSelect(sheetName)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-4 h-4 rounded-full border-2 flex items-center justify-center
|
<div
|
||||||
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-500' : 'border-gray-300'}">
|
class="flex h-4 w-4 items-center justify-center rounded-full border-2
|
||||||
|
{selectedSheetName === sheetName
|
||||||
|
? 'border-blue-500 bg-blue-500'
|
||||||
|
: 'border-gray-300'}"
|
||||||
|
>
|
||||||
{#if selectedSheetName === sheetName}
|
{#if selectedSheetName === sheetName}
|
||||||
<div class="w-2 h-2 rounded-full bg-white"></div>
|
<div class="h-2 w-2 rounded-full bg-white"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -567,8 +610,12 @@
|
|||||||
|
|
||||||
{#if selectedSheetName === sheetName}
|
{#if selectedSheetName === sheetName}
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<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
|
||||||
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -582,23 +629,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Column Mapping Section -->
|
<!-- Column Mapping Section -->
|
||||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
<h3 class="mb-3 text-sm font-medium text-gray-700">Step 2: Map Columns</h3>
|
||||||
Step 2: Map Columns
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{#if isLoadingData}
|
{#if isLoadingData}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div>
|
<div
|
||||||
|
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
|
||||||
|
></div>
|
||||||
<span class="text-sm text-gray-600">Loading sheet data...</span>
|
<span class="text-sm text-gray-600">Loading sheet data...</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if sheetHeaders.length === 0}
|
{:else if sheetHeaders.length === 0}
|
||||||
<div class="text-center py-8 text-gray-500">
|
<div class="py-8 text-center text-gray-500">
|
||||||
<p class="text-sm">Select a sheet above to map columns</p>
|
<p class="text-sm">Select a sheet above to map columns</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
<p class="mb-4 text-sm text-gray-600">
|
||||||
Map the columns from your sheet to the required fields:
|
Map the columns from your sheet to the required fields:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -619,11 +666,15 @@
|
|||||||
<select
|
<select
|
||||||
id={`field-${field.key}`}
|
id={`field-${field.key}`}
|
||||||
bind:value={mappedIndices[field.key]}
|
bind:value={mappedIndices[field.key]}
|
||||||
onchange={() => handleColumnMapping(field.key as keyof ColumnMappingType, mappedIndices[field.key])}
|
onchange={() =>
|
||||||
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"
|
handleColumnMapping(
|
||||||
|
field.key as keyof ColumnMappingType,
|
||||||
|
mappedIndices[field.key]
|
||||||
|
)}
|
||||||
|
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={-1}>-- Select column --</option>
|
<option value={-1}>-- Select column --</option>
|
||||||
{#each Array.from({length: Math.max(sheetHeaders.length, 26)}, (_, i) => i) as index}
|
{#each Array.from({ length: Math.max(sheetHeaders.length, 26) }, (_, i) => i) as index}
|
||||||
<option value={index}>
|
<option value={index}>
|
||||||
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
|
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
|
||||||
{#if !sheetHeaders[index]}
|
{#if !sheetHeaders[index]}
|
||||||
@@ -640,30 +691,40 @@
|
|||||||
<!-- Data preview -->
|
<!-- Data preview -->
|
||||||
{#if previewData.length > 0}
|
{#if previewData.length > 0}
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h4 class="text-sm font-medium text-gray-700 mb-3">Data Preview:</h4>
|
<h4 class="mb-3 text-sm font-medium text-gray-700">Data Preview:</h4>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg">
|
<table
|
||||||
|
class="min-w-full divide-y divide-gray-200 rounded-lg border border-gray-200"
|
||||||
|
>
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
{#each Array.from({length: Math.min(Math.max(sheetHeaders.length, previewData[0]?.length || 0), 26)}, (_, i) => i) as index}
|
{#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, previewData[0]?.length || 0), 26) }, (_, i) => i) as index}
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider
|
<th
|
||||||
{Object.values(mappedIndices).includes(index) ? 'bg-blue-100' : ''}">
|
class="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase
|
||||||
|
{Object.values(mappedIndices).includes(index)
|
||||||
|
? 'bg-blue-100'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
|
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
|
||||||
{#if Object.values(mappedIndices).includes(index)}
|
{#if Object.values(mappedIndices).includes(index)}
|
||||||
<div class="text-blue-600 text-xs mt-1">
|
<div class="mt-1 text-xs text-blue-600">
|
||||||
{requiredFields.find(f => mappedIndices[f.key] === index)?.label}
|
{requiredFields.find((f) => mappedIndices[f.key] === index)?.label}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</th>
|
</th>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
{#each previewData as row}
|
{#each previewData as row}
|
||||||
<tr>
|
<tr>
|
||||||
{#each Array.from({length: Math.min(Math.max(sheetHeaders.length, row.length), 26)}, (_, i) => i) as index}
|
{#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, row.length), 26) }, (_, i) => i) as index}
|
||||||
<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate
|
<td
|
||||||
{Object.values(mappedIndices).includes(index) ? 'bg-blue-50' : ''}">
|
class="max-w-xs truncate px-3 py-2 text-sm text-gray-500
|
||||||
|
{Object.values(mappedIndices).includes(index)
|
||||||
|
? 'bg-blue-50'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
{row[index] || ''}
|
{row[index] || ''}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -677,16 +738,14 @@
|
|||||||
|
|
||||||
<!-- Mapping status -->
|
<!-- Mapping status -->
|
||||||
{#if mappingComplete}
|
{#if mappingComplete}
|
||||||
<div class="bg-green-50 border border-green-200 rounded p-3">
|
<div class="rounded border border-green-200 bg-green-50 p-3">
|
||||||
<p class="text-sm text-green-800">
|
<p class="text-sm text-green-800">
|
||||||
✓ All required fields are mapped! You can continue to the next step.
|
✓ All required fields are mapped! You can continue to the next step.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="bg-yellow-50 border border-yellow-200 rounded p-3">
|
<div class="rounded border border-yellow-200 bg-yellow-50 p-3">
|
||||||
<p class="text-sm text-yellow-800">
|
<p class="text-sm text-yellow-800">Please map all required fields to continue.</p>
|
||||||
Please map all required fields to continue.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -694,12 +753,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
onclick={() => currentStep.set(2)}
|
onclick={() => currentStep.set(2)}
|
||||||
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 Sheet Selection
|
← Back to Sheet Selection
|
||||||
</button>
|
</button>
|
||||||
@@ -707,12 +765,9 @@
|
|||||||
<button
|
<button
|
||||||
onclick={handleContinue}
|
onclick={handleContinue}
|
||||||
disabled={!mappingComplete}
|
disabled={!mappingComplete}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{mappingComplete
|
{mappingComplete ? 'Continue →' : 'Select a column mapping'}
|
||||||
? 'Continue →'
|
|
||||||
: 'Select a column mapping'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
console.log('Initializing face detector...');
|
||||||
await tf.setBackend('webgl');
|
await tf.setBackend('webgl');
|
||||||
await tf.ready();
|
await tf.ready();
|
||||||
detector = await blazeface.load();
|
detector = await blazeface.load();
|
||||||
console.log('BlazeFace model loaded');
|
console.log('BlazeFace model loaded');
|
||||||
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
|
})();
|
||||||
console.log('Processing photos for gallery step');
|
}
|
||||||
processPhotos();
|
return detectorPromise;
|
||||||
} else {
|
|
||||||
console.log('No data to process:', { dataLength: $filteredSheetData.length, pictureUrlMapping: $columnMapping.pictureUrl });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
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++) {
|
||||||
|
const promise = (async () => {
|
||||||
await loadPhoto(i);
|
await loadPhoto(i);
|
||||||
await detectFaceForPhoto(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,56 +422,15 @@
|
|||||||
</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}
|
||||||
{#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
|
<PhotoCard
|
||||||
imageUrl={photo.objectUrl}
|
{photo}
|
||||||
personName={photo.name}
|
onCropUpdated={(e) => handleCropUpdate(index, e)}
|
||||||
isProcessing={photo.faceDetectionStatus === 'processing'}
|
onRetry={() => retryPhoto(index)}
|
||||||
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { selectedSheet, columnMapping, rawSheetData, filteredSheetData, currentStep, sheetData } from '$lib/stores';
|
import {
|
||||||
|
selectedSheet,
|
||||||
|
columnMapping,
|
||||||
|
rawSheetData,
|
||||||
|
filteredSheetData,
|
||||||
|
currentStep,
|
||||||
|
sheetData
|
||||||
|
} from '$lib/stores';
|
||||||
import type { RowData } from '$lib/stores';
|
import type { RowData } from '$lib/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getSheetNames, getSheetData } from '$lib/google';
|
import { getSheetNames, getSheetData } from '$lib/google';
|
||||||
@@ -17,8 +24,8 @@
|
|||||||
$: {
|
$: {
|
||||||
// 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())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -50,8 +57,7 @@
|
|||||||
|
|
||||||
// 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];
|
||||||
@@ -59,19 +65,15 @@
|
|||||||
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();
|
|
||||||
if (!$rawSheetData || $rawSheetData.length === 0 || !$columnMapping) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get headers from the mapping
|
// Get headers from the mapping
|
||||||
headers = Object.keys($columnMapping);
|
headers = Object.keys($columnMapping);
|
||||||
|
|
||||||
|
await fetchRawSheetData();
|
||||||
|
|
||||||
// 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 = {
|
||||||
@@ -94,8 +96,8 @@
|
|||||||
|
|
||||||
// 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) {
|
||||||
@@ -108,7 +110,7 @@
|
|||||||
// 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
|
||||||
@@ -122,7 +124,7 @@
|
|||||||
// 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();
|
||||||
@@ -144,14 +146,14 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -162,10 +164,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -178,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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',
|
||||||
@@ -199,8 +207,8 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -210,8 +218,8 @@
|
|||||||
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
|
||||||
@@ -219,24 +227,21 @@
|
|||||||
</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="text-xl font-semibold text-gray-900 mb-2">
|
<h2 class="mb-2 text-xl font-semibold text-gray-900">Filter and Select Rows</h2>
|
||||||
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
|
||||||
@@ -244,19 +249,17 @@
|
|||||||
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
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id="sort"
|
id="sort"
|
||||||
bind:value={sortColumn}
|
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"
|
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>
|
<option value="">No sorting</option>
|
||||||
{#each headers as header}
|
{#each headers as header}
|
||||||
@@ -267,20 +270,34 @@
|
|||||||
</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"
|
||||||
|
>Printed: {processedData.filter((row) => isRowAlreadyPrinted(row)).length}</span
|
||||||
|
>
|
||||||
<span>Filtered rows: {filteredData.length}</span>
|
<span>Filtered rows: {filteredData.length}</span>
|
||||||
<span class="font-medium text-blue-600">Selected: {selectedValidCount}</span>
|
<span class="font-medium text-blue-600">Selected: {selectedValidCount}</span>
|
||||||
<button
|
<button
|
||||||
onclick={processSheetData}
|
onclick={processSheetData}
|
||||||
disabled={isLoading}
|
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"
|
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}
|
{#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">
|
<svg
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
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" />
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
</svg>
|
</svg>
|
||||||
Refreshing...
|
Refreshing...
|
||||||
@@ -292,19 +309,21 @@
|
|||||||
</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"
|
||||||
|
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>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No data found</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
@@ -322,24 +341,33 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={selectAll}
|
bind:checked={selectAll}
|
||||||
onchange={toggleSelectAll}
|
onchange={toggleSelectAll}
|
||||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</th>
|
</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
|
||||||
|
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}
|
{: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"/>
|
<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}
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -348,14 +376,51 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Status Column -->
|
<!-- Status Column -->
|
||||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
|
class="px-3 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<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 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 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}
|
{#each filteredData as row}
|
||||||
<tr class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(row) ? 'bg-orange-50' : ''}">
|
<tr
|
||||||
|
class="hover:bg-gray-50 {!row._isValid ? 'opacity-50' : ''} {isRowAlreadyPrinted(
|
||||||
|
row
|
||||||
|
)
|
||||||
|
? 'bg-orange-50'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
<!-- Selection Checkbox -->
|
<!-- Selection Checkbox -->
|
||||||
<td class="px-3 py-4">
|
<td class="px-3 py-4">
|
||||||
{#if row._isValid}
|
{#if row._isValid}
|
||||||
@@ -363,16 +428,16 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedRows.has(row._rowIndex)}
|
checked={selectedRows.has(row._rowIndex)}
|
||||||
onchange={() => toggleRowSelection(row._rowIndex)}
|
onchange={() => toggleRowSelection(row._rowIndex)}
|
||||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-4 h-4 bg-gray-200 rounded"></div>
|
<div class="h-4 w-4 rounded bg-gray-200"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Data Columns -->
|
<!-- Data Columns -->
|
||||||
{#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="max-w-xs truncate px-3 py-4 text-sm text-gray-900">
|
||||||
{row[header] || ''}
|
{row[header] || ''}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -381,17 +446,23 @@
|
|||||||
<td class="px-3 py-4 text-sm">
|
<td class="px-3 py-4 text-sm">
|
||||||
<div class="flex flex-col space-y-1">
|
<div class="flex flex-col space-y-1">
|
||||||
{#if row._isValid}
|
{#if row._isValid}
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
|
<span
|
||||||
|
class="inline-flex rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800"
|
||||||
|
>
|
||||||
Valid
|
Valid
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
|
<span
|
||||||
|
class="inline-flex rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800"
|
||||||
|
>
|
||||||
Missing data
|
Missing data
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isRowAlreadyPrinted(row)}
|
{#if isRowAlreadyPrinted(row)}
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded-full">
|
<span
|
||||||
|
class="inline-flex rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800"
|
||||||
|
>
|
||||||
Already Printed
|
Already Printed
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -399,6 +470,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,13 +479,18 @@
|
|||||||
|
|
||||||
<!-- Selection Summary -->
|
<!-- Selection Summary -->
|
||||||
{#if selectedValidCount > 0}
|
{#if selectedValidCount > 0}
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<span class="text-sm text-blue-800">
|
<span class="text-sm text-blue-800">
|
||||||
<strong>{selectedValidCount}</strong> {selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation
|
<strong>{selectedValidCount}</strong>
|
||||||
|
{selectedValidCount === 1 ? 'row' : 'rows'} selected for card generation
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,19 +500,18 @@
|
|||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
onclick={() => currentStep.set(3)}
|
onclick={() => currentStep.set(3)}
|
||||||
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 Colum Selection
|
← Back to Colum Selection
|
||||||
</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 with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} →`
|
? `Continue with ${selectedValidCount} ${selectedValidCount === 1 ? 'row' : 'rows'} →`
|
||||||
: 'Select rows to continue'}
|
: 'Select rows to continue'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
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
|
||||||
@@ -74,20 +74,17 @@
|
|||||||
</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="text-xl font-semibold text-gray-900 mb-2">
|
<h2 class="mb-2 text-xl font-semibold text-gray-900">Select Google Sheet</h2>
|
||||||
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>
|
||||||
|
|
||||||
@@ -97,17 +94,21 @@
|
|||||||
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
|
||||||
|
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
|
></div>
|
||||||
{:else}
|
{:else}
|
||||||
Search
|
Search
|
||||||
{/if}
|
{/if}
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
</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}
|
||||||
@@ -124,7 +125,7 @@
|
|||||||
<!-- 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'}
|
||||||
@@ -134,26 +135,35 @@
|
|||||||
<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 ===
|
||||||
|
(sheet.spreadsheetId || sheet.id)
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200'}"
|
||||||
onclick={() => handleSelectSheet(sheet)}
|
onclick={() => handleSelectSheet(sheet)}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); }}
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-gray-900">{sheet.name}</p>
|
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">ID: {sheet.id}</p>
|
<p class="mt-1 text-xs text-gray-500">ID: {sheet.id}</p>
|
||||||
</div>
|
</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
|
||||||
|
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>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,9 +172,19 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-center py-8 bg-gray-50 rounded-lg border border-gray-200">
|
<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">
|
<svg
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
|
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,33 +194,40 @@
|
|||||||
<!-- If we have recent sheets and haven't searched yet, show them -->
|
<!-- If we have recent sheets and haven't searched yet, show them -->
|
||||||
{#if recentSheets.length > 0 && !hasSearched}
|
{#if recentSheets.length > 0 && !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">Recent sheets</h3>
|
||||||
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 ===
|
||||||
|
(sheet.spreadsheetId || sheet.id)
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200'}"
|
||||||
onclick={() => handleSelectSheet(sheet)}
|
onclick={() => handleSelectSheet(sheet)}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet); }}
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') handleSelectSheet(sheet);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-gray-900">{sheet.name}</p>
|
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">Recently used</p>
|
<p class="mt-1 text-xs text-gray-500">Recently used</p>
|
||||||
</div>
|
</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
|
||||||
|
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>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,21 +236,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200 mb-6">
|
<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">
|
<svg
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3>
|
<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">
|
<p class="mt-1 text-sm text-gray-500">Enter a name or keyword to find your Google Sheets</p>
|
||||||
Enter a name or keyword to find your Google Sheets
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -232,7 +265,7 @@
|
|||||||
<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>
|
||||||
@@ -240,12 +273,9 @@
|
|||||||
<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 →'
|
|
||||||
: 'Select a sheet to continue'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user