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": {
|
"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"
|
||||||
|
|||||||
161
src/app.css
161
src/app.css
@@ -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 {
|
||||||
|
--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;
|
@apply border-border outline-ring/50;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family:
|
@apply bg-background text-foreground;
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
background-color: #f8f8f8;
|
|
||||||
color: #333;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
<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>
|
||||||
|
|||||||
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 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
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 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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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']
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user