...
This commit is contained in:
122
src/lib/components/ContactForm.svelte
Normal file
122
src/lib/components/ContactForm.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<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>
|
||||
79
src/lib/components/MapPoint.svelte
Normal file
79
src/lib/components/MapPoint.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<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 © <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'])} />
|
||||
41
src/lib/components/PageSelector.svelte
Normal file
41
src/lib/components/PageSelector.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<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>
|
||||
231
src/lib/components/Router.svelte
Normal file
231
src/lib/components/Router.svelte
Normal file
@@ -0,0 +1,231 @@
|
||||
<script module lang="ts">
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { onMount, type Component, type Snippet } from "svelte";
|
||||
|
||||
type RouteInfo = {
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
component: Component<{}>;
|
||||
};
|
||||
|
||||
const routes = new SvelteMap<string, RouteInfo>();
|
||||
|
||||
let currentUrl = $state.raw(new URL(window.location.href));
|
||||
|
||||
const currentRoute = $derived.by(() => {
|
||||
const normalizedPath = normalizePath(currentUrl.pathname);
|
||||
|
||||
for (const [_, routeInfo] of routes) {
|
||||
if (normalizedPath.match(routeInfo.pattern)) {
|
||||
return routeInfo;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const currentParams = $derived.by(() => {
|
||||
if (!currentRoute) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const match = currentUrl.pathname.match(currentRoute.pattern);
|
||||
if (!match) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
currentRoute.paramNames.forEach((name, index) => {
|
||||
result[name] = match[index + 1];
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new route with the router.
|
||||
*/
|
||||
export function route(path: string, component: Component<{}>): void {
|
||||
if (routes.has(path)) {
|
||||
console.warn(`Route already registered for path: '${path}'.`);
|
||||
}
|
||||
|
||||
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),
|
||||
paramNames: params,
|
||||
component: component,
|
||||
});
|
||||
}
|
||||
|
||||
export class RouteError extends Error {}
|
||||
export class MissingRouteError extends RouteError {}
|
||||
export class RouteFormatError extends RouteError {}
|
||||
|
||||
/**
|
||||
* Gets the value of a route parameter.
|
||||
*/
|
||||
export function paramString(name: string): string {
|
||||
const value = currentParams[name];
|
||||
if (!value) {
|
||||
throw new MissingRouteError(
|
||||
`Route does not have a parameter matching the name '${name}''`,
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a route parameter as an integer.
|
||||
*/
|
||||
export function paramNumber(name: string): number {
|
||||
const value = paramString(name);
|
||||
const parsed = parseInt(value, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new RouteFormatError(
|
||||
`parameter ${value} could not be parsed as an integer`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a query parameter.
|
||||
* If the query parameter is not present, the default value (if provided) will be used instead, otherwise, null.
|
||||
*/
|
||||
export function queryString(name: string): string | null;
|
||||
export function queryString(name: string, defaultValue: string): string;
|
||||
export function queryString(
|
||||
name: string,
|
||||
defaultValue?: string,
|
||||
): string | null {
|
||||
const value = currentUrl.searchParams.get(name);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return defaultValue ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a query parameter as an integer.
|
||||
* If the query parameter cannot be parsed, the default value (if provided) will be used instead, otherwise, null.
|
||||
*/
|
||||
export function queryNumber(name: string): number | null;
|
||||
export function queryNumber(name: string, defaultValue: number): number;
|
||||
export function queryNumber(
|
||||
name: string,
|
||||
defaultValue?: number,
|
||||
): number | null {
|
||||
const value = currentUrl.searchParams.get(name);
|
||||
|
||||
if (value) {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue ?? null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
notfound: Snippet;
|
||||
}
|
||||
|
||||
let { notfound }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (window.navigation) {
|
||||
function handleNavigate(event: NavigateEvent) {
|
||||
event.intercept({
|
||||
handler: async () => {
|
||||
currentUrl = new URL(event.destination.url);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
window.navigation.addEventListener("navigate", handleNavigate);
|
||||
|
||||
return () => {
|
||||
window.navigation.removeEventListener(
|
||||
"navigate",
|
||||
handleNavigate,
|
||||
);
|
||||
};
|
||||
} else {
|
||||
function refresh() {
|
||||
currentUrl = new URL(window.location.href);
|
||||
}
|
||||
|
||||
function makeProxy(target: any) {
|
||||
return new Proxy(target, {
|
||||
apply: (target, thisArg, argArray) => {
|
||||
target.apply(thisArg, argArray);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
window.history.pushState = makeProxy(window.history.pushState);
|
||||
window.history.replaceState = makeProxy(
|
||||
window.history.replaceState,
|
||||
);
|
||||
window.history.go = makeProxy(window.history.go);
|
||||
window.history.forward = makeProxy(window.history.forward);
|
||||
window.history.back = makeProxy(window.history.back);
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
if (e.target.target === "_blank") {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = new URL(e.target.href, document.baseURI);
|
||||
const documentUrl = new URL(document.baseURI);
|
||||
|
||||
if (targetUrl.origin == documentUrl.origin) {
|
||||
e.preventDefault();
|
||||
history.pushState({}, "", targetUrl);
|
||||
currentUrl = targetUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", refresh);
|
||||
document.addEventListener("click", handleClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", refresh);
|
||||
document.removeEventListener("click", handleClick);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if currentRoute}
|
||||
<currentRoute.component />
|
||||
{:else}
|
||||
{@render notfound()}
|
||||
{/if}
|
||||
34
src/lib/components/Spinner.svelte
Normal file
34
src/lib/components/Spinner.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
|
||||
<svg {...$$restProps} width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient x1="8.042%" y1="0%" x2="65.682%" y2="23.865%" id="a">
|
||||
<stop stop-color="currentColor" stop-opacity="0" offset="0%" />
|
||||
<stop stop-color="currentColor" stop-opacity=".631" offset="63.146%" />
|
||||
<stop stop-color="currentColor" offset="100%" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(1 1)">
|
||||
<path d="M36 18c0-9.94-8.06-18-18-18" id="Oval-2" stroke="url(#a)" stroke-width="2">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 18 18"
|
||||
to="360 18 18"
|
||||
dur="0.9s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<circle fill="currentColor" cx="36" cy="18" r="1">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 18 18"
|
||||
to="360 18 18"
|
||||
dur="0.9s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
322
src/lib/components/TermsOfSale.svelte
Normal file
322
src/lib/components/TermsOfSale.svelte
Normal file
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import Link from './link/Link.svelte';
|
||||
</script>
|
||||
|
||||
<h2 class="mb-1 text-xl font-semibold">Innledning</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Dette kjøpet er regulert av de nedenstående standard salgsbetingelser for forbrukerkjøp av varer
|
||||
over Internett. Forbrukerkjøp over internett reguleres hovedsakelig av avtaleloven,
|
||||
forbrukerkjøpsloven, markedsføringsloven, angrerettloven og ehandelsloven, og disse lovene gir
|
||||
forbrukeren ufravikelige rettigheter. Lovene er tilgjengelig på
|
||||
<Link href="www.lovdata.no">www.lovdata.no</Link>. Vilkårene i denne avtalen skal ikke forstås som
|
||||
noen begrensning i de lovbestemte rettighetene, men oppstiller partenes viktigste rettigheter og
|
||||
plikter for handelen.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Salgsbetingelsene er utarbeidet og anbefalt av Forbrukertilsynet. For en bedre forståelse av disse
|
||||
salgsbetingelsene, se Forbrukertilsynets veileder her.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">1. Avtalen</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Avtalen består av disse salgsbetingelsene, opplysninger gitt i bestillingsløsningen og eventuelt
|
||||
særskilt avtalte vilkår. Ved eventuell motstrid mellom opplysningene, går det som særskilt er
|
||||
avtalt mellom partene foran, så fremt det ikke strider mot ufravikelig lovgivning.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Avtalen vil i tillegg bli utfylt av relevante lovbestemmelser som regulerer kjøp av varer mellom
|
||||
næringsdrivende og forbrukere.
|
||||
</p>
|
||||
|
||||
<h2 class="mb-1 text-xl font-semibold">2. Partene</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Selger er Tor Stian Stene, adresse: Solnørdalsvegen 40, 6240 Ørskog, e-post:
|
||||
minibusservice@hotmail.com, tlf: 45266161, org.nr: 816 230 942, og betegnes i det følgende som
|
||||
selgeren.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøper er den forbrukeren som foretar bestillingen, og betegnes i det følgende som kjøperen.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">3. Pris</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Den oppgitte prisen for varen og tjenester er den totale prisen kjøper skal betale. Denne prisen
|
||||
inkluderer alle avgifter og tilleggskostnader. Ytterligere kostnader som selger før kjøpet ikke
|
||||
har informert om, skal kjøper ikke bære.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">4. Avtaleinngåelse</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Avtalen er bindende for begge parter når kjøperen har sendt sin bestilling til selgeren.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Avtalen er likevel ikke bindende hvis det har forekommet skrive- eller tastefeil i tilbudet fra
|
||||
selgeren i bestillingsløsningen i nettbutikken eller i kjøperens bestilling, og den annen part
|
||||
innså eller burde ha innsett at det forelå en slik feil.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">5. Betalingen</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Selgeren kan kreve betaling for varen fra det tidspunkt den blir sendt fra selgeren til kjøperen.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Dersom kjøperen bruker kredittkort eller debetkort ved betaling, kan selgeren reservere
|
||||
kjøpesummen på kortet ved bestilling. Kortet blir belastet samme dag som varen sendes.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Ved betaling med faktura, blir fakturaen til kjøperen utstedt ved forsendelse av varen.
|
||||
Betalingsfristen fremgår av fakturaen og er på minimum 14 dager fra mottak.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">Kjøpere under 18 år kan ikke betale med etterfølgende faktura.</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">6. Levering</h2>
|
||||
|
||||
<p class="mb-2">Levering er skjedd når kjøperen, eller hans representant, har overtatt tingen.</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Hvis ikke leveringstidspunkt fremgår av bestillingsløsningen, skal selgeren levere varen til
|
||||
kjøper uten unødig opphold og senest 30 dager etter bestillingen fra kunden. Varen skal leveres
|
||||
hos kjøperen med mindre annet er særskilt avtalt mellom partene.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">7. Risikoen for varen</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Risikoen for varen går over på kjøper når han, eller kjøpers representant, har fått varene levert
|
||||
i tråd med punkt 6.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">8. Angrerett</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Med mindre avtalen er unntatt fra angrerett, kan kjøperen angre kjøpet av varen i henhold til
|
||||
angrerettloven.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøperen må gi selger melding om bruk av angreretten innen 14 dager fra fristen begynner å løpe. I
|
||||
fristen inkluderes alle kalenderdager. Dersom fristen ender på en lørdag, helligdag eller
|
||||
høytidsdag forlenges fristen til nærmeste virkedag.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Angrefristen anses overholdt dersom melding er sendt før utløpet av fristen. Kjøper har
|
||||
bevisbyrden for at angreretten er blitt gjort gjeldende, og meldingen bør derfor skje skriftlig
|
||||
(angrerettskjema, e-post eller brev).
|
||||
</p>
|
||||
|
||||
<p class="mb-2">Angrefristen begynner å løpe:</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Ved kjøp av enkeltstående varer vil angrefristen løpe fra dagen etter varen(e) er mottatt.
|
||||
</li>
|
||||
<li>
|
||||
Selges et abonnement, eller innebærer avtalen regelmessig levering av identiske varer, løper
|
||||
fristen fra dagen etter første forsendelse er mottatt.
|
||||
</li>
|
||||
<li>
|
||||
Består kjøpet av flere leveranser, vil angrefristen løpe fra dagen etter siste leveranse er
|
||||
mottatt.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="mb-2">
|
||||
Angrefristen utvides til 12 måneder etter utløpet av den opprinnelige fristen dersom selger ikke
|
||||
før avtaleinngåelsen opplyser om at det foreligger angrerett og standardisert angreskjema.
|
||||
Tilsvarende gjelder ved manglende opplysning om vilkår, tidsfrister og fremgangsmåte for å benytte
|
||||
angreretten. Sørger den næringsdrivende for å gi opplysningene i løpet av disse 12 månedene,
|
||||
utløper angrefristen likevel 14 dager etter den dagen kjøperen mottok opplysningene.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Ved bruk av angreretten må varen leveres tilbake til selgeren uten unødig opphold og senest 14
|
||||
dager fra melding om bruk av angreretten er gitt. Kjøper dekker de direkte kostnadene ved å
|
||||
returnere varen, med mindre annet er avtalt eller selger har unnlatt å opplyse om at kjøper skal
|
||||
dekke returkostnadene. Selgeren kan ikke fastsette gebyr for kjøperens bruk av angreretten.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøper kan prøve eller teste varen på en forsvarlig måte for å fastslå varens art, egenskaper og
|
||||
funksjon, uten at angreretten faller bort. Dersom prøving eller test av varen går utover hva som
|
||||
er forsvarlig og nødvendig, kan kjøperen bli ansvarlig for eventuell redusert verdi på varen.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Selgeren er forpliktet til å tilbakebetale kjøpesummen til kjøperen uten unødig opphold, og senest
|
||||
14 dager fra selgeren fikk melding om kjøperens beslutning om å benytte angreretten. Selger har
|
||||
rett til å holde tilbake betalingen til han/hun har mottatt varene fra kjøperen, eller til kjøper
|
||||
har lagt frem dokumentasjon for at varene er sendt tilbake.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">
|
||||
9. Forsinkelse og manglende levering - kjøpernes rettigheter og frist for å melde krav
|
||||
</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Dersom selgeren ikke leverer varen eller leverer den for sent i henhold til avtalen mellom
|
||||
partene, og dette ikke skyldes kjøperen eller forhold på kjøperens side, kan kjøperen i henhold
|
||||
til reglene i forbrukerkjøpslovens kapittel 5 etter omstendighetene holde kjøpesummen tilbake,
|
||||
kreve oppfyllelse, heve avtalen og/eller kreve erstatning fra selgeren.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Ved krav om misligholdsbeføyelser bør meldingen av bevishensyn være skriftlig (for eksempel
|
||||
e-post).
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Oppfyllelse</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøper kan fastholde kjøpet og kreve oppfyllelse fra selger. Kjøper kan imidlertid ikke kreve
|
||||
oppfyllelse dersom det foreligger en hindring som selgeren ikke kan overvinne, eller dersom
|
||||
oppfyllelse vil medføre en så stor ulempe eller kostnad for selger at det står i vesentlig
|
||||
misforhold til kjøperens interesse i at selgeren oppfyller. Skulle vanskene falle bort innen
|
||||
rimelig tid, kan kjøper likevel kreve oppfyllelse.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøperen taper sin rett til å kreve oppfyllelse om han eller hun venter urimelig lenge med å
|
||||
fremme kravet.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Heving</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Dersom selgeren ikke leverer varen på leveringstidspunktet, skal kjøperen oppfordre selger til å
|
||||
levere innen en rimelig tilleggsfrist for oppfyllelse. Dersom selger ikke leverer varen innen
|
||||
tilleggsfristen, kan kjøperen heve kjøpet.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøper kan imidlertid heve kjøpet umiddelbart hvis selger nekter å levere varen. Tilsvarende
|
||||
gjelder dersom levering til avtalt tid var avgjørende for inngåelsen av avtalen, eller dersom
|
||||
kjøperen har underrettet selger om at leveringstidspunktet er avgjørende.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Leveres tingen etter tilleggsfristen forbrukeren har satt eller etter leveringstidspunktet som var
|
||||
avgjørende for inngåelsen av avtalen, må krav om heving gjøres gjeldende innen rimelig tid etter
|
||||
at kjøperen fikk vite om leveringen.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Erstatning</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøperen kan kreve erstatning for lidt tap som følge av forsinkelsen. Dette gjelder imidlertid
|
||||
ikke dersom selgeren godtgjør at forsinkelsen skyldes hindring utenfor selgers kontroll som ikke
|
||||
med rimelighet kunne blitt tatt i betraktning på avtaletiden, unngått, eller overvunnet følgene
|
||||
av.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">
|
||||
10. Mangel ved varen - kjøperens rettigheter og reklamasjonsfrist
|
||||
</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Hvis det foreligger en mangel ved varen må kjøper innen rimelig tid etter at den ble oppdaget
|
||||
eller burde ha blitt oppdaget, gi selger melding om at han eller hun vil påberope seg mangelen.
|
||||
Kjøper har alltid reklamert tidsnok dersom det skjer innen 2 mnd. fra mangelen ble oppdaget eller
|
||||
burde blitt oppdaget. Reklamasjon kan skje senest to år etter at kjøper overtok varen. Dersom
|
||||
varen eller deler av den er ment å vare vesentlig lenger enn to år, er reklamasjonsfristen fem år.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Dersom varen har en mangel og dette ikke skyldes kjøperen eller forhold på kjøperens side, kan
|
||||
kjøperen i henhold til reglene i forbrukerkjøpsloven kapittel 6 etter omstendighetene holde
|
||||
kjøpesummen tilbake, velge mellom retting og omlevering, kreve prisavslag, kreve avtalen hevet
|
||||
og/eller kreve erstatning fra selgeren.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">Reklamasjon til selgeren bør skje skriftlig.</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Retting eller omlevering</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøperen kan velge mellom å kreve mangelen rettet eller levering av tilsvarende ting. Selger kan
|
||||
likevel motsette seg kjøperens krav dersom gjennomføringen av kravet er umulig eller volder
|
||||
selgeren urimelige kostnader. Retting eller omlevering skal foretas innen rimelig tid. Selger har
|
||||
i utgangspunktet ikke rett til å foreta mer enn to avhjelpsforsøk for samme mangel.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Prisavslag</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Kjøper kan kreve et passende prisavslag dersom varen ikke blir rettet eller omlevert. Dette
|
||||
innebærer at forholdet mellom nedsatt og avtalt pris svarer til forholdet mellom tingens verdi i
|
||||
mangelfull og kontraktsmessig stand. Dersom særlige grunner taler for det, kan prisavslaget i
|
||||
stedet settes lik mangelens betydning for kjøperen.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Heving</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Dersom varen ikke er rettet eller omlevert, kan kjøperen også heve kjøpet når mangelen ikke er
|
||||
uvesentlig.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">11. Selgerens rettigheter ved kjøperens mislighold</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Dersom kjøperen ikke betaler eller oppfyller de øvrige pliktene etter avtalen eller loven, og
|
||||
dette ikke skyldes selgeren eller forhold på selgerens side, kan selgeren i henhold til reglene i
|
||||
forbrukerkjøpsloven kapittel 9 etter omstendighetene holde varen tilbake, kreve oppfyllelse av
|
||||
avtalen, kreve avtalen hevet samt kreve erstatning fra kjøperen. Selgeren vil også etter
|
||||
omstendighetene kunne kreve renter ved forsinket betaling, inkassogebyr og et rimelig gebyr ved
|
||||
uavhentede varer.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Oppfyllelse</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Selger kan fastholde kjøpet og kreve at kjøperen betaler kjøpesummen. Er varen ikke levert, taper
|
||||
selgeren sin rett dersom han venter urimelig lenge med å fremme kravet.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Heving</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Selger kan heve avtalen dersom det foreligger vesentlig betalingsmislighold eller annet vesentlig
|
||||
mislighold fra kjøperens side. Selger kan likevel ikke heve dersom hele kjøpesummen er betalt.
|
||||
Fastsetter selger en rimelig tilleggsfrist for oppfyllelse og kjøperen ikke betaler innen denne
|
||||
fristen, kan selger heve kjøpet.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Renter ved forsinket betaling/inkassogebyr</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Dersom kjøperen ikke betaler kjøpesummen i henhold til avtalen, kan selger kreve renter av
|
||||
kjøpesummen etter forsinkelsesrenteloven. Ved manglende betaling kan kravet, etter forutgående
|
||||
varsel, bli sendt til inkasso. Kjøper kan da bli holdt ansvarlig for gebyr etter inkassoloven.
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">Gebyr ved uavhentede ikke-forskuddsbetalte varer</h3>
|
||||
|
||||
<p class="mb-2">
|
||||
Dersom kjøperen unnlater å hente ubetalte varer, kan selger belaste kjøper med et gebyr. Gebyret
|
||||
skal maksimalt dekke selgerens faktiske utlegg for å levere varen til kjøperen. Et slikt gebyr kan
|
||||
ikke belastes kjøpere under 18 år.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">12. Garanti</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Garanti som gis av selgeren eller produsenten, gir kjøperen rettigheter i tillegg til de kjøperen
|
||||
allerede har etter ufravikelig lovgivning. En garanti innebærer dermed ingen begrensninger i
|
||||
kjøperens rett til reklamasjon og krav ved forsinkelse eller mangler etter punkt 9 og 10.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">13. Personopplysninger</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Behandlingsansvarlig for innsamlede personopplysninger er selger. Med mindre kjøperen samtykker
|
||||
til noe annet, kan selgeren, i tråd med personopplysningsloven, kun innhente og lagre de
|
||||
personopplysninger som er nødvendig for at selgeren skal kunne gjennomføre forpliktelsene etter
|
||||
avtalen. Kjøperens personopplysninger vil kun bli utlevert til andre hvis det er nødvendig for at
|
||||
selger skal få gjennomført avtalen med kjøperen, eller i lovbestemte tilfelle.
|
||||
</p>
|
||||
<h2 class="mb-1 text-xl font-semibold">14. Konfliktløsning</h2>
|
||||
|
||||
<p class="mb-2">
|
||||
Klager rettes til selger innen rimelig tid, jf. punkt 9 og 10. Partene skal forsøke å løse
|
||||
eventuelle tvister i minnelighet. Dersom dette ikke lykkes, kan kjøperen ta kontakt med
|
||||
Forbrukertilsynet for mekling. Forbrukertilsynet er tilgjengelig på telefon 23 400 600 eller
|
||||
<Link href="www.forbrukertilsynet.no">www.forbrukertilsynet.no</Link>.
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
Europa-Kommisjonens klageportal kan også brukes hvis du ønsker å inngi en klage. Det er særlig
|
||||
relevant, hvis du er forbruker bosatt i et annet EU-land. Klagen inngis her:
|
||||
<Link href="http://ec.europa.eu/odr">http://ec.europa.eu/odr</Link>.
|
||||
</p>
|
||||
23
src/lib/components/Toggle.svelte
Normal file
23
src/lib/components/Toggle.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
export let checked: boolean;
|
||||
|
||||
export let leftIcon: string | null = null;
|
||||
export let rightIcon: string | null = null;
|
||||
|
||||
export let invertColor = false;
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{#if leftIcon}
|
||||
<img src={leftIcon} class={`h-6 ${invertColor && 'dark:invert'}`} alt="" />
|
||||
{/if}
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input on:change {checked} type="checkbox" value="" class="peer sr-only" />
|
||||
<div
|
||||
class="h-6 w-11 rounded-full border-2 border-contrast-100 outline-2 transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border-2 after:border-contrast-100 after:bg-contrast-900 after:transition-all after:content-[''] peer-checked:bg-accent peer-checked:after:translate-x-full dark:peer-focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
{#if rightIcon}
|
||||
<img src={rightIcon} class={`h-6 ${invertColor && 'dark:invert'}`} alt="" />
|
||||
{/if}
|
||||
</div>
|
||||
30
src/lib/components/button/Button.svelte
Normal file
30
src/lib/components/button/Button.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import Spinner from '../Spinner.svelte';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** @description Disallow clicking and reduce opacity **/
|
||||
export let disabled = false;
|
||||
/** @description Show a loading spinner **/
|
||||
export let loading = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click
|
||||
{...$$restProps}
|
||||
disabled={disabled || loading}
|
||||
class:opacity-50={disabled || loading}
|
||||
class:cursor-not-allowed={disabled || loading}
|
||||
class={twMerge(
|
||||
'relative rounded-md border-2 border-contrast-200 bg-primary-300 px-4 py-2 transition-colors hover:border-contrast-900 dark:border-contrast-100 dark:hover:border-accent',
|
||||
$$restProps['class']
|
||||
)}
|
||||
>
|
||||
<span class:opacity-0={loading}>
|
||||
<slot />
|
||||
</span>
|
||||
{#if loading}
|
||||
<span class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Spinner class="h-6" />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
61
src/lib/components/carousel/Carousel.svelte
Normal file
61
src/lib/components/carousel/Carousel.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export let items: T[];
|
||||
|
||||
/** @description Tailwind classes for styling the button colors */
|
||||
export let buttonClass = '';
|
||||
|
||||
let index = 0;
|
||||
|
||||
const next = () => {
|
||||
index = (index + 1) % items.length;
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
index = (index - 1 + items.length) % items.length;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
style={`--index: ${index}`}
|
||||
{...$$restProps}
|
||||
class={twMerge('inline-flex w-full gap-5 overflow-auto sm:overflow-hidden', $$restProps['class'])}
|
||||
>
|
||||
{#each items as item, index}
|
||||
<div class="carousel transition-transform">
|
||||
<slot {item} {index} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block">
|
||||
{#if items.length > 1}
|
||||
<div class="mt-2 flex justify-between md:text-lg">
|
||||
<button
|
||||
class={twMerge(
|
||||
'rounded-md border-2 border-contrast-100 bg-primary-300 px-4 py-2 transition-colors hover:border-contrast-200 dark:hover:border-accent',
|
||||
buttonClass
|
||||
)}
|
||||
on:click={prev}
|
||||
>
|
||||
Forrige
|
||||
</button>
|
||||
<button
|
||||
class={twMerge(
|
||||
'rounded-md border-2 border-contrast-100 bg-primary-300 px-4 py-2 transition-colors hover:border-contrast-200 dark:hover:border-accent',
|
||||
buttonClass
|
||||
)}
|
||||
on:click={next}
|
||||
>
|
||||
Neste
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.carousel {
|
||||
transform: translateX(calc((-100% - 20px) * var(--index)));
|
||||
}
|
||||
</style>
|
||||
79
src/lib/components/icons/logo/Logo.svelte
Normal file
79
src/lib/components/icons/logo/Logo.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export let layout: 'narrow' | 'wide' = 'wide';
|
||||
</script>
|
||||
|
||||
{#if layout === 'wide'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={twMerge('h-full', $$restProps['class'])}
|
||||
{...$$restProps}
|
||||
viewBox="0 0 1840.81 395.57"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M102,153.89,50.09,81.21V191.75H.34V6.59H50.09l64.66,94.79L179.42,6.59h49.74V191.75H179.42V81.21l-51.95,72.68Z"
|
||||
/>
|
||||
<path fill="currentColor" d="M336.11,6.59V191.75H286.36V6.59Z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M535.35,125.42V6.59H585.1V191.75H535.35l-97-118.83V191.75H388.61V6.59h49.74Z"
|
||||
/>
|
||||
<path fill="currentColor" d="M692,6.59V191.75H642.3V6.59Z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M845.69,1.07h7.74c61.9,0,79,24.87,79,55.27v5.52c0,16.31-8,30.4-32.88,37,29,6.64,38.41,21.28,38.41,37.59V142c0,30.39-18.24,55.27-84.56,55.27h-7.74a927.22,927.22,0,0,1-101.14-5.53V6.59A927.25,927.25,0,0,1,845.69,1.07Zm-51.4,46.7V78.44h49.19c44.77,0,44.77-7.18,44.77-16.58,0-9.12-1.38-16.58-41.45-16.58C833.25,45.28,812.53,46.11,794.29,47.77Zm0,74.89v27.91c18.24,1.66,38.69,2.49,53.33,2.49,44.22,0,46.16-7.46,46.16-16.58,0-7.74,0-13.82-50.3-13.82Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M1185.32,6.59V114.65c0,45.32-20.73,82.63-94.51,82.63h-10c-73.79,0-94.51-37.31-94.51-82.63V6.59h49.74V107.46c0,24,0,45.6,49.74,45.6s49.75-21.56,49.75-45.6V6.59Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M1369.37,137c0-13-11.61-14.92-43.67-15.75-28.18-.83-89.53-2.76-89.53-56.65V61c0-31.78,20.45-60,90.64-60h10a290.93,290.93,0,0,1,72.12,9.12V55.78c-26.8-6.35-53.05-10.22-80.14-10.22-40.07,0-42.83,9.12-42.83,15.47,0,13.27,12.16,15.2,43.39,16,26.25.56,89.81,2.49,89.81,56.93v3.59c0,31.51-20.45,59.7-90.92,59.7h-10a400.9,400.9,0,0,1-82.9-8.85V142c29.84,6.63,63.28,11.05,91.47,11.05C1366.33,153.06,1369.37,143.94,1369.37,137Z"
|
||||
/>
|
||||
<path
|
||||
fill="#ff1d25"
|
||||
d="M846.11,350.74c0-9.9-8.84-11.37-33.26-12-21.48-.63-68.22-2.11-68.22-43.16v-2.74c0-24.21,15.58-45.69,69.06-45.69h7.58a222,222,0,0,1,55,6.95v34.74c-20.42-4.84-40.43-7.79-61.06-7.79-30.53,0-32.63,6.95-32.63,11.79,0,10.11,9.26,11.58,33.05,12.21,20,.42,68.43,1.9,68.43,43.37v2.74c0,24-15.58,45.48-69.27,45.48h-7.58A305.66,305.66,0,0,1,744,389.9V354.53c22.74,5.05,48.21,8.42,69.69,8.42C843.79,363,846.11,356,846.11,350.74Z"
|
||||
/>
|
||||
<path
|
||||
fill="#ff1d25"
|
||||
d="M1056.87,285.05H959.81V306.1h76v33.69h-76v18.95h97.06v33.69h-135V251.36h135Z"
|
||||
/>
|
||||
<path
|
||||
fill="#ff1d25"
|
||||
d="M1201.09,392.43l-28.21-44.22c-15,0-29.06,0-42.53-2.1v46.32h-37.9V251.36a707.77,707.77,0,0,1,77.06-4.21h5.89c47.17,0,60.22,20.85,60.22,46.32v8.42c0,17.69-5.9,33.06-24,40.85l26.1,41.26v8.43Zm-70.74-109.7v29.9a266.16,266.16,0,0,0,33.05,1.9c33.69,0,34.32-5.9,34.32-19.16,0-7.58-.21-14.53-27.79-14.53C1160.25,280.84,1144.25,281.47,1130.35,282.73Z"
|
||||
/>
|
||||
<path
|
||||
fill="#ff1d25"
|
||||
d="M1426,251.36v8.43l-62.75,132.64h-37.89l-62.74-132.64v-8.43h34.1l47.58,107.38,47.59-107.38Z"
|
||||
/>
|
||||
<path fill="#ff1d25" d="M1496.49,251.36V392.43h-37.9V251.36Z" />
|
||||
<path
|
||||
fill="#ff1d25"
|
||||
d="M1534.39,316.42c0-38.11,16.63-69.27,76.22-69.27h7.57a218.19,218.19,0,0,1,54.32,6.95v34.32c-19.37-5.69-41-7.79-55.37-7.79-44,0-44,17.26-44,41.26s0,41.48,44,41.48a212.15,212.15,0,0,0,55.37-8V389.9a228.46,228.46,0,0,1-54.32,6.74h-7.57c-59.59,0-76.22-31.16-76.22-69.27Z"
|
||||
/>
|
||||
<path
|
||||
fill="#ff1d25"
|
||||
d="M1841.15,285.05h-97.06V306.1h76v33.69h-76v18.95h97.06v33.69h-135V251.36h135Z"
|
||||
/>
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="1458.66 108.93 1458.66 83.93 1565.16 96.43 1671.66 108.93 1565.16 121.43 1458.66 133.93 1458.66 108.93"
|
||||
/>
|
||||
</svg>
|
||||
{:else if layout === 'narrow'}
|
||||
<svg class={$$restProps['class']} {...$$restProps} viewBox="0 0 1920 1920">
|
||||
<g>
|
||||
<path
|
||||
fill="#ff1d25"
|
||||
d="M780.1,1364.81l88.49-508.71l-323.41,419.08h-88.42L280.44,867l-87.78,497.81H8.56l149.5-847.85h162.3 l224.43,526.88l405.4-526.88h161.09l-147.08,847.85H780.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#ff1d25"
|
||||
d="M1113.38,1281.23l92.67-147.77c62.28,52.08,162.46,88.42,264.2,88.42c116.28,0,170.35-38.76,179.53-90.84 c27.98-158.67-484.21-49.66-428.68-364.58c25.42-144.14,162.83-264.05,403.87-264.05c106.59,0,211.11,25.44,281.08,75.1 l-86.83,148.98c-70.83-44.81-148.13-66.62-223.23-66.62c-116.28,0-168.78,43.6-178.18,96.9 c-27.55,156.25,484.42,48.45,429.53,359.73c-24.99,141.71-163.83,262.83-406.08,262.83 C1306.83,1379.34,1178.22,1339.37,1113.38,1281.23z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
32
src/lib/components/icons/severity/ErrorIcon.svelte
Normal file
32
src/lib/components/icons/severity/ErrorIcon.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={twMerge('h-full', $$restProps['class'])}
|
||||
{...$$restProps}
|
||||
fill="currentColor"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 493.488 493.488"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<g>
|
||||
<g>
|
||||
<path
|
||||
d="M492.358,143.652L350.266,1.304c-0.736-0.736-1.716-1.136-2.74-1.136L146.406,0c-1.012,0-1.996,0.4-2.716,1.108
|
||||
L1.358,143.216c-0.732,0.712-1.148,1.692-1.148,2.724L0.03,347.052c0,1.02,0.404,2,1.12,2.72l142.1,142.336
|
||||
c0.72,0.712,1.692,1.168,2.716,1.168l201.12,0.212h0.008c1.02,0,1.996-0.44,2.704-1.16l142.36-142.1
|
||||
c0.736-0.716,1.136-1.704,1.136-2.724l0.164-201.128C493.458,145.352,493.062,144.368,492.358,143.652z M257.318,373.324
|
||||
c-2.864,2.856-6.848,4.476-10.9,4.476c-4.068,0-8.052-1.636-10.928-4.5c-2.86-2.872-4.424-6.848-4.416-10.936
|
||||
c0-4.048,1.728-8.016,4.572-10.872c2.905-2.853,7.191-4.51,11.024-4.532c4.005,0.029,7.866,1.678,10.744,4.556
|
||||
c2.872,2.856,4.456,6.816,4.456,10.88C261.862,366.492,260.206,370.456,257.318,373.324z M261.894,300.728
|
||||
c-0.008,8.484-6.916,15.408-15.428,15.408c-8.512-0.008-15.428-6.94-15.42-15.436l0.216-169.668
|
||||
c0.008-8.512,7.776-15.428,15.488-15.428v-0.006c4.099,0.019,7.926,1.624,10.816,4.538c2.912,2.916,4.496,6.796,4.496,10.908
|
||||
L261.894,300.728z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
31
src/lib/components/icons/severity/InfoIcon.svelte
Normal file
31
src/lib/components/icons/severity/InfoIcon.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={twMerge('h-full', $$restProps['class'])}
|
||||
{...$$restProps}
|
||||
fill="currentColor"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 493.636 493.636"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<g>
|
||||
<g>
|
||||
<path
|
||||
d="M421.428,72.476C374.868,25.84,312.86,0.104,246.724,0.044C110.792,0.044,0.112,110.624,0,246.548
|
||||
c-0.068,65.912,25.544,127.944,72.1,174.584c46.564,46.644,108.492,72.46,174.4,72.46h0.58v-0.048
|
||||
c134.956,0,246.428-110.608,246.556-246.532C493.7,181.12,468,119.124,421.428,72.476z M257.516,377.292
|
||||
c-2.852,2.856-6.844,4.5-10.904,4.5c-4.052,0-8.044-1.66-10.932-4.516c-2.856-2.864-4.496-6.852-4.492-10.916
|
||||
c0.004-4.072,1.876-8.044,4.732-10.884c2.884-2.86,7.218-4.511,11.047-4.542c3.992,0.038,7.811,1.689,10.677,4.562
|
||||
c2.872,2.848,4.46,6.816,4.456,10.884C262.096,370.46,260.404,374.432,257.516,377.292z M262.112,304.692
|
||||
c-0.008,8.508-6.928,15.404-15.448,15.404c-8.5-0.008-15.42-6.916-15.416-15.432L231.528,135
|
||||
c0.004-8.484,3.975-15.387,15.488-15.414c4.093,0.021,7.895,1.613,10.78,4.522c2.912,2.916,4.476,6.788,4.472,10.912
|
||||
L262.112,304.692z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
28
src/lib/components/icons/severity/SuccessIcon.svelte
Normal file
28
src/lib/components/icons/severity/SuccessIcon.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={twMerge('h-full', $$restProps['class'])}
|
||||
{...$$restProps}
|
||||
fill="currentColor"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 493.464 493.464"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<g>
|
||||
<g>
|
||||
<path
|
||||
d="M246.736,0C110.692,0,0.004,110.68,0.004,246.732c0,136.06,110.688,246.732,246.732,246.732
|
||||
c136.048,0,246.724-110.672,246.724-246.732C493.456,110.68,382.78,0,246.736,0z M360.524,208.716L230.98,338.268
|
||||
c-2.82,2.824-7.816,2.824-10.64,0l-86.908-86.912c-1.412-1.416-2.192-3.3-2.192-5.324c0.004-2.016,0.784-3.912,2.192-5.336
|
||||
l11.108-11.104c1.412-1.408,3.3-2.18,5.328-2.18c2.016,0,3.908,0.772,5.316,2.18l67.752,67.752c1.5,1.516,3.94,1.516,5.444,0
|
||||
l110.392-110.392c2.824-2.824,7.828-2.824,10.644,0l11.108,11.124c1.412,1.4,2.208,3.304,2.208,5.308
|
||||
C362.732,205.412,361.936,207.3,360.524,208.716z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
29
src/lib/components/icons/severity/WarningIcon.svelte
Normal file
29
src/lib/components/icons/severity/WarningIcon.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={twMerge('h-full', $$restProps['class'])}
|
||||
{...$$restProps}
|
||||
fill="currentColor"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<g>
|
||||
<g>
|
||||
<path
|
||||
d="M507.494,426.066L282.864,53.537c-5.677-9.415-15.87-15.172-26.865-15.172c-10.995,0-21.188,5.756-26.865,15.172
|
||||
L4.506,426.066c-5.842,9.689-6.015,21.774-0.451,31.625c5.564,9.852,16.001,15.944,27.315,15.944h449.259
|
||||
c11.314,0,21.751-6.093,27.315-15.944C513.508,447.839,513.336,435.755,507.494,426.066z M256.167,167.227
|
||||
c12.901,0,23.817,7.278,23.817,20.178c0,39.363-4.631,95.929-4.631,135.292c0,10.255-11.247,14.554-19.186,14.554
|
||||
c-10.584,0-19.516-4.3-19.516-14.554c0-39.363-4.63-95.929-4.63-135.292C232.021,174.505,242.605,167.227,256.167,167.227z
|
||||
M256.498,411.018c-14.554,0-25.471-11.908-25.471-25.47c0-13.893,10.916-25.47,25.471-25.47c13.562,0,25.14,11.577,25.14,25.47
|
||||
C281.638,399.11,270.06,411.018,256.498,411.018z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
15
src/lib/components/link/Link.svelte
Normal file
15
src/lib/components/link/Link.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
export let href: string;
|
||||
</script>
|
||||
|
||||
<a
|
||||
{...$$restProps}
|
||||
{href}
|
||||
class={twMerge(
|
||||
'font-medium transition-colors hover:text-accent hover:underline',
|
||||
$$restProps['class']
|
||||
)}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
123
src/lib/components/listbox/Listbox.svelte
Normal file
123
src/lib/components/listbox/Listbox.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts" generics="T">
|
||||
import ListboxItem from './ListboxItem.svelte';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import { setListboxContext, type ListboxItemAbstract } from './context';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** @description Determine if multiple items can be selected */
|
||||
export let allowMultiple = true;
|
||||
|
||||
/** @description Determine if multiple items can be selected */
|
||||
export let items: T[];
|
||||
|
||||
/** @description Determine if multiple items can be selected */
|
||||
export let defaultSelectedItems: T[];
|
||||
|
||||
/** @description Function called when the selected items changes */
|
||||
export let onChange: (items: T[]) => void;
|
||||
|
||||
let selectedItems: Writable<ListboxItemAbstract<T>[]> = writable([]);
|
||||
let registeredItems: Writable<ListboxItemAbstract<T>[]> = writable([]);
|
||||
let focusedItem: Writable<ListboxItemAbstract<T> | null> = writable(null);
|
||||
|
||||
function toggleItemSelected(item: ListboxItemAbstract<T>): void {
|
||||
if (item) {
|
||||
setFocus(item);
|
||||
if ($selectedItems.includes(item)) {
|
||||
if (allowMultiple) {
|
||||
$selectedItems = $selectedItems.filter((x) => x !== item);
|
||||
} else {
|
||||
$selectedItems = [];
|
||||
}
|
||||
} else {
|
||||
if (allowMultiple) {
|
||||
$selectedItems.push(item);
|
||||
$selectedItems = $selectedItems;
|
||||
} else {
|
||||
$selectedItems = [item];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChange($selectedItems.map((x) => x.item));
|
||||
}
|
||||
|
||||
function setFocus(item: ListboxItemAbstract<T>) {
|
||||
const itemEl = document.getElementById(item.id);
|
||||
$focusedItem = item;
|
||||
itemEl?.focus();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent, item: ListboxItemAbstract<T>) {
|
||||
const index = $registeredItems.findIndex((x) => x === item);
|
||||
if (index === -1) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if ($registeredItems.length - 1 === index) {
|
||||
setFocus($registeredItems[0]);
|
||||
} else {
|
||||
setFocus($registeredItems[index + 1]);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (index === 0) {
|
||||
setFocus($registeredItems[$registeredItems.length - 1]);
|
||||
} else {
|
||||
setFocus($registeredItems[index - 1]);
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
toggleItemSelected(item);
|
||||
break;
|
||||
case 'Home':
|
||||
setFocus($registeredItems[0]);
|
||||
break;
|
||||
case 'End':
|
||||
setFocus($registeredItems[$registeredItems.length - 1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function registerItem(item: ListboxItemAbstract<T>): void {
|
||||
$registeredItems.push(item);
|
||||
$registeredItems = $registeredItems;
|
||||
if (defaultSelectedItems.includes(item.item)) {
|
||||
$selectedItems.push(item);
|
||||
$selectedItems = $selectedItems;
|
||||
}
|
||||
if ($registeredItems.length === 1) {
|
||||
$focusedItem = $registeredItems[0];
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterItem(item: ListboxItemAbstract<T>): void {
|
||||
$registeredItems = $registeredItems.filter((x) => x.id !== item.id);
|
||||
$selectedItems = $selectedItems.filter((x) => x.id !== item.id);
|
||||
}
|
||||
|
||||
setListboxContext<T>({
|
||||
registerItem: registerItem,
|
||||
unregisterItem: unregisterItem,
|
||||
selectedItems: selectedItems,
|
||||
focusedItem: focusedItem
|
||||
});
|
||||
</script>
|
||||
|
||||
<ul
|
||||
class={twMerge('flex flex-col gap-2 p-4', $$restProps['class'])}
|
||||
role="listbox"
|
||||
aria-activedescendant={$focusedItem?.id}
|
||||
aria-multiselectable={allowMultiple}
|
||||
tabindex={0}
|
||||
>
|
||||
{#each items as item}
|
||||
<ListboxItem {onKeyDown} onClick={(e, x) => toggleItemSelected(x)} {item}>
|
||||
<slot {item} />
|
||||
</ListboxItem>
|
||||
{/each}
|
||||
</ul>
|
||||
51
src/lib/components/listbox/ListboxItem.svelte
Normal file
51
src/lib/components/listbox/ListboxItem.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { onMount } from 'svelte';
|
||||
import { getListboxContext, type ListboxItemAbstract } from './context';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export let item: T;
|
||||
export let onClick: (e: MouseEvent, item: ListboxItemAbstract<T>) => void;
|
||||
export let onKeyDown: (e: KeyboardEvent, item: ListboxItemAbstract<T>) => void;
|
||||
|
||||
const id: string = `listbox-item-${
|
||||
crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.floor(Date.now() * Math.random() * 100).toString()
|
||||
}`;
|
||||
|
||||
const itemAbstract: ListboxItemAbstract<T> = { id: id, item: item };
|
||||
|
||||
let {
|
||||
selectedItems: selectedItems,
|
||||
registerItem: register,
|
||||
unregisterItem: unregister,
|
||||
focusedItem: focusedItem
|
||||
} = getListboxContext<T>();
|
||||
|
||||
onMount(() => {
|
||||
register(itemAbstract);
|
||||
|
||||
return () => {
|
||||
unregister(itemAbstract);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<li
|
||||
{id}
|
||||
role="option"
|
||||
aria-selected={$selectedItems.includes(itemAbstract)}
|
||||
on:click={(e) => onClick(e, itemAbstract)}
|
||||
on:keydown={(e) => onKeyDown(e, itemAbstract)}
|
||||
tabindex={$focusedItem && $focusedItem === itemAbstract ? 0 : -1}
|
||||
class={twMerge(
|
||||
`cursor-pointer overflow-hidden rounded-md border-2 border-contrast-200 opacity-80 ${
|
||||
$selectedItems.includes(itemAbstract)
|
||||
? 'border-contrast-900 opacity-100 dark:border-accent'
|
||||
: ''
|
||||
}`,
|
||||
$$restProps['class']
|
||||
)}
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
22
src/lib/components/listbox/context.ts
Normal file
22
src/lib/components/listbox/context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
type ListboxContext<T> = {
|
||||
registerItem: (item: ListboxItemAbstract<T>) => void;
|
||||
unregisterItem: (item: ListboxItemAbstract<T>) => void;
|
||||
selectedItems: Writable<ListboxItemAbstract<T>[]>;
|
||||
focusedItem: Writable<ListboxItemAbstract<T> | null>;
|
||||
};
|
||||
|
||||
export type ListboxItemAbstract<T> = {
|
||||
id: string;
|
||||
item: T;
|
||||
};
|
||||
|
||||
export function setListboxContext<T>(context: ListboxContext<T>): void {
|
||||
setContext('listbox', context);
|
||||
}
|
||||
|
||||
export function getListboxContext<T>(): ListboxContext<T> {
|
||||
return getContext('listbox') as ListboxContext<T>;
|
||||
}
|
||||
59
src/lib/components/modal/Modal.svelte
Normal file
59
src/lib/components/modal/Modal.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { scale } from 'svelte/transition';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export const toggle = () => {
|
||||
if (visible) {
|
||||
close();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
};
|
||||
|
||||
export const show = () => {
|
||||
modalRef.showModal();
|
||||
onShow();
|
||||
visible = true;
|
||||
};
|
||||
|
||||
export const close = () => {
|
||||
modalRef.close();
|
||||
onClose();
|
||||
visible = false;
|
||||
};
|
||||
|
||||
export let onShow = () => {};
|
||||
export let onClose = () => {};
|
||||
|
||||
let visible = false;
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<dialog
|
||||
bind:this={modalRef}
|
||||
on:close={() => (visible = false)}
|
||||
transition:scale={{ duration: 200 }}
|
||||
class={twMerge(
|
||||
`fixed inset-0 z-40 flex max-h-[calc(100vh-16rem)] flex-col rounded-md border-2 border-contrast-100 bg-primary-100 p-6 text-contrast-900 lg:w-3/4`,
|
||||
$$restProps['class'],
|
||||
visible ? '' : 'hidden'
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<div class="mb-4 flex">
|
||||
<h1 class="grow text-2xl font-semibold">
|
||||
<slot name="header" />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="grow overflow-auto rounded-md bg-primary-300 p-4 shadow-inner">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-4">
|
||||
<slot name="button-row" />
|
||||
</div>
|
||||
</dialog>
|
||||
38
src/lib/components/tabs/Tab.svelte
Normal file
38
src/lib/components/tabs/Tab.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getTabsContext } from './context';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export let label: string;
|
||||
|
||||
const id: string = `tab-${
|
||||
crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.floor(Date.now() * Math.random() * 100).toString()
|
||||
}`;
|
||||
|
||||
const context = getTabsContext();
|
||||
const { selectedTab } = context;
|
||||
|
||||
onMount(() => {
|
||||
context.register({
|
||||
id: id,
|
||||
label: label
|
||||
});
|
||||
|
||||
return () => {
|
||||
context.unregister(id);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $selectedTab === id}
|
||||
<div
|
||||
{...$$restProps}
|
||||
class={twMerge('rounded-md bg-primary-300 p-4 shadow-inner', $$restProps['class'])}
|
||||
role="tabpanel"
|
||||
aria-labelledby={id}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
102
src/lib/components/tabs/Tabs.svelte
Normal file
102
src/lib/components/tabs/Tabs.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { setTabsContext, type TabAbstract } from './context';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
|
||||
export let buttonClass = '';
|
||||
|
||||
let tabs: TabAbstract[] = [];
|
||||
let selectedTab: Writable<string | null> = writable(null);
|
||||
|
||||
function setTab(id: string): void {
|
||||
const tab = tabs.find((x) => x.id == id);
|
||||
if (tab) {
|
||||
$selectedTab = tab.id;
|
||||
}
|
||||
}
|
||||
|
||||
function registerTab(tab: TabAbstract): void {
|
||||
tabs.push(tab);
|
||||
tabs = tabs;
|
||||
|
||||
if (tabs.length === 1) {
|
||||
$selectedTab = tabs[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterTab(id: string): void {
|
||||
tabs = tabs.filter((x) => x.id !== id);
|
||||
}
|
||||
|
||||
function setFocus(tab: TabAbstract) {
|
||||
const tabEl = document.getElementById(tab.id);
|
||||
tabEl?.focus();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent, tab: TabAbstract) {
|
||||
const index = tabs.findIndex((x) => x.id === tab.id);
|
||||
if (index === -1) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (tabs.length - 1 === index) {
|
||||
setFocus(tabs[0]);
|
||||
} else {
|
||||
setFocus(tabs[index + 1]);
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (index === 0) {
|
||||
setFocus(tabs[tabs.length - 1]);
|
||||
} else {
|
||||
setFocus(tabs[index - 1]);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
setFocus(tabs[0]);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
setFocus(tabs[tabs.length - 1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setTabsContext({
|
||||
register: registerTab,
|
||||
unregister: unregisterTab,
|
||||
selectedTab: selectedTab
|
||||
});
|
||||
</script>
|
||||
|
||||
<div {...$$restProps} class={twMerge('flex flex-col gap-4', $$restProps['class'])}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-labelledby="tablist"
|
||||
class="flex gap-4 overflow-auto rounded-md bg-primary-300 p-4 shadow-inner"
|
||||
>
|
||||
{#each tabs as tab, i}
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
id={tab.id}
|
||||
tabindex={$selectedTab === tab.id || ($selectedTab === null && i === 0) ? 0 : -1}
|
||||
aria-selected={$selectedTab === tab.id}
|
||||
aria-controls={tab.id}
|
||||
class={twMerge(
|
||||
`rounded-md border-2 border-contrast-200 bg-primary-100 px-4 py-2 hover:border-contrast-900 dark:hover:border-accent ${$selectedTab === tab.id ? 'border-contrast-900 dark:border-accent' : ''}`,
|
||||
buttonClass
|
||||
)}
|
||||
on:click={() => setTab(tab.id)}
|
||||
on:keydown={(e) => onKeyDown(e, tab)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
21
src/lib/components/tabs/context.ts
Normal file
21
src/lib/components/tabs/context.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
type TabsContext = {
|
||||
register: (tab: TabAbstract) => void;
|
||||
unregister: (id: string) => void;
|
||||
selectedTab: Writable<string | null>;
|
||||
};
|
||||
|
||||
export type TabAbstract = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function setTabsContext(context: TabsContext): void {
|
||||
setContext('tabs', context);
|
||||
}
|
||||
|
||||
export function getTabsContext(): TabsContext {
|
||||
return getContext('tabs') as TabsContext;
|
||||
}
|
||||
58
src/lib/components/toast/ToastProvider.svelte
Normal file
58
src/lib/components/toast/ToastProvider.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<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}
|
||||
46
src/lib/components/toast/toast.ts
Normal file
46
src/lib/components/toast/toast.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
|
||||
type Toast = {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
text: string;
|
||||
};
|
||||
|
||||
function addToast(
|
||||
text: string,
|
||||
type: 'success' | 'error' | 'warning' | 'info' = 'info',
|
||||
dismissible: boolean
|
||||
) {
|
||||
const id: string = `toast-${
|
||||
crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.floor(Date.now() * Math.random() * 100).toString()
|
||||
}`;
|
||||
|
||||
toasts.update((all) => [
|
||||
...all,
|
||||
{
|
||||
id: id,
|
||||
text: text,
|
||||
dismissible: dismissible,
|
||||
type: type
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
toasts.update((all) => all.filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
clear: () => toasts.set([]),
|
||||
error: (text: string | null, dismissible = true) =>
|
||||
addToast(text ?? 'En feil oppstod', 'error', dismissible),
|
||||
info: (text: string, dismissible = true) => addToast(text, 'info', dismissible),
|
||||
warning: (text: string, dismissible = true) => addToast(text, 'warning', dismissible),
|
||||
success: (text: string | null, dismissible = true) =>
|
||||
addToast(text ?? 'Suksess', 'success', dismissible),
|
||||
dismiss: dismiss
|
||||
};
|
||||
44
src/lib/stores/theme.svelte.ts
Normal file
44
src/lib/stores/theme.svelte.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
function createThemeToggler() {
|
||||
let defaultValue = false;
|
||||
if (browser) {
|
||||
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 (defaultValue) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
const { subscribe, update } = writable(defaultValue);
|
||||
|
||||
return {
|
||||
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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const darkTheme = createThemeToggler();
|
||||
8
src/lib/util/debounce.ts
Normal file
8
src/lib/util/debounce.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function debounce<T extends Function>(cb: T, wait = 500) {
|
||||
let h: any;
|
||||
let callable = () => {
|
||||
clearTimeout(h);
|
||||
h = setTimeout(() => cb(), wait);
|
||||
};
|
||||
return callable;
|
||||
}
|
||||
Reference in New Issue
Block a user