refactor(shared-ui): centralize toast system across all web apps

- Add toast module to @manacore/shared-ui (toastStore, ToastContainer)
- Remove local toast implementations from:
  - calendar/web
  - chat/web
  - clock/web
  - contacts/web
  - picture/web
  - storage/web
- Update all apps to import toast from shared-ui
- Consistent toast API: toast.success(), toast.error(), toast.info()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 14:03:29 +01:00
parent 6d0d9d4f67
commit 14ce457c7b
56 changed files with 487 additions and 1249 deletions

View file

@ -195,3 +195,7 @@ export type {
// Immersive Mode
export { default as ImmersiveModeToggle } from './components/ImmersiveModeToggle.svelte';
// Toast
export { toastStore, toast, handleApiError, ToastContainer } from './toast';
export type { Toast, ToastType } from './toast';

View file

@ -1,5 +1,14 @@
<script lang="ts">
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X, Focus, Keyboard } from 'lucide-svelte';
import {
MagnifyingGlass,
MagnifyingGlassPlus,
MagnifyingGlassMinus,
ArrowCounterClockwise,
Funnel,
X,
Crosshair,
Keyboard,
} from '@manacore/shared-icons';
import type { NetworkTag } from './network.types';
interface Props {
@ -126,7 +135,7 @@
<!-- Search bar -->
{#if showSearch}
<div class="search-container">
<Search size={18} class="search-icon" />
<MagnifyingGlass size={18} class="search-icon" />
<input
bind:this={searchInputElement}
type="text"
@ -152,7 +161,7 @@
aria-label="Filter anzeigen"
title="Filter"
>
<Filter size={18} />
<Funnel size={18} />
{#if hasActiveFilters}
<span class="filter-badge"></span>
{/if}
@ -162,7 +171,7 @@
<!-- Zoom controls -->
<div class="zoom-controls">
<button onclick={onZoomIn} class="control-btn" aria-label="Vergrößern" title="Vergrößern (+)">
<ZoomIn size={18} />
<MagnifyingGlassPlus size={18} />
</button>
<button
onclick={onZoomOut}
@ -170,7 +179,7 @@
aria-label="Verkleinern"
title="Verkleinern (-)"
>
<ZoomOut size={18} />
<MagnifyingGlassMinus size={18} />
</button>
<button
onclick={onResetZoom}
@ -178,7 +187,7 @@
aria-label="Ansicht zurücksetzen"
title="Zurücksetzen (0)"
>
<RotateCcw size={18} />
<ArrowCounterClockwise size={18} />
</button>
<button
onclick={onFocusSelected}
@ -186,7 +195,7 @@
aria-label="Auf Auswahl fokussieren"
title="Fokus auf Auswahl (F)"
>
<Focus size={18} />
<Crosshair size={18} />
</button>
</div>

View file

@ -0,0 +1,139 @@
<script lang="ts">
import { toastStore } from './toast.svelte';
import type { Toast } from './toast.svelte';
import { X, CheckCircle, XCircle, Warning, Info } from '@manacore/shared-icons';
let toasts = $derived(toastStore.toasts);
const icons = {
success: CheckCircle,
error: XCircle,
warning: Warning,
info: Info,
};
const colors = {
success: 'bg-green-500/90 text-white',
error: 'bg-destructive/90 text-destructive-foreground',
warning: 'bg-amber-500/90 text-white',
info: 'bg-primary/90 text-primary-foreground',
};
function handleDismiss(id: string) {
toastStore.dismiss(id);
}
</script>
{#if toasts.length > 0}
<div class="toast-container">
{#each toasts as toast (toast.id)}
{@const Icon = icons[toast.type]}
<div class="toast-item {colors[toast.type]}" role="alert">
<Icon size={20} weight="fill" class="toast-icon" />
<p class="toast-message">{toast.message}</p>
<button
onclick={() => handleDismiss(toast.id)}
class="toast-dismiss"
aria-label="Schließen"
>
<X size={16} weight="bold" />
</button>
</div>
{/each}
</div>
{/if}
<style>
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 24rem;
}
/* Mobile: full width at bottom */
@media (max-width: 640px) {
.toast-container {
left: 1rem;
right: 1rem;
bottom: 1rem;
max-width: none;
}
}
.toast-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
animation: toast-slide-in 0.3s ease-out;
}
.toast-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
.toast-message {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
margin: 0;
}
.toast-dismiss {
flex-shrink: 0;
padding: 0.25rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
opacity: 0.8;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
}
.toast-dismiss:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.2);
}
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Mobile: slide up instead of from right */
@media (max-width: 640px) {
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
</style>

View file

@ -0,0 +1,3 @@
export { toastStore, toast, handleApiError } from './toast.svelte';
export type { Toast, ToastType } from './toast.svelte';
export { default as ToastContainer } from './ToastContainer.svelte';

View file

@ -0,0 +1,146 @@
/**
* Toast Store - Centralized notification system using Svelte 5 runes
*
* Usage:
* ```ts
* import { toastStore } from '@manacore/shared-ui';
*
* // Show notifications
* toastStore.success('Saved successfully');
* toastStore.error('Something went wrong');
* toastStore.warning('Please check your input');
* toastStore.info('New update available');
*
* // Manual control
* const id = toastStore.show('Custom message', 'info', 5000);
* toastStore.dismiss(id);
* toastStore.dismissAll();
* ```
*/
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration: number;
}
// State
let toasts = $state<Toast[]>([]);
// Auto-incrementing ID with timestamp for uniqueness
let nextId = 0;
function generateId(): string {
return `toast-${++nextId}-${Date.now()}`;
}
export const toastStore = {
/**
* Get all active toasts (reactive)
*/
get toasts() {
return toasts;
},
/**
* Show a toast notification
* @param message - The message to display
* @param type - Toast type: 'success' | 'error' | 'warning' | 'info'
* @param duration - Duration in ms (0 = permanent, default: 4000)
* @returns The toast ID for manual dismissal
*/
show(message: string, type: ToastType = 'info', duration = 4000): string {
const id = generateId();
const toast: Toast = { id, type, message, duration };
toasts = [...toasts, toast];
// Auto-remove after duration (unless permanent)
if (duration > 0) {
setTimeout(() => {
this.dismiss(id);
}, duration);
}
return id;
},
/**
* Show a success toast (green)
* @param message - The message to display
* @param duration - Duration in ms (default: 4000)
*/
success(message: string, duration?: number): string {
return this.show(message, 'success', duration);
},
/**
* Show an error toast (red) - longer default duration
* @param message - The message to display
* @param duration - Duration in ms (default: 6000)
*/
error(message: string, duration = 6000): string {
return this.show(message, 'error', duration);
},
/**
* Show a warning toast (amber)
* @param message - The message to display
* @param duration - Duration in ms (default: 4000)
*/
warning(message: string, duration?: number): string {
return this.show(message, 'warning', duration);
},
/**
* Show an info toast (blue)
* @param message - The message to display
* @param duration - Duration in ms (default: 4000)
*/
info(message: string, duration?: number): string {
return this.show(message, 'info', duration);
},
/**
* Dismiss a specific toast by ID
* @param id - The toast ID to dismiss
*/
dismiss(id: string): void {
toasts = toasts.filter((t) => t.id !== id);
},
/**
* Dismiss all active toasts
*/
dismissAll(): void {
toasts = [];
},
};
/**
* Helper function for API error handling
* Shows an error toast and returns the error message
*
* @example
* ```ts
* try {
* await api.save(data);
* } catch (error) {
* handleApiError(error, 'Could not save data');
* }
* ```
*/
export function handleApiError(
error: unknown,
fallbackMessage = 'Ein Fehler ist aufgetreten'
): string {
const message = error instanceof Error ? error.message : fallbackMessage;
toastStore.error(message);
return message;
}
// Backwards compatible alias
export const toast = toastStore;