feat: add toast notification system, replace all alert() calls
Create shared toast store and Toast component mounted in root layout. Wire toast.error() into all fitness API calls that previously failed silently, and replace all alert() calls across recipes and cospend.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<script>
|
||||
import { X } from 'lucide-svelte';
|
||||
import { getToasts } from '$lib/js/toast.svelte';
|
||||
|
||||
const toasts = getToasts();
|
||||
</script>
|
||||
|
||||
{#if toasts.items.length > 0}
|
||||
<div class="toast-container">
|
||||
{#each toasts.items as t (t.id)}
|
||||
<div class="toast toast-{t.type}" role="alert">
|
||||
<span class="toast-msg">{t.message}</span>
|
||||
<button class="toast-close" onclick={() => toasts.remove(t.id)} aria-label="Dismiss">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: auto;
|
||||
animation: slide-up 0.2s ease-out;
|
||||
}
|
||||
.toast-error {
|
||||
background: var(--nord11);
|
||||
color: var(--nord6, #eceff4);
|
||||
}
|
||||
.toast-success {
|
||||
background: var(--nord14);
|
||||
color: var(--nord0, #2e3440);
|
||||
}
|
||||
.toast-info {
|
||||
background: var(--nord10);
|
||||
color: var(--nord6, #eceff4);
|
||||
}
|
||||
.toast-msg {
|
||||
flex: 1;
|
||||
}
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
}
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(0.5rem); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
|
||||
import Cross from '$lib/assets/icons/Cross.svelte'
|
||||
import { toast } from '$lib/js/toast.svelte'
|
||||
import "$lib/css/shake.css"
|
||||
import "$lib/css/icon.css"
|
||||
import { onMount } from 'svelte'
|
||||
@@ -32,14 +33,14 @@ function handleFileSelect(event: Event) {
|
||||
|
||||
// Validate MIME type
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
alert('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
|
||||
toast.error('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(`File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`);
|
||||
toast.error(`File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`);
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'error' | 'success' | 'info';
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
export function getToasts() {
|
||||
return {
|
||||
get items() { return toasts; },
|
||||
remove(id: number) {
|
||||
toasts = toasts.filter(t => t.id !== id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function add(message: string, type: Toast['type'], duration = 5000) {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { id, message, type }];
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter(t => t.id !== id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
error: (msg: string) => add(msg, 'error', 6000),
|
||||
success: (msg: string) => add(msg, 'success', 3000),
|
||||
info: (msg: string) => add(msg, 'info', 4000),
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { onNavigate } from '$app/navigation';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
let { children } = $props();
|
||||
|
||||
onNavigate((navigation) => {
|
||||
@@ -20,4 +21,5 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
{@render children()}
|
||||
<Toast />
|
||||
@@ -8,6 +8,7 @@
|
||||
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
|
||||
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
|
||||
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import '$lib/css/action_button.css';
|
||||
|
||||
@@ -109,11 +110,11 @@
|
||||
function prepareSubmit() {
|
||||
// Client-side validation
|
||||
if (!short_name.trim()) {
|
||||
alert('Bitte geben Sie einen Kurznamen ein');
|
||||
toast.error('Bitte geben Sie einen Kurznamen ein');
|
||||
return;
|
||||
}
|
||||
if (!card_data.name) {
|
||||
alert('Bitte geben Sie einen Namen ein');
|
||||
toast.error('Bitte geben Sie einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,7 +160,7 @@
|
||||
// Display form errors if any
|
||||
$effect(() => {
|
||||
if (form?.error) {
|
||||
alert(`Fehler: ${form.error}`);
|
||||
toast.error(form.error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { season } from '$lib/js/season_store';
|
||||
import { portions } from '$lib/js/portions_store';
|
||||
import '$lib/css/action_button.css';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
@@ -173,11 +174,11 @@
|
||||
function prepareSubmit() {
|
||||
// Client-side validation
|
||||
if (!short_name.trim()) {
|
||||
alert('Bitte geben Sie einen Kurznamen ein');
|
||||
toast.error('Bitte geben Sie einen Kurznamen ein');
|
||||
return;
|
||||
}
|
||||
if (!card_data.name) {
|
||||
alert('Bitte geben Sie einen Namen ein');
|
||||
toast.error('Bitte geben Sie einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@
|
||||
// Display form errors if any
|
||||
$effect(() => {
|
||||
if (form?.error) {
|
||||
alert(`Fehler: ${form.error}`);
|
||||
toast.error(form.error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
|
||||
@@ -88,7 +89,7 @@
|
||||
|
||||
payments = payments.filter((/** @type {any} */ p) => p._id !== paymentId);
|
||||
} catch (err) {
|
||||
alert('Error: ' + (err instanceof Error ? err.message : String(err)));
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
|
||||
import { getFrequencyDescription, formatNextExecution } from '$lib/utils/recurring';
|
||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
import { formatCurrency } from '$lib/utils/formatters';
|
||||
|
||||
@@ -53,7 +54,7 @@
|
||||
// Refresh the list
|
||||
await fetchRecurringPayments();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@
|
||||
// Refresh the list
|
||||
await fetchRecurringPayments();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { page as appPage } from '$app/stores';
|
||||
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang($appPage.url.pathname));
|
||||
|
||||
@@ -31,10 +32,11 @@
|
||||
page++;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/sessions?limit=50&skip=${sessions.length}`);
|
||||
if (!res.ok) { toast.error('Failed to load sessions'); loading = false; return; }
|
||||
const data = await res.json();
|
||||
sessions = [...sessions, ...(data.sessions ?? [])];
|
||||
total = data.total ?? total;
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to load sessions'); }
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge, Flame, Info } from 'lucide-svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
const sl = $derived(fitnessSlugs(lang));
|
||||
@@ -166,8 +167,11 @@
|
||||
editing = false;
|
||||
editData = null;
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to save session');
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to save session'); }
|
||||
saving = false;
|
||||
}
|
||||
|
||||
@@ -262,8 +266,11 @@
|
||||
const res = await fetch(`/api/fitness/sessions/${session._id}/recalculate`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to recalculate');
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to recalculate'); }
|
||||
recalculating = false;
|
||||
}
|
||||
|
||||
@@ -274,8 +281,11 @@
|
||||
const res = await fetch(`/api/fitness/sessions/${session._id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
await goto(`/fitness/${sl.history}`);
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to delete session');
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to delete session'); }
|
||||
deleting = false;
|
||||
}
|
||||
|
||||
@@ -483,8 +493,11 @@
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to upload GPX');
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to upload GPX'); }
|
||||
uploading = -1;
|
||||
};
|
||||
input.click();
|
||||
@@ -501,8 +514,11 @@
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to remove GPX');
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to remove GPX'); }
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { Pencil, Trash2 } from 'lucide-svelte';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
@@ -186,7 +187,7 @@
|
||||
async function refreshLatest() {
|
||||
try {
|
||||
const latestRes = await fetch('/api/fitness/measurements/latest');
|
||||
latest = await latestRes.json();
|
||||
if (latestRes.ok) latest = await latestRes.json();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -207,6 +208,9 @@
|
||||
await refreshLatest();
|
||||
showForm = false;
|
||||
resetForm();
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to save measurement');
|
||||
}
|
||||
} else {
|
||||
const res = await fetch('/api/fitness/measurements', {
|
||||
@@ -220,9 +224,12 @@
|
||||
await refreshLatest();
|
||||
showForm = false;
|
||||
resetForm();
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to save measurement');
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to save measurement'); }
|
||||
saving = false;
|
||||
}
|
||||
|
||||
@@ -238,8 +245,11 @@
|
||||
showForm = false;
|
||||
resetForm();
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to delete measurement');
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to delete measurement'); }
|
||||
}
|
||||
|
||||
/** @param {string} dateStr */
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
|
||||
@@ -59,8 +60,11 @@
|
||||
goalWeekly = d.weeklyWorkouts;
|
||||
goalStreak = d.streak;
|
||||
goalEditing = false;
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to save goal');
|
||||
}
|
||||
} finally {
|
||||
} catch { toast.error('Failed to save goal'); } finally {
|
||||
goalSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
const sl = $derived(fitnessSlugs(lang));
|
||||
@@ -198,6 +199,9 @@
|
||||
if (res.ok) {
|
||||
const { template } = await res.json();
|
||||
templates = templates.map((t) => t._id === template._id ? { ...template, lastUsed: t.lastUsed } : t);
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to save template');
|
||||
}
|
||||
} else {
|
||||
const res = await fetch('/api/fitness/templates', {
|
||||
@@ -208,10 +212,13 @@
|
||||
if (res.ok) {
|
||||
const { template } = await res.json();
|
||||
templates = [...templates, template];
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to save template');
|
||||
}
|
||||
}
|
||||
closeEditor();
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to save template'); }
|
||||
editorSaving = false;
|
||||
}
|
||||
|
||||
@@ -227,8 +234,11 @@
|
||||
scheduleOrder = scheduleOrder.filter((id) => id !== template._id);
|
||||
saveSchedule(scheduleOrder);
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to delete template');
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to delete template'); }
|
||||
}
|
||||
|
||||
// Schedule editor functions
|
||||
@@ -273,8 +283,11 @@
|
||||
const schedRes = await fetch('/api/fitness/schedule');
|
||||
const schedData = await schedRes.json();
|
||||
nextTemplateId = schedData.nextTemplateId ?? null;
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.error ?? 'Failed to save schedule');
|
||||
}
|
||||
} catch {}
|
||||
} catch { toast.error('Failed to save schedule'); }
|
||||
}
|
||||
|
||||
async function saveAndCloseSchedule() {
|
||||
|
||||
Reference in New Issue
Block a user