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
+63
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>
+145
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>