✨ feat: add loading bar and toast notification system with responsive design
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
import { metricCall } from "./config"
|
import { metricCall } from "./config"
|
||||||
import { location } from "./lib/store"
|
import { location } from "./lib/store"
|
||||||
import { _, locale } from "./lib/i18n/index"
|
import { _, locale } from "./lib/i18n/index"
|
||||||
|
import LoadingBar from "./widgets/LoadingBar.svelte"
|
||||||
|
import ToastContainer from "./widgets/ToastContainer.svelte"
|
||||||
import {
|
import {
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
LANGUAGE_LABELS,
|
LANGUAGE_LABELS,
|
||||||
@@ -60,6 +62,9 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<LoadingBar />
|
||||||
|
<ToastContainer />
|
||||||
|
|
||||||
<header class="text-white p-4 bg-red-900">
|
<header class="text-white p-4 bg-red-900">
|
||||||
<div class="container mx-auto flex flex-wrap items-center justify-between gap-2">
|
<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>
|
<a href={localizedPath("/")} class="text-xl font-bold shrink-0">Tibi Svelte Starter</a>
|
||||||
|
|||||||
18
frontend/src/lib/actions/portal.ts
Normal file
18
frontend/src/lib/actions/portal.ts
Normal 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
85
frontend/src/lib/toast.ts
Normal 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,
|
||||||
|
}
|
||||||
63
frontend/src/widgets/LoadingBar.svelte
Normal file
63
frontend/src/widgets/LoadingBar.svelte
Normal 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>
|
||||||
145
frontend/src/widgets/ToastContainer.svelte
Normal file
145
frontend/src/widgets/ToastContainer.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user