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

16
components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -21,11 +21,14 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@lucide/svelte": "^0.544.0",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^22", "@types/node": "^22",
"clsx": "^2.1.1",
"drizzle-kit": "^0.30.2", "drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.40.0", "drizzle-orm": "^0.40.0",
"eslint": "^9.22.0", "eslint": "^9.22.0",
@@ -33,11 +36,17 @@
"eslint-plugin-storybook": "^9.1.8", "eslint-plugin-storybook": "^9.1.8",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.20.0", "typescript-eslint": "^8.20.0",
"vite": "^7.0.4" "vite": "^7.0.4"

View File

@@ -1,54 +1,121 @@
@font-face { @import "tailwindcss";
font-family: 'Washington';
src: url('/fonts/Washington.ttf') format('truetype'); @import "tw-animate-css";
font-weight: normal;
font-style: normal; @custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
} }
* { .dark {
box-sizing: border-box; --background: oklch(0.141 0.005 285.823);
margin: 0; --foreground: oklch(0.985 0 0);
padding: 0; --card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
} }
body { @theme inline {
font-family: --radius-sm: calc(var(--radius) - 4px);
system-ui, --radius-md: calc(var(--radius) - 2px);
-apple-system, --radius-lg: var(--radius);
BlinkMacSystemFont, --radius-xl: calc(var(--radius) + 4px);
'Segoe UI', --color-background: var(--background);
Roboto, --color-foreground: var(--foreground);
sans-serif; --color-card: var(--card);
line-height: 1.5; --color-card-foreground: var(--card-foreground);
background-color: #f8f8f8; --color-popover: var(--popover);
color: #333; --color-popover-foreground: var(--popover-foreground);
margin: 0 auto; --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
} }
h1 { @layer base {
font-family: 'Washington', serif; * {
font-weight: normal; @apply border-border outline-ring/50;
} }
body {
h2, @apply bg-background text-foreground;
h3, }
h4,
h5,
h6 {
font-family: 'Times New Roman', Times, serif;
font-weight: normal;
}
button {
cursor: pointer;
font-family: inherit;
}
input {
font-family: inherit;
}
a {
color: inherit;
text-decoration: none;
} }

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"> <script lang="ts">
import { MapLibre } from 'svelte-maplibre'; import { MapLibre } from 'svelte-maplibre';
import type { StyleSpecification } from 'svelte-maplibre'; import type { StyleSpecification } from 'svelte-maplibre';
import { coordinates, getMapCenter, getMapZoom } from '$lib/stores/location';
import LocationButton from './LocationButton.svelte';
interface Props { interface Props {
style?: StyleSpecification; style?: StyleSpecification;
center?: [number, number]; center?: [number, number];
zoom?: number; zoom?: number;
class?: string; class?: string;
showLocationButton?: boolean;
autoCenter?: boolean;
} }
let { let {
@@ -28,19 +32,110 @@
} }
] ]
}, },
center = [0, 0], center,
zoom = 2, zoom,
class: className = '' class: className = '',
showLocationButton = true,
autoCenter = true
}: Props = $props(); }: 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> </script>
<div class="map-container {className}"> <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> </div>
<style> <style>
.map-container {
position: relative;
height: 400px;
width: 100%;
}
.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);
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> </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>

View File

@@ -7,3 +7,8 @@ export { default as ProfilePanel } from './components/ProfilePanel.svelte';
export { default as Header } from './components/Header.svelte'; export { default as Header } from './components/Header.svelte';
export { default as Modal } from './components/Modal.svelte'; 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';
// Location utilities and stores
export { geolocationService } from './utils/geolocation';
export { locationActions, locationStore, coordinates, locationStatus, locationError, isLocationLoading, hasLocationAccess, getMapCenter, getMapZoom } from './stores/location';

184
src/lib/stores/location.ts Normal file
View File

@@ -0,0 +1,184 @@
import { writable, derived } from 'svelte/store';
import { geolocationService, type LocationCoordinates, type LocationError, type LocationStatus } from '$lib/utils/geolocation';
interface LocationState {
coordinates: LocationCoordinates | null;
status: LocationStatus;
error: LocationError | null;
isWatching: boolean;
lastUpdated: Date | null;
}
const initialState: LocationState = {
coordinates: null,
status: 'idle',
error: null,
isWatching: false,
lastUpdated: null
};
// Main location store
export const locationStore = writable<LocationState>(initialState);
// Derived stores for easier access
export const coordinates = derived(locationStore, ($location) => $location.coordinates);
export const locationStatus = derived(locationStore, ($location) => $location.status);
export const locationError = derived(locationStore, ($location) => $location.error);
export const isLocationLoading = derived(locationStore, ($location) => $location.status === 'loading');
export const hasLocationAccess = derived(locationStore, ($location) => $location.coordinates !== null);
// Location actions
export const locationActions = {
/**
* Get current position once
*/
async getCurrentLocation(options?: PositionOptions): Promise<LocationCoordinates | null> {
locationStore.update(state => ({
...state,
status: 'loading',
error: null
}));
try {
const coordinates = await geolocationService.getCurrentPosition(options);
locationStore.update(state => ({
...state,
coordinates,
status: 'success',
error: null,
lastUpdated: new Date()
}));
return coordinates;
} catch (error) {
const locationError = error as LocationError;
locationStore.update(state => ({
...state,
status: 'error',
error: locationError,
lastUpdated: new Date()
}));
return null;
}
},
/**
* Start watching position changes
*/
startWatching(options?: PositionOptions): void {
if (!geolocationService.isSupported()) {
locationStore.update(state => ({
...state,
status: 'error',
error: {
code: -1,
message: 'Geolocation is not supported by this browser'
}
}));
return;
}
locationStore.update(state => ({
...state,
status: 'loading',
isWatching: true,
error: null
}));
geolocationService.watchPosition(
(coordinates) => {
locationStore.update(state => ({
...state,
coordinates,
status: 'success',
error: null,
lastUpdated: new Date()
}));
},
(error) => {
locationStore.update(state => ({
...state,
status: 'error',
error,
lastUpdated: new Date()
}));
},
options
);
},
/**
* Stop watching position changes
*/
stopWatching(): void {
geolocationService.clearWatch();
locationStore.update(state => ({
...state,
isWatching: false
}));
},
/**
* Clear location data and reset state
*/
clearLocation(): void {
geolocationService.clearWatch();
locationStore.set(initialState);
},
/**
* Set coordinates manually (useful for testing or setting default location)
*/
setCoordinates(coordinates: LocationCoordinates): void {
locationStore.update(state => ({
...state,
coordinates,
status: 'success',
error: null,
lastUpdated: new Date()
}));
},
/**
* Check if location data is stale (older than specified minutes)
*/
isLocationStale(maxAgeMinutes: number = 5): boolean {
let currentState: LocationState = initialState;
const unsubscribe = locationStore.subscribe(state => {
currentState = state;
});
unsubscribe();
if (!currentState.lastUpdated) return true;
const ageInMinutes = (Date.now() - currentState.lastUpdated.getTime()) / (1000 * 60);
return ageInMinutes > maxAgeMinutes;
}
};
// Utility function to get map center coordinates
export const getMapCenter = derived(coordinates, ($coordinates) => {
if ($coordinates) {
return [$coordinates.longitude, $coordinates.latitude] as [number, number];
}
// Default to a reasonable center (e.g., London)
return [0, 51.505] as [number, number];
});
// Utility function to get appropriate zoom level based on accuracy
export const getMapZoom = derived(coordinates, ($coordinates) => {
if ($coordinates?.accuracy) {
// Adjust zoom based on accuracy (lower accuracy = lower zoom)
if ($coordinates.accuracy < 10) return 18; // Very accurate
if ($coordinates.accuracy < 50) return 16; // Good accuracy
if ($coordinates.accuracy < 100) return 14; // Moderate accuracy
if ($coordinates.accuracy < 500) return 12; // Low accuracy
return 10; // Very low accuracy
}
return 13; // Default zoom level
});

13
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

View File

@@ -0,0 +1,208 @@
export interface LocationCoordinates {
latitude: number;
longitude: number;
accuracy?: number;
}
export interface LocationError {
code: number;
message: string;
}
export type LocationStatus = 'idle' | 'loading' | 'success' | 'error';
export class GeolocationService {
private static instance: GeolocationService;
private currentPosition: LocationCoordinates | null = null;
private status: LocationStatus = 'idle';
private error: LocationError | null = null;
private watchId: number | null = null;
static getInstance(): GeolocationService {
if (!GeolocationService.instance) {
GeolocationService.instance = new GeolocationService();
}
return GeolocationService.instance;
}
/**
* Check if geolocation is supported by the browser
*/
isSupported(): boolean {
return 'geolocation' in navigator;
}
/**
* Get current position once
*/
async getCurrentPosition(options?: PositionOptions): Promise<LocationCoordinates> {
if (!this.isSupported()) {
throw new Error('Geolocation is not supported by this browser');
}
this.status = 'loading';
this.error = null;
const defaultOptions: PositionOptions = {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 300000, // 5 minutes
...options
};
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(position) => {
const coordinates: LocationCoordinates = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
};
this.currentPosition = coordinates;
this.status = 'success';
resolve(coordinates);
},
(error) => {
const locationError: LocationError = {
code: error.code,
message: this.getErrorMessage(error.code)
};
this.error = locationError;
this.status = 'error';
reject(locationError);
},
defaultOptions
);
});
}
/**
* Watch position changes
*/
watchPosition(
onSuccess: (coordinates: LocationCoordinates) => void,
onError?: (error: LocationError) => void,
options?: PositionOptions
): number | null {
if (!this.isSupported()) {
const error: LocationError = {
code: -1,
message: 'Geolocation is not supported by this browser'
};
onError?.(error);
return null;
}
const defaultOptions: PositionOptions = {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 60000, // 1 minute for watch
...options
};
this.watchId = navigator.geolocation.watchPosition(
(position) => {
const coordinates: LocationCoordinates = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
};
this.currentPosition = coordinates;
this.status = 'success';
onSuccess(coordinates);
},
(error) => {
const locationError: LocationError = {
code: error.code,
message: this.getErrorMessage(error.code)
};
this.error = locationError;
this.status = 'error';
onError?.(locationError);
},
defaultOptions
);
return this.watchId;
}
/**
* Stop watching position
*/
clearWatch(): void {
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
}
/**
* Get the last known position
*/
getLastKnownPosition(): LocationCoordinates | null {
return this.currentPosition;
}
/**
* Get current status
*/
getStatus(): LocationStatus {
return this.status;
}
/**
* Get current error
*/
getError(): LocationError | null {
return this.error;
}
/**
* Convert error code to human-readable message
*/
private getErrorMessage(code: number): string {
switch (code) {
case 1: // PERMISSION_DENIED
return 'Location access denied by user. Please enable location permissions to use this feature.';
case 2: // POSITION_UNAVAILABLE
return 'Location information is unavailable. Please check your device settings.';
case 3: // TIMEOUT
return 'Location request timed out. Please try again.';
default:
return 'An unknown error occurred while retrieving location.';
}
}
/**
* Calculate distance between two coordinates in kilometers
*/
static calculateDistance(
coord1: LocationCoordinates,
coord2: LocationCoordinates
): number {
const R = 6371; // Earth's radius in kilometers
const dLat = this.toRadians(coord2.latitude - coord1.latitude);
const dLon = this.toRadians(coord2.longitude - coord1.longitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(coord1.latitude)) *
Math.cos(this.toRadians(coord2.latitude)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private static toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
}
// Export singleton instance for easy use
export const geolocationService = GeolocationService.getInstance();

View File

@@ -3,9 +3,9 @@
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
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';
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);
</script> </script>
@@ -14,6 +14,8 @@
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
<Toaster />
{#if showHeader && data.user} {#if showHeader && data.user}
<Header user={data.user} /> <Header user={data.user} />
{/if} {/if}

View File

@@ -4,24 +4,49 @@
<div class="home-container"> <div class="home-container">
<main class="main-content"> <main class="main-content">
<Map /> <div class="map-section">
<Map showLocationButton={true} autoCenter={true} />
</div>
</main> </main>
</div> </div>
<style> <style>
.home-container { .home-container {
background-color: #f8f8f8; background-color: #f8f8f8;
min-height: 100vh;
} }
.main-content { .main-content {
padding: 24px 20px; padding: 24px 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.map-section {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.map-section :global(.map-container) {
height: 500px;
border-radius: 0;
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
gap: 16px;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.main-content { .main-content {
padding: 20px 16px; padding: 12px;
} }
} }
</style> </style>

View File

@@ -14,6 +14,9 @@ const config = {
adapter: adapter(), adapter: adapter(),
csrf: { csrf: {
trustedOrigins: ['http://localhost:3000', 'https://serengo.ziasvannes.tech'] trustedOrigins: ['http://localhost:3000', 'https://serengo.ziasvannes.tech']
},
alias: {
"@/*": "./src/lib/*"
} }
} }
}; };

View File

@@ -1,9 +1,8 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [tailwindcss(), sveltekit()],
preview: { preview: { allowedHosts: ['ziasvannes.tech'] }
allowedHosts: ['ziasvannes.tech']
}
}); });