feat:find and watch location, use shadcn sonner toast for errors.

- added tailwindcss with shadcn-svelte sonner toasts
- added a location store and a geolation util to find, watch, use and
store locations and their updates
- added a LocationButton to navigate to the current location and handle
errors.
- updated the Map to use LocationButton and go to current location.
This commit is contained in:
2025-10-03 15:03:49 +02:00
parent b44a69ba91
commit 0abf4f9737
15 changed files with 951 additions and 59 deletions

View File

@@ -0,0 +1,235 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import {
locationActions,
locationStatus,
locationError,
isLocationLoading
} from '$lib/stores/location';
interface Props {
class?: string;
variant?: 'primary' | 'secondary' | 'icon';
size?: 'small' | 'medium' | 'large';
showLabel?: boolean;
}
let {
class: className = '',
variant = 'primary',
size = 'medium',
showLabel = true
}: Props = $props();
async function handleLocationClick() {
const result = await locationActions.getCurrentLocation({
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 300000
});
if (!result && $locationError) {
toast.error($locationError.message);
}
}
const buttonText = $derived(() => {
if ($isLocationLoading) return 'Finding location...';
if ($locationStatus === 'success') return 'Update location';
return 'Find my location';
});
const iconClass = $derived(() => {
if ($isLocationLoading) return 'loading';
if ($locationStatus === 'success') return 'success';
if ($locationStatus === 'error') return 'error';
return 'default';
});
</script>
<button
class="location-button {variant} {size} {className}"
onclick={handleLocationClick}
disabled={$isLocationLoading}
title={buttonText()}
>
<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>
{:else if $locationStatus === 'success'}
<svg viewBox="0 0 24 24">
<path
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
/>
</svg>
{:else if $locationStatus === 'error'}
<svg viewBox="0 0 24 24">
<path
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
/>
</svg>
{:else}
<svg viewBox="0 0 24 24">
<path
d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z"
/>
</svg>
{/if}
</span>
{#if showLabel && variant !== 'icon'}
<span class="label">{buttonText()}</span>
{/if}
</button>
<style>
.location-button {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
transition: all 0.2s ease;
position: relative;
}
.location-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Variants */
.primary {
background: #2563eb;
color: white;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
}
.primary:hover:not(:disabled) {
background: #1d4ed8;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3);
}
.secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.secondary:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
transform: translateY(-1px);
}
.icon {
background: transparent;
color: #6b7280;
padding: 8px;
border-radius: 50%;
border: 1px solid #d1d5db;
}
.icon:hover:not(:disabled) {
background: #f3f4f6;
color: #374151;
}
/* Sizes */
.small {
padding: 6px 12px;
font-size: 14px;
}
.small.icon {
padding: 6px;
}
.medium {
padding: 8px 16px;
font-size: 16px;
}
.medium.icon {
padding: 8px;
}
.large {
padding: 12px 20px;
font-size: 18px;
}
.large.icon {
padding: 12px;
}
/* Icon styles */
.icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.small .icon svg {
width: 16px;
height: 16px;
}
.large .icon svg {
width: 24px;
height: 24px;
}
.icon.loading svg {
color: #3b82f6;
fill: #3b82f6;
}
.icon.success svg {
color: #10b981;
fill: #10b981;
}
.icon.error svg {
color: #ef4444;
fill: #ef4444;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.label {
white-space: nowrap;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.location-button .label {
display: none;
}
.location-button {
padding: 8px;
border-radius: 50%;
}
}
</style>

View File

@@ -1,12 +1,16 @@
<script lang="ts">
import { MapLibre } from 'svelte-maplibre';
import type { StyleSpecification } from 'svelte-maplibre';
import { coordinates, getMapCenter, getMapZoom } from '$lib/stores/location';
import LocationButton from './LocationButton.svelte';
interface Props {
style?: StyleSpecification;
center?: [number, number];
zoom?: number;
class?: string;
showLocationButton?: boolean;
autoCenter?: boolean;
}
let {
@@ -28,19 +32,110 @@
}
]
},
center = [0, 0],
zoom = 2,
class: className = ''
center,
zoom,
class: className = '',
showLocationButton = true,
autoCenter = true
}: Props = $props();
// Reactive center and zoom based on location or props
const mapCenter = $derived(
$coordinates && autoCenter
? ([$coordinates.longitude, $coordinates.latitude] as [number, number])
: center || $getMapCenter
);
const mapZoom = $derived($coordinates && autoCenter ? zoom || $getMapZoom : zoom || 13);
</script>
<div class="map-container {className}">
<MapLibre {style} {center} {zoom} />
<MapLibre {style} center={mapCenter} zoom={mapZoom} />
{#if showLocationButton}
<div class="location-controls">
<LocationButton variant="icon" size="medium" showLabel={false} />
</div>
{/if}
</div>
<style>
.map-container {
position: relative;
height: 400px;
width: 100%;
}
.map-container :global(.maplibregl-map) {
margin: 0 auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border-radius: 12px;
overflow: hidden;
}
.location-controls {
position: absolute;
top: 12px;
right: 12px;
z-index: 1000;
}
/* Location marker styles */
:global(.location-marker) {
width: 24px;
height: 24px;
cursor: pointer;
}
:global(.marker-outer) {
width: 24px;
height: 24px;
background: rgba(37, 99, 235, 0.2);
border: 2px solid #2563eb;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
:global(.marker-inner) {
width: 8px;
height: 8px;
background: #2563eb;
border-radius: 50%;
}
:global(.marker-pulse) {
position: absolute;
top: -2px;
left: -2px;
width: 24px;
height: 24px;
border: 2px solid rgba(37, 99, 235, 0.6);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
@media (max-width: 768px) {
.map-container {
height: 300px;
}
.location-controls {
top: 8px;
right: 8px;
}
}
</style>

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
import { mode } from 'mode-watcher';
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
/>
<style>
:global([data-sonner-toast][data-type='error']) {
background-color: #fef2f2 !important;
border-color: #ef4444 !important;
color: #dc2626 !important;
}
:global([data-sonner-toast][data-type='error'] [data-icon]) {
color: #ef4444 !important;
}
:global([data-sonner-toast][data-type='error'] [data-content]) {
color: #dc2626 !important;
font-weight: 500;
}
</style>