Better error norifications
This commit is contained in:
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -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!
|
||||
|
||||
|
||||
25
src/lib/components/ToastContainer.svelte
Normal file
25
src/lib/components/ToastContainer.svelte
Normal 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>
|
||||
115
src/lib/components/ToastNotification.svelte
Normal file
115
src/lib/components/ToastNotification.svelte
Normal 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
63
src/lib/stores/toast.ts
Normal 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();
|
||||
@@ -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">
|
||||
|
||||
<div class="container mx-auto max-w-2xl bg-white p-2">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<slot />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -431,7 +448,7 @@
|
||||
}}
|
||||
/>
|
||||
{:else if currentStep === 1}
|
||||
<EventDetailsStep bind:eventData bind:errors />
|
||||
<EventDetailsStep bind:eventData />
|
||||
{:else if currentStep === 2}
|
||||
<GoogleSheetsStep bind:sheetsData bind:errors {loadRecentSheets} {selectSheet} {toggleSheetList} />
|
||||
{:else if currentStep === 3}
|
||||
|
||||
@@ -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,7 @@
|
||||
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}
|
||||
<!-- Errors now shown as toast notifications -->
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}>();
|
||||
@@ -23,10 +22,6 @@
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
{#if errors.google}
|
||||
<div class="mt-4 text-sm text-red-600">
|
||||
{errors.google}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Error messages are now shown as toast notifications -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 } = $props<{
|
||||
sheetsData: {
|
||||
availableSheets: GoogleSheet[];
|
||||
selectedSheet: GoogleSheet | null;
|
||||
@@ -16,7 +16,6 @@
|
||||
loading: boolean;
|
||||
expandedSheetList: boolean;
|
||||
};
|
||||
errors: Record<string, string>;
|
||||
loadRecentSheets: () => Promise<void>;
|
||||
selectSheet: (sheet: GoogleSheet) => Promise<void>;
|
||||
toggleSheetList: () => void;
|
||||
@@ -257,9 +256,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if errors.sheet}
|
||||
<p class="mt-2 text-sm text-red-600">{errors.sheet}</p>
|
||||
{/if}
|
||||
<!-- Error messages are now shown as toast notifications -->
|
||||
</div>
|
||||
|
||||
{#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0}
|
||||
@@ -374,9 +371,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if errors.sheetData}
|
||||
<p class="text-sm text-red-600">{errors.sheetData}</p>
|
||||
{/if}
|
||||
<!-- Error messages are now shown as toast notifications -->
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user