Compare commits

...

3 Commits

Author SHA1 Message Date
Roman Krček
6466665549 Redirect now directly to the event 2025-07-14 14:56:49 +02:00
Roman Krček
b9db3d22a3 Cleanup for error notifications 2025-07-14 14:34:38 +02:00
Roman Krček
06f2553b42 Better error norifications 2025-07-14 14:30:55 +02:00
15 changed files with 615 additions and 158 deletions

View File

@@ -9,6 +9,8 @@ Basics: These you need to really follow!
- Never use supabse-js. I am using supabse-ssr and supabase client is located in:
- client: $props.data.supabse
- 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.
Do not fall back to the legacy $: label syntax or Svelte 3/4 stores! This is important!

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { toast } from '$lib/stores/toast';
import ToastNotification from './ToastNotification.svelte';
// Subscribe to the toast store using Svelte 5 reactivity
let toasts = $derived($toast);
function handleDismiss(id: string) {
toast.remove(id);
}
</script>
<!-- Toast container positioned in top-left corner -->
<div class="fixed top-4 p-2 space-y-3 pointer-events-none max-w-2xl">
{#each toasts as toastItem (toastItem.id)}
<div class="pointer-events-auto">
<ToastNotification
message={toastItem.message}
type={toastItem.type}
duration={toastItem.duration}
onDismiss={() => handleDismiss(toastItem.id)}
/>
</div>
{/each}
</div>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
message,
type = 'error',
duration = 5000,
onDismiss
} = $props<{
message: string;
type?: 'error' | 'success' | 'warning' | 'info';
duration?: number;
onDismiss?: () => void;
}>();
let visible = $state(true);
let timeoutId: ReturnType<typeof setTimeout>;
// Auto-dismiss after specified duration
onMount(() => {
if (duration > 0) {
timeoutId = setTimeout(() => {
dismiss();
}, duration);
}
// Cleanup timeout on component destroy
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function dismiss() {
visible = false;
if (onDismiss) {
onDismiss();
}
}
// Get styles based on toast type
const getToastStyles = (type: string) => {
switch (type) {
case 'success':
return 'border-green-200 bg-green-50 text-green-800';
case 'warning':
return 'border-yellow-200 bg-yellow-50 text-yellow-800';
case 'info':
return 'border-blue-200 bg-blue-50 text-blue-800';
case 'error':
default:
return 'border-red-200 bg-red-50 text-red-800';
}
};
// Get icon SVG path based on toast type
const getIconSvg = (type: string) => {
switch (type) {
case 'success':
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />`;
case 'warning':
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.268 16c-.77.833.192 2.5 1.732 2.5z" />`;
case 'info':
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />`;
case 'error':
default:
return `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />`;
}
};
</script>
{#if visible && message}
<div
class="rounded-lg border p-4 shadow-lg w-full {getToastStyles(type)}"
role="alert"
aria-live="polite"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex-shrink-0">
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
{@html getIconSvg(type)}
</svg>
</div>
<!-- Message -->
<div class="flex-1">
<p class="text-sm font-medium">
{message}
</p>
</div>
<!-- Close button -->
<button
onclick={dismiss}
class="flex-shrink-0 ml-2 text-current opacity-70 hover:opacity-100 transition-opacity"
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Toast has no progress bar as requested -->
</div>
{/if}

63
src/lib/stores/toast.ts Normal file
View File

@@ -0,0 +1,63 @@
import { writable } from 'svelte/store';
export interface Toast {
id: string;
message: string;
type: 'error' | 'success' | 'warning' | 'info';
duration?: number;
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
const store = {
subscribe,
// Add a new toast
add: (toast: Omit<Toast, 'id'>) => {
const id = crypto.randomUUID();
const newToast: Toast = {
id,
duration: 5000, // Default 5 seconds
...toast
};
update(toasts => [...toasts, newToast]);
return id;
},
// Remove a toast by ID
remove: (id: string) => {
update(toasts => toasts.filter(toast => toast.id !== id));
},
// Clear all toasts
clear: () => {
update(() => []);
}
};
// Add convenience methods that reference the same store instance
return {
...store,
// Convenience methods for different toast types
success: (message: string, duration?: number) => {
return store.add({ message, type: 'success', duration });
},
error: (message: string, duration?: number) => {
return store.add({ message, type: 'error', duration });
},
warning: (message: string, duration?: number) => {
return store.add({ message, type: 'warning', duration });
},
info: (message: string, duration?: number) => {
return store.add({ message, type: 'info', duration });
}
};
}
export const toast = createToastStore();

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { browser } from '$app/environment';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastContainer from '$lib/components/ToastContainer.svelte';
const queryClient = new QueryClient({
defaultOptions: {
@@ -27,8 +28,14 @@
</div>
</nav>
<QueryClientProvider client={queryClient}>
<div class="container mx-auto max-w-2xl bg-white p-2">
<QueryClientProvider client={queryClient}>
<slot />
</div>
</QueryClientProvider>
</div>
<ToastContainer />

View File

@@ -3,6 +3,7 @@
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';
// Import Components
import GoogleAuthStep from './components/GoogleAuthStep.svelte';
@@ -110,7 +111,8 @@
'/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.width / 2 - 250) +
',top=' +
Math.round(window.screen.height / 2 - 300)
);
@@ -189,7 +191,6 @@
cleanUp();
}
}, 60 * 1000); // Reduced from 3min to 1min
} catch (error) {
console.error('Error connecting to Google:', error);
authData.error = 'Failed to connect to Google';
@@ -259,23 +260,32 @@
}
function validateCurrentStep(): boolean {
// Clear previous errors
errors = {};
let isValid = true;
if (currentStep === 0) {
if (!authData.isConnected) {
toast.error('Please connect your Google account to continue');
errors.auth = 'Please connect your Google account to continue';
return false;
}
} else if (currentStep === 1) {
if (!eventData.name.trim()) {
toast.error('Event name is required');
errors.name = 'Event name is required';
isValid = false;
}
if (!eventData.date) {
toast.error('Event date is required');
errors.date = 'Event date is required';
isValid = false;
}
} else if (currentStep === 2) {
if (!sheetsData.selectedSheet) {
toast.error('Please select a Google Sheet');
errors.sheet = 'Please select a Google Sheet';
isValid = false;
}
if (sheetsData.selectedSheet) {
@@ -289,19 +299,26 @@
if (!confirmation) missingColumns.push('Confirmation');
if (missingColumns.length > 0) {
errors.sheetData = `Please map the following columns: ${missingColumns.join(', ')}`;
const errorMsg = `Please map the following columns: ${missingColumns.join(', ')}`;
toast.error(errorMsg);
errors.sheetData = errorMsg;
isValid = false;
}
}
} else if (currentStep === 3) {
if (!emailData.subject.trim()) {
toast.error('Email subject is required');
errors.subject = 'Email subject is required';
isValid = false;
}
if (!emailData.body.trim()) {
toast.error('Email body is required');
errors.body = 'Email body is required';
isValid = false;
}
}
return Object.keys(errors).length === 0;
return isValid;
}
// Google Sheets functions
@@ -315,7 +332,7 @@
const response = await fetch('/private/api/google/sheets/recent', {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}`
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
}
});
@@ -346,7 +363,7 @@
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('google_refresh_token')}`
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
}
});
@@ -367,13 +384,26 @@
sheetsData.expandedSheetList = !sheetsData.expandedSheetList;
}
// Reset sheet selection and show sheet list
function resetSheetSelection() {
sheetsData.selectedSheet = null;
sheetsData.sheetData = [];
sheetsData.columnMapping = {
name: 0,
surname: 0,
email: 0,
confirmation: 0
};
sheetsData.expandedSheetList = true;
}
// Final submission
async function createEvent() {
if (!validateCurrentStep()) return;
loading = true;
try {
const { error } = await data.supabase.rpc('create_event', {
const { data: newEvent, error } = await data.supabase.rpc('create_event', {
p_name: eventData.name,
p_date: eventData.date,
p_email_subject: emailData.subject,
@@ -387,11 +417,19 @@
if (error) throw error;
// Redirect to events list or show success message
// Display success message
toast.success(`Event "${eventData.name}" was created successfully`);
// Redirect to the event view page using the returned event ID
if (newEvent) {
goto(`/private/events/event/view?id=${newEvent.id}`);
} else {
// Fallback to events list if for some reason the event ID wasn't returned
goto('/private/events');
}
} catch (error) {
console.error('Error creating event:', error);
errors.submit = 'Failed to create event. Please try again.';
toast.error('Failed to create event. Please try again.');
} finally {
loading = false;
}
@@ -410,15 +448,13 @@
});
</script>
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<StepNavigator {currentStep} {totalSteps} />
<!-- Step Content -->
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 rounded border border-gray-300 bg-white p-6">
{#if currentStep === 0}
<GoogleAuthStep
bind:errors
onSuccess={(token) => {
authData.error = null;
authData.token = token;
@@ -431,17 +467,17 @@
}}
/>
{:else if currentStep === 1}
<EventDetailsStep bind:eventData bind:errors />
<EventDetailsStep bind:eventData />
{:else if currentStep === 2}
<GoogleSheetsStep bind:sheetsData bind:errors {loadRecentSheets} {selectSheet} {toggleSheetList} />
<GoogleSheetsStep
bind:sheetsData
{loadRecentSheets}
{selectSheet}
{toggleSheetList}
{resetSheetSelection}
/>
{:else if currentStep === 3}
<EmailSettingsStep bind:emailData bind:errors />
{/if}
{#if errors.submit}
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded">
<p class="text-sm text-red-600">{errors.submit}</p>
</div>
<EmailSettingsStep bind:emailData />
{/if}
</div>
@@ -455,4 +491,3 @@
{nextStep}
{createEvent}
/>
</div>

View File

@@ -1,10 +1,9 @@
<script lang="ts">
let { emailData = $bindable(), errors = $bindable() } = $props<{
let { emailData = $bindable() } = $props<{
emailData: {
subject: string;
body: string;
};
errors: Record<string, string>;
}>();
const templateVariables = [
@@ -37,9 +36,6 @@
Detected templates: {subjectTemplatesDetected.map((v) => v.name).join(', ')}
</p>
{/if}
{#if errors.subject}
<p class="text-sm text-red-600">{errors.subject}</p>
{/if}
</div>
<div>
@@ -58,9 +54,6 @@
Detected templates: {bodyTemplatesDetected.map((v) => v.name).join(', ')}
</p>
{/if}
{#if errors.body}
<p class="mt-1 text-sm text-red-600">{errors.body}</p>
{/if}
</div>
<div>

View File

@@ -1,10 +1,9 @@
<script lang="ts">
let { eventData = $bindable(), errors = $bindable() } = $props<{
let { eventData = $bindable() } = $props<{
eventData: {
name: string;
date: string;
};
errors: Record<string, string>;
}>();
</script>
@@ -22,9 +21,6 @@
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter event name"
/>
{#if errors.name}
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
{/if}
</div>
<div>
@@ -37,8 +33,5 @@
bind:value={eventData.date}
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{#if errors.date}
<p class="mt-1 text-sm text-red-600">{errors.date}</p>
{/if}
</div>
</div>

View File

@@ -2,8 +2,7 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props
let { errors, onSuccess, onError } = $props<{
errors: Record<string, string>;
let { onSuccess, onError } = $props<{
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
}>();
@@ -22,11 +21,5 @@
onSuccess={onSuccess}
onError={onError}
/>
{#if errors.google}
<div class="mt-4 text-sm text-red-600">
{errors.google}
</div>
{/if}
</div>
</div>

View File

@@ -2,7 +2,7 @@
import type { GoogleSheet } from '$lib/google/sheets/types.ts';
// Props
let { sheetsData = $bindable(), errors = $bindable(), loadRecentSheets, selectSheet, toggleSheetList } = $props<{
let { sheetsData = $bindable(), loadRecentSheets, selectSheet, toggleSheetList, resetSheetSelection } = $props<{
sheetsData: {
availableSheets: GoogleSheet[];
selectedSheet: GoogleSheet | null;
@@ -16,10 +16,10 @@
loading: boolean;
expandedSheetList: boolean;
};
errors: Record<string, string>;
loadRecentSheets: () => Promise<void>;
selectSheet: (sheet: GoogleSheet) => Promise<void>;
toggleSheetList: () => void;
resetSheetSelection: () => void;
}>();
// Search functionality
@@ -124,13 +124,13 @@
</div>
</div>
<button
onclick={toggleSheetList}
onclick={resetSheetSelection}
class="text-blue-600 hover:text-blue-800 flex items-center"
aria-label="Show all sheets"
aria-label="Reset selection and show all sheets"
>
<span class="text-sm mr-1">Change</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
</svg>
</button>
</div>
@@ -256,10 +256,6 @@
{/if}
</div>
{/if}
{#if errors.sheet}
<p class="mt-2 text-sm text-red-600">{errors.sheet}</p>
{/if}
</div>
{#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0}
@@ -373,10 +369,6 @@
<div class="text-gray-600">Loading sheet data...</div>
</div>
{/if}
{#if errors.sheetData}
<p class="text-sm text-red-600">{errors.sheetData}</p>
{/if}
</div>

View File

@@ -6,7 +6,7 @@
}>();
</script>
<div class="mb-8">
<div class="mb-8 mt-6">
<div class="flex items-center justify-center gap-4 w-full">
{#each Array(totalSteps) as _, index}
<div class="flex items-center gap-2">

View File

@@ -9,8 +9,9 @@
import ParticipantsTable from './components/ParticipantsTable.svelte';
import EmailSending from './components/EmailSending.svelte';
import EmailResults from './components/EmailResults.svelte';
import ErrorMessage from './components/ErrorMessage.svelte';
import Statistics from './components/Statistics.svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { toast } from '$lib/stores/toast.js';
let { data } = $props();
@@ -49,7 +50,6 @@
let sendingEmails = $state(false);
let emailProgress = $state({ sent: 0, total: 0 });
let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null);
let error = $state('');
// Get event ID from URL params
let eventId = $derived(page.url.searchParams.get('id'));
@@ -74,7 +74,10 @@
event = eventData;
} catch (err) {
console.error('Error loading event:', err);
error = 'Failed to load event';
toast.add({
message: 'Failed to load event',
type: 'error'
});
} finally {
loading = false;
}
@@ -95,7 +98,10 @@
participants = participantsData || [];
} catch (err) {
console.error('Error loading participants:', err);
error = 'Failed to load participants';
toast.add({
message: 'Failed to load participants',
type: 'error'
});
} finally {
participantsLoading = false;
}
@@ -107,12 +113,14 @@
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
error = 'Please connect your Google account first to sync participants';
toast.add({
message: 'Please connect your Google account first to sync participants',
type: 'error'
});
return;
}
syncingParticipants = true;
error = '';
try {
// Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
@@ -177,7 +185,10 @@
await loadParticipants();
} catch (err) {
console.error('Error syncing participants:', err);
error = 'Failed to sync participants';
toast.add({
message: 'Failed to sync participants',
type: 'error'
});
} finally {
syncingParticipants = false;
}
@@ -189,20 +200,25 @@
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
error = 'Please connect your Google account first to send emails';
toast.add({
message: 'Please connect your Google account first to send emails',
type: 'error'
});
return;
}
const uncontactedParticipants = participants.filter(p => !p.email_sent);
if (uncontactedParticipants.length === 0) {
error = 'No uncontacted participants found';
toast.add({
message: 'No uncontacted participants found',
type: 'warning'
});
return;
}
sendingEmails = true;
emailProgress = { sent: 0, total: uncontactedParticipants.length };
emailResults = null;
error = '';
try {
// Send all emails in batch
@@ -235,33 +251,40 @@
});
} else {
const errorData = await response.json();
error = errorData.error || 'Failed to send emails';
toast.add({
message: errorData.error || 'Failed to send emails',
type: 'error'
});
console.error('Email sending failed:', errorData);
}
} catch (err) {
console.error('Error sending emails:', err);
error = 'Failed to send emails to participants';
toast.add({
message: 'Failed to send emails to participants',
type: 'error'
});
} finally {
sendingEmails = false;
}
}
function handleGoogleAuthSuccess() {
error = '';
// Success handled by toast in the component
}
function handleGoogleAuthError(errorMsg: string) {
error = errorMsg;
toast.add({
message: errorMsg,
type: 'error'
});
}
</script>
<!-- Header -->
<div class="mt-2 mb-4">
<h1 class="text-center text-2xl font-bold">Event Overview</h1>
</div>
<!-- Composable components -->
<EventInformation {event} {loading} {error} />
<EventInformation {event} {loading} />
<GoogleAuthentication
{loading}
@@ -303,7 +326,3 @@ onSyncParticipants={syncParticipants}
{#if emailResults}
<EmailResults {emailResults} />
{/if}
{#if error}
<ErrorMessage {error} />
{/if}

View File

@@ -1,11 +1,127 @@
<script lang="ts">
let { error } = $props<{
error: string;
import { onMount } from 'svelte';
let {
message,
type = 'error',
duration = 50000,
onDismiss
} = $props<{
message: string;
type?: 'error' | 'success' | 'warning' | 'info';
duration?: number;
onDismiss?: () => void;
}>();
let visible = $state(true);
let timeoutId: ReturnType<typeof setTimeout>;
// Auto-dismiss after specified duration
onMount(() => {
if (duration > 0) {
timeoutId = setTimeout(() => {
dismiss();
}, duration);
}
// Cleanup timeout on component destroy
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function dismiss() {
visible = false;
if (onDismiss) {
onDismiss();
}
}
// Get styles based on toast type
const getToastStyles = (type: string) => {
switch (type) {
case 'success':
return 'border-green-200 bg-green-50 text-green-800';
case 'warning':
return 'border-yellow-200 bg-yellow-50 text-yellow-800';
case 'info':
return 'border-blue-200 bg-blue-50 text-blue-800';
case 'error':
default:
return 'border-red-200 bg-red-50 text-red-800';
}
};
// Get icon based on toast type
const getIcon = (type: string) => {
switch (type) {
case 'success':
return '✓';
case 'warning':
return '⚠';
case 'info':
return '';
case 'error':
default:
return '✕';
}
};
</script>
{#if error}
<div class="mt-4 rounded border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-600">{error}</p>
{#if visible && message}
<div
class="fixed top-4 left-4 z-50 max-w-sm rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out {getToastStyles(type)}"
role="alert"
aria-live="polite"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex-shrink-0">
<span class="text-lg font-semibold" aria-hidden="true">
{getIcon(type)}
</span>
</div>
<!-- Message -->
<div class="flex-1">
<p class="text-sm font-medium">
{message}
</p>
</div>
<!-- Close button -->
<button
onclick={dismiss}
class="flex-shrink-0 ml-2 text-current opacity-70 hover:opacity-100 transition-opacity"
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Progress bar for auto-dismiss -->
{#if duration > 0}
<div class="mt-2 h-1 w-full bg-black bg-opacity-10 rounded-full overflow-hidden">
<div
class="h-full bg-current opacity-30 transition-all ease-linear"
style="animation: progress {duration}ms linear forwards;"
></div>
</div>
{/if}
</div>
{/if}
<style>
@keyframes progress {
from {
width: 100%;
}
to {
width: 0%;
}
}
</style>

View File

@@ -6,10 +6,9 @@
sheet_id: string;
}
let { event, loading, error } = $props<{
let { event, loading } = $props<{
event: Event | null;
loading: boolean;
error: string;
}>();
function formatDate(dateString: string) {
@@ -80,9 +79,9 @@
</div>
</div>
</div>
{:else if error}
{:else}
<div class="py-8 text-center">
<p class="text-red-600">{error}</p>
<p class="text-gray-600">No event information available</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
message,
type = 'error',
duration = 5000,
onDismiss
} = $props<{
message: string;
type?: 'error' | 'success' | 'warning' | 'info';
duration?: number;
onDismiss?: () => void;
}>();
let visible = $state(true);
let timeoutId: ReturnType<typeof setTimeout>;
// Auto-dismiss after specified duration
onMount(() => {
if (duration > 0) {
timeoutId = setTimeout(() => {
dismiss();
}, duration);
}
// Cleanup timeout on component destroy
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function dismiss() {
visible = false;
if (onDismiss) {
onDismiss();
}
}
// Get styles based on toast type
const getToastStyles = (type: string) => {
const baseStyles = "fixed top-4 left-4 z-50 p-4 rounded-lg shadow-lg border max-w-sm";
switch (type) {
case 'success':
return `${baseStyles} bg-green-50 border-green-200 text-green-800`;
case 'warning':
return `${baseStyles} bg-yellow-50 border-yellow-200 text-yellow-800`;
case 'info':
return `${baseStyles} bg-blue-50 border-blue-200 text-blue-800`;
case 'error':
default:
return `${baseStyles} bg-red-50 border-red-200 text-red-800`;
}
};
const getIconSvg = (type: string) => {
switch (type) {
case 'success':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />`;
case 'warning':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />`;
case 'info':
return `<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />`;
case 'error':
default:
return `<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />`;
}
};
</script>
{#if visible}
<div class={getToastStyles(type)} role="alert">
<div class="flex items-start gap-3">
<!-- Icon -->
<svg
class="h-5 w-5 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
{@html getIconSvg(type)}
</svg>
<!-- Message -->
<div class="flex-1">
<p class="text-sm font-medium">{message}</p>
</div>
<!-- Close button -->
<button
onclick={dismiss}
class="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}