✨ 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 { 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>
|
||||
|
||||
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