4 Commits

Author SHA1 Message Date
Roman Krček
e52aea9dd3 Fixed private pages being cached
All checks were successful
Build Docker image / build (push) Successful in 1m22s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 1m8s
2025-09-02 18:43:49 +02:00
cdc8b89916 Merge pull request 'Added loading indicator' (#24) from development into main
All checks were successful
Build Docker image / build (push) Successful in 3m19s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 1m57s
Reviewed-on: #24
2025-09-02 18:26:28 +02:00
9dd79514f5 Merge branch 'main' into development 2025-09-02 18:21:55 +02:00
Roman Krček
cd37d8da0f Added loading indicator 2025-09-02 18:18:58 +02:00
8 changed files with 322 additions and 179 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "scan-wave", "name": "scan-wave",
"private": true, "private": true,
"version": "0.0.2", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View File

@@ -6,14 +6,12 @@
let { let {
onSuccess, onSuccess,
onError, onError,
onDisconnect,
disabled = false, disabled = false,
size = 'default', size = 'default',
variant = 'primary' variant = 'primary'
} = $props<{ } = $props<{
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
disabled?: boolean; disabled?: boolean;
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
variant?: 'primary' | 'secondary'; variant?: 'primary' | 'secondary';
@@ -43,7 +41,6 @@
async function handleDisconnect() { async function handleDisconnect() {
await authManager.disconnectGoogle(); await authManager.disconnectGoogle();
onDisconnect?.();
} }
// Size classes // Size classes

View File

@@ -51,48 +51,25 @@ export async function getRecentSpreadsheets(
* Get data from a Google Sheet * Get data from a Google Sheet
* @param refreshToken - Google refresh token * @param refreshToken - Google refresh token
* @param spreadsheetId - ID of the spreadsheet * @param spreadsheetId - ID of the spreadsheet
* @param range - Optional cell range. If not provided, it will fetch the entire first sheet. * @param range - Cell range to retrieve (default: A1:Z10)
* @returns Sheet data as a 2D array * @returns Sheet data as a 2D array
*/ */
export async function getSpreadsheetData( export async function getSpreadsheetData(
refreshToken: string, refreshToken: string,
spreadsheetId: string, spreadsheetId: string,
range?: string range: string = 'A1:Z10'
): Promise<SheetData> { ): Promise<SheetData> {
const oauth = getAuthenticatedClient(refreshToken); const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth }); const sheets = google.sheets({ version: 'v4', auth: oauth });
let effectiveRange = range; const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range
});
// If no range is provided, get the name of the first sheet and use that as the range return {
// to fetch all its content. values: response.data.values || []
if (!effectiveRange) { };
try {
const info = await getSpreadsheetInfo(refreshToken, spreadsheetId);
const firstSheetName = info.sheets?.[0]?.properties?.title;
if (firstSheetName) {
// To use a sheet name as a range, it must be quoted if it contains spaces or special characters.
effectiveRange = `'${firstSheetName}'`;
} else {
// Fallback if sheet name can't be determined.
effectiveRange = 'A1:Z1000'; // A sensible default for a large preview
}
} catch (error) {
console.error(`Failed to get sheet info for spreadsheet ${spreadsheetId}`, error);
// Fallback if the info call fails
effectiveRange = 'A1:Z1000';
}
}
const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range: effectiveRange
});
return {
values: response.data.values || []
};
} }
/** /**

View File

@@ -2,18 +2,17 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { googleSheetsServer } from '$lib/google/sheets/server.js'; import { googleSheetsServer } from '$lib/google/sheets/server.js';
export const GET: RequestHandler = async ({ params, request, url }) => { export const GET: RequestHandler = async ({ params, request }) => {
try { try {
const { sheetId } = params; const { sheetId } = params;
const authHeader = request.headers.get('authorization'); const authHeader = request.headers.get('authorization');
const range = url.searchParams.get('range') || undefined;
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
} }
const refreshToken = authHeader.slice(7); const refreshToken = authHeader.slice(7);
const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, range); const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10');
return json(sheetData); return json(sheetData);
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { isTokenValid, getUserInfo, revokeToken } from '$lib/google/auth/client.js';
import type { GoogleSheet } from '$lib/google/sheets/types.ts'; import type { GoogleSheet } from '$lib/google/sheets/types.ts';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
@@ -15,11 +16,19 @@
let { data } = $props(); let { data } = $props();
// Step management // Step management
let currentStep = $state(0); let currentStep = $state(0); // Start at step 0 for Google auth check
const totalSteps = 4; const totalSteps = 4; // Increased to include auth step
// Step 0: Google Auth // Step 0: Google Auth
let isGoogleConnected = $state(false); let authData = $state({
isConnected: false,
checking: true,
connecting: false,
showCancelOption: false,
token: null as string | null,
error: null as string | null,
userEmail: null as string | null
});
// Step 1: Event Details // Step 1: Event Details
let eventData = $state({ let eventData = $state({
@@ -33,13 +42,13 @@
selectedSheet: null as GoogleSheet | null, selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][], sheetData: [] as string[][],
columnMapping: { columnMapping: {
name: 0, name: 0, // Initialize to 0 (no column selected)
surname: 0, surname: 0,
email: 0, email: 0,
confirmation: 0 confirmation: 0
}, },
loading: false, loading: false,
expandedSheetList: true expandedSheetList: true // Add this flag to control sheet list expansion
}); });
// Step 3: Email // Step 3: Email
@@ -53,11 +62,189 @@
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
onMount(async () => { onMount(async () => {
// Check Google auth status on mount
await checkGoogleAuth();
if (currentStep === 2) { if (currentStep === 2) {
await loadRecentSheets(); await loadRecentSheets();
} }
}); });
// Google Auth functions
async function checkGoogleAuth() {
authData.checking = true;
try {
const accessToken = localStorage.getItem('google_access_token');
const refreshToken = localStorage.getItem('google_refresh_token');
if (accessToken && refreshToken) {
// Check if token is still valid
const isValid = await isTokenValid(accessToken);
authData.isConnected = isValid;
authData.token = accessToken;
if (isValid) {
// Fetch user info
await fetchUserInfo(accessToken);
}
} else {
authData.isConnected = false;
authData.userEmail = null;
}
} catch (error) {
console.error('Error checking Google auth:', error);
authData.isConnected = false;
authData.error = 'Error checking Google connection';
authData.userEmail = null;
} finally {
authData.checking = false;
}
}
async function connectToGoogle() {
authData.error = '';
authData.connecting = true;
try {
// Open popup window for OAuth
const popup = window.open(
'/auth/google',
'google-auth',
'width=500,height=600,scrollbars=yes,resizable=yes,left=' +
Math.round(window.screen.width / 2 - 250) +
',top=' +
Math.round(window.screen.height / 2 - 300)
);
if (!popup) {
authData.error = 'Failed to open popup window. Please allow popups for this site.';
authData.connecting = false;
return;
}
let authCompleted = false;
let popupTimer: number | null = null;
let cancelTimeout: number | null = null;
// Store current timestamp to detect changes in localStorage
const startTimestamp = localStorage.getItem('google_auth_timestamp') || '0';
// Poll localStorage for auth completion
const pollInterval = setInterval(() => {
try {
const currentTimestamp = localStorage.getItem('google_auth_timestamp');
// If timestamp has changed, auth is complete
if (currentTimestamp && currentTimestamp !== startTimestamp) {
handleAuthSuccess();
}
} catch (e) {
console.error('Error checking auth timestamp:', e);
}
}, 500); // Poll every 500ms
// Common handler for authentication success
function handleAuthSuccess() {
if (authCompleted) return; // Prevent duplicate handling
authCompleted = true;
authData.connecting = false;
authData.showCancelOption = false;
// Clean up timers
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
// Update auth state
setTimeout(checkGoogleAuth, 100);
}
// Clean up function to handle all cleanup in one place
const cleanUp = () => {
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
authData.connecting = false;
};
// Set a timeout for initial auth check
popupTimer = setTimeout(() => {
// Only check if auth isn't already completed
if (!authCompleted) {
cleanUp();
// Check if tokens were stored by the popup before it was closed
setTimeout(checkGoogleAuth, 100);
}
}, 30 * 1000) as unknown as number; // Reduced from 60s to 30s
// Show cancel option sooner
cancelTimeout = setTimeout(() => {
if (!authCompleted) {
authData.showCancelOption = true;
}
}, 10 * 1000) as unknown as number; // Reduced from 20s to 10s
// Final cleanup timeout
setTimeout(() => {
if (!authCompleted) {
cleanUp();
}
}, 60 * 1000); // Reduced from 3min to 1min
} catch (error) {
console.error('Error connecting to Google:', error);
authData.error = 'Failed to connect to Google';
authData.connecting = false;
}
}
function cancelGoogleAuth() {
authData.connecting = false;
authData.showCancelOption = false;
}
async function fetchUserInfo(accessToken: string) {
try {
// Use the new getUserInfo function from our lib
const userData = await getUserInfo(accessToken);
if (userData) {
authData.userEmail = userData.email;
} else {
authData.userEmail = null;
}
} catch (error) {
console.error('Error fetching user info:', error);
authData.userEmail = null;
}
}
async function disconnectGoogle() {
try {
// First revoke the token at Google using our API
const accessToken = localStorage.getItem('google_access_token');
if (accessToken) {
await revokeToken(accessToken);
}
// Remove tokens from local storage
localStorage.removeItem('google_access_token');
localStorage.removeItem('google_refresh_token');
// Update auth state
authData.isConnected = false;
authData.token = null;
authData.userEmail = null;
// Clear any selected sheets data
sheetsData.availableSheets = [];
sheetsData.selectedSheet = null;
sheetsData.sheetData = [];
} catch (error) {
console.error('Error disconnecting from Google:', error);
authData.error = 'Failed to disconnect from Google';
}
}
// Step navigation // Step navigation
function nextStep() { function nextStep() {
if (validateCurrentStep()) { if (validateCurrentStep()) {
@@ -78,7 +265,7 @@
let isValid = true; let isValid = true;
if (currentStep === 0) { if (currentStep === 0) {
if (!isGoogleConnected) { if (!authData.isConnected) {
toast.error('Please connect your Google account to continue'); toast.error('Please connect your Google account to continue');
errors.auth = 'Please connect your Google account to continue'; errors.auth = 'Please connect your Google account to continue';
return false; return false;
@@ -172,8 +359,8 @@
} }
try { try {
// Use the new unified API endpoint, requesting only a preview range // Use the new unified API endpoint
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data?range=A1:Z10`, { const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}` Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
@@ -250,13 +437,13 @@
// Computed values // Computed values
let canProceed = $derived(() => { let canProceed = $derived(() => {
if (currentStep === 0) return isGoogleConnected; if (currentStep === 0) return authData.isConnected;
if (currentStep === 1) return !!(eventData.name && eventData.date); if (currentStep === 1) return eventData.name && eventData.date;
if (currentStep === 2) { if (currentStep === 2) {
const { name, surname, email, confirmation } = sheetsData.columnMapping; const { name, surname, email, confirmation } = sheetsData.columnMapping;
return !!(sheetsData.selectedSheet && name && surname && email && confirmation); return sheetsData.selectedSheet && name && surname && email && confirmation;
} }
if (currentStep === 3) return !!(emailData.subject && emailData.body); if (currentStep === 3) return emailData.subject && emailData.body;
return false; return false;
}); });
</script> </script>
@@ -268,9 +455,16 @@
<div class="mb-4 rounded border border-gray-300 bg-white p-6"> <div class="mb-4 rounded border border-gray-300 bg-white p-6">
{#if currentStep === 0} {#if currentStep === 0}
<GoogleAuthStep <GoogleAuthStep
onSuccess={() => (isGoogleConnected = true)} onSuccess={(token) => {
onDisconnect={() => (isGoogleConnected = false)} authData.error = null;
onError={(err) => toast.error(err)} authData.token = token;
authData.isConnected = true;
setTimeout(checkGoogleAuth, 100);
}}
onError={(error) => {
authData.error = error;
authData.isConnected = false;
}}
/> />
{:else if currentStep === 1} {:else if currentStep === 1}
<EventDetailsStep bind:eventData /> <EventDetailsStep bind:eventData />
@@ -291,7 +485,7 @@
<StepNavigation <StepNavigation
{currentStep} {currentStep}
{totalSteps} {totalSteps}
canProceed={canProceed()} {canProceed}
{loading} {loading}
{prevStep} {prevStep}
{nextStep} {nextStep}

View File

@@ -2,10 +2,9 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte'; import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props // Props
let { onSuccess, onError, onDisconnect } = $props<{ let { onSuccess, onError } = $props<{
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
}>(); }>();
</script> </script>
@@ -16,12 +15,11 @@
To create events and import participants from Google Sheets, you need to connect your Google account. To create events and import participants from Google Sheets, you need to connect your Google account.
</p> </p>
<GoogleAuthButton <GoogleAuthButton
size="large" size="large"
variant="primary" variant="primary"
{onSuccess} onSuccess={onSuccess}
{onError} onError={onError}
{onDisconnect} />
/>
</div> </div>
</div> </div>

View File

@@ -115,8 +115,6 @@
} }
syncingParticipants = true; syncingParticipants = true;
const previousCount = participants.length; // Capture count before sync
try { try {
// Fetch sheet data // Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, { const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
@@ -138,64 +136,35 @@
if (rows.length === 0) throw new Error('No data found in sheet'); if (rows.length === 0) throw new Error('No data found in sheet');
// --- Start of new logic to handle duplicates --- // Extract participant data based on column mapping
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
// First, extract all potential participants from the sheet // Skip header row (start from index 1)
const potentialParticipants = [];
for (let i = 1; i < rows.length; i++) { for (let i = 1; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
if (row.length > 0) { if (row.length > 0) {
const name = row[event.name_column - 1] || ''; const name = row[event.name_column - 1] || '';
const surname = row[event.surname_column - 1] || ''; const surname = row[event.surname_column - 1] || '';
const email = (row[event.email_column - 1] || '').trim(); const email = row[event.email_column - 1] || '';
const confirmation = row[event.confirmation_column - 1] || ''; const confirmation = row[event.confirmation_column - 1] || '';
// Only add if the row has meaningful data (not all empty) AND confirmation is TRUE
const isConfirmed = const isConfirmed =
confirmation.toString().toLowerCase() === 'true' || confirmation.toString().toLowerCase() === 'true' ||
confirmation.toString().toLowerCase() === 'yes' || confirmation.toString().toLowerCase() === 'yes' ||
confirmation === '1' || confirmation === '1' ||
confirmation === 'x'; confirmation === 'x';
if ((name.trim() || surname.trim() || email) && isConfirmed) { if ((name.trim() || surname.trim() || email.trim()) && isConfirmed) {
potentialParticipants.push({ name: name.trim(), surname: surname.trim(), email }); names.push(name.trim());
surnames.push(surname.trim());
emails.push(email.trim());
} }
} }
} }
// Create a map to count occurrences of each unique participant combination
const participantCounts = new Map<string, number>();
for (const p of potentialParticipants) {
const key = `${p.name}|${p.surname}|${p.email}`.toLowerCase(); // Create a unique key
participantCounts.set(key, (participantCounts.get(key) || 0) + 1);
}
// Create final arrays, modifying duplicate surnames to be unique
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
const processedParticipants = new Map<string, number>();
for (const p of potentialParticipants) {
const key = `${p.name}|${p.surname}|${p.email}`.toLowerCase();
let finalSurname = p.surname;
// If this participant is a duplicate
if (participantCounts.get(key)! > 1) {
const count = (processedParticipants.get(key) || 0) + 1;
processedParticipants.set(key, count);
// If it's not the first occurrence, append a counter to the surname
if (count > 1) {
finalSurname = `${p.surname} (${count})`;
}
}
names.push(p.name);
surnames.push(finalSurname);
emails.push(p.email); // Keep the original email
}
// --- End of new logic ---
// Call database function to add participants // Call database function to add participants
const { error: syncError } = await data.supabase.rpc('participants_add_bulk', { const { error: syncError } = await data.supabase.rpc('participants_add_bulk', {
p_event: eventId, p_event: eventId,
@@ -208,23 +177,16 @@
// Reload participants // Reload participants
await loadParticipants(); await loadParticipants();
// Show success message with accurate count of changes // Show success message with count of synced participants
const newCount = participants.length; const previousCount = participants.length;
const diff = newCount - previousCount; const newCount = names.length;
const processedCount = names.length; const addedCount = Math.max(0, participants.length - previousCount);
let message = `Sync complete. ${processedCount} confirmed entries processed from the sheet.`; toast.success(
`Successfully synced participants. ${newCount} entries processed, ${addedCount} new participants added.`,
if (diff > 0) { 5000
message += ` ${diff} new participants added.`; );
} else if (diff < 0) {
message += ` ${-diff} participants removed.`;
} else {
message += ` No changes to the participant list.`;
}
toast.success(message, 6000);
} catch (err) { } catch (err) {
console.error('Error syncing participants:', err); console.error('Error syncing participants:', err);
toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`); toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);

View File

@@ -1,71 +1,87 @@
/// <reference lib="webworker" />
/// <reference types="@sveltejs/kit" /> /// <reference types="@sveltejs/kit" />
import { build, files, version } from '$service-worker'; import { build, files, version } from '$service-worker';
declare const self: ServiceWorkerGlobalScope; // Create a unique cache name for this deployment
const CACHE = `cache-${version}`; const CACHE = `cache-${version}`;
const ASSETS = [ const ASSETS = [
...build, ...build, // the app itself
...files ...files // everything in `static`
]; ];
self.addEventListener('install', (event: ExtendableEvent) => { self.addEventListener('install', (event) => {
const addFilesToCache = async () => { // Create a new cache and add all files to it
const cache = await caches.open(CACHE); async function addFilesToCache() {
await cache.addAll(ASSETS); const cache = await caches.open(CACHE);
}; await cache.addAll(ASSETS);
}
console.log("[SW] Installing new service worker"); event.waitUntil(addFilesToCache());
event.waitUntil(addFilesToCache());
self.skipWaiting();
}); });
self.addEventListener('activate', (event: ExtendableEvent) => { self.addEventListener('activate', (event) => {
const deleteOldCaches = async () => { // Remove previous cached data from disk
for (const key of await caches.keys()) { async function deleteOldCaches() {
if (key !== CACHE) await caches.delete(key); for (const key of await caches.keys()) {
console.log("[SW] Removing old service worker") if (key !== CACHE) await caches.delete(key);
} }
}; }
event.waitUntil(deleteOldCaches()); event.waitUntil(deleteOldCaches());
self.clients.claim();
}); });
self.addEventListener('fetch', (event: FetchEvent) => { self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return; // ignore POST requests etc
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url); async function respond() {
const url = new URL(event.request.url);
// Skip caching for auth routes
if (url.pathname.startsWith('/auth/')) {
return fetch(event.request);
}
// Never cache private routes const cache = await caches.open(CACHE);
if (url.pathname.startsWith('/private')) {
event.respondWith(fetch(event.request));
return;
}
const respond = async () => { // `build`/`files` can always be served from the cache
const cache = await caches.open(CACHE); if (ASSETS.includes(url.pathname)) {
const response = await cache.match(url.pathname);
if (ASSETS.includes(url.pathname)) { if (response) {
const cached = await cache.match(url.pathname); return response;
if (cached) return cached; }
} }
try { // for everything else, try the network first, but
const response = await fetch(event.request); // fall back to the cache if we're offline
if (response.status === 200 && build.length > 0 && url.pathname.startsWith(`/${build[0]}/`)) { try {
cache.put(event.request, response.clone()); const response = await fetch(event.request);
}
return response;
} catch {
const cached = await cache.match(event.request);
if (cached) return cached;
}
return new Response('Not found', { status: 404 }); // if we're offline, fetch can return a value that is not a Response
}; // instead of throwing - and we can't pass this non-Response to respondWith
if (!(response instanceof Response)) {
throw new Error('invalid response from fetch');
}
event.respondWith(respond()); // Do not cache private pages
}); if (response.status === 200 && !url.pathname.startsWith('/private')) {
cache.put(event.request, response.clone());
}
return response;
} catch (err) {
const response = await cache.match(event.request);
if (response) {
return response;
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw err;
}
}
event.respondWith(respond());
});