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:
16
components.json
Normal file
16
components.json
Normal 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"
|
||||
}
|
||||
11
package.json
11
package.json
@@ -21,11 +21,14 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@types/node": "^22",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"eslint": "^9.22.0",
|
||||
@@ -33,11 +36,17 @@
|
||||
"eslint-plugin-storybook": "^9.1.8",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"svelte": "^5.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-eslint": "^8.20.0",
|
||||
"vite": "^7.0.4"
|
||||
|
||||
161
src/app.css
161
src/app.css
@@ -1,54 +1,121 @@
|
||||
@font-face {
|
||||
font-family: 'Washington';
|
||||
src: url('/fonts/Washington.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@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 {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 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);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--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);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
background-color: #f8f8f8;
|
||||
color: #333;
|
||||
margin: 0 auto;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Washington', serif;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
h2,
|
||||
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;
|
||||
}
|
||||
|
||||
235
src/lib/components/LocationButton.svelte
Normal file
235
src/lib/components/LocationButton.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
1
src/lib/components/sonner/index.ts
Normal file
1
src/lib/components/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
||||
30
src/lib/components/sonner/sonner.svelte
Normal file
30
src/lib/components/sonner/sonner.svelte
Normal 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>
|
||||
@@ -7,3 +7,8 @@ export { default as ProfilePanel } from './components/ProfilePanel.svelte';
|
||||
export { default as Header } from './components/Header.svelte';
|
||||
export { default as Modal } from './components/Modal.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
184
src/lib/stores/location.ts
Normal 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
13
src/lib/utils.ts
Normal 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 };
|
||||
208
src/lib/utils/geolocation.ts
Normal file
208
src/lib/utils/geolocation.ts
Normal 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();
|
||||
@@ -3,9 +3,9 @@
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { Header } from '$lib';
|
||||
import { page } from '$app/state';
|
||||
import { Toaster } from '$lib/components/sonner/index.js';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
||||
let showHeader = $derived(!isLoginRoute && data?.user);
|
||||
</script>
|
||||
@@ -14,6 +14,8 @@
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<Toaster />
|
||||
|
||||
{#if showHeader && data.user}
|
||||
<Header user={data.user} />
|
||||
{/if}
|
||||
|
||||
@@ -4,24 +4,49 @@
|
||||
|
||||
<div class="home-container">
|
||||
<main class="main-content">
|
||||
<Map />
|
||||
<div class="map-section">
|
||||
<Map showLocationButton={true} autoCenter={true} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.home-container {
|
||||
background-color: #f8f8f8;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px 20px;
|
||||
max-width: 1200px;
|
||||
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) {
|
||||
.main-content {
|
||||
padding: 20px 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,9 @@ const config = {
|
||||
adapter: adapter(),
|
||||
csrf: {
|
||||
trustedOrigins: ['http://localhost:3000', 'https://serengo.ziasvannes.tech']
|
||||
},
|
||||
alias: {
|
||||
"@/*": "./src/lib/*"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
preview: {
|
||||
allowedHosts: ['ziasvannes.tech']
|
||||
}
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
preview: { allowedHosts: ['ziasvannes.tech'] }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user