diff --git a/components.json b/components.json
new file mode 100644
index 0000000..21bb45d
--- /dev/null
+++ b/components.json
@@ -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"
+}
diff --git a/package.json b/package.json
index c6e839b..83047be 100644
--- a/package.json
+++ b/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"
diff --git a/src/app.css b/src/app.css
index ec4c004..bd2ca54 100644
--- a/src/app.css
+++ b/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);
}
-* {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
+.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);
}
-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;
+@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);
}
-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;
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
}
diff --git a/src/lib/components/LocationButton.svelte b/src/lib/components/LocationButton.svelte
new file mode 100644
index 0000000..431cd5c
--- /dev/null
+++ b/src/lib/components/LocationButton.svelte
@@ -0,0 +1,235 @@
+
+
+
+
+
diff --git a/src/lib/components/Map.svelte b/src/lib/components/Map.svelte
index 0951f9d..8a1fa23 100644
--- a/src/lib/components/Map.svelte
+++ b/src/lib/components/Map.svelte
@@ -1,12 +1,16 @@
-
+
+
+ {#if showLocationButton}
+
+
+
+ {/if}
diff --git a/src/lib/components/sonner/index.ts b/src/lib/components/sonner/index.ts
new file mode 100644
index 0000000..1ad9f4a
--- /dev/null
+++ b/src/lib/components/sonner/index.ts
@@ -0,0 +1 @@
+export { default as Toaster } from "./sonner.svelte";
diff --git a/src/lib/components/sonner/sonner.svelte b/src/lib/components/sonner/sonner.svelte
new file mode 100644
index 0000000..6fbb8cc
--- /dev/null
+++ b/src/lib/components/sonner/sonner.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 872683b..e98c3c2 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -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';
diff --git a/src/lib/stores/location.ts b/src/lib/stores/location.ts
new file mode 100644
index 0000000..95e7e3b
--- /dev/null
+++ b/src/lib/stores/location.ts
@@ -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(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 {
+ 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
+});
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..55b3a91
--- /dev/null
+++ b/src/lib/utils.ts
@@ -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 extends { child?: any } ? Omit : T;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChildren = T extends { children?: any } ? Omit : T;
+export type WithoutChildrenOrChild = WithoutChildren>;
+export type WithElementRef = T & { ref?: U | null };
diff --git a/src/lib/utils/geolocation.ts b/src/lib/utils/geolocation.ts
new file mode 100644
index 0000000..3229c9f
--- /dev/null
+++ b/src/lib/utils/geolocation.ts
@@ -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 {
+ 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();
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 6b77f0b..b232ed1 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -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);
@@ -14,6 +14,8 @@
+
+
{#if showHeader && data.user}
{/if}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index d7ba663..d4bd1b3 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -4,24 +4,49 @@
diff --git a/svelte.config.js b/svelte.config.js
index 2a59758..3f5f8ac 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -14,6 +14,9 @@ const config = {
adapter: adapter(),
csrf: {
trustedOrigins: ['http://localhost:3000', 'https://serengo.ziasvannes.tech']
+ },
+ alias: {
+ "@/*": "./src/lib/*"
}
}
};
diff --git a/vite.config.ts b/vite.config.ts
index 9716b6b..3d7e472 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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'] }
});