This commit is contained in:
nub31
2026-03-16 23:14:51 +01:00
parent 3bf281c7a7
commit 7aea328d4f
17 changed files with 913 additions and 356 deletions

View File

@@ -1,122 +1,38 @@
<script lang="ts">
import type { MessageResponseBodyData } from '$lib/types/dtos/message/MessageResponseBodyData';
import type { MinibusserviceResponseBody } from '$lib/types/dtos/MinibusserviceResponseBody';
import Button from './button/Button.svelte';
import Link from './link/Link.svelte';
import MapPoint from './MapPoint.svelte';
import { toast } from './toast/toast';
let buttonDisabled = false;
let name = '';
let email = '';
let phone = '';
let message = '';
let posting = false;
async function sendMessage() {
if (!(name && email && message)) {
toast.warning('Alle påkrevde felt må fyllest inn');
return;
}
posting = true;
buttonDisabled = true;
try {
let res = await fetch('/api/v1/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name,
email,
phone,
message
})
});
let data: MinibusserviceResponseBody<MessageResponseBodyData> = await res.json();
if (res.ok) {
toast.success(data.message);
} else {
console.error(data.message);
toast.error(data.message);
}
} catch (err) {
console.error(err);
buttonDisabled = false;
toast.error('En feil oppstod under sending av meldingen');
} finally {
posting = false;
}
}
</script>
<div class="mt-2 flex flex-col gap-8 md:flex-row md:text-lg">
<div class="flex flex-1 flex-col">
<p class="block pt-2 font-medium opacity-70 md:hidden">
Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet under
</p>
<p class="hidden pt-2 font-medium opacity-70 md:block">
Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet til høyre
</p>
<Link class="w-fit font-bold text-accent hover:underline" href="tel:45256161">
Tlf: +47 45 25 61 61
</Link>
<Link class="w-fit font-bold text-accent hover:underline" href="mailto:minibusstur@hotmail.com">
E-post: minibusstur@hotmail.com
</Link>
<MapPoint
class="card mt-5 min-h-96 grow overflow-hidden rounded-md border-2"
coordinates={[
{
latitude: 62.48303957042255,
longitude: 6.8108274964451745
}
]}
/>
</div>
<form
on:submit|preventDefault={sendMessage}
class="flex flex-1 flex-col gap-4 pt-4 text-xl md:pt-0"
>
<label>
<span class="text-xl font-semibold">
<span class="text-accent"> * </span>
Navn
</span>
<input required bind:value={name} placeholder="Navn" class="bg-primary-100" type="text" />
</label>
<label>
<span class="text-xl font-semibold">
<span class="text-accent"> * </span>
E-post
</span>
<input
required
bind:value={email}
placeholder="E-postadresse"
class="bg-primary-100"
type="email"
/>
</label>
<label>
<span class="text-xl font-semibold">Telefonnummer</span>
<input bind:value={phone} placeholder="Telefonnummer" class="bg-primary-100" type="tel" />
</label>
<label>
<span class="text-xl font-semibold">
<span class="text-accent"> * </span>
Melding
</span>
<textarea required bind:value={message} class="h-60 bg-primary-100" placeholder="Melding" />
</label>
<div>
<Button class="w-full" disabled={buttonDisabled} loading={posting} type="submit">Send</Button>
</div>
</form>
</div>
<script lang="ts">
import Link from "./link/Link.svelte";
import MapPoint from "./MapPoint.svelte";
</script>
<div class="mt-2 flex flex-col gap-8 md:flex-row md:text-lg">
<div class="flex flex-1 flex-col">
<p class="block pt-2 font-medium opacity-70 md:hidden">
Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet under
</p>
<p class="hidden pt-2 font-medium opacity-70 md:block">
Spørsmål?<br />Kontakt oss via tlf, e-post eller via skjemaet til
høyre
</p>
<Link
class="w-fit font-bold text-accent hover:underline"
href="tel:45256161"
>
Tlf: +47 45 25 61 61
</Link>
<Link
class="w-fit font-bold text-accent hover:underline"
href="mailto:minibusstur@hotmail.com"
>
E-post: minibusstur@hotmail.com
</Link>
<MapPoint
class="card mt-5 min-h-96 grow overflow-hidden rounded-md border-2"
coordinates={[
{
latitude: 62.48303957042255,
longitude: 6.8108274964451745,
},
]}
/>
</div>
</div>

View File

@@ -1,79 +1,86 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import 'leaflet/dist/leaflet.css';
import { env } from '$env/dynamic/public';
import { twMerge } from 'tailwind-merge';
interface MarkerPoint {
latitude: number;
longitude: number;
tooltip?: string;
}
export let coordinates: MarkerPoint[];
let mapElement: HTMLDivElement;
let map: L.Map | null = null;
let markers: L.Marker<any>[] = [];
var icon: L.Icon;
let L: typeof import('leaflet/index');
onMount(async () => {
L = await import('leaflet');
icon = L.icon({
iconUrl: '/map_marker.png',
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [-3, -76]
});
map = L.map(mapElement);
if (coordinates.length >= 1) {
map.setView([coordinates[0].latitude, coordinates[0].longitude], 15);
}
L.tileLayer(env.PUBLIC_TILE_SERVER_URL, {
maxZoom: 18,
attribution:
'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>',
id: 'base'
}).addTo(map);
updateMarkers();
});
onDestroy(async () => {
map?.remove();
});
function addMarker(point: MarkerPoint) {
let marker = L.marker([point.latitude, point.longitude], { icon: icon });
if (point.tooltip)
marker.bindTooltip(point.tooltip, {
direction: 'bottom'
});
markers.push(marker);
map?.addLayer(marker);
}
function updateMarkers() {
if (L && map) {
markers.forEach((point) => map?.removeLayer(point));
markers = [];
coordinates.forEach(addMarker);
map?.flyToBounds(L.featureGroup(markers).getBounds(), {
duration: 1
});
}
}
$: coordinates && updateMarkers();
</script>
<div bind:this={mapElement} class={twMerge('z-0 h-full w-full', $$restProps['class'])} />
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import "leaflet/dist/leaflet.css";
import { twMerge } from "tailwind-merge";
interface MarkerPoint {
latitude: number;
longitude: number;
tooltip?: string;
}
export let coordinates: MarkerPoint[];
let mapElement: HTMLDivElement;
let map: L.Map | null = null;
let markers: L.Marker<any>[] = [];
var icon: L.Icon;
let L: typeof import("leaflet/index");
onMount(async () => {
L = await import("leaflet");
icon = L.icon({
iconUrl: "/map_marker.png",
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [-3, -76],
});
map = L.map(mapElement);
if (coordinates.length >= 1) {
map.setView(
[coordinates[0].latitude, coordinates[0].longitude],
15,
);
}
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
id: "base",
}).addTo(map);
updateMarkers();
});
onDestroy(async () => {
map?.remove();
});
function addMarker(point: MarkerPoint) {
let marker = L.marker([point.latitude, point.longitude], {
icon: icon,
});
if (point.tooltip)
marker.bindTooltip(point.tooltip, {
direction: "bottom",
});
markers.push(marker);
map?.addLayer(marker);
}
function updateMarkers() {
if (L && map) {
markers.forEach((point) => map?.removeLayer(point));
markers = [];
coordinates.forEach(addMarker);
map?.flyToBounds(L.featureGroup(markers).getBounds(), {
duration: 1,
});
}
}
$: coordinates && updateMarkers();
</script>
<div
bind:this={mapElement}
class={twMerge("z-0 h-full w-full", $$restProps["class"])}
></div>

View File

@@ -1,41 +1,42 @@
<script lang="ts">
import { page } from '$app/stores';
import Button from '$lib/components/button/Button.svelte';
import { twMerge } from 'tailwind-merge';
export let maxItems: number;
let pageParam = $page.url.searchParams.get('page');
let currentPage = 1;
if (pageParam) {
let pageParamAsNumber = Number(pageParam);
if (pageParamAsNumber) {
currentPage = pageParamAsNumber;
}
}
</script>
<section class={twMerge('mt-8 flex justify-center gap-5', $$restProps['class'])}>
{#if currentPage > 1}
<a
on:click={() =>
setTimeout(() => {
currentPage = currentPage - 1;
}, 10)}
href={`?page=${currentPage - 1}`}
>
<Button>Forrige</Button>
</a>
{/if}
{#if (currentPage + 1) * 10 - 10 < maxItems}
<a
on:click={() =>
setTimeout(() => {
currentPage = currentPage + 1;
}, 10)}
href={`?page=${currentPage + 1}`}
>
<Button>Neste</Button>
</a>
{/if}
</section>
<script lang="ts">
import Button from "./button/Button.svelte";
import { twMerge } from "tailwind-merge";
export let maxItems: number;
let pageParam = $page.url.searchParams.get("page");
let currentPage = 1;
if (pageParam) {
let pageParamAsNumber = Number(pageParam);
if (pageParamAsNumber) {
currentPage = pageParamAsNumber;
}
}
</script>
<section
class={twMerge("mt-8 flex justify-center gap-5", $$restProps["class"])}
>
{#if currentPage > 1}
<a
on:click={() =>
setTimeout(() => {
currentPage = currentPage - 1;
}, 10)}
href={`?page=${currentPage - 1}`}
>
<Button>Forrige</Button>
</a>
{/if}
{#if (currentPage + 1) * 10 - 10 < maxItems}
<a
on:click={() =>
setTimeout(() => {
currentPage = currentPage + 1;
}, 10)}
href={`?page=${currentPage + 1}`}
>
<Button>Neste</Button>
</a>
{/if}
</section>

View File

@@ -3,6 +3,7 @@
import { onMount, type Component, type Snippet } from "svelte";
type RouteInfo = {
path: string;
pattern: RegExp;
paramNames: string[];
component: Component<{}>;
@@ -44,7 +45,16 @@
});
function normalizePath(path: string): string {
return path.replace(/\/+$/, "");
return path.replace(/\/+$/, "").trim();
}
function createRoutePattern(path: string): string {
const normalizedPath = normalizePath(path);
const escapedPath = normalizedPath.replace(/[.*+?^${}()|\\/]/g, "\\$&");
const pathPattern = escapedPath.replace(/\[([^\]]+)\]/g, "([^/]+)");
const regexPattern = `^${pathPattern}\\/?$`;
return regexPattern;
}
/**
@@ -55,17 +65,13 @@
console.warn(`Route already registered for path: '${path}'.`);
}
const params = Array.from(
path.matchAll(/\[([^\]]+)\]/g).map((x) => x[1]),
const params = Array.from(path.matchAll(/\[([^\]]+)\]/g)).map(
(x) => x[1],
);
const normalizedPath = normalizePath(path);
const escapedPath = normalizedPath.replace(/[.*+?^${}()|\\/]/g, "\\$&");
const pathPattern = escapedPath.replace(/\[([^\]]+)\]/g, "([^/]+)");
const regexPattern = `^${pathPattern}\\/?$`;
routes.set(path, {
pattern: new RegExp(regexPattern),
pattern: new RegExp(createRoutePattern(path)),
path: path,
paramNames: params,
component: component,
});
@@ -75,6 +81,19 @@
export class MissingRouteError extends RouteError {}
export class RouteFormatError extends RouteError {}
export function isCurrentPath(path: string): boolean {
if (!currentRoute) {
return false;
}
const match = path.match(currentRoute.pattern);
if (!match) {
return false;
}
return true;
}
/**
* Gets the value of a route parameter.
*/
@@ -178,8 +197,9 @@
function makeProxy(target: any) {
return new Proxy(target, {
apply: (target, thisArg, argArray) => {
target.apply(thisArg, argArray);
const result = target.apply(thisArg, argArray);
refresh();
return result;
},
});
}
@@ -197,19 +217,17 @@
return;
}
if (e.target instanceof HTMLAnchorElement) {
if (e.target.target === "_blank") {
return;
}
const anchor = (e.target as Element)?.closest("a");
if (!(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target === "_blank") return;
const targetUrl = new URL(e.target.href, document.baseURI);
const documentUrl = new URL(document.baseURI);
const targetUrl = new URL(anchor.href, document.baseURI);
const documentUrl = new URL(document.baseURI);
if (targetUrl.origin == documentUrl.origin) {
e.preventDefault();
history.pushState({}, "", targetUrl);
currentUrl = targetUrl;
}
if (targetUrl.origin === documentUrl.origin) {
e.preventDefault();
history.pushState({}, "", targetUrl);
currentUrl = targetUrl;
}
}

View File

@@ -1,58 +1,68 @@
<script lang="ts">
import { toast, toasts } from '$lib/components/toast/toast';
import { fade } from 'svelte/transition';
import InfoIcon from '../icons/severity/InfoIcon.svelte';
import WarningIcon from '../icons/severity/WarningIcon.svelte';
import ErrorIcon from '../icons/severity/ErrorIcon.svelte';
import SuccessIcon from '../icons/severity/SuccessIcon.svelte';
</script>
{#if $toasts.length >= 1}
<div class="fixed bottom-0 right-0 z-50 flex w-full flex-col gap-2 p-4 text-white sm:w-96">
{#each $toasts as toastItem}
<div
class:bg-red-500={toastItem.type === 'error'}
class:bg-green-500={toastItem.type === 'success'}
class:bg-orange-500={toastItem.type === 'warning'}
class:bg-blue-500={toastItem.type === 'info'}
class="card flex items-center gap-3 border-contrast-900 px-4 py-3 shadow-md dark:border-contrast-100"
transition:fade={{ duration: 200 }}
>
<div
class:text-red-900={toastItem.type === 'error'}
class:text-green-900={toastItem.type === 'success'}
class:text-orange-900={toastItem.type === 'warning'}
class:text-blue-900={toastItem.type === 'info'}
class="shrink-0"
>
{#if toastItem.type === 'info'}
<InfoIcon class="w-7"></InfoIcon>
{:else if toastItem.type === 'warning'}
<WarningIcon class="w-7"></WarningIcon>
{:else if toastItem.type === 'error'}
<ErrorIcon class="w-7"></ErrorIcon>
{:else if toastItem.type === 'success'}
<SuccessIcon class="w-7"></SuccessIcon>
{/if}
</div>
<p class="grow">
{toastItem.text}
</p>
<button on:click={() => toast.dismiss(toastItem.id)} class="shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<script lang="ts">
import { toast, toasts } from "./toast";
import { fade } from "svelte/transition";
import InfoIcon from "../icons/severity/InfoIcon.svelte";
import WarningIcon from "../icons/severity/WarningIcon.svelte";
import ErrorIcon from "../icons/severity/ErrorIcon.svelte";
import SuccessIcon from "../icons/severity/SuccessIcon.svelte";
</script>
{#if $toasts.length >= 1}
<div
class="fixed bottom-0 right-0 z-50 flex w-full flex-col gap-2 p-4 text-white sm:w-96"
>
{#each $toasts as toastItem}
<div
class:bg-red-500={toastItem.type === "error"}
class:bg-green-500={toastItem.type === "success"}
class:bg-orange-500={toastItem.type === "warning"}
class:bg-blue-500={toastItem.type === "info"}
class="card flex items-center gap-3 border-contrast-900 px-4 py-3 shadow-md dark:border-contrast-100"
transition:fade={{ duration: 200 }}
>
<div
class:text-red-900={toastItem.type === "error"}
class:text-green-900={toastItem.type === "success"}
class:text-orange-900={toastItem.type === "warning"}
class:text-blue-900={toastItem.type === "info"}
class="shrink-0"
>
{#if toastItem.type === "info"}
<InfoIcon class="w-7"></InfoIcon>
{:else if toastItem.type === "warning"}
<WarningIcon class="w-7"></WarningIcon>
{:else if toastItem.type === "error"}
<ErrorIcon class="w-7"></ErrorIcon>
{:else if toastItem.type === "success"}
<SuccessIcon class="w-7"></SuccessIcon>
{/if}
</div>
<p class="grow">
{toastItem.text}
</p>
<button
on:click={() => toast.dismiss(toastItem.id)}
class="shrink-0"
aria-label="dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/each}
</div>
{/if}

99
src/lib/global/routes.ts Normal file
View File

@@ -0,0 +1,99 @@
import homeIcon from "../../assets/icons/home_icon.svg";
import contactIcon from "../../assets/icons/contact_icon.svg";
import busIcon from "../../assets/icons/bus_icon.svg";
type Route = {
url: string;
text: string;
icon: string | null;
target: string | null;
};
export class Routes {
static home: Route = {
url: "/",
text: "Hjem",
icon: homeIcon,
target: null,
};
static bus: Route = {
url: "/bus",
text: "Buss",
icon: busIcon,
target: null,
};
static taxi: Route = {
url: "/taxi",
text: "Taxi",
icon: null,
target: null,
};
static accessibility: Route = {
url: "/accessibility",
text: "Tilgjengelighetsfunksjoner",
icon: null,
target: null,
};
static contact: Route = {
url: "/contact",
text: "Ta kontakt",
icon: contactIcon,
target: null,
};
static facebook: Route = {
url: "https://www.facebook.com/MinibusserviceSteneAS/",
text: "Facebook",
icon: null,
target: "_blank",
};
static email: Route = {
url: "mailto:minibusstur@hotmail.com",
text: "E-post",
icon: null,
target: null,
};
static phone: Route = {
url: "tel:45256161",
text: "Telefon",
icon: null,
target: null,
};
static topbarRoutes: Route[] = [Routes.home, Routes.bus, Routes.contact];
static sidebarRoutes: Route[] = [
Routes.home,
Routes.bus,
Routes.taxi,
Routes.accessibility,
Routes.contact,
];
static footerRoutes: { text: string; routes: Route[] }[] = [
{
text: "HJELP",
routes: [Routes.contact],
},
{
text: "SIDER",
routes: [
Routes.home,
Routes.bus,
Routes.taxi,
Routes.accessibility,
Routes.contact,
],
},
{
text: "MINIBUSSERVICE",
routes: [Routes.facebook, Routes.email, Routes.phone],
},
];
}

View File

@@ -1,25 +1,22 @@
import { writable } from "svelte/store";
import { browser } from "$app/environment";
function createThemeToggler() {
let defaultValue = false;
if (browser) {
let savedValue = localStorage.getItem("dark_theme");
let savedValue = localStorage.getItem("dark_theme");
if (savedValue) {
defaultValue = JSON.parse(savedValue);
} else if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
defaultValue = true;
}
if (savedValue) {
defaultValue = JSON.parse(savedValue);
} else if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
defaultValue = true;
}
if (defaultValue) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
if (defaultValue) {
document.documentElement.style.colorScheme = "dark";
} else {
document.documentElement.style.colorScheme = "light";
}
const { subscribe, update } = writable(defaultValue);
@@ -28,15 +25,13 @@ function createThemeToggler() {
subscribe,
set: (value: boolean) => {
update(() => value);
if (browser) {
if (value) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
localStorage.setItem("dark_theme", JSON.stringify(value));
if (value) {
document.documentElement.style.colorScheme = "dark";
} else {
document.documentElement.style.colorScheme = "light";
}
localStorage.setItem("dark_theme", JSON.stringify(value));
},
};
}