use:skeleton for loading in content

This commit is contained in:
2025-10-03 15:58:38 +02:00
parent d82f590fab
commit 5cb0b9eb53
8 changed files with 265 additions and 56 deletions

View File

@@ -6,6 +6,7 @@
locationError,
isLocationLoading
} from '$lib/stores/location';
import { Skeleton } from './skeleton';
interface Props {
class?: string;
@@ -55,11 +56,9 @@
>
<span class="icon {iconClass()}">
{#if $isLocationLoading}
<svg viewBox="0 0 24 24" class="spin">
<path
d="M12,4a8,8 0 0,1 7.89,6.7 1.53,1.53 0 0,0 1.49,1.3 1.5,1.5 0 0,0 1.48-1.75 11,11 0 0,0-21.72,0A1.5,1.5 0 0,0 2.62,11.25 1.53,1.53 0 0,0 4.11,10.7 8,8 0 0,1 12,4Z"
/>
</svg>
<div class="loading-skeleton">
<Skeleton class="h-5 w-5 rounded-full" />
</div>
{:else if $locationStatus === 'success'}
<svg viewBox="0 0 24 24">
<path
@@ -189,9 +188,12 @@
height: 24px;
}
.icon.loading svg {
color: #3b82f6;
fill: #3b82f6;
.loading-skeleton {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.icon.success svg {

View File

@@ -9,6 +9,7 @@
locationActions
} from '$lib/stores/location';
import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton';
interface Props {
style?: StyleSpecification;
@@ -45,6 +46,22 @@
autoCenter = true
}: Props = $props();
let mapLoaded = $state(false);
let styleLoaded = $state(false);
let isIdle = $state(false);
// Handle comprehensive map loading events
function handleStyleLoad() {
styleLoaded = true;
}
function handleIdle() {
isIdle = true;
}
// Map is considered fully ready when it's loaded, style is loaded, and it's idle
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
// Reactive center and zoom based on location or props
const mapCenter = $derived(
$coordinates && (autoCenter || $shouldZoomToLocation)
@@ -75,24 +92,43 @@
</script>
<div class="map-container {className}">
<MapLibre {style} center={mapCenter} zoom={mapZoom()}>
{#if $coordinates}
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
<div class="location-marker">
<div class="marker-pulse"></div>
<div class="marker-outer">
<div class="marker-inner"></div>
</div>
</div>
</Marker>
{/if}
</MapLibre>
{#if showLocationButton}
<div class="location-controls">
<LocationButton variant="icon" size="medium" showLabel={false} />
{#if !mapReady}
<div class="map-skeleton">
<Skeleton class="h-full w-full rounded-xl" />
<div class="skeleton-overlay">
<Skeleton class="mb-2 h-4 w-16" />
<Skeleton class="h-3 w-24" />
</div>
</div>
{/if}
<div class="map-wrapper" class:hidden={!mapReady}>
<MapLibre
{style}
center={mapCenter}
zoom={mapZoom()}
bind:loaded={mapLoaded}
onstyleload={handleStyleLoad}
onidle={handleIdle}
>
{#if $coordinates}
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
<div class="location-marker">
<div class="marker-pulse"></div>
<div class="marker-outer">
<div class="marker-inner"></div>
</div>
</div>
</Marker>
{/if}
</MapLibre>
{#if showLocationButton}
<div class="location-controls">
<LocationButton variant="icon" size="medium" showLabel={false} />
</div>
{/if}
</div>
</div>
<style>
@@ -102,6 +138,28 @@
width: 100%;
}
.map-skeleton {
position: relative;
width: 100%;
height: 100%;
}
.skeleton-overlay {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
}
.map-wrapper {
width: 100%;
height: 100%;
}
.map-wrapper.hidden {
display: none;
}
.map-container :global(.maplibregl-map) {
margin: 0 auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);

View File

@@ -8,13 +8,15 @@
DropdownMenuTrigger
} from './dropdown-menu';
import { Avatar, AvatarFallback } from './avatar';
import { Skeleton } from './skeleton';
interface Props {
username: string;
id: string;
loading?: boolean;
}
let { username, id }: Props = $props();
let { username, id, loading = false }: Props = $props();
// Get the first letter of username for avatar
const initial = username.charAt(0).toUpperCase();
@@ -22,37 +24,65 @@
<DropdownMenu>
<DropdownMenuTrigger class="profile-trigger">
<Avatar class="profile-avatar">
<AvatarFallback class="profile-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
{#if loading}
<Skeleton class="h-10 w-10 rounded-full" />
{:else}
<Avatar class="profile-avatar">
<AvatarFallback class="profile-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
{/if}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="profile-dropdown-content">
<div class="profile-header">
<span class="profile-title">Profile</span>
</div>
{#if loading}
<div class="profile-header">
<Skeleton class="h-4 w-16" />
</div>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<div class="user-info-item">
<span class="info-label">Username</span>
<span class="info-value">{username}</span>
</div>
<div class="user-info-item">
<Skeleton class="mb-1 h-3 w-12" />
<Skeleton class="h-3 w-20" />
</div>
<div class="user-info-item">
<span class="info-label">User ID</span>
<span class="info-value">{id}</span>
</div>
<div class="user-info-item">
<Skeleton class="mb-1 h-3 w-12" />
<Skeleton class="h-3 w-24" />
</div>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<form method="post" action="/logout" use:enhance>
<DropdownMenuItem variant="destructive" class="logout-item">
<button type="submit" class="logout-button">Sign out</button>
</DropdownMenuItem>
</form>
<div class="logout-item-skeleton">
<Skeleton class="h-8 w-full rounded" />
</div>
{:else}
<div class="profile-header">
<span class="profile-title">Profile</span>
</div>
<DropdownMenuSeparator />
<div class="user-info-item">
<span class="info-label">Username</span>
<span class="info-value">{username}</span>
</div>
<div class="user-info-item">
<span class="info-label">User ID</span>
<span class="info-value">{id}</span>
</div>
<DropdownMenuSeparator />
<form method="post" action="/logout" use:enhance>
<DropdownMenuItem variant="destructive" class="logout-item">
<button type="submit" class="logout-button">Sign out</button>
</DropdownMenuItem>
</form>
{/if}
</DropdownMenuContent>
</DropdownMenu>
@@ -131,6 +161,11 @@
margin: 4px;
}
.logout-item-skeleton {
padding: 0;
margin: 4px;
}
.logout-button {
background: none;
border: none;

View File

@@ -1,17 +1,23 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Skeleton } from '../skeleton';
let {
ref = $bindable(null),
class: className,
loading = false,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
}: AvatarPrimitive.FallbackProps & { loading?: boolean } = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn('flex size-full items-center justify-center rounded-full bg-muted', className)}
{...restProps}
/>
{#if loading}
<Skeleton class={cn('size-full rounded-full', className)} />
{:else}
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn('flex size-full items-center justify-center rounded-full bg-muted', className)}
{...restProps}
/>
{/if}

View File

@@ -1,7 +1,9 @@
import Root from './skeleton.svelte';
import SkeletonVariants from './skeleton-variants.svelte';
export {
Root,
//
Root as Skeleton
Root as Skeleton,
SkeletonVariants
};

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import Skeleton from './skeleton.svelte';
import { cn } from '$lib/utils.js';
interface Props {
variant: 'map' | 'profile' | 'card' | 'list-item' | 'avatar-card';
class?: string;
}
let { variant, class: className }: Props = $props();
</script>
{#if variant === 'map'}
<div class={cn('relative h-full w-full', className)}>
<Skeleton class="h-full w-full rounded-xl" />
<div class="absolute top-4 left-4">
<Skeleton class="mb-2 h-4 w-16" />
<Skeleton class="h-3 w-24" />
</div>
<div class="absolute top-4 right-4">
<Skeleton class="h-10 w-10 rounded-lg" />
</div>
</div>
{:else if variant === 'profile'}
<div class={cn('flex items-center space-x-3', className)}>
<Skeleton class="h-10 w-10 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-3 w-16" />
</div>
</div>
{:else if variant === 'card'}
<div class={cn('space-y-3 rounded-lg border p-4', className)}>
<div class="flex items-center space-x-3">
<Skeleton class="h-8 w-8 rounded-full" />
<div class="space-y-1">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-3 w-16" />
</div>
</div>
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-4 w-1/2" />
</div>
{:else if variant === 'list-item'}
<div class={cn('flex items-center space-x-3 py-2', className)}>
<Skeleton class="h-6 w-6 rounded" />
<div class="flex-1 space-y-1">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
<Skeleton class="h-8 w-16 rounded" />
</div>
{:else if variant === 'avatar-card'}
<div class={cn('flex items-start space-x-4 rounded-lg border p-4', className)}>
<Skeleton class="h-12 w-12 rounded-full" />
<div class="flex-1 space-y-2">
<Skeleton class="h-5 w-32" />
<Skeleton class="h-4 w-24" />
<div class="space-y-1">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-5/6" />
<Skeleton class="h-4 w-4/6" />
</div>
</div>
</div>
{/if}

View File

@@ -8,6 +8,9 @@ export { default as Modal } from './components/Modal.svelte';
export { default as Map } from './components/Map.svelte';
export { default as LocationButton } from './components/LocationButton.svelte';
// Skeleton Loading Components
export { Skeleton, SkeletonVariants } from './components/skeleton';
// Location utilities and stores
export { geolocationService } from './utils/geolocation';
export {

View File

@@ -4,10 +4,12 @@
import { Header } from '$lib';
import { page } from '$app/state';
import { Toaster } from '$lib/components/sonner/index.js';
import { Skeleton } from '$lib/components/skeleton';
let { children, data } = $props();
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
let showHeader = $derived(!isLoginRoute && data?.user);
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null);
</script>
<svelte:head>
@@ -18,6 +20,40 @@
{#if showHeader && data.user}
<Header user={data.user} />
{:else if isLoading}
<header class="header-skeleton">
<div class="header-content">
<Skeleton class="h-8 w-32" />
<Skeleton class="h-10 w-10 rounded-full" />
</div>
</header>
{/if}
{@render children?.()}
<style>
.header-skeleton {
border-bottom: 1px solid #e5e7eb;
background: white;
padding: 0 20px;
height: 64px;
display: flex;
align-items: center;
}
.header-content {
max-width: 1200px;
width: 100%;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
@media (max-width: 768px) {
.header-skeleton {
padding: 0 16px;
height: 56px;
}
}
</style>