feat: add loading bar and toast notification system with responsive design

This commit is contained in:
2026-02-25 16:30:45 +00:00
parent e13e696253
commit fdeeac88e2
5 changed files with 316 additions and 0 deletions

View File

@@ -2,6 +2,8 @@
import { metricCall } from "./config"
import { location } from "./lib/store"
import { _, locale } from "./lib/i18n/index"
import LoadingBar from "./widgets/LoadingBar.svelte"
import ToastContainer from "./widgets/ToastContainer.svelte"
import {
SUPPORTED_LANGUAGES,
LANGUAGE_LABELS,
@@ -60,6 +62,9 @@
})
</script>
<LoadingBar />
<ToastContainer />
<header class="text-white p-4 bg-red-900">
<div class="container mx-auto flex flex-wrap items-center justify-between gap-2">
<a href={localizedPath("/")} class="text-xl font-bold shrink-0">Tibi Svelte Starter</a>

View File

@@ -0,0 +1,18 @@
/**
* Portal action — teleports an element to document.body.
* Usage: <div use:portal>…</div>
* SSR-safe: only runs when document is available.
*/
export function portal(node: HTMLElement) {
if (typeof document === "undefined") return
document.body.appendChild(node)
return {
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node)
}
},
}
}

85
frontend/src/lib/toast.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Toast Notification System
* Provides a simple toast notification API with auto-dismiss and responsive positioning.
*/
import { writable, derived } from "svelte/store"
export type ToastType = "success" | "error" | "warning" | "info"
export interface Toast {
id: string
message: string
type: ToastType
duration: number
createdAt: number
}
interface ToastStore {
toasts: Toast[]
}
const DEFAULT_DURATION = 3000 // 3 seconds
// Create the toast store
const toastStore = writable<ToastStore>({ toasts: [] })
/**
* Generate unique toast ID
*/
function generateToastId(): string {
return `toast-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* Add a new toast notification
*/
export function addToast(message: string, type: ToastType = "info", duration: number = DEFAULT_DURATION): string {
const id = generateToastId()
const toast: Toast = {
id,
message,
type,
duration,
createdAt: Date.now(),
}
toastStore.update((store) => ({
toasts: [...store.toasts, toast],
}))
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
removeToast(id)
}, duration)
}
return id
}
/**
* Remove a toast by ID
*/
export function removeToast(id: string): void {
toastStore.update((store) => ({
toasts: store.toasts.filter((t) => t.id !== id),
}))
}
/**
* Clear all toasts
*/
export function clearToasts(): void {
toastStore.set({ toasts: [] })
}
// Export derived readable store for reactive access
export const toasts = derived(toastStore, ($store) => $store.toasts)
// Export convenience object
export const toast = {
add: addToast,
remove: removeToast,
clear: clearToasts,
}

View File

@@ -0,0 +1,63 @@
<script lang="ts">
/**
* Indeterminate loading bar — shows when API requests are in flight.
* 300ms delay prevents flickering on fast requests.
*/
import { activeRequests } from "../lib/requestsStore"
let showLoading = $state(false)
let hideTimeout: ReturnType<typeof setTimeout>
let showTimeout: ReturnType<typeof setTimeout>
$effect(() => {
if ($activeRequests > 0) {
if (hideTimeout) clearTimeout(hideTimeout)
showTimeout = setTimeout(() => {
showLoading = true
}, 300)
} else {
if (showTimeout) clearTimeout(showTimeout)
hideTimeout = setTimeout(() => {
showLoading = false
}, 150)
}
return () => {
if (hideTimeout) clearTimeout(hideTimeout)
if (showTimeout) clearTimeout(showTimeout)
}
})
</script>
<div class="fixed top-0 left-0 right-0 h-1 z-50 bg-blue-600/10 loading-bar" class:visible={showLoading}>
<div class="h-full bg-blue-600 loading-bar-progress shadow-lg"></div>
</div>
<style>
.loading-bar {
opacity: 0;
transition: opacity 300ms ease-out;
pointer-events: none;
}
.loading-bar.visible {
opacity: 1;
}
.loading-bar-progress {
width: 100%;
animation: loadingBarSlide 2s ease-in-out infinite;
}
@keyframes loadingBarSlide {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
/**
* Toast Container
* Displays toast notifications with responsive positioning.
* Mobile: bottom-center, Desktop: top-right
*/
import { toasts, removeToast, type Toast } from "../lib/toast"
import { portal } from "../lib/actions/portal"
function getToastClasses(type: Toast["type"]): string {
const base = "flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg"
switch (type) {
case "success":
return `${base} bg-green-600 text-white`
case "error":
return `${base} bg-red-600 text-white`
case "warning":
return `${base} bg-amber-500 text-white`
case "info":
default:
return `${base} bg-gray-800 text-white`
}
}
function getIcon(type: Toast["type"]): string {
switch (type) {
case "success":
return "M9 12.75 11.25 15 15 9.75"
case "error":
return "M6 6L18 18M6 18L18 6"
case "warning":
return "M12 9v3.75m0 3v.008"
case "info":
default:
return "M12 9v3.75m0 3v.008"
}
}
</script>
{#if $toasts.length > 0}
<!-- Desktop: top-right -->
<div
use:portal
class="fixed top-4 right-4 z-50 flex-col gap-2 max-w-sm w-full hidden md:flex"
role="region"
aria-label="Notifications"
aria-live="polite"
>
{#each $toasts as t (t.id)}
<div class={getToastClasses(t.type)} role="alert">
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"></circle>
<path
d={getIcon(t.type)}
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<p class="text-sm flex-1">{t.message}</p>
<button
onclick={() => removeToast(t.id)}
class="shrink-0 p-1 hover:bg-white/20 rounded transition-colors"
aria-label="Close"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
></path>
</svg>
</button>
</div>
{/each}
</div>
<!-- Mobile: bottom-center -->
<div
use:portal
class="fixed bottom-20 left-4 right-4 z-50 flex flex-col gap-2 md:hidden"
role="region"
aria-label="Notifications"
aria-live="polite"
>
{#each $toasts as t (t.id)}
<div class={getToastClasses(t.type)} role="alert">
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"></circle>
<path
d={getIcon(t.type)}
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<p class="text-sm flex-1">{t.message}</p>
<button
onclick={() => removeToast(t.id)}
class="shrink-0 p-1 hover:bg-white/20 rounded transition-colors"
aria-label="Close"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
></path>
</svg>
</button>
</div>
{/each}
</div>
{/if}
<style>
/* Toast entrance animation */
div[role="alert"] {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Mobile: slide up from bottom */
:global(.md\:hidden) div[role="alert"] {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>