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, locationError,
isLocationLoading isLocationLoading
} from '$lib/stores/location'; } from '$lib/stores/location';
import { Skeleton } from './skeleton';
interface Props { interface Props {
class?: string; class?: string;
@@ -55,11 +56,9 @@
> >
<span class="icon {iconClass()}"> <span class="icon {iconClass()}">
{#if $isLocationLoading} {#if $isLocationLoading}
<svg viewBox="0 0 24 24" class="spin"> <div class="loading-skeleton">
<path <Skeleton class="h-5 w-5 rounded-full" />
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" </div>
/>
</svg>
{:else if $locationStatus === 'success'} {:else if $locationStatus === 'success'}
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path <path
@@ -189,9 +188,12 @@
height: 24px; height: 24px;
} }
.icon.loading svg { .loading-skeleton {
color: #3b82f6; display: flex;
fill: #3b82f6; align-items: center;
justify-content: center;
width: 20px;
height: 20px;
} }
.icon.success svg { .icon.success svg {

View File

@@ -9,6 +9,7 @@
locationActions locationActions
} from '$lib/stores/location'; } from '$lib/stores/location';
import LocationButton from './LocationButton.svelte'; import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton';
interface Props { interface Props {
style?: StyleSpecification; style?: StyleSpecification;
@@ -45,6 +46,22 @@
autoCenter = true autoCenter = true
}: Props = $props(); }: 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 // Reactive center and zoom based on location or props
const mapCenter = $derived( const mapCenter = $derived(
$coordinates && (autoCenter || $shouldZoomToLocation) $coordinates && (autoCenter || $shouldZoomToLocation)
@@ -75,24 +92,43 @@
</script> </script>
<div class="map-container {className}"> <div class="map-container {className}">
<MapLibre {style} center={mapCenter} zoom={mapZoom()}> {#if !mapReady}
{#if $coordinates} <div class="map-skeleton">
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}> <Skeleton class="h-full w-full rounded-xl" />
<div class="location-marker"> <div class="skeleton-overlay">
<div class="marker-pulse"></div> <Skeleton class="mb-2 h-4 w-16" />
<div class="marker-outer"> <Skeleton class="h-3 w-24" />
<div class="marker-inner"></div> </div>
</div>
</div>
</Marker>
{/if}
</MapLibre>
{#if showLocationButton}
<div class="location-controls">
<LocationButton variant="icon" size="medium" showLabel={false} />
</div> </div>
{/if} {/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> </div>
<style> <style>
@@ -102,6 +138,28 @@
width: 100%; 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) { .map-container :global(.maplibregl-map) {
margin: 0 auto; margin: 0 auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);

View File

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

View File

@@ -1,17 +1,23 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui'; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';
import { Skeleton } from '../skeleton';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
loading = false,
...restProps ...restProps
}: AvatarPrimitive.FallbackProps = $props(); }: AvatarPrimitive.FallbackProps & { loading?: boolean } = $props();
</script> </script>
<AvatarPrimitive.Fallback {#if loading}
bind:ref <Skeleton class={cn('size-full rounded-full', className)} />
data-slot="avatar-fallback" {:else}
class={cn('flex size-full items-center justify-center rounded-full bg-muted', className)} <AvatarPrimitive.Fallback
{...restProps} 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 Root from './skeleton.svelte';
import SkeletonVariants from './skeleton-variants.svelte';
export { export {
Root, 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 Map } from './components/Map.svelte';
export { default as LocationButton } from './components/LocationButton.svelte'; export { default as LocationButton } from './components/LocationButton.svelte';
// Skeleton Loading Components
export { Skeleton, SkeletonVariants } from './components/skeleton';
// Location utilities and stores // Location utilities and stores
export { geolocationService } from './utils/geolocation'; export { geolocationService } from './utils/geolocation';
export { export {

View File

@@ -4,10 +4,12 @@
import { Header } from '$lib'; import { Header } from '$lib';
import { page } from '$app/state'; import { page } from '$app/state';
import { Toaster } from '$lib/components/sonner/index.js'; import { Toaster } from '$lib/components/sonner/index.js';
import { Skeleton } from '$lib/components/skeleton';
let { children, data } = $props(); let { children, data } = $props();
let isLoginRoute = $derived(page.url.pathname.startsWith('/login')); let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
let showHeader = $derived(!isLoginRoute && data?.user); let showHeader = $derived(!isLoginRoute && data?.user);
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null);
</script> </script>
<svelte:head> <svelte:head>
@@ -18,6 +20,40 @@
{#if showHeader && data.user} {#if showHeader && data.user}
<Header user={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} {/if}
{@render children?.()} {@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>