use:skeleton for loading in content
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Root from './skeleton.svelte';
|
||||
import SkeletonVariants from './skeleton-variants.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton
|
||||
Root as Skeleton,
|
||||
SkeletonVariants
|
||||
};
|
||||
|
||||
67
src/lib/components/skeleton/skeleton-variants.svelte
Normal file
67
src/lib/components/skeleton/skeleton-variants.svelte
Normal 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}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user