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:
2026-03-25 07:40:37 +01:00
parent f80ddb7e78
commit 45bc9fca29
13 changed files with 185 additions and 25 deletions
+77
View File
@@ -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>
+3 -2
View File
@@ -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;
}
+31
View File
@@ -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),
};