Compare commits

...

5 Commits

Author SHA1 Message Date
e78902d79f Merge pull request 'development' (#25) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m14s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 28s
Reviewed-on: #25
2025-09-02 19:37:17 +02:00
Roman Krček
7b4a179428 Fix diff counting logic 2025-09-02 19:35:49 +02:00
Roman Krček
5ef9735ea5 Fixed hardcoded range 2025-09-02 19:30:33 +02:00
Roman Krček
03eeef5c69 Updated google button 2025-09-02 19:16:52 +02:00
Roman Krček
fa685e6ba9 Added loading indicator 2025-09-02 18:21:18 +02:00
6 changed files with 87 additions and 243 deletions

View File

@@ -6,12 +6,14 @@
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';
@@ -41,6 +43,7 @@
async function handleDisconnect() { async function handleDisconnect() {
await authManager.disconnectGoogle(); await authManager.disconnectGoogle();
onDisconnect?.();
} }
// Size classes // Size classes

View File

@@ -51,25 +51,48 @@ 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 - Cell range to retrieve (default: A1:Z10) * @param range - Optional cell range. If not provided, it will fetch the entire first sheet.
* @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 = 'A1:Z10' range?: string
): 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 });
const response = await sheets.spreadsheets.values.get({ let effectiveRange = range;
spreadsheetId,
range
});
return { // If no range is provided, get the name of the first sheet and use that as the range
values: response.data.values || [] // to fetch all its content.
}; 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,17 +2,18 @@ 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 }) => { export const GET: RequestHandler = async ({ params, request, url }) => {
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, 'A1:Z10'); const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, range);
return json(sheetData); return json(sheetData);
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,5 @@
<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';
@@ -16,19 +15,11 @@
let { data } = $props(); let { data } = $props();
// Step management // Step management
let currentStep = $state(0); // Start at step 0 for Google auth check let currentStep = $state(0);
const totalSteps = 4; // Increased to include auth step const totalSteps = 4;
// Step 0: Google Auth // Step 0: Google Auth
let authData = $state({ let isGoogleConnected = $state(false);
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({
@@ -42,13 +33,13 @@
selectedSheet: null as GoogleSheet | null, selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][], sheetData: [] as string[][],
columnMapping: { columnMapping: {
name: 0, // Initialize to 0 (no column selected) name: 0,
surname: 0, surname: 0,
email: 0, email: 0,
confirmation: 0 confirmation: 0
}, },
loading: false, loading: false,
expandedSheetList: true // Add this flag to control sheet list expansion expandedSheetList: true
}); });
// Step 3: Email // Step 3: Email
@@ -62,189 +53,11 @@
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()) {
@@ -265,7 +78,7 @@
let isValid = true; let isValid = true;
if (currentStep === 0) { if (currentStep === 0) {
if (!authData.isConnected) { if (!isGoogleConnected) {
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;
@@ -359,8 +172,8 @@
} }
try { try {
// Use the new unified API endpoint // Use the new unified API endpoint, requesting only a preview range
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, { const response = await fetch(`/private/api/google/sheets/${sheet.id}/data?range=A1:Z10`, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}` Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
@@ -437,13 +250,13 @@
// Computed values // Computed values
let canProceed = $derived(() => { let canProceed = $derived(() => {
if (currentStep === 0) return authData.isConnected; if (currentStep === 0) return isGoogleConnected;
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>
@@ -455,16 +268,9 @@
<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={(token) => { onSuccess={() => (isGoogleConnected = true)}
authData.error = null; onDisconnect={() => (isGoogleConnected = false)}
authData.token = token; onError={(err) => toast.error(err)}
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 />
@@ -485,7 +291,7 @@
<StepNavigation <StepNavigation
{currentStep} {currentStep}
{totalSteps} {totalSteps}
{canProceed} canProceed={canProceed()}
{loading} {loading}
{prevStep} {prevStep}
{nextStep} {nextStep}

View File

@@ -2,9 +2,10 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte'; import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props // Props
let { onSuccess, onError } = $props<{ let { onSuccess, onError, onDisconnect } = $props<{
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
}>(); }>();
</script> </script>
@@ -15,11 +16,12 @@
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,6 +115,8 @@
} }
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`, {
@@ -177,16 +179,23 @@
// Reload participants // Reload participants
await loadParticipants(); await loadParticipants();
// Show success message with count of synced participants // Show success message with accurate count of changes
const previousCount = participants.length; const newCount = participants.length;
const newCount = names.length; const diff = newCount - previousCount;
const addedCount = Math.max(0, participants.length - previousCount); const processedCount = names.length;
toast.success( let message = `Sync complete. ${processedCount} confirmed entries processed from the sheet.`;
`Successfully synced participants. ${newCount} entries processed, ${addedCount} new participants added.`,
5000 if (diff > 0) {
); 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'}`);