25 Commits

Author SHA1 Message Date
Roman Krček
e616165a77 Fix service worker mess 2025-09-03 10:38:30 +02:00
Roman Krček
238d2eebc5 Fix worker reloads 2025-09-03 10:22:59 +02:00
Roman Krček
aedf260551 Fixed problem where auth is bypassed 2025-09-03 10:17:20 +02:00
Roman Krček
f1179ddc09 Fix when people order multiple times 2025-09-03 08:34:22 +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
eb0276e475 Dummy change to trigger actions 2025-09-02 18:00:47 +02:00
183854effd Merge pull request 'Add ability to search shared drives' (#22) from development into main
Reviewed-on: #22
2025-09-02 17:57:49 +02:00
Roman Krček
0fa20dffa5 Add ability to search shared drives 2025-09-02 17:56:31 +02:00
438f7299b4 Merge pull request 'CSS Styling' (#21) from development into main
Some checks failed
Build Docker image / build (push) Successful in 3m41s
Build Docker image / deploy (push) Has been cancelled
Build Docker image / verify (push) Has been cancelled
Reviewed-on: #21
2025-07-15 11:23:47 +02:00
Roman Krček
f4146e599b CSS Styling 2025-07-15 11:23:10 +02:00
dc6602a904 Merge pull request 'development' (#20) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m15s
Build Docker image / deploy (push) Successful in 8s
Build Docker image / verify (push) Successful in 37s
Reviewed-on: #20
2025-07-14 22:28:06 +02:00
Roman Krček
eb9fa14d28 Remember last event selected in scanner 2025-07-14 22:27:18 +02:00
Roman Krček
30f441a956 Styling and minor changes 2025-07-14 22:27:00 +02:00
Roman Krček
5b26b6951c Icons and better auth flow 2025-07-14 22:16:03 +02:00
fa2185a6a1 Merge pull request 'development' (#19) from development into main
All checks were successful
Build Docker image / build (push) Successful in 5m50s
Build Docker image / deploy (push) Successful in 7s
Build Docker image / verify (push) Successful in 1m22s
Reviewed-on: #19
2025-07-14 21:40:16 +02:00
Roman Krček
ffbd3c7cda Home styling 2025-07-14 21:39:49 +02:00
Roman Krček
5d957b18ee More notifications in participants table 2025-07-14 21:37:05 +02:00
Roman Krček
396d29c76b Make emails editable 2025-07-14 21:25:57 +02:00
Roman Krček
d0f555a7c5 Minor styling changes 2025-07-14 16:05:29 +02:00
Roman Krček
f14213a5d4 Add role base access control for events module 2025-07-14 15:50:07 +02:00
b0e530ed62 Merge pull request 'development' (#18) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m40s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 25s
Reviewed-on: #18
2025-07-12 22:01:27 +02:00
c18a67e926 Merge pull request 'development' (#17) from development into main
All checks were successful
Build Docker image / build (push) Successful in 1m5s
Build Docker image / deploy (push) Successful in 2s
Build Docker image / verify (push) Successful in 28s
Reviewed-on: #17
2025-07-12 15:44:07 +02:00
29 changed files with 1056 additions and 450 deletions

View File

@@ -11,6 +11,10 @@ Basics: These you need to really follow!
- server: $locals.supabase
- Avoid unnceessary iterations. Once the problem is solved, ask me if i want to to continue and only then continue iterating.
- Avoid sweeping changes throught the project. If you want to change something globally, ask me first.
- to add a notification, use the toast component
- example: toast.success, toast.info, toast.warning, toast.error
Do not fall back to the legacy $: label syntax or Svelte 3/4 stores! This is important!

View File

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

13
src/app.d.ts vendored
View File

@@ -1,17 +1,30 @@
import type { Session, SupabaseClient, User } from '@supabase/supabase-js'
import type { Database } from './database.types.ts' // import generated types
// Define the profile type based on the database schema
type Profile = {
display_name: string | null
section_position: string | null
section: {
name: string | null
} | null
}
declare global {
namespace App {
// interface Error {}
interface Locals {
supabase: SupabaseClient<Database>
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>
getUserProfile: (userId: string) => Promise<Profile | null>
session: Session | null
user: User | null
profile: Profile | null
}
interface PageData {
session: Session | null
user: User | null
profile: Profile | null
}
// interface PageState {}
// interface Platform {}

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
@@ -16,5 +16,4 @@
body {
font-family: "Roboto", sans-serif;
}
</style>

View File

@@ -51,6 +51,22 @@ const supabase: Handle = async ({ event, resolve }) => {
return { session, user }
}
/**
* Fetch user profile data including display name, section position, and section name
*/
event.locals.getUserProfile = async (userId) => {
if (!userId) return null
const { data: profile, error } = await event.locals.supabase
.from('profiles')
.select('display_name, section_position, section:sections (name)')
.eq('id', userId)
.single()
if (error) return null
return profile
}
return resolve(event, {
filterSerializedResponseHeaders(name) {
/**
@@ -67,14 +83,26 @@ const authGuard: Handle = async ({ event, resolve }) => {
event.locals.session = session
event.locals.user = user
// Fetch the user's profile if they're authenticated
if (user) {
event.locals.profile = await event.locals.getUserProfile(user.id)
}
if (!event.locals.session && event.url.pathname.startsWith('/private')) {
redirect(303, '/auth')
redirect(303, '/auth/login')
}
if (event.locals.session && event.url.pathname === '/auth') {
redirect(303, '/private/home')
}
// Role-based access control for events routes
if (event.url.pathname.startsWith('/private/events')) {
if (!event.locals.profile || event.locals.profile.section_position !== 'events_manager') {
redirect(303, '/private/errors/events/denied')
}
}
return resolve(event)
}

View File

@@ -6,12 +6,14 @@
let {
onSuccess,
onError,
onDisconnect,
disabled = false,
size = 'default',
variant = 'primary'
} = $props<{
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
onDisconnect?: () => void;
disabled?: boolean;
size?: 'small' | 'default' | 'large';
variant?: 'primary' | 'secondary';
@@ -21,8 +23,8 @@
let authState = $state(createGoogleAuthState());
let authManager = new GoogleAuthManager(authState);
onMount(() => {
authManager.checkConnection();
onMount(async () => {
await authManager.checkConnection();
});
async function handleConnect() {
@@ -41,6 +43,7 @@
async function handleDisconnect() {
await authManager.disconnectGoogle();
onDisconnect?.();
}
// Size classes
@@ -57,7 +60,14 @@
};
</script>
{#if authState.isConnected}
{#if authState.checking}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 border border-gray-300 whitespace-nowrap">
<div class="w-4 h-4 animate-spin rounded-full border-2 border-current border-t-transparent text-gray-600"></div>
<span class="text-sm font-medium text-gray-800">Checking connection...</span>
</div>
</div>
{:else if authState.isConnected}
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 border border-green-300 whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">

View File

@@ -30,7 +30,7 @@ export class GoogleAuthManager {
this.state = state;
}
checkConnection(): void {
async checkConnection(): Promise<void> {
this.state.checking = true;
this.state.error = null;
@@ -38,12 +38,39 @@ export class GoogleAuthManager {
const token = localStorage.getItem('google_refresh_token');
const email = localStorage.getItem('google_user_email');
this.state.isConnected = !!token;
if (!token) {
this.state.isConnected = false;
this.state.token = null;
this.state.userEmail = null;
return;
}
// Verify the token by calling our backend endpoint
const response = await fetch('/private/api/google/auth/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken: token })
});
if (response.ok) {
this.state.isConnected = true;
this.state.token = token;
this.state.userEmail = email;
} else {
// Token is invalid or expired
await this.disconnectGoogle();
if (response.status === 401) {
this.state.error = 'Google session expired. Please reconnect.';
} else {
this.state.error = 'Failed to verify connection.';
}
}
} catch (error) {
console.error('Error checking connection:', error);
this.state.error = 'Failed to check connection status';
this.state.error = 'Failed to verify connection status';
this.state.isConnected = false;
} finally {
this.state.checking = false;
}

View File

@@ -32,7 +32,9 @@ export async function getRecentSpreadsheets(
q: "mimeType='application/vnd.google-apps.spreadsheet'",
orderBy: 'modifiedTime desc',
pageSize: limit,
fields: 'files(id,name,modifiedTime,webViewLink)'
fields: 'files(id,name,modifiedTime,webViewLink)',
includeItemsFromAllDrives: true,
supportsAllDrives: true
});
return (
@@ -49,20 +51,43 @@ export async function getRecentSpreadsheets(
* Get data from a Google Sheet
* @param refreshToken - Google refresh token
* @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
*/
export async function getSpreadsheetData(
refreshToken: string,
spreadsheetId: string,
range: string = 'A1:Z10'
range?: string
): Promise<SheetData> {
const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth });
let effectiveRange = range;
// If no range is provided, get the name of the first sheet and use that as the range
// 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
range: effectiveRange
});
return {
@@ -113,7 +138,9 @@ export async function searchSheets(
q,
orderBy: 'modifiedTime desc',
pageSize: limit,
fields: 'files(id,name,modifiedTime,webViewLink)'
fields: 'files(id,name,modifiedTime,webViewLink)',
includeItemsFromAllDrives: true,
supportsAllDrives: true
});
return (

View File

@@ -1,9 +1,18 @@
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
const { session } = await safeGetSession()
export const load: LayoutServerLoad = async ({ locals: { safeGetSession, getUserProfile }, cookies }) => {
const { session, user } = await safeGetSession()
// Get the user profile if the user is authenticated
let profile = null
if (user) {
profile = await getUserProfile(user.id)
}
return {
session,
user,
profile,
cookies: cookies.getAll(),
}
}

View File

@@ -39,5 +39,10 @@ export const load: LayoutLoad = async ({ data, depends, fetch }) => {
data: { user },
} = await supabase.auth.getUser()
return { session, supabase, user }
return {
session,
supabase,
user,
profile: data.profile
}
}

View File

@@ -1,5 +1,4 @@
<div class="min-h-screen flex flex-col justify-center items-center">
<!-- SVG QR Code Art on Top -->
<div class="mb-8">
<img class="w-32 h-auto" src="/qr-code.png" alt="">
</div>

View File

@@ -3,6 +3,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastContainer from '$lib/components/ToastContainer.svelte';
let { data, children } = $props();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -17,12 +19,13 @@
<nav class="border-b border-gray-300 bg-gray-50 p-2 text-gray-900">
<div class="container mx-auto max-w-2xl p-2">
<div class="flex items-center justify-between">
<div class="text-lg font-bold">ScanWave</div>
<a href="/private/home" class="text-lg font-bold" aria-label="ScanWave Home">ScanWave</a>
<ul class="flex space-x-4">
<li><a href="/private/home">Home</a></li>
<li><a href="/private/scanner">Scanner</a></li>
{#if data.profile?.section_position === 'events_manager'}
<li><a href="/private/events">Events</a></li>
{/if}
</ul>
</div>
</div>
@@ -31,7 +34,7 @@
<div class="container mx-auto max-w-2xl bg-white p-2">
<QueryClientProvider client={queryClient}>
<slot />
{@render children()}
</QueryClientProvider>
</div>

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { getAuthenticatedClient } from '$lib/google/auth/server';
/**
* @description Verify the validity of a Google refresh token
* @method POST
* @param {Request} request
* @returns {Response}
*/
export async function POST({ request }: { request: Request }): Promise<Response> {
try {
const { refreshToken } = await request.json();
if (!refreshToken) {
return json({ error: 'Refresh token is required' }, { status: 400 });
}
// Get an authenticated client. This will attempt to get a new access token,
// which effectively validates the refresh token.
const oauth2Client = getAuthenticatedClient(refreshToken);
// Attempt to get a new access token
await oauth2Client.getAccessToken();
// If no error is thrown, the token is valid
return json({ success: true });
} catch (error) {
console.error('Failed to verify Google refresh token:', error);
// The token is likely invalid or revoked
return json({ error: 'Invalid or expired refresh token' }, { status: 401 });
}
}

View File

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

View File

@@ -0,0 +1,50 @@
<script lang="ts">
// Get the profile from the page data if available
let { data } = $props();
let profile = $derived(data.profile);
</script>
<div class="flex flex-col items-center justify-center min-h-[70vh] p-6">
<div class="rounded-lg border border-gray-300 p-6 max-w-md w-full flex flex-col gap-6 text-center">
<div class="flex flex-col items-center gap-2">
<div class="text-red-600 bg-red-50 p-3 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h1 class="text-2xl font-semibold text-gray-800">Access Denied</h1>
<p class="text-gray-600">You don't have permission to access the events section.</p>
</div>
<div class="flex flex-col gap-4">
{#if profile}
<p class="text-sm text-gray-500">
Your current role: <span class="font-semibold">{profile.section_position || 'Not assigned'}</span>
</p>
{#if profile.section}
<p class="text-sm text-gray-500">
Section: <span class="font-semibold">{profile.section.name}</span>
</p>
{/if}
{/if}
<p class="text-gray-600">
You need the <span class="font-semibold">events_manager</span> role to access this section.
Please contact your administrator for assistance.
</p>
</div>
<div class="flex flex-col gap-3">
<a href="/private/home" class="rounded-md px-4 py-2 bg-blue-600 text-white">
Go to Dashboard
</a>
<button
onclick={() => window.history.back()}
class="rounded-md px-4 py-2 border border-gray-300 text-gray-700"
aria-label="Go back"
>
Go Back
</button>
</div>
</div>
</div>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { isTokenValid, getUserInfo, revokeToken } from '$lib/google/auth/client.js';
import type { GoogleSheet } from '$lib/google/sheets/types.ts';
import { goto } from '$app/navigation';
import { toast } from '$lib/stores/toast.js';
@@ -16,19 +15,11 @@
let { data } = $props();
// Step management
let currentStep = $state(0); // Start at step 0 for Google auth check
const totalSteps = 4; // Increased to include auth step
let currentStep = $state(0);
const totalSteps = 4;
// Step 0: Google Auth
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
});
let isGoogleConnected = $state(false);
// Step 1: Event Details
let eventData = $state({
@@ -42,13 +33,13 @@
selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][],
columnMapping: {
name: 0, // Initialize to 0 (no column selected)
name: 0,
surname: 0,
email: 0,
confirmation: 0
},
loading: false,
expandedSheetList: true // Add this flag to control sheet list expansion
expandedSheetList: true
});
// Step 3: Email
@@ -62,189 +53,11 @@
let errors = $state<Record<string, string>>({});
onMount(async () => {
// Check Google auth status on mount
await checkGoogleAuth();
if (currentStep === 2) {
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
function nextStep() {
if (validateCurrentStep()) {
@@ -265,7 +78,7 @@
let isValid = true;
if (currentStep === 0) {
if (!authData.isConnected) {
if (!isGoogleConnected) {
toast.error('Please connect your Google account to continue');
errors.auth = 'Please connect your Google account to continue';
return false;
@@ -359,8 +172,8 @@
}
try {
// Use the new unified API endpoint
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, {
// Use the new unified API endpoint, requesting only a preview range
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data?range=A1:Z10`, {
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
@@ -437,13 +250,13 @@
// Computed values
let canProceed = $derived(() => {
if (currentStep === 0) return authData.isConnected;
if (currentStep === 1) return eventData.name && eventData.date;
if (currentStep === 0) return isGoogleConnected;
if (currentStep === 1) return !!(eventData.name && eventData.date);
if (currentStep === 2) {
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;
});
</script>
@@ -455,16 +268,9 @@
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
{#if currentStep === 0}
<GoogleAuthStep
onSuccess={(token) => {
authData.error = null;
authData.token = token;
authData.isConnected = true;
setTimeout(checkGoogleAuth, 100);
}}
onError={(error) => {
authData.error = error;
authData.isConnected = false;
}}
onSuccess={() => (isGoogleConnected = true)}
onDisconnect={() => (isGoogleConnected = false)}
onError={(err) => toast.error(err)}
/>
{:else if currentStep === 1}
<EventDetailsStep bind:eventData />
@@ -485,7 +291,7 @@
<StepNavigation
{currentStep}
{totalSteps}
{canProceed}
canProceed={canProceed()}
{loading}
{prevStep}
{nextStep}

View File

@@ -2,9 +2,10 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props
let { onSuccess, onError } = $props<{
let { onSuccess, onError, onDisconnect } = $props<{
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
onDisconnect?: () => void;
}>();
</script>
@@ -18,8 +19,9 @@
<GoogleAuthButton
size="large"
variant="primary"
onSuccess={onSuccess}
onError={onError}
{onSuccess}
{onError}
{onDisconnect}
/>
</div>
</div>

View File

@@ -10,7 +10,6 @@
import EmailSending from './components/EmailSending.svelte';
import EmailResults from './components/EmailResults.svelte';
import Statistics from './components/Statistics.svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { toast } from '$lib/stores/toast.js';
let { data } = $props();
@@ -48,6 +47,7 @@
let participantsLoading = $state(true);
let syncingParticipants = $state(false);
let sendingEmails = $state(false);
let updatingEmail = $state(false);
let emailProgress = $state({ sent: 0, total: 0 });
let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null);
@@ -74,10 +74,7 @@
event = eventData;
} catch (err) {
console.error('Error loading event:', err);
toast.add({
message: 'Failed to load event',
type: 'error'
});
toast.error('Failed to load event');
} finally {
loading = false;
}
@@ -98,29 +95,28 @@
participants = participantsData || [];
} catch (err) {
console.error('Error loading participants:', err);
toast.add({
message: 'Failed to load participants',
type: 'error'
});
toast.error('Failed to load participants');
} finally {
participantsLoading = false;
}
}
async function syncParticipants() {
if (!event || !event.sheet_id) return;
if (!event || !event.sheet_id) {
toast.error('Cannot sync participants: No Google Sheet is connected to this event');
return;
}
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
toast.add({
message: 'Please connect your Google account first to sync participants',
type: 'error'
});
toast.error('Please connect your Google account first to sync participants');
return;
}
syncingParticipants = true;
const previousCount = participants.length; // Capture count before sync
try {
// Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
@@ -142,35 +138,64 @@
if (rows.length === 0) throw new Error('No data found in sheet');
// Extract participant data based on column mapping
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
// --- Start of new logic to handle duplicates ---
// Skip header row (start from index 1)
// First, extract all potential participants from the sheet
const potentialParticipants = [];
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
if (row.length > 0) {
const name = row[event.name_column - 1] || '';
const surname = row[event.surname_column - 1] || '';
const email = row[event.email_column - 1] || '';
const email = (row[event.email_column - 1] || '').trim();
const confirmation = row[event.confirmation_column - 1] || '';
// Only add if the row has meaningful data (not all empty) AND confirmation is TRUE
const isConfirmed =
confirmation.toString().toLowerCase() === 'true' ||
confirmation.toString().toLowerCase() === 'yes' ||
confirmation === '1' ||
confirmation === 'x';
if ((name.trim() || surname.trim() || email.trim()) && isConfirmed) {
names.push(name.trim());
surnames.push(surname.trim());
emails.push(email.trim());
if ((name.trim() || surname.trim() || email) && isConfirmed) {
potentialParticipants.push({ name: name.trim(), surname: surname.trim(), email });
}
}
}
// 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
const { error: syncError } = await data.supabase.rpc('participants_add_bulk', {
p_event: eventId,
@@ -183,12 +208,26 @@
// Reload participants
await loadParticipants();
// Show success message with accurate count of changes
const newCount = participants.length;
const diff = newCount - previousCount;
const processedCount = names.length;
let message = `Sync complete. ${processedCount} confirmed entries processed from the sheet.`;
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) {
console.error('Error syncing participants:', err);
toast.add({
message: 'Failed to sync participants',
type: 'error'
});
toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
syncingParticipants = false;
}
@@ -268,6 +307,42 @@
}
}
// For Email Template updating
async function handleEmailUpdate(eventId: string, subject: string, body: string) {
updatingEmail = true;
try {
// Call the email_modify RPC function
const { error } = await data.supabase.rpc('email_modify', {
p_event_id: eventId,
p_email_subject: subject,
p_email_body: body
});
if (error) throw error;
// Update the local event data on success
if (event) {
event.email_subject = subject;
event.email_body = body;
}
toast.add({
message: 'Email template updated successfully',
type: 'success'
});
} catch (err) {
console.error('Error updating email template:', err);
toast.add({
message: 'Failed to update email template',
type: 'error'
});
} finally {
updatingEmail = false;
}
}
function handleGoogleAuthSuccess() {
// Success handled by toast in the component
}
@@ -312,7 +387,12 @@ onSyncParticipants={syncParticipants}
/>
</div>
<EmailTemplate {event} {loading} />
<EmailTemplate
{event}
{loading}
{updatingEmail}
onUpdateEmail={handleEmailUpdate}
/>
<EmailSending
{loading}

View File

@@ -1,18 +1,104 @@
<script lang="ts">
interface Event {
id: string;
email_subject: string;
email_body: string;
}
let { event, loading } = $props<{
let {
event,
loading,
updatingEmail,
onUpdateEmail
} = $props<{
event: Event | null;
loading: boolean;
updatingEmail: boolean;
onUpdateEmail: (eventId: string, subject: string, body: string) => void;
}>();
// State for form
let isEditing = $state(false);
let emailSubject = $state('');
let emailBody = $state('');
// Update form values when event changes
$effect(() => {
if (event) {
emailSubject = event.email_subject;
emailBody = event.email_body;
}
});
// Toggle editing mode
function toggleEdit() {
isEditing = !isEditing;
// Reset form when exiting edit mode without saving
if (!isEditing && event) {
emailSubject = event.email_subject;
emailBody = event.email_body;
}
}
// Track the previous updatingEmail state to detect changes
let wasUpdating = $state(false);
// Save email template
function handleSave() {
if (!event) return;
onUpdateEmail(event.id, emailSubject, emailBody);
}
// Watch for updatingEmail changes to detect when operation completes
$effect(() => {
// Detect the transition from updating to not updating (operation completed)
if (wasUpdating && !updatingEmail) {
// If event data matches our form data, the update was successful
// Turn off editing mode after successful update
if (event && event.email_subject === emailSubject && event.email_body === emailBody) {
isEditing = false;
}
}
// Store current state for next comparison
wasUpdating = updatingEmail;
});
</script>
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Email Template</h2>
{#if !loading && event}
<div class="flex gap-3">
{#if isEditing}
<button
onclick={handleSave}
disabled={updatingEmail}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Save email template"
>
{updatingEmail ? 'Saving...' : 'Save'}
</button>
<button
onclick={toggleEdit}
class="rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={updatingEmail}
aria-label="Cancel editing"
>
Cancel
</button>
{:else}
<button
onclick={toggleEdit}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={updatingEmail}
aria-label="Edit email template"
>
Edit Email
</button>
{/if}
</div>
{/if}
</div>
{#if loading}
@@ -31,17 +117,34 @@
{:else if event}
<div class="space-y-4">
<div>
<span class="block mb-1 text-sm font-medium text-gray-700">Subject:</span>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<p class="text-sm text-gray-900">{event.email_subject}</p>
</div>
<label for="emailSubject" class="block mb-1 text-sm font-medium text-gray-700">Subject:</label>
<input
id="emailSubject"
type="text"
bind:value={emailSubject}
disabled={!isEditing || updatingEmail}
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-blue-500 disabled:cursor-default disabled:bg-gray-100"
/>
</div>
<div>
<span class="block mb-1 text-sm font-medium text-gray-700">Body:</span>
<div class="bg-gray-50 p-3 rounded-lg border border-gray-200 max-h-48 overflow-y-auto">
<p class="text-sm whitespace-pre-wrap text-gray-900">{event.email_body}</p>
</div>
</div>
<label for="emailBody" class="block mb-1 text-sm font-medium text-gray-700">Body:</label>
<textarea
id="emailBody"
bind:value={emailBody}
rows="6"
disabled={!isEditing || updatingEmail}
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-blue-500 disabled:cursor-default disabled:bg-gray-100"
></textarea>
{#if isEditing}
<div class="mt-2 text-xs text-gray-500">
Template variables: <span class="font-mono bg-gray-100 px-1 rounded">&#123;name&#125;</span>,
<span class="font-mono bg-gray-100 px-1 rounded">&#123;surname&#125;</span>
</div>
{/if}
</div>
<!-- Save button moved to the header -->
</div>
{/if}
</div>

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { toast } from '$lib/stores/toast.js';
interface Participant {
id: string;
name: string;
@@ -28,13 +30,23 @@
syncingParticipants: boolean;
onSyncParticipants: () => void;
}>();
// Handle sync participants with toast notifications
function handleSyncParticipants() {
// Show initial notification about sync starting
toast.info('Starting participant synchronization...', 5000);
// Call the parent component's sync function
onSyncParticipants();
}
</script>
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Participants</h2>
<button
onclick={onSyncParticipants}
onclick={handleSyncParticipants}
disabled={syncingParticipants || !event || loading}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>

View File

@@ -1,22 +0,0 @@
// src/routes/my-page/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// get the logged-in user
const { data: { user }, error: authError } = await locals.supabase.auth.getUser();
const { data: user_profile, error: profileError } = await locals.supabase.from('profiles').select('*, section:sections (id, name)').eq('id', user?.id).single();
if (authError) {
console.error('Supabase auth error:', authError);
throw new Error('Could not get user');
}
if (profileError) {
console.error('Supabase profile error:', profileError);
throw new Error('Could not get user profile');
}
return { user, user_profile };
};

View File

@@ -1,51 +1,73 @@
<script lang="ts">
import type { User } from '@supabase/supabase-js';
export let data: {
user: User | null,
user_profile: any | null
};
let { data } = $props();
</script>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Profile</h1>
<h1 class="mt-2 mb-4 text-center text-2xl font-bold">User Dashboard</h1>
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3 mb-4">
<div class="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-600">
{data.user?.user_metadata.display_name?.[0] ?? "U"}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column: User Profile -->
<div class="lg:col-span-1">
<div class="flex h-full flex-col rounded-lg border border-gray-300 bg-white p-6">
<div class="flex flex-grow flex-col items-center text-center">
<div
class="mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-4xl font-bold text-gray-600"
>
{data.profile?.display_name?.[0]?.toUpperCase() ?? 'U'}
</div>
<div>
<span class="text-lg font-semibold text-gray-800">{data.user?.user_metadata.display_name}</span>
<div class="text-sm text-gray-500">{data.user?.email}</div>
<h2 class="text-xl font-semibold text-gray-900">{data.profile?.display_name}</h2>
<p class="text-sm text-gray-500">{data.user?.email}</p>
</div>
</div>
<div class="flex flex-col gap-1">
<div>
<span class="font-medium text-gray-700">Section:</span>
<span class="text-gray-900">{data.user_profile?.section.name ?? "N/A"}</span>
</div>
<div>
<span class="font-medium text-gray-700">Position:</span>
<span class="text-gray-900">{data.user_profile?.section_position ?? "N/A"}</span>
</div>
</div>
<h2 class="text-lg mb-2 mt-4">User guide</h2>
<p class="text-gray-700 text-sm leading-relaxed">
To scan a QR code, head over to Scanner in the top right corner. Click on Start scanning and allow camera permissions.
If you close and open your browser and your camera is stuck, simply refresh the page or click Stop scanning and then Start scanning again.
When you scan a QR code, a request is sent to the server to get the user's personal information and to mark their tickets as scanned.
</p>
<h2 class="text-lg mb-2 mt-4">Administrator guide</h2>
<p class="text-gray-700 text-sm leading-relaxed">
You can view events
</p>
</div>
</div>
<div class="mt-6 text-center">
<a
href="/auth/signout"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-red-500 hover:bg-red-600 text-white font-semibold py-3 px-8 rounded-full shadow-none border border-gray-300 transition"
class="text-sm text-red-500 transition hover:text-red-700 hover:underline">Sign Out</a
>
Sign out
</a>
</div>
</div>
</div>
<!-- Right Column: Information -->
<div class="space-y-6 lg:col-span-2">
<!-- Role Information -->
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Your Role</h2>
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Section</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">
{data.profile?.section?.name ?? 'N/A'}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Position</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">
{data.profile?.section_position ?? 'N/A'}
</dd>
</div>
</dl>
</div>
<!-- User Guide -->
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-2 text-lg font-semibold text-gray-900">User Guide</h2>
<p class="text-sm leading-relaxed text-gray-700">
To scan a QR code, head over to <strong>Scanner</strong> in the top right corner. Click on "Start
Scanning" and allow camera permissions. If your camera gets stuck, simply refresh the page or
click "Stop Scanning" and then "Start Scanning" again. When you scan a QR code, the participant's
ticket is automatically marked as scanned.
</p>
</div>
<!-- Events Manager Guide -->
{#if data.profile?.section_position === 'events_manager'}
<div class="rounded-lg border border-gray-300 bg-white p-6">
<h2 class="mb-2 text-lg font-semibold text-gray-900">Events Manager Guide</h2>
<p class="text-sm leading-relaxed text-gray-700">
As an Events Manager, you have access to the <strong>Events</strong> section. Here you can
create new events, manage participants by syncing with Google Sheets, send email invitations
with QR codes, and view event statistics.
</p>
</div>
{/if}
</div>
</div>

View File

@@ -24,6 +24,11 @@
let eventsError = $state('');
onMount(async () => {
// Load the persisted event ID from local storage
const storedEventId = localStorage.getItem('selectedScannerEventId');
if (storedEventId) {
selectedEventId = storedEventId;
}
await loadEvents();
});
@@ -40,9 +45,14 @@
if (error) throw error;
events = eventsData || [];
// If there are events, select the first one by default
if (events.length > 0) {
// Check if the previously selected event is still in the list
const selectedEventExists = events.some((event) => event.id === selectedEventId);
// If no event is selected, or the selected one is no longer valid, default to the first event
if ((!selectedEventId || !selectedEventExists) && events.length > 0) {
selectedEventId = events[0].id;
} else if (events.length === 0) {
selectedEventId = ''; // No events available
}
} catch (err) {
console.error('Error loading events:', err);
@@ -52,6 +62,13 @@
}
}
// Persist the selected event ID to local storage whenever it changes
$effect(() => {
if (selectedEventId) {
localStorage.setItem('selectedScannerEventId', selectedEventId);
}
});
// Process a scanned QR code
$effect(() => {
if (scanned_id === '') return;

View File

@@ -45,9 +45,9 @@
});
</script>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded-lg border border-gray-300"></div>
<div id="qr-scanner" class="w-full h-full max-w-none overflow-hidden rounded"></div>
<style>
<style lang="postcss">
/* Hide unwanted icons */
#qr-scanner :global(img[alt='Info icon']),
#qr-scanner :global(img[alt='Camera based scan']) {
@@ -58,21 +58,51 @@
color: black !important;
}
/* Change camera permission button text */
#qr-scanner :global(#html5-qrcode-button-camera-permission) {
visibility: hidden;
}
#qr-scanner :global(#html5-qrcode-button-camera-permission::after) {
position: absolute;
inset: auto 0 0;
display: block;
content: 'Allow camera access';
visibility: visible;
padding: 10px 0;
}
#qr-scanner :global(#qr-scanner__scan_region) {
min-height: auto !important;
aspect-ratio: 1 !important;
}
#qr-scanner :global(button.html5-qrcode-element) {
border-radius: 0.375rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
background-color: #2563eb;
color: #fff;
font-weight: 500;
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: 150ms;
outline: none;
}
#qr-scanner :global(button.html5-qrcode-element:hover) {
background-color: #1d4ed8;
}
#qr-scanner :global(button.html5-qrcode-element:focus) {
box-shadow: 0 0 0 2px #60a5fa, 0 0 0 4px #fff;
}
#qr-scanner :global(input) {
border-radius: 0.375rem; /* rounded-md */
border-width: 1px;
border-color: #d1d5db; /* border-gray-300 */
padding-left: 1rem; /* px-4 */
padding-right: 1rem;
padding-top: 0.5rem; /* py-2 */
padding-bottom: 0.5rem;
background-color: #f9fafb; /* bg-gray-50 */
color: #111827; /* text-gray-900 */
font-size: 1rem;
line-height: 1.5rem;
outline: none;
transition-property: border-color, box-shadow;
transition-duration: 150ms;
}
#qr-scanner :global(input:focus) {
border-color: #2563eb; /* border-blue-600 */
box-shadow: 0 0 0 2px #60a5fa;
}
</style>

View File

@@ -47,7 +47,7 @@
</div>
<p class="text-amber-700 mb-2">This ticket belongs to a different event:</p>
<div class="bg-white rounded p-3 border border-amber-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p>
<p class="font-bold">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div>
</div>
@@ -64,7 +64,7 @@
{ticket_data.scanned_at ? `on ${formatScannedAt(ticket_data.scanned_at)}` : ''}
</p>
<div class="bg-white rounded p-3 border border-amber-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p>
<p class="font-bold">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div>
</div>
@@ -78,7 +78,7 @@
</div>
<p class="text-green-700">Ticket successfully validated.</p>
<div class="bg-white rounded p-3 border border-green-200 mt-auto">
<p class="font-medium">{ticket_data.event?.name || ''}</p>
<p class="font-bold">{ticket_data.event?.name || ''}</p>
<p>{ticket_data.name || ''} {ticket_data.surname || ''}</p>
</div>
</div>

View File

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

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

364
styling.md Normal file
View File

@@ -0,0 +1,364 @@
# ScanWave Styling Guide
This document outlines the design system and styling conventions used in the ScanWave application. Use this as a reference when creating new applications with similar visual design.
## Table of Contents
- [Color Palette](#color-palette)
- [Typography](#typography)
- [Layout Patterns](#layout-patterns)
- [Component Patterns](#component-patterns)
- [Form Elements](#form-elements)
- [Buttons](#buttons)
- [Cards and Containers](#cards-and-containers)
- [Navigation](#navigation)
- [Tables](#tables)
- [Loading States](#loading-states)
- [Toast Notifications](#toast-notifications)
- [Responsive Design](#responsive-design)
## Color Palette
### Primary Colors
- **Blue**: Primary action color
- `bg-blue-600` / `text-blue-600` - Primary buttons, links
- `bg-blue-700` / `text-blue-700` - Hover states
- `bg-blue-50` / `text-blue-800` - Info notifications
- `border-blue-600` / `focus:ring-blue-600` - Focus states
### Gray Scale
- **Text Colors**:
- `text-gray-900` - Primary text (headings, important content)
- `text-gray-700` - Secondary text (labels, descriptions)
- `text-gray-500` - Tertiary text (metadata, placeholders)
- **Background Colors**:
- `bg-white` - Main content backgrounds
- `bg-gray-50` - Page backgrounds, subtle sections
- `bg-gray-100` - Disabled form fields
- `bg-gray-200` - Loading placeholders
- **Border Colors**:
- `border-gray-300` - Standard borders (cards, inputs)
- `border-gray-200` - Subtle borders (table rows)
### Status Colors
- **Success**: `bg-green-50 text-green-800 border-green-300`
- **Warning**: `bg-yellow-50 text-yellow-800 border-yellow-300`
- **Error**: `bg-red-50 text-red-800 border-red-300`
- **Info**: `bg-blue-50 text-blue-800 border-blue-300`
### Accent Colors
- **Red**: `text-red-600` / `hover:text-red-700` - Danger actions (sign out)
- **Green**: `text-green-600` - Success indicators
## Typography
### Headings
```html
<!-- Page titles -->
<h1 class="mb-6 text-2xl font-bold text-center text-gray-800">Page Title</h1>
<!-- Section headings -->
<h2 class="mb-4 text-xl font-semibold text-gray-900">Section Title</h2>
<h2 class="mb-2 text-lg font-semibold text-gray-900">Subsection Title</h2>
```
### Body Text
```html
<!-- Primary text -->
<p class="text-sm text-gray-900">Important content</p>
<!-- Secondary text -->
<p class="text-sm text-gray-700">Regular content</p>
<p class="text-sm leading-relaxed text-gray-700">Longer content blocks</p>
<!-- Metadata/labels -->
<span class="text-sm font-medium text-gray-500">Label</span>
<span class="text-sm font-medium text-gray-700">Form Label</span>
<!-- Small text -->
<p class="text-xs text-gray-500">Helper text</p>
```
### Text Utilities
- **Font Weight**: `font-bold`, `font-semibold`, `font-medium`
- **Text Alignment**: `text-center`, `text-left`
- **Line Height**: `leading-relaxed` for longer text blocks
## Layout Patterns
### Container Pattern
```html
<div class="container mx-auto max-w-2xl bg-white p-4">
<!-- Content -->
</div>
```
### Grid Layouts
```html
<!-- Dashboard grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-1"><!-- Sidebar --></div>
<div class="lg:col-span-2"><!-- Main content --></div>
</div>
<!-- Two-column responsive -->
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Items -->
</dl>
```
### Spacing
- **Standard spacing**: `space-y-6`, `gap-6` - Between major sections
- **Component spacing**: `mb-4`, `mt-6`, `p-6` - Around components
- **Small spacing**: `gap-3`, `mb-2`, `mt-2` - Between related elements
- **Container padding**: `p-4`, `p-6` - Internal container spacing
## Component Patterns
### Card Structure
```html
<div class="rounded-lg border border-gray-300 bg-white p-6">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Title</h2>
<!-- Actions -->
</div>
<!-- Content -->
</div>
```
### Avatar/Profile Picture
```html
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-4xl font-bold text-gray-600">
{initials}
</div>
```
## Form Elements
### Input Fields
```html
<!-- Standard input -->
<input
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-600 focus:ring-blue-600 focus:outline-none"
type="text"
/>
<!-- Disabled input -->
<input
class="w-full rounded-md border border-gray-300 px-3 py-2 disabled:cursor-default disabled:bg-gray-100"
disabled
/>
```
### Textarea
```html
<textarea
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-600 focus:ring-blue-600 focus:outline-none"
rows="6"
></textarea>
```
### Select Dropdown
```html
<select class="w-full rounded-md border border-gray-300 p-2 focus:ring-2 focus:ring-blue-600 focus:outline-none">
<option>Option</option>
</select>
```
### Form Labels
```html
<label class="block mb-1 text-sm font-medium text-gray-700">Label Text</label>
```
## Buttons
### Primary Buttons
```html
<button class="rounded-md bg-blue-600 px-4 py-2 text-white font-medium transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50">
Primary Action
</button>
```
### Secondary/Outline Buttons
```html
<button class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-700 font-medium transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
Secondary Action
</button>
```
### Danger/Red Buttons
```html
<button class="rounded-md bg-red-600 px-4 py-2 text-white font-medium transition hover:bg-red-700">
Danger Action
</button>
```
### Button States
- **Loading**: Replace text with "Loading..." or "Saving..."
- **Disabled**: `disabled:cursor-not-allowed disabled:opacity-50`
## Cards and Containers
### Standard Card
```html
<div class="mb-6 rounded-md border border-gray-300 bg-white p-6">
<!-- Content -->
</div>
```
### Card with Header Actions
```html
<div class="rounded-md border border-gray-300 bg-white p-6">
<div class="mb-4 flex justify-between items-center">
<h2 class="text-xl font-semibold text-gray-900">Title</h2>
<div class="flex gap-3">
<!-- Action buttons -->
</div>
</div>
<!-- Content -->
</div>
```
## Navigation
### Top Navigation
```html
<nav class="border-b border-gray-300 bg-gray-50 p-4 text-gray-900">
<div class="container mx-auto max-w-2xl">
<div class="flex items-center justify-between">
<a href="/" class="text-lg font-bold">App Name</a>
<ul class="flex space-x-4">
<li><a href="/page" class="hover:text-blue-600 transition">Page</a></li>
</ul>
</div>
</div>
</nav>
```
## Tables
### Standard Table
```html
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Header</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-900">Data</td>
</tr>
</tbody>
</table>
</div>
```
### Definition List (Key-Value Pairs)
```html
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Key</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">Value</dd>
</div>
</dl>
```
## Loading States
### Skeleton Loading
```html
<div class="space-y-4">
<div class="h-4 animate-pulse rounded-md bg-gray-200"></div>
<div class="h-10 w-full animate-pulse rounded-md bg-gray-200"></div>
</div>
```
### Loading Spinner
```html
<div class="flex h-10 items-center justify-center">
<div class="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
</div>
```
## Toast Notifications
### Toast Container Structure
```html
<div class="rounded-md border p-4 shadow-lg w-full {colorClasses}">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<!-- Icon -->
</div>
<div class="flex-1">
<p class="text-sm font-medium">{message}</p>
</div>
<button class="flex-shrink-0">
<!-- Close button -->
</button>
</div>
</div>
```
### Toast Color Variants
- **Success**: `border-green-300 bg-green-50 text-green-800`
- **Warning**: `border-yellow-300 bg-yellow-50 text-yellow-800`
- **Info**: `border-blue-300 bg-blue-50 text-blue-800`
- **Error**: `border-red-300 bg-red-50 text-red-800`
## Responsive Design
### Breakpoints
- **Mobile First**: Default styles for mobile
- **sm**: `sm:` prefix for small screens and up
- **lg**: `lg:` prefix for large screens and up
### Common Responsive Patterns
```html
<!-- Responsive grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Responsive padding -->
<div class="p-4 sm:p-6">
<!-- Responsive columns -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
```
## Common Utility Classes
### Flexbox
- `flex items-center justify-between` - Header with title and actions
- `flex items-start gap-3` - Toast notification layout
- `flex flex-col` - Vertical stacking
- `flex-grow` - Fill available space
### Positioning
- `relative` / `absolute` - Positioning contexts
- `fixed bottom-6 left-1/2 -translate-x-1/2` - Centered fixed positioning
### Visibility
- `hidden` / `block` - Show/hide elements
- `overflow-hidden` - Clip content
- `overflow-x-auto` - Horizontal scroll for tables
### Borders and Shadows
- `rounded-md` - Standard border radius for all components
- `rounded-full` - Circular elements (avatars)
- `shadow-lg` - Toast notifications and elevated elements
- `shadow-none` - Remove default shadows when needed
## Design Tokens Summary
### Standardized Values
- **Border Radius**: `rounded-md` (6px) for all rectangular components
- **Border Colors**: `border-gray-300` (standard), `border-gray-200` (subtle)
- **Focus States**: `focus:border-blue-600 focus:ring-blue-600`
- **Spacing**: `gap-4` (1rem), `gap-6` (1.5rem), `p-4` (1rem), `p-6` (1.5rem)
- **Font Weights**: `font-medium` for buttons and emphasis, `font-semibold` for headings, `font-bold` for titles
- **Status Border**: All status colors use `-300` shade for borders (e.g., `border-green-300`)
This styling guide captures the core design patterns used throughout the ScanWave application. Follow these conventions to maintain visual consistency across your new applications.