Migrates to svelte 5

This commit is contained in:
Christian Beutel
2025-01-24 17:46:13 +01:00
parent 7571f6e9b9
commit 3721b7f537
106 changed files with 5524 additions and 4232 deletions

6
db/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "db",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

4782
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,17 +15,17 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.42.1", "@playwright/test": "^1.42.1",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/node": "^20.11.25", "@types/node": "^20.11.25",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"postcss": "^8.4.33", "postcss": "^8.4.33",
"svelte": "^4.2.7", "svelte": "^5.0.0",
"svelte-check": "^3.6.0", "svelte-check": "^4.0.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.5.0",
"vite": "^5.0.3", "vite": "^5.4.4",
"vitest": "^1.2.0" "vitest": "^1.2.0"
}, },
"type": "module", "type": "module",
@@ -33,8 +33,8 @@
"@felte/validator-zod": "^1.0.18", "@felte/validator-zod": "^1.0.18",
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@sveltejs/adapter-node": "^4.0.1", "@sveltejs/adapter-node": "^4.0.1",
"@threlte/core": "^7.1.0", "@threlte/core": "^8.0.0-next.41",
"@threlte/extras": "^8.7.5", "@threlte/extras": "^9.0.0-next.55",
"@turf/destination": "^7.1.0", "@turf/destination": "^7.1.0",
"@turf/distance": "^7.1.0", "@turf/distance": "^7.1.0",
"@types/chart.js": "^2.9.41", "@types/chart.js": "^2.9.41",
@@ -59,7 +59,6 @@
"photoswipe": "^5.4.3", "photoswipe": "^5.4.3",
"pocketbase": "^0.21.0", "pocketbase": "^0.21.0",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"svelte-chartjs": "^3.1.5",
"svelte-i18n": "^4.0.0", "svelte-i18n": "^4.0.0",
"three": "^0.161.0", "three": "^0.161.0",
"xmldom": "^0.6.0", "xmldom": "^0.6.0",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,38 @@
<script lang="ts"> <script lang="ts">
export let type: "button" | "submit" | "reset" | null | undefined = import type { Snippet } from "svelte";
undefined; import type { MouseEventHandler } from "svelte/elements";
export let icon: string = ""; import { createBubbler } from "svelte/legacy";
export let extraClasses: string = "";
export let primary: boolean = false; const bubble = createBubbler();
export let secondary: boolean = false; interface Props {
export let large: boolean = false; type?: "button" | "submit" | "reset" | null | undefined;
export let loading: boolean = false; icon?: string;
export let disabled: boolean = false; extraClasses?: string;
export let tooltip: string | undefined = undefined; primary?: boolean;
export let id: string | undefined = undefined secondary?: boolean;
large?: boolean;
loading?: boolean;
disabled?: boolean;
tooltip?: string | undefined;
id?: string | undefined;
children?: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>;
}
let {
type = undefined,
icon = "",
extraClasses = "",
primary = false,
secondary = false,
large = false,
loading = false,
disabled = false,
tooltip = undefined,
id = undefined,
children,
onclick = undefined,
}: Props = $props();
</script> </script>
<button <button
@@ -21,7 +44,7 @@
disabled={disabled || loading} disabled={disabled || loading}
class:tooltip={(tooltip?.length ?? 0) > 0} class:tooltip={(tooltip?.length ?? 0) > 0}
data-title={tooltip} data-title={tooltip}
on:click {onclick}
{type} {type}
{id} {id}
> >
@@ -29,7 +52,7 @@
{#if icon} {#if icon}
<i class="fa fa-{icon} mr-2"></i> <i class="fa fa-{icon} mr-2"></i>
{/if} {/if}
<slot /> {@render children?.()}
</div> </div>
{#if loading} {#if loading}
<div class="absolute aspect-square spinner"></div> <div class="absolute aspect-square spinner"></div>

View File

@@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import type { SummitLog } from "$lib/models/summit_log"; import type { SummitLog } from "$lib/models/summit_log";
import { createEventDispatcher } from "svelte";
import { isSameDay, isToday } from "../../util/date_util"; import { isSameDay, isToday } from "../../util/date_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let logs: SummitLog[] = []; interface Props {
export let colorMap: Record<string, string> = {}; logs?: SummitLog[];
colorMap?: Record<string, string>;
onforward?: (data: { start: Date; end: Date }) => void;
onbackward?: (data: { start: Date; end: Date }) => void;
onclick?: (date: Date) => void;
}
const dispatch = createEventDispatcher<{ let {
forward: { logs = [],
start: Date; colorMap = {},
end: Date; onforward,
}; onbackward,
backward: { onclick,
start: Date; }: Props = $props();
end: Date;
};
click: Date;
}>();
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const months = [ const months = [
@@ -34,14 +34,13 @@
"Dezember", "Dezember",
]; ];
const today = new Date(); const today = new Date();
let currentMonth = today.getMonth(); let currentMonth = $state(today.getMonth());
let currentYear = today.getFullYear(); let currentYear = $state(today.getFullYear());
let currentMonthArray: ({ let currentMonthArray: ({
date: Date | undefined; date: Date | undefined;
today: boolean; today: boolean;
log?: SummitLog; log?: SummitLog;
} | null)[]; } | null)[] = $derived(generateMonthArray(currentYear, currentMonth, logs));
$: currentMonthArray = generateMonthArray(currentYear, currentMonth, logs);
function calculateFirstDayOfMonthDayOfWeek(year: number, month: number) { function calculateFirstDayOfMonthDayOfWeek(year: number, month: number) {
const date = new Date(year, month, 1); const date = new Date(year, month, 1);
@@ -94,7 +93,7 @@
} else { } else {
currentMonth++; currentMonth++;
} }
dispatch("forward", { onforward?.({
start: new Date(currentYear, currentMonth, 1), start: new Date(currentYear, currentMonth, 1),
end: new Date(currentYear, currentMonth + 1, 0), end: new Date(currentYear, currentMonth + 1, 0),
}); });
@@ -107,7 +106,7 @@
} else { } else {
currentMonth--; currentMonth--;
} }
dispatch("backward", { onbackward?.({
start: new Date(currentYear, currentMonth, 1), start: new Date(currentYear, currentMonth, 1),
end: new Date(currentYear, currentMonth + 1, 0), end: new Date(currentYear, currentMonth + 1, 0),
}); });
@@ -124,7 +123,7 @@
if (!date) { if (!date) {
return; return;
} }
dispatch("click", date); onclick?.(date);
} }
</script> </script>
@@ -133,10 +132,12 @@
<span class="text-lg">{months[currentMonth]}</span> <span class="text-lg">{months[currentMonth]}</span>
<span>{currentYear}</span> <span>{currentYear}</span>
</div> </div>
<button class="btn-icon mr-2" on:click={monthMinus} <button
><i class="fa fa-caret-left"></i></button aria-label="Previous month"
class="btn-icon mr-2"
onclick={monthMinus}><i class="fa fa-caret-left"></i></button
> >
<button class="btn-icon" on:click={monthPlus} <button aria-label="Next month" class="btn-icon" onclick={monthPlus}
><i class="fa fa-caret-right"></i></button ><i class="fa fa-caret-right"></i></button
> >
</div> </div>
@@ -157,7 +158,7 @@
{#each { length: 42 } as _, i} {#each { length: 42 } as _, i}
<button <button
class="calendar-day flex items-center justify-center rounded-xl" class="calendar-day flex items-center justify-center rounded-xl"
on:click={() => handleDateClick(currentMonthArray[i]?.date)} onclick={() => handleDateClick(currentMonthArray[i]?.date)}
class:today={currentMonthArray[i]?.today} class:today={currentMonthArray[i]?.today}
style="background-color: {colorMap[ style="background-color: {colorMap[
colorKey(currentMonthArray, i) colorKey(currentMonthArray, i)

View File

@@ -1,4 +1,4 @@
<script context="module" lang="ts"> <script module lang="ts">
export type ComboboxItem = { export type ComboboxItem = {
text: string; text: string;
value: any; value: any;
@@ -7,22 +7,41 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, tick } from "svelte"; import type { ChangeEventHandler } from "svelte/elements";
import TextField from "./text_field.svelte"; import TextField from "./text_field.svelte";
import { tick } from "svelte";
export let name: string = ""; interface Props {
export let icon: string = ""; name?: string;
export let label: string = ""; icon?: string;
export let value: string = ""; label?: string;
export let items: ComboboxItem[] = []; value?: string;
export let placeholder: string = ""; items?: ComboboxItem[];
export let extraClasses: string = ""; placeholder?: string;
extraClasses?: string;
onchange?: ChangeEventHandler<HTMLInputElement>;
onupdate?: (q: string) => void;
onclick?: (item: ComboboxItem) => void;
}
const dispatch = createEventDispatcher(); let {
name = "",
icon = "",
label = "",
value = $bindable(""),
items = [],
placeholder = "",
extraClasses = "",
onchange,
onupdate,
onclick,
}: Props = $props();
let searching: boolean = false; let searching: boolean = $state(false);
$: dropDownOpen = value.length > 0 && items.length > 0 && searching; let dropDownOpen = $derived(
value.length > 0 && items.length > 0 && searching,
);
async function onSearchType() { async function onSearchType() {
await tick(); await tick();
@@ -45,12 +64,13 @@
} }
function update(q: string) { function update(q: string) {
dispatch("update", q); onupdate?.(q);
} }
function handleItemClick(item: ComboboxItem) { function handleItemClick(e: Event, item: ComboboxItem) {
e.stopPropagation();
value = item.value; value = item.value;
dispatch("click", item); onclick?.(item);
} }
</script> </script>
@@ -63,10 +83,10 @@
{label} {label}
{placeholder} {placeholder}
bind:value bind:value
on:change {onchange}
on:input={onSearchType} oninput={onSearchType}
on:focusin={() => (searching = true)} onfocusin={() => (searching = true)}
on:focusout={() => (searching = false)} onfocusout={() => (searching = false)}
></TextField> ></TextField>
{#if dropDownOpen} {#if dropDownOpen}
@@ -76,13 +96,13 @@
style="z-index: 1001" style="z-index: 1001"
> >
{#each items as item} {#each items as item}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<li <li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors" class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
tabindex="0" tabindex="0"
on:mousedown|stopPropagation={() => handleItemClick(item)} onmousedown={(e) => handleItemClick(e, item)}
on:keydown|stopPropagation={() => handleItemClick(item)} onkeydown={(e) => handleItemClick(e, item)}
> >
<i class="fa fa-{item.icon} mr-6"></i> <i class="fa fa-{item.icon} mr-6"></i>

View File

@@ -1,10 +1,22 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import type { ChangeEventHandler } from "svelte/elements";
export let name: string = ""; interface Props {
export let value: string | Date = ""; name?: string;
export let label: string = ""; value?: string | Date;
export let error: string | string[] | null = ""; label?: string;
error?: string | string[] | null;
onchange?: ChangeEventHandler<HTMLInputElement>;
}
let {
name = "",
value = $bindable(),
label = "",
error = "",
onchange
}: Props = $props();
</script> </script>
<div> <div>
@@ -21,7 +33,7 @@
class:bg-input-background-error={(error?.length ?? 0) > 0} class:bg-input-background-error={(error?.length ?? 0) > 0}
type="date" type="date"
bind:value bind:value
on:change {onchange}
/> />
</div> </div>

View File

@@ -1,17 +1,25 @@
<script lang="ts"> <script lang="ts">
import * as noUiSlider from "nouislider"; import * as noUiSlider from "nouislider";
import "nouislider/dist/nouislider.css"; import "nouislider/dist/nouislider.css";
import { createEventDispatcher, onMount } from "svelte"; import { onMount } from "svelte";
export let minValue = 0; interface Props {
export let maxValue = 100; minValue?: number;
maxValue?: number;
currentMin?: any;
currentMax?: any;
onset?: (data: [number, number]) => void;
}
export let currentMin = minValue; let {
export let currentMax = maxValue; minValue = 0,
maxValue = 100,
currentMin = $bindable(minValue),
currentMax = $bindable(maxValue),
onset,
}: Props = $props();
let sliderContainer: any; let sliderContainer: any = $state();
const dispatch = createEventDispatcher();
onMount(() => { onMount(() => {
const updateValues = (values: string[]) => { const updateValues = (values: string[]) => {
@@ -31,7 +39,7 @@
sliderContainer.noUiSlider.on("update", updateValues); sliderContainer.noUiSlider.on("update", updateValues);
sliderContainer.noUiSlider.on("set", () => { sliderContainer.noUiSlider.on("set", () => {
dispatch("set", [currentMin, currentMax]); onset?.([currentMin, currentMax]);
}); });
}); });
</script> </script>

View File

@@ -1,12 +1,20 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { beforeNavigate } from "$app/navigation"; import { beforeNavigate } from "$app/navigation";
import type { Snippet } from 'svelte';
import { backIn, backOut } from "svelte/easing"; import { backIn, backOut } from "svelte/easing";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
export let open: boolean = false; interface Props {
open?: boolean;
children?: Snippet;
}
$: if (open && browser) { let { open = $bindable(false), children }: Props = $props();
$effect(() => {
if (open && browser) {
document.body.style.position = "fixed"; document.body.style.position = "fixed";
document.body.style.top = `-${window.scrollY}px`; document.body.style.top = `-${window.scrollY}px`;
} else if (browser) { } else if (browser) {
@@ -15,6 +23,7 @@
document.body.style.top = ""; document.body.style.top = "";
window.scrollTo(0, parseInt(scrollY || "0") * -1); window.scrollTo(0, parseInt(scrollY || "0") * -1);
} }
});
beforeNavigate(() => { beforeNavigate(() => {
open = false; open = false;
@@ -24,8 +33,8 @@
{#if open} {#if open}
<div <div
class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500/50 cursor-pointer" class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500/50 cursor-pointer"
on:click={() => (open = false)} onclick={() => (open = false)}
on:keydown={(e) => { onkeydown={(e) => {
if (e.key == "Escape") open = false; if (e.key == "Escape") open = false;
}} }}
role="button" role="button"
@@ -36,6 +45,6 @@
in:slide={{ axis: "x", easing: backOut }} in:slide={{ axis: "x", easing: backOut }}
out:slide={{ axis: "x", easing: backIn }} out:slide={{ axis: "x", easing: backIn }}
> >
<slot /> {@render children?.()}
</div> </div>
{/if} {/if}

View File

@@ -1,4 +1,4 @@
<script context="module" lang="ts"> <script module lang="ts">
export type DropdownItem = { export type DropdownItem = {
text: string; text: string;
value: any; value: any;
@@ -7,15 +7,19 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; import { type Snippet } from "svelte";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
export let items: DropdownItem[] = []; interface Props {
export let size: string = "regular"; items?: DropdownItem[];
size?: string;
children?: Snippet<[any]>;
onchange?: (item: DropdownItem) => void;
}
const dispatch = createEventDispatcher(); let { items = [], size = "regular", children, onchange }: Props = $props();
let isOpen = false; let isOpen = $state(false);
export function toggleMenu(e: MouseEvent) { export function toggleMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
@@ -27,13 +31,18 @@
isOpen = false; isOpen = false;
} }
function handleItemClick(item: { text: string; value: any }) { function handleItemClick(e: Event, item: { text: string; value: any }) {
dispatch("change", item); e.stopPropagation();
onchange?.(item);
closeMenu(); closeMenu();
} }
function handleWindowClick(e: MouseEvent) { function handleWindowClick(e: MouseEvent) {
if ((e.target as HTMLElement).parentElement?.classList.contains("dropdown-toggle")) { if (
(e.target as HTMLElement).parentElement?.classList.contains(
"dropdown-toggle",
)
) {
return; return;
} }
@@ -41,19 +50,20 @@
} }
</script> </script>
<svelte:window on:mouseup={handleWindowClick} /> <svelte:window onmouseup={handleWindowClick} />
<div class="dropdown relative"> <div class="dropdown relative">
<div class="dropdown-toggle"> <div class="dropdown-toggle">
<slot {toggleMenu}> {#if children}{@render children({ toggleMenu })}{:else}
<button <button
aria-label="Toggle menu"
class="btn-icon flex items-center justify-center" class="btn-icon flex items-center justify-center"
on:click={toggleMenu} onclick={toggleMenu}
type="button" type="button"
> >
<i class="fa fa-ellipsis-vertical text-{size}"></i> <i class="fa fa-ellipsis-vertical text-{size}"></i>
</button> </button>
</slot> {/if}
</div> </div>
{#if isOpen} {#if isOpen}
@@ -68,7 +78,7 @@
<li <li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors" class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
role="presentation" role="presentation"
on:mousedown|stopPropagation={() => handleItemClick(item)} onmousedown={(e) => handleItemClick(e, item)}
> >
{#if item.icon} {#if item.icon}
<i class="fa fa-{item.icon} mr-3"></i> <i class="fa fa-{item.icon} mr-3"></i>

View File

@@ -1,9 +1,21 @@
<script lang="ts"> <script lang="ts">
export let label: string = ""; import { createBubbler } from 'svelte/legacy';
export let name: string = "";
export let accept: string = "*";
export let files: FileList | null = null; const bubble = createBubbler();
interface Props {
label?: string;
name?: string;
accept?: string;
files?: FileList | null;
}
let {
label = "",
name = "",
accept = "*",
files = $bindable(null)
}: Props = $props();
</script> </script>
<div> <div>
@@ -18,6 +30,6 @@
bind:files bind:files
class="cursor-pointer bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full file:rounded-lg file:px-4 file:py-2 file:border file:border-input-border-focus file:font-medium file:mr-4" class="cursor-pointer bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full file:rounded-lg file:px-4 file:py-2 file:border file:border-input-border-focus file:font-medium file:mr-4"
type="file" type="file"
on:change onchange={bubble('change')}
/> />
</div> </div>

View File

@@ -1,7 +1,23 @@
<script lang="ts"> <script lang="ts">
export let id: string; import type { Snippet } from "svelte";
export let title: string;
export let size: string = "max-w-2xl"; interface Props {
id: string;
title: string;
size?: string;
children?: Snippet<[any]>;
content?: Snippet;
footer?: Snippet<[any]>;
}
let {
id,
title,
size = "max-w-2xl",
children,
content,
footer,
}: Props = $props();
export function openModal() { export function openModal() {
document.body.style.top = `-${window.scrollY}px`; document.body.style.top = `-${window.scrollY}px`;
@@ -21,7 +37,7 @@
} }
</script> </script>
<slot {openModal} /> {@render children?.({ openModal })}
<dialog <dialog
{id} {id}
tabindex="-1" tabindex="-1"
@@ -40,7 +56,7 @@
<button <button
type="button" type="button"
class="rounded-full btn-icon" class="rounded-full btn-icon"
on:click={closeModal} onclick={closeModal}
> >
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
<span class="sr-only">Close modal</span> <span class="sr-only">Close modal</span>
@@ -48,11 +64,11 @@
</div> </div>
<!-- Modal body --> <!-- Modal body -->
<div class="p-4 md:p-5 space-y-4"> <div class="p-4 md:p-5 space-y-4">
<slot name="content" /> {@render content?.()}
</div> </div>
<!-- Modal footer --> <!-- Modal footer -->
<div class="p-4 md:p-5 border-t border-separator rounded-b"> <div class="p-4 md:p-5 border-t border-separator rounded-b">
<slot name="footer" {closeModal} /> {@render footer?.({ closeModal })}
</div> </div>
</div> </div>
</dialog> </dialog>

View File

@@ -1,45 +1,58 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import type { SelectItem } from "./select.svelte"; import type { SelectItem } from "./select.svelte";
export let items: SelectItem[] = []; interface Props {
export let value: SelectItem[] = []; items?: SelectItem[];
export let label: string = ""; value?: SelectItem[];
export let name: string = ""; label?: string;
export let placeholder: string = ""; name?: string;
placeholder?: string;
onchange?: (value: SelectItem[]) => void
}
let showDropdown = false; let {
let dropdownRef; items = [],
value = $bindable([]),
label = "",
name = "",
placeholder = "",
onchange
}: Props = $props();
const dispatch = createEventDispatcher(); let showDropdown = $state(false);
let dropdownRef = $state();
function toggleItem(item: SelectItem) { function toggleItem(item: SelectItem) {
if (value.includes(item)) { const itemIndex = value.findIndex((i) => i.value == item.value);
value = value.filter((i) => i !== item);
if (itemIndex >= 0) {
value.splice(itemIndex, 1);
} else { } else {
value = [...value, item]; value.push(item);
} }
dispatch("change", value); onchange?.(value);
} }
function removeItem(item: SelectItem) { function removeItem(e: Event, item: SelectItem) {
e.stopPropagation();
value = value.filter((i) => i !== item); value = value.filter((i) => i !== item);
dispatch("change", value); onchange?.(value);
} }
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="relative max-w-full"> <div class="relative max-w-full">
{#if label.length} {#if label.length}
<label for={name} class="text-sm font-medium pb-1 block"> <label for={name} class="text-sm font-medium pb-1 block">
{label} {label}
</label> </label>
{/if} {/if}
<button <div
role="presentation"
class="min-w-44 w-full flex flex-wrap items-center gap-2 border border-input-border bg-input-background min-h-[50px] p-3 rounded-md transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0" class="min-w-44 w-full flex flex-wrap items-center gap-2 border border-input-border bg-input-background min-h-[50px] p-3 rounded-md transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
on:click={() => (showDropdown = !showDropdown)} onclick={() => (showDropdown = !showDropdown)}
> >
{#if value.length === 0} {#if value.length === 0}
<span class="text-gray-400">{placeholder}</span> <span class="text-gray-400">{placeholder}</span>
@@ -50,14 +63,15 @@
> >
<span class="text-sm">{$_(item.text)}</span> <span class="text-sm">{$_(item.text)}</span>
<button <button
on:click|stopPropagation={() => removeItem(item)} aria-label="Close"
onclick={(e) => removeItem(e, item)}
class="text-white hover:bg-primary-hover rounded-full w-4 h-4 flex items-center justify-center" class="text-white hover:bg-primary-hover rounded-full w-4 h-4 flex items-center justify-center"
> >
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</button> </button>
</div> </div>
{/each} {/each}
</button> </div>
<!-- Dropdown menu --> <!-- Dropdown menu -->
{#if showDropdown} {#if showDropdown}
@@ -67,11 +81,11 @@
> >
{#each items as item} {#each items as item}
<button <button
on:click={() => toggleItem(item)} onclick={() => toggleItem(item)}
class="px-3 py-2 hover:bg-menu-item-background-hover cursor-pointer flex justify-between items-center w-full" class="px-3 py-2 hover:bg-menu-item-background-hover cursor-pointer flex justify-between items-center w-full"
> >
<span>{$_(item.text)}</span> <span>{$_(item.text)}</span>
{#if value.includes(item)} {#if value.findIndex((i) => i.value == item.value) >= 0}
<div class="ml-auto"> <div class="ml-auto">
<i class="fa fa-check"></i> <i class="fa fa-check"></i>
</div> </div>

View File

@@ -1,20 +1,23 @@
<script lang="ts"> <script lang="ts">
import { range } from "$lib/util/array_util"; import { range } from "$lib/util/array_util";
import { createEventDispatcher } from "svelte";
export let page: number; interface Props {
export let totalPages: number; page: number;
totalPages: number;
onpagination?: (page: number) => void;
}
let { page, totalPages, onpagination }: Props = $props();
const maxPagesToRender = 6; const maxPagesToRender = 6;
const dispatch = createEventDispatcher();
function update(clickedPage: number) { function update(clickedPage: number) {
if (page !== clickedPage) { if (page !== clickedPage) {
dispatch("pagination", clickedPage); onpagination?.(clickedPage);
} }
} }
$: numberGenerator = () => { let numberGenerator = $derived(() => {
if (totalPages < 4) { if (totalPages < 4) {
return []; return [];
} }
@@ -31,21 +34,22 @@
return range(page + Math.max(1, maxPagesToRender - 5), page - 2); return range(page + Math.max(1, maxPagesToRender - 5), page - 2);
} }
return []; return [];
}; });
</script> </script>
<div class="flex gap-x-4 justify-center"> <div class="flex gap-x-4 justify-center">
{#if totalPages > 1} {#if totalPages > 1}
<button <button
aria-label="Next page"
class:text-gray-500={page == 1} class:text-gray-500={page == 1}
disabled={page == 1} disabled={page == 1}
on:click={() => update(page - 1)} onclick={() => update(page - 1)}
><i class="fa fa-caret-left"></i></button ><i class="fa fa-caret-left"></i></button
> >
<button <button
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover" class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
class:border={page == 1} class:border={page == 1}
on:click={() => update(1)}>1</button onclick={() => update(1)}>1</button
> >
{#if totalPages > maxPagesToRender && page >= maxPagesToRender - 2 && page > 2} {#if totalPages > maxPagesToRender && page >= maxPagesToRender - 2 && page > 2}
<span>...</span> <span>...</span>
@@ -53,14 +57,14 @@
<button <button
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover" class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
class:border={page == 2} class:border={page == 2}
on:click={() => update(2)}>2</button onclick={() => update(2)}>2</button
> >
{/if} {/if}
{#each numberGenerator() as i} {#each numberGenerator() as i}
<button <button
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover" class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
class:border={page == i + 1} class:border={page == i + 1}
on:click={() => update(i + 1)}>{i + 1}</button onclick={() => update(i + 1)}>{i + 1}</button
> >
{/each} {/each}
{#if totalPages > maxPagesToRender && page < totalPages - 3} {#if totalPages > maxPagesToRender && page < totalPages - 3}
@@ -69,18 +73,19 @@
<button <button
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover" class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
class:border={page == totalPages - 1} class:border={page == totalPages - 1}
on:click={() => update(totalPages - 1)}>{totalPages - 1}</button onclick={() => update(totalPages - 1)}>{totalPages - 1}</button
> >
{/if} {/if}
<button <button
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover" class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
class:border={page == totalPages} class:border={page == totalPages}
on:click={() => update(totalPages)}>{totalPages}</button onclick={() => update(totalPages)}>{totalPages}</button
> >
<button <button
aria-label="Previous page"
class:text-gray-500={page == totalPages} class:text-gray-500={page == totalPages}
disabled={page == totalPages} disabled={page == totalPages}
on:click={() => update(page + 1)} onclick={() => update(page + 1)}
><i class="fa fa-caret-right"></i></button ><i class="fa fa-caret-right"></i></button
> >
{/if} {/if}

View File

@@ -1,4 +1,4 @@
<script context="module" lang="ts"> <script module lang="ts">
export type RadioItem = { export type RadioItem = {
text: string; text: string;
value: string; value: string;
@@ -8,17 +8,19 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; interface Props {
items: RadioItem[];
name: string;
selected?: number;
onchange?: (item: RadioItem) => void
}
export let items: RadioItem[]; let { items, name, selected = $bindable(0), onchange }: Props = $props();
export let name: string;
export let selected: number = 0;
const dispatch = createEventDispatcher();
function handleRadioChange(radioIndex: number) { function handleRadioChange(radioIndex: number) {
selected = radioIndex; selected = radioIndex;
dispatch("change", items[selected]); onchange?.(items[selected]);
} }
</script> </script>
@@ -31,7 +33,7 @@
checked={i == selected} checked={i == selected}
value={item.value} value={item.value}
class="w-4 h-4 accent-primary border-input-border focus:ring-input-ring focus:ring-2" class="w-4 h-4 accent-primary border-input-border focus:ring-input-ring focus:ring-2"
on:change={() => handleRadioChange(i)} onchange={() => handleRadioChange(i)}
/> />
<div class="ms-2 text-sm"> <div class="ms-2 text-sm">
<label for="{name}-radio-{i}" <label for="{name}-radio-{i}"

View File

@@ -1,4 +1,5 @@
<script context="module" lang="ts"> <!-- @migration-task Error while migrating Svelte code: This migration would change the name of a slot making the component unusable -->
<script module lang="ts">
export type SearchItem = { export type SearchItem = {
text: string; text: string;
description?: string; description?: string;
@@ -8,26 +9,43 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import TextField from "./text_field.svelte"; import TextField from "./text_field.svelte";
import type { Snippet } from "svelte";
export let maxSearchLength: number = 5; interface Props {
export let value: string = ""; maxSearchLength?: number;
export let items: SearchItem[] = []; value?: string;
export let placeholder: string = "Search..."; items?: SearchItem[];
export let large: boolean = false; placeholder?: string;
export let extraClasses: string = ""; large?: boolean;
export let label: string = ""; extraClasses?: string;
export let clearAfterSelect: boolean = true; label?: string;
clearAfterSelect?: boolean;
prepend?: Snippet<[any]>;
onupdate?: (q: string) => void;
onclick?: (item: SearchItem) => void;
}
const dispatch = createEventDispatcher(); let {
maxSearchLength = 5,
value = $bindable(""),
items = $bindable([]),
placeholder = "Search...",
large = false,
extraClasses = "",
label = "",
clearAfterSelect = true,
prepend,
onupdate,
onclick
}: Props = $props();
let lastSearch: string = ""; let lastSearch: string = "";
let searching: boolean = false; let searching: boolean = $state(false);
let typingTimer!: any; let typingTimer!: any;
$: dropDownOpen = value.length > 0 && items.length > 0 && searching; let dropDownOpen = $derived(value.length > 0 && items.length > 0 && searching);
function onSearchType() { function onSearchType() {
clearTimeout(typingTimer); clearTimeout(typingTimer);
@@ -42,12 +60,13 @@
function update(q: string) { function update(q: string) {
lastSearch = q; lastSearch = q;
dispatch("update", q); onupdate?.(q);
} }
function handleItemClick(item: SearchItem) { function handleItemClick(e: Event, item: SearchItem) {
e.stopPropagation();
searching = false; searching = false;
dispatch("click", item); onclick?.(item);
if (clearAfterSelect) { if (clearAfterSelect) {
clear(); clear();
} }
@@ -70,11 +89,12 @@
</span> </span>
{#if value.length > 0} {#if value.length > 0}
<button <button
aria-label="Clear"
type="button" type="button"
class="btn-icon absolute {large class="btn-icon absolute {large
? 'bottom-[31px]' ? 'bottom-[31px]'
: 'bottom-[25px]'} translate-y-1/2 right-0 mr-2" : 'bottom-[25px]'} translate-y-1/2 right-0 mr-2"
on:click={clear} onclick={clear}
in:fade={{ duration: 150 }} in:fade={{ duration: 150 }}
out:fade={{ duration: 150 }} out:fade={{ duration: 150 }}
> >
@@ -91,9 +111,9 @@
: "px-10"} : "px-10"}
{placeholder} {placeholder}
bind:value bind:value
on:input={onSearchType} oninput={onSearchType}
on:focusin={() => (searching = true)} onfocusin={() => (searching = true)}
on:focusout={() => (searching = false)} onfocusout={() => (searching = false)}
></TextField> ></TextField>
{#if dropDownOpen} {#if dropDownOpen}
@@ -103,17 +123,17 @@
style="z-index: 1001" style="z-index: 1001"
> >
{#each items as item} {#each items as item}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<li <li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors" class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
tabindex="0" tabindex="0"
on:mousedown|stopPropagation={() => handleItemClick(item)} onmousedown={(e) => handleItemClick(e, item)}
on:keydown|stopPropagation={() => handleItemClick(item)} onkeydown={(e) => handleItemClick(e, item)}
> >
<slot name="item-header" {item}> {#if prepend}{@render prepend({ item, })}{:else}
<i class="fa fa-{item.icon} mr-6"></i> <i class="fa fa-{item.icon} mr-6"></i>
</slot> {/if}
<div> <div>
<p>{item.text}</p> <p>{item.text}</p>

View File

@@ -1,4 +1,4 @@
<script context="module" lang="ts"> <script module lang="ts">
export type SelectItem = { export type SelectItem = {
text: string; text: string;
value: any; value: any;
@@ -6,18 +6,26 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; interface Props {
name?: string;
items?: SelectItem[];
value?: any;
label?: string;
disabled?: boolean;
onchange?: (value: any) => void
}
export let name: string = ""; let {
export let items: SelectItem[] = []; name = "",
export let value: any = items.at(0)?.value ?? ""; items = [],
export let label: string = ""; value = $bindable(items.at(0)?.value ?? ""),
export let disabled: boolean = false; label = "",
disabled = false,
const dispatch = createEventDispatcher(); onchange
}: Props = $props();
function onChange(target: any) { function onChange(target: any) {
dispatch("change", target?.value); onchange?.(target?.value);
} }
</script> </script>
@@ -33,7 +41,7 @@
class:text-gray-500={disabled} class:text-gray-500={disabled}
{disabled} {disabled}
bind:value bind:value
on:change={(e) => onChange(e.target)} onchange={(e) => onChange(e.target)}
> >
{#each items as item} {#each items as item}
<option value={item.value}>{item.text}</option> <option value={item.value}>{item.text}</option>

View File

@@ -1,16 +1,23 @@
<script lang="ts"> <script lang="ts">
import noUiSlider from "nouislider"; import noUiSlider from "nouislider";
import "nouislider/dist/nouislider.css"; import "nouislider/dist/nouislider.css";
import { createEventDispatcher, onMount } from "svelte"; import { onMount } from "svelte";
export let minValue = 0; interface Props {
export let maxValue = 100; minValue?: number;
maxValue?: number;
currentValue?: any;
onset?: (value: number) => void;
}
export let currentValue = maxValue / 2; let {
minValue = 0,
maxValue = 100,
currentValue = $bindable(maxValue / 2),
onset,
}: Props = $props();
let sliderContainer: any; let sliderContainer: any = $state();
const dispatch = createEventDispatcher();
onMount(() => { onMount(() => {
const updateValues = (values: string[]) => { const updateValues = (values: string[]) => {
@@ -29,7 +36,7 @@
sliderContainer.noUiSlider.on("update", updateValues); sliderContainer.noUiSlider.on("update", updateValues);
sliderContainer.noUiSlider.on("set", () => { sliderContainer.noUiSlider.on("set", () => {
dispatch("set", currentValue); onset?.(currentValue);
}); });
}); });
</script> </script>

View File

@@ -1,18 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { cubicOut } from "svelte/easing"; import { cubicOut } from "svelte/easing";
import { tweened } from "svelte/motion"; import { Tween } from "svelte/motion";
export let tabs: string[]; interface Props {
export let activeTab: number; tabs: string[];
export let extraClasses: string = ""; activeTab: number;
extraClasses?: string;
}
const indicatorPosition = tweened(0, { let { tabs, activeTab = $bindable(), extraClasses = "" }: Props = $props();
const indicatorPosition = new Tween(0, {
duration: 300, duration: 300,
easing: cubicOut, easing: cubicOut,
}); });
const indicatorWidth = tweened(0, { const indicatorWidth = new Tween(0, {
duration: 300, duration: 300,
easing: cubicOut, easing: cubicOut,
}); });
@@ -37,13 +41,13 @@
<div id="tabs" class="flex gap-2 overflow-x-auto relative {extraClasses}"> <div id="tabs" class="flex gap-2 overflow-x-auto relative {extraClasses}">
<div <div
class="absolute h-full bg-menu-item-background-hover rounded-t-lg top-0 z-0" class="absolute h-full bg-menu-item-background-hover rounded-t-lg top-0 z-0"
style="width: {$indicatorWidth}px; left: {$indicatorPosition}px;" style="width: {indicatorWidth.current}px; left: {indicatorPosition.current}px;"
></div> ></div>
{#each tabs as tab, i} {#each tabs as tab, i}
<button <button
class="tab z-10" class="tab z-10"
class:tab-active={activeTab == i} class:tab-active={activeTab == i}
on:click={() => switchTabs(i)}>{tab}</button onclick={() => switchTabs(i)}>{tab}</button
> >
{/each} {/each}
</div> </div>

View File

@@ -1,16 +1,40 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import type { ChangeEventHandler, FocusEventHandler, FormEventHandler } from "svelte/elements";
export let name: string = ""; interface Props {
export let value: string | number = ""; name?: string;
export let placeholder: string = ""; value?: string | number;
export let disabled: boolean = false; placeholder?: string;
export let label: string = ""; disabled?: boolean;
export let error: string | string[] | null = ""; label?: string;
export let icon: string = ""; error?: string | string[] | null;
export let extraClasses: string = ""; icon?: string;
export let type: "text" | "password" | "search" = "text"; extraClasses?: string;
export let autocomplete: "on" | "off" = "on"; type?: "text" | "password" | "search";
autocomplete?: "on" | "off";
onchange?: ChangeEventHandler<HTMLInputElement>;
oninput?: FormEventHandler<HTMLInputElement>;
onfocusin?: FocusEventHandler<HTMLInputElement>;
onfocusout?: FocusEventHandler<HTMLInputElement>;
}
let {
name = "",
value = $bindable(""),
placeholder = "",
disabled = false,
label = "",
error = "",
icon = "",
extraClasses = "",
type = "text",
autocomplete = "on",
onchange,
oninput,
onfocusin,
onfocusout
}: Props = $props();
function typeAction(node: HTMLInputElement) { function typeAction(node: HTMLInputElement) {
node.type = type; node.type = type;
} }
@@ -38,10 +62,10 @@
{autocomplete} {autocomplete}
use:typeAction use:typeAction
bind:value bind:value
on:change {onchange}
on:input {oninput}
on:focusin {onfocusin}
on:focusout {onfocusout}
{placeholder} {placeholder}
/> />
</div> </div>

View File

@@ -1,13 +1,28 @@
<script lang="ts"> <script lang="ts">
import { createBubbler } from 'svelte/legacy';
const bubble = createBubbler();
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let name: string = ""; interface Props {
export let value: string | number = ""; name?: string;
export let placeholder: string = ""; value?: string | number;
export let rows: number = 3; placeholder?: string;
export let label: string = ""; rows?: number;
export let error: string | string[] | null = ""; label?: string;
export let extraClasses: string = ""; error?: string | string[] | null;
extraClasses?: string;
}
let {
name = "",
value = $bindable(""),
placeholder = "",
rows = 3,
label = "",
error = "",
extraClasses = ""
}: Props = $props();
</script> </script>
<div> <div>
@@ -24,8 +39,8 @@
class:border-red-400={(error?.length ?? 0) > 0} class:border-red-400={(error?.length ?? 0) > 0}
class:bg-input-background-error={(error?.length ?? 0) > 0} class:bg-input-background-error={(error?.length ?? 0) > 0}
bind:value bind:value
on:change onchange={bubble('change')}
/> ></textarea>
{#if error} {#if error}
<span class="textfield-error text-xs text-red-400"> <span class="textfield-error text-xs text-red-400">
{error instanceof Array ? $_(error[0]) : error} {error instanceof Array ? $_(error[0]) : error}

View File

@@ -25,7 +25,7 @@
<button <button
type="button" type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white ml-6" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white ml-6"
on:click={closeToast} onclick={closeToast}
> >
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
<span class="sr-only">Close modal</span> <span class="sr-only">Close modal</span>

View File

@@ -1,15 +1,24 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
export let name: string = ""; interface Props {
export let value: boolean = false; name?: string;
export let label: string = ""; value?: boolean;
export let error: string = ""; label?: string;
error?: string;
onchange?: (value: boolean) => void
}
let {
name = "",
value = $bindable(false),
label = "",
error = "",
onchange
}: Props = $props();
const dispatch = createEventDispatcher();
function handleToggleChange() { function handleToggleChange() {
dispatch("change", value); onchange?.(value);
} }
</script> </script>
@@ -20,7 +29,7 @@
type="checkbox" type="checkbox"
class="sr-only peer" class="sr-only peer"
value="1" value="1"
on:change={handleToggleChange} onchange={handleToggleChange}
/> />
<div <div
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary" class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"

View File

@@ -2,7 +2,11 @@
import type { Category } from "$lib/models/category"; import type { Category } from "$lib/models/category";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let category: Category; interface Props {
category: Category;
}
let { category }: Props = $props();
</script> </script>
<div <div

View File

@@ -1,34 +1,43 @@
<script lang="ts"> <script lang="ts">
import type { Comment } from "$lib/models/comment"; import type { Comment } from "$lib/models/comment";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { createEventDispatcher } from "svelte";
import TextField from "../base/text_field.svelte"; import TextField from "../base/text_field.svelte";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { formatTimeSince } from "$lib/util/format_util"; import { formatTimeSince } from "$lib/util/format_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let comment: Comment; interface Props {
export let mode: "show" | "edit" = "show"; comment: Comment;
mode?: "show" | "edit";
ondelete?: (comment: Comment) => void
onedit?: (data: {comment: Comment, text: string}) => void
}
const dispatch = createEventDispatcher(); let { comment, mode = "show", ondelete, onedit }: Props = $props();
let editing: boolean = false; let editing: boolean = $state(false);
let editedComment = comment.text; let editedComment = $state(comment.text);
$: avatarSrc = comment.expand?.author.avatar $effect(() => {
editedComment = comment.text;
});
let avatarSrc = $derived(
comment.expand?.author.avatar
? getFileURL(comment.expand.author, comment.expand.author.avatar) ? getFileURL(comment.expand.author, comment.expand.author.avatar)
: `https://api.dicebear.com/7.x/initials/svg?seed=${comment.expand?.author.username ?? ""}&backgroundType=gradientLinear`; : `https://api.dicebear.com/7.x/initials/svg?seed=${comment.expand?.author.username ?? ""}&backgroundType=gradientLinear`,
);
$: timeSince = formatTimeSince(new Date(comment.created ?? "")); let timeSince = $derived(formatTimeSince(new Date(comment.created ?? "")));
function deleteComment() { function deleteComment() {
dispatch("delete", comment); ondelete?.(comment)
} }
function toggleEdit() { function toggleEdit() {
if (editing) { if (editing) {
dispatch("edit", { comment: comment, text: editedComment }); onedit?.({ comment: comment, text: editedComment });
} }
editing = !editing; editing = !editing;
} }
@@ -74,22 +83,27 @@
<span class="text-xs text-gray-500 ml-2" <span class="text-xs text-gray-500 ml-2"
>{$_(`n-${timeSince.unit}-ago`, { >{$_(`n-${timeSince.unit}-ago`, {
values: { n: timeSince.value }, values: { n: timeSince.value },
})} {comment.updated != comment.created ? `(${$_("edited")})` : ""}</span })}
{comment.updated != comment.created
? `(${$_("edited")})`
: ""}</span
> >
</p> </p>
{#if mode == "edit"} {#if mode == "edit"}
<button <button
aria-label="Edit comment"
type="button" type="button"
class="btn-icon ml-2" class="btn-icon ml-2"
style="font-size: 0.75rem;" style="font-size: 0.75rem;"
on:click={toggleEdit} onclick={toggleEdit}
><i class="fa fa-{editing ? 'check' : 'pen'}"></i></button ><i class="fa fa-{editing ? 'check' : 'pen'}"></i></button
> >
<button <button
aria-label="Delete comment"
type="button" type="button"
class="btn-icon text-xs" class="btn-icon text-xs"
style="font-size: 0.75rem;" style="font-size: 0.75rem;"
on:click={deleteComment}><i class="fa fa-trash"></i></button onclick={deleteComment}><i class="fa fa-trash"></i></button
> >
{/if} {/if}
</div> </div>

View File

@@ -1,32 +1,51 @@
<script lang="ts"> <script lang="ts">
import Modal from "$lib/components/base/modal.svelte"; import Modal from "$lib/components/base/modal.svelte";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let openModal: (() => void) | undefined = undefined; interface Props {
export let closeModal: (() => void) | undefined = undefined; title?: string;
text: string;
action?: string;
id?: string;
onconfirm?: () => void
}
export let title: string = $_('confirm-deletion'); let {
export let text: string; title = $_("confirm-deletion"),
export let action: string = "delete"; text,
export let id: string = "confirm-modal"; action = "delete",
id = "confirm-modal",
onconfirm
}: Props = $props();
const dispatch = createEventDispatcher(); let modal: Modal;
export function openModal() {
modal.openModal();
}
function confirm() { function confirm() {
dispatch("confirm"); onconfirm?.()
closeModal!(); modal.closeModal!();
} }
</script> </script>
<Modal {id} {title} bind:openModal bind:closeModal> <Modal {id} {title} bind:this={modal}>
<p slot="content">{text}</p> {#snippet content()}
<div slot="footer" class="flex items-center gap-4"> <p>{text}</p>
<button class="btn-secondary" on:click={closeModal} {/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button >{$_("cancel")}</button
> >
<button id="confirm" class={action === "delete" ? "btn-danger" : "btn-primary"} type="button" on:click={confirm} name="delete" <button
>{$_(action)}</button id="confirm"
class={action === "delete" ? "btn-danger" : "btn-primary"}
type="button"
onclick={confirm}
name="delete">{$_(action)}</button
> >
</div></Modal </div>
{/snippet}</Modal
> >

View File

@@ -4,7 +4,11 @@
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let width: number = 192; interface Props {
width?: number;
}
let { width = 192 }: Props = $props();
</script> </script>
<div class="flex flex-col justify-center items-center gap-y-4"> <div class="flex flex-col justify-center items-center gap-y-4">

View File

@@ -4,7 +4,11 @@
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let width: number = 192; interface Props {
width?: number;
}
let { width = 192 }: Props = $props();
</script> </script>
<div class="flex flex-col justify-center items-center gap-y-4"> <div class="flex flex-col justify-center items-center gap-y-4">

View File

@@ -4,7 +4,11 @@
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let width: number = 192; interface Props {
width?: number;
}
let { width = 192 }: Props = $props();
</script> </script>
<div class="flex flex-col justify-center items-center gap-y-4"> <div class="flex flex-col justify-center items-center gap-y-4">

View File

@@ -4,7 +4,11 @@
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let width: number = 256; interface Props {
width?: number;
}
let { width = 256 }: Props = $props();
</script> </script>
<div class="flex flex-col justify-center items-center gap-y-4"> <div class="flex flex-col justify-center items-center gap-y-4">

View File

@@ -4,7 +4,11 @@
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let width: number = 192; interface Props {
width?: number;
}
let { width = 192 }: Props = $props();
</script> </script>
<div class="flex flex-col justify-center items-center gap-y-4"> <div class="flex flex-col justify-center items-center gap-y-4">

View File

@@ -12,30 +12,34 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte"; import ShareInfo from "../share_info.svelte";
export let list: List; interface Props {
export let active: boolean = false; list: List;
active?: boolean;
}
$: cumulativeDistance = list.expand?.trails?.reduce( let { list, active = false }: Props = $props();
let cumulativeDistance = $derived(list.expand?.trails?.reduce(
(s, b) => s + b.distance!, (s, b) => s + b.distance!,
0, 0,
); ));
$: cumulativeElevationGain = list.expand?.trails?.reduce( let cumulativeElevationGain = $derived(list.expand?.trails?.reduce(
(s, b) => s + b.elevation_gain!, (s, b) => s + b.elevation_gain!,
0, 0,
); ));
$: cumulativeElevationLoss = list.expand?.trails?.reduce( let cumulativeElevationLoss = $derived(list.expand?.trails?.reduce(
(s, b) => s + b.elevation_loss!, (s, b) => s + b.elevation_loss!,
0, 0,
); ));
$: cumulativeDuration = list.expand?.trails?.reduce( let cumulativeDuration = $derived(list.expand?.trails?.reduce(
(s, b) => s + b.duration!, (s, b) => s + b.duration!,
0, 0,
); ));
$: listIsShared = (list.expand?.list_share_via_list?.length ?? 0) > 0; let listIsShared = $derived((list.expand?.list_share_via_list?.length ?? 0) > 0);
</script> </script>
<div <div

View File

@@ -11,44 +11,50 @@
formatElevation, formatElevation,
formatTimeHHMM, formatTimeHHMM,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Dropdown from "../base/dropdown.svelte"; import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import ShareInfo from "../share_info.svelte"; import ShareInfo from "../share_info.svelte";
import TrailListItem from "../trail/trail_list_item.svelte"; import TrailListItem from "../trail/trail_list_item.svelte";
export let list: List; interface Props {
list: List;
onclick?: (data: { trail: Trail; index: number }) => void;
onmouseenter?: (data: { trail: Trail; index: number }) => void;
onmouseleave?: (data: { trail: Trail; index: number }) => void;
onchange?: (item: DropdownItem) => void;
}
const dispatch = createEventDispatcher(); let { list, onclick, onmouseenter, onmouseleave, onchange }: Props =
$props();
$: cumulativeDistance = list.expand?.trails?.reduce( let cumulativeDistance = $derived(
(s, b) => s + b.distance!, list.expand?.trails?.reduce((s, b) => s + b.distance!, 0),
0,
); );
$: cumulativeElevationGain = list.expand?.trails?.reduce( let cumulativeElevationGain = $derived(
(s, b) => s + b.elevation_gain!, list.expand?.trails?.reduce((s, b) => s + b.elevation_gain!, 0),
0,
); );
$: cumulativeElevationLoss = list.expand?.trails?.reduce( let cumulativeElevationLoss = $derived(
(s, b) => s + b.elevation_loss!, list.expand?.trails?.reduce((s, b) => s + b.elevation_loss!, 0),
0,
); );
$: cumulativeDuration = list.expand?.trails?.reduce( let cumulativeDuration = $derived(
(s, b) => s + b.duration!, list.expand?.trails?.reduce((s, b) => s + b.duration!, 0),
0,
); );
$: allowEdit = let allowEdit = $derived(
list.author == $currentUser?.id || list.author == $currentUser?.id ||
list.expand?.list_share_via_list?.some((s) => s.permission == "edit"); list.expand?.list_share_via_list?.some(
(s) => s.permission == "edit",
),
);
$: allowShare = let allowShare = $derived(
list.author == $currentUser?.id && list.author == $currentUser?.id &&
!list.expand?.trails?.some((t) => t.author !== $currentUser?.id); !list.expand?.trails?.some((t) => t.author !== $currentUser?.id),
);
$: dropdownItems = [ let dropdownItems = $derived([
...(allowShare ...(allowShare
? [{ text: $_("share"), value: "share", icon: "share" }] ? [{ text: $_("share"), value: "share", icon: "share" }]
: []), : []),
@@ -58,22 +64,24 @@
...(list.author == $currentUser?.id ...(list.author == $currentUser?.id
? [{ text: $_("delete"), value: "delete", icon: "trash" }] ? [{ text: $_("delete"), value: "delete", icon: "trash" }]
: []), : []),
]; ]);
$: listIsShared = (list.expand?.list_share_via_list?.length ?? 0) > 0; let listIsShared = $derived(
(list.expand?.list_share_via_list?.length ?? 0) > 0,
);
let fullDescription: boolean = false; let fullDescription: boolean = $state(false);
function handleTrailSelect(trail: Trail, index: number) { function handleTrailSelect(trail: Trail, index: number) {
dispatch("click", { trail, index }); onclick?.({ trail, index });
} }
function handleTrailMouseEnter(trail: Trail, index: number) { function handleTrailMouseEnter(trail: Trail, index: number) {
dispatch("mouseenter", { trail, index }); onmouseenter?.({ trail, index });
} }
function handleTrailMouseLeave(trail: Trail, index: number) { function handleTrailMouseLeave(trail: Trail, index: number) {
dispatch("mouseleave", { trail, index }); onmouseleave?.({ trail, index });
} }
</script> </script>
@@ -100,17 +108,17 @@
{/if} {/if}
{#if dropdownItems.length} {#if dropdownItems.length}
<div class="absolute bottom-8 right-8"> <div class="absolute bottom-8 right-8">
<Dropdown <Dropdown items={dropdownItems} {onchange}
items={dropdownItems} >{#snippet children({ toggleMenu: openDropdown })}
on:change <button
let:toggleMenu={openDropdown} aria-label="Open dropdown"
><button
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12" class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
on:click={openDropdown} onclick={openDropdown}
> >
<i class="fa fa-ellipsis-vertical"></i> <i class="fa fa-ellipsis-vertical"></i>
</button></Dropdown </button>
> {/snippet}
</Dropdown>
</div> </div>
{/if} {/if}
<img <img
@@ -182,7 +190,7 @@
? list.description?.substring(0, 100) ? list.description?.substring(0, 100)
: list.description} : list.description}
{#if (list.description?.length ?? 0) > 100 && !fullDescription} {#if (list.description?.length ?? 0) > 100 && !fullDescription}
<button on:click={() => (fullDescription = true)}> <button onclick={() => (fullDescription = true)}>
... <span class="underline">{$_("read-more")}</span></button ... <span class="underline">{$_("read-more")}</span></button
> >
{/if} {/if}
@@ -195,9 +203,9 @@
{#each list.expand?.trails ?? [] as trail, i} {#each list.expand?.trails ?? [] as trail, i}
<div <div
role="presentation" role="presentation"
on:click={() => handleTrailSelect(trail, i)} onclick={() => handleTrailSelect(trail, i)}
on:mouseenter={() => handleTrailMouseEnter(trail, i)} onmouseenter={() => handleTrailMouseEnter(trail, i)}
on:mouseleave={() => handleTrailMouseLeave(trail, i)} onmouseleave={() => handleTrailMouseLeave(trail, i)}
> >
<TrailListItem {trail} showDescription={false}></TrailListItem> <TrailListItem {trail} showDescription={false}></TrailListItem>
</div> </div>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; import { type Snippet } from "svelte";
import type { List } from "$lib/models/list"; import type { List } from "$lib/models/list";
import { trail } from "$lib/stores/trail_store"; import { trail } from "$lib/stores/trail_store";
@@ -10,38 +10,47 @@
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg"; import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg"; import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
export let lists: List[]; interface Props {
lists: List[];
children?: Snippet<[any]>;
onchange?: (list: List) => void
}
export let openModal: (() => void) | undefined = undefined; let { lists, children, onchange }: Props = $props();
export let closeModal: (() => void) | undefined = undefined;
const dispatch = createEventDispatcher(); let modal: Modal;
export function openModal() {
modal.openModal();
}
function handleSelect(list: List) { function handleSelect(list: List) {
dispatch("change", list); onchange?.(list);
closeModal!(); modal.closeModal!();
} }
function listContainsCurrentTrail(list: List) { function listContainsCurrentTrail(list: List) {
return list.trails?.includes($trail.id!); return list.trails?.includes($trail.id!);
} }
const children_render = $derived(children);
</script> </script>
<Modal <Modal
id="list-modal" id="list-modal"
title={$_("select-list")} title={$_("select-list")}
size="max-w-sm" size="max-w-sm"
let:openModal bind:this={modal}
bind:openModal
bind:closeModal
> >
<slot {openModal} /> {#snippet children({ openModal })}
{@render children_render?.({ openModal })}
<ul slot="content"> {/snippet}
{#snippet content()}
<ul>
{#each lists as list} {#each lists as list}
<li <li
class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer" class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer"
on:click={() => handleSelect(list)} onclick={() => handleSelect(list)}
role="presentation" role="presentation"
> >
<img <img
@@ -64,4 +73,5 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{/snippet}
</Modal> </Modal>

View File

@@ -16,28 +16,36 @@
trail_share_index, trail_share_index,
} from "$lib/stores/trail_share_store"; } from "$lib/stores/trail_share_store";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Button from "../base/button.svelte"; import Button from "../base/button.svelte";
import type { SelectItem } from "../base/select.svelte"; import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte"; import Select from "../base/select.svelte";
import UserSearch from "../user_search.svelte"; import UserSearch from "../user_search.svelte";
let openModal: (() => void) | undefined = undefined; interface Props {
export let closeModal: (() => void) | undefined = undefined; list: List;
export function openShareModal() { onsave?: () => void,
shares.set([]); onupdate?: (list: List) => void
fetchShares();
if (openModal) {
openModal();
}
} }
export let list: List; let {
list,
onsave,
onupdate
}: Props = $props();
const dispatch = createEventDispatcher(); let modal: Modal;
let copyButtonText = $_("copy-link"); export function openModal() {
openShareModal();
}
async function openShareModal() {
await fetchShares();
modal.openModal();
}
let copyButtonText = $state($_("copy-link"));
let sharesLoading: boolean = false; let sharesLoading: boolean = false;
@@ -56,8 +64,8 @@
} }
function close() { function close() {
dispatch("save"); onsave?.()
closeModal!(); modal.closeModal!();
} }
async function shareTrails(userId: string) { async function shareTrails(userId: string) {
@@ -109,10 +117,10 @@
const fetchedShares = await list_share_index(list.id!); const fetchedShares = await list_share_index(list.id!);
list.expand = { list.expand = {
trails: list.expand?.trails ?? [], trails: list.expand?.trails ?? [],
list_share_via_list: fetchedShares, list_share_via_list: fetchedShares.items,
}; };
sharesLoading = false; sharesLoading = false;
dispatch("update", list) onupdate?.(list);
} }
</script> </script>
@@ -120,14 +128,17 @@
id="share-modal" id="share-modal"
title={$_("share-this-list")} title={$_("share-this-list")}
size="max-w-sm overflow-visible" size="max-w-sm overflow-visible"
bind:openModal bind:this={modal}
bind:closeModal
> >
<div slot="content"> {#snippet content()}
<div>
<p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500"> <p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500">
{$_("list-share-warning")} {$_("list-share-warning")}
</p> </p>
<UserSearch includeSelf={false} on:click={(e) => shareList(e.detail)}></UserSearch> <UserSearch
includeSelf={false}
onclick={(item) => shareList(item)}
></UserSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4> <h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#if $shares.length == 0} {#if $shares.length == 0}
@@ -156,14 +167,15 @@
<Select <Select
bind:value={share.permission} bind:value={share.permission}
items={permissionSelectItems} items={permissionSelectItems}
on:change={(e) => onchange={(value) =>
updateSharePermission(share, e.detail)} updateSharePermission(share, value)}
></Select> ></Select>
</div> </div>
<button <button
aria-label="Delete share"
class="btn-icon text-red-500" class="btn-icon text-red-500"
on:click={() => deleteShare(share)} onclick={() => deleteShare(share)}
><i class="fa fa-trash"></i></button ><i class="fa fa-trash"></i></button
> >
</div> </div>
@@ -171,15 +183,18 @@
{/each} {/each}
{/if} {/if}
</div> </div>
<div slot="footer" class="flex justify-between items-center gap-4"> {/snippet}
{#snippet footer()}
<div class="flex justify-between items-center gap-4">
<Button <Button
secondary={true} secondary={true}
disabled={copyButtonText == $_("link-copied")} disabled={copyButtonText == $_("link-copied")}
on:click={copyURLToClipboard} onclick={copyURLToClipboard}
> >
<i class="fa fa-link mr-2"></i> <i class="fa fa-link mr-2"></i>
{copyButtonText} {copyButtonText}
</Button> </Button>
<button class="btn-primary" on:click={close}>{$_("close")}</button> <button class="btn-primary" onclick={close}>{$_("close")}</button>
</div></Modal </div>
{/snippet}</Modal
> >

View File

@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
export let height: number = 64; interface Props {
height?: number;
}
let { height = 64 }: Props = $props();
</script> </script>
<svg <svg

View File

@@ -1,67 +1,82 @@
<!--
Auto-generated by: https://github.com/threlte/threlte/tree/main/packages/gltf
Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
-->
<script lang="ts"> <script lang="ts">
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { T, forwardEventHandlers } from "@threlte/core"; import { T } from "@threlte/core";
import { useGltf } from "@threlte/extras"; import { useDraco, useGltf } from "@threlte/extras";
import { backInOut } from "svelte/easing"; import { backInOut } from "svelte/easing";
import { tweened } from "svelte/motion"; import { Tween } from "svelte/motion";
import { Group, Mesh } from "three"; import { Group, Mesh } from "three";
import { interactivity } from "@threlte/extras"; import { interactivity } from "@threlte/extras";
import type { Snippet } from "svelte";
interactivity(); interactivity();
export const ref = new Group(); export const ref = new Group();
export let rotation: number = 0; interface Props {
rotation?: number;
fallback?: Snippet;
errorSlot?: Snippet<[any]>;
[key: string]: any
}
let dog: Mesh; let { rotation = 0, fallback, errorSlot, ...rest }: Props = $props();
const gltf = useGltf("/models/mountain.glb", { useDraco: true }); let dog: Mesh | undefined = $state();
const component = forwardEventHandlers(); const dracoLoader = useDraco()
const gltf = useGltf("/models/mountain.glb", { dracoLoader });
const bark = new Audio("/audio/sophie.mp3"); const bark = $state(new Audio("/audio/sophie.mp3"));
bark.volume = 0.5 bark.volume = 0.5
const sunZPosition = tweened($theme == "light" ? 11.7 : -20, { const sunZPosition = new Tween($theme == "light" ? 11.7 : -20, {
duration: 1000, duration: 1000,
easing: backInOut, easing: backInOut,
}); });
$: sunZPosition.set($theme == "light" ? 11.7 : -20); $effect(() => {
sunZPosition.set($theme == "light" ? 11.7 : -20);
});
const moonZPosition = tweened($theme == "dark" ? 14 : -20, { const moonZPosition = new Tween($theme == "dark" ? 14 : -20, {
duration: 1000, duration: 1000,
easing: backInOut, easing: backInOut,
}); });
$: moonZPosition.set($theme == "dark" ? 14 : -20); $effect(() => {
moonZPosition.set($theme == "dark" ? 14 : -20);
});
const campfireLightIntensity = tweened($theme == "dark" ? 2 : 0, { const campfireLightIntensity = new Tween($theme == "dark" ? 2 : 0, {
duration: 500, duration: 500,
}); });
$: campfireLightIntensity.set($theme == "dark" ? 2 : 0); $effect(() => {
campfireLightIntensity.set($theme == "dark" ? 2 : 0);
});
const cabinLightIntensity = tweened($theme == "dark" ? 4 : 0, { const cabinLightIntensity = new Tween($theme == "dark" ? 4 : 0, {
duration: 400, duration: 400,
}); });
$: cabinLightIntensity.set($theme == "dark" ? 4 : 0); $effect(() => {
cabinLightIntensity.set($theme == "dark" ? 4 : 0);
});
const sunLightIntensity = tweened($theme == "light" ? 5 : 0, { const sunLightIntensity = new Tween($theme == "light" ? 5 : 0, {
duration: 500, duration: 500,
}); });
$: sunLightIntensity.set($theme == "light" ? 5 : 0); $effect(() => {
sunLightIntensity.set($theme == "light" ? 5 : 0);
});
const moonLightIntensity = tweened($theme == "dark" ? 0.8 : 0, { const moonLightIntensity = new Tween($theme == "dark" ? 0.8 : 0, {
duration: 500, duration: 500,
}); });
$: moonLightIntensity.set($theme == "dark" ? 0.8 : 0); $effect(() => {
moonLightIntensity.set($theme == "dark" ? 0.8 : 0);
});
</script> </script>
<T is={ref} dispose={false} {...$$restProps} bind:this={$component}> <T is={ref} dispose={false} {...rest}>
{#await gltf} {#await gltf}
<slot name="fallback" /> {@render fallback?.()}
{:then gltf} {:then gltf}
<T.Group <T.Group
position={[3.98, 4.61, 3.15]} position={[3.98, 4.61, 3.15]}
@@ -135,7 +150,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
<T.PointLight <T.PointLight
position={[14.37, 3.8, 4.11]} position={[14.37, 3.8, 4.11]}
color="#C98A76" color="#C98A76"
intensity={$campfireLightIntensity} intensity={campfireLightIntensity.current}
></T.PointLight> ></T.PointLight>
<T.Mesh <T.Mesh
geometry={gltf.nodes.campfire.geometry} geometry={gltf.nodes.campfire.geometry}
@@ -153,7 +168,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
<T.Mesh <T.Mesh
geometry={gltf.nodes.Moon.geometry} geometry={gltf.nodes.Moon.geometry}
material={gltf.materials.DD9944} material={gltf.materials.DD9944}
position={[-23.68, $moonZPosition, -6.37]} position={[-23.68, moonZPosition.current, -6.37]}
rotation={[0, -0.81, 0]} rotation={[0, -0.81, 0]}
scale={0.53} scale={0.53}
><T.PointLight position={[0.5, 5, 0.5]} color="#a2cbf5" intensity={4} ><T.PointLight position={[0.5, 5, 0.5]} color="#a2cbf5" intensity={4}
@@ -161,12 +176,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
<T.DirectionalLight <T.DirectionalLight
position={[20, 20, 40]} position={[20, 20, 40]}
color="#a2cbf5" color="#a2cbf5"
intensity={$moonLightIntensity} intensity={moonLightIntensity.current}
castShadow castShadow
></T.DirectionalLight></T.Mesh ></T.DirectionalLight></T.Mesh
> >
<T.Group <T.Group
position={[-5.61, $sunZPosition, -26.34]} position={[-5.61, sunZPosition.current, -26.34]}
rotation={[0, 0.75, 0]} rotation={[0, 0.75, 0]}
scale={0.11} scale={0.11}
> >
@@ -189,7 +204,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
<T.DirectionalLight <T.DirectionalLight
position={[20, 40, 20]} position={[20, 40, 20]}
color="#FCF9D9" color="#FCF9D9"
intensity={$sunLightIntensity} intensity={sunLightIntensity.current}
castShadow castShadow
/></T.Mesh /></T.Mesh
> >
@@ -198,7 +213,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
position={[-4.58, 4.85, 13.62]} position={[-4.58, 4.85, 13.62]}
angle={Math.PI / 5} angle={Math.PI / 5}
color="#ffffff" color="#ffffff"
intensity={$campfireLightIntensity * 1.5} intensity={campfireLightIntensity.current * 1.5}
target={dog} target={dog}
></T.SpotLight> ></T.SpotLight>
<T.Mesh <T.Mesh
@@ -215,7 +230,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
rotation={[-2.94, 0.25, Math.PI]} rotation={[-2.94, 0.25, Math.PI]}
scale={1.5} scale={1.5}
bind:ref={dog} bind:ref={dog}
on:click={() => bark.play()} onclick={() => bark.play()}
/> />
<T.Mesh <T.Mesh
geometry={gltf.nodes.ground.geometry} geometry={gltf.nodes.ground.geometry}
@@ -769,7 +784,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
<T.PointLight <T.PointLight
position={[-8.6, 9.3, -1]} position={[-8.6, 9.3, -1]}
color="#FCF9D9" color="#FCF9D9"
intensity={$cabinLightIntensity} intensity={cabinLightIntensity.current}
></T.PointLight> ></T.PointLight>
<T.Mesh <T.Mesh
geometry={gltf.nodes.Cabin.geometry} geometry={gltf.nodes.Cabin.geometry}
@@ -804,12 +819,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
<T.PointLight <T.PointLight
position={[-1, 0.8, 0]} position={[-1, 0.8, 0]}
color="#ffffff" color="#ffffff"
intensity={$campfireLightIntensity} intensity={campfireLightIntensity.current}
></T.PointLight> ></T.PointLight>
<T.PointLight <T.PointLight
position={[1, 0.8, 0]} position={[1, 0.8, 0]}
color="#ffffff" color="#ffffff"
intensity={$campfireLightIntensity} intensity={campfireLightIntensity.current}
></T.PointLight></T.Mesh ></T.PointLight></T.Mesh
> >
</T.Group> </T.Group>
@@ -828,12 +843,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
<T.PointLight <T.PointLight
position={[-1, 0.8, 0]} position={[-1, 0.8, 0]}
color="#ffffff" color="#ffffff"
intensity={$campfireLightIntensity} intensity={campfireLightIntensity.current}
></T.PointLight> ></T.PointLight>
<T.PointLight <T.PointLight
position={[1, 0.8, 0]} position={[1, 0.8, 0]}
color="#ffffff" color="#ffffff"
intensity={$campfireLightIntensity} intensity={campfireLightIntensity.current}
></T.PointLight></T.Mesh ></T.PointLight></T.Mesh
> >
</T.Group> </T.Group>
@@ -849,18 +864,16 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
<T.PointLight <T.PointLight
position={[-1, 0.8, 0]} position={[-1, 0.8, 0]}
color="#ffffff" color="#ffffff"
intensity={$campfireLightIntensity} intensity={campfireLightIntensity.current}
></T.PointLight> ></T.PointLight>
<T.PointLight <T.PointLight
position={[1, 0.8, 0]} position={[1, 0.8, 0]}
color="#ffffff" color="#ffffff"
intensity={$campfireLightIntensity} intensity={campfireLightIntensity.current}
></T.PointLight></T.Mesh ></T.PointLight></T.Mesh
> >
</T.Group> </T.Group>
{:catch error} {:catch error}
<slot name="error" {error} /> {@render errorSlot?.({ error, })}
{/await} {/await}
<slot {ref} />
</T> </T>

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto } from "$app/navigation"; import { afterNavigate, goto } from "$app/navigation";
import { page } from "$app/stores";
import LogoText from "$lib/components/logo/logo_text.svelte"; import LogoText from "$lib/components/logo/logo_text.svelte";
import { pb } from "$lib/pocketbase"; import { pb } from "$lib/pocketbase";
import { theme, toggleTheme } from "$lib/stores/theme_store"; import { theme, toggleTheme } from "$lib/stores/theme_store";
@@ -8,12 +7,13 @@
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { backInOut, cubicOut } from "svelte/easing"; import { backInOut, cubicOut } from "svelte/easing";
import { tweened } from "svelte/motion";
import Drawer from "./base/drawer.svelte"; import Drawer from "./base/drawer.svelte";
import Dropdown from "./base/dropdown.svelte"; import Dropdown from "./base/dropdown.svelte";
import LogoTextLight from "./logo/logo_text_light.svelte"; import LogoTextLight from "./logo/logo_text_light.svelte";
import NotificationDropdown from "./notification/notification_dropdown.svelte"; import NotificationDropdown from "./notification/notification_dropdown.svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { page } from "$app/state";
import { Tween } from "svelte/motion";
let navBarItems = [ let navBarItems = [
{ text: "Home", value: "/" }, { text: "Home", value: "/" },
@@ -27,22 +27,22 @@
{ text: $_("logout"), value: "logout", icon: "right-from-bracket" }, { text: $_("logout"), value: "logout", icon: "right-from-bracket" },
]; ];
const indicatorPosition = tweened(0, { const indicatorPosition = new Tween(0, {
duration: 300, duration: 300,
easing: cubicOut, easing: cubicOut,
}); });
const indicatorWidth = tweened(0, { const indicatorWidth = new Tween(0, {
duration: 300, duration: 300,
easing: cubicOut, easing: cubicOut,
}); });
const indicatorScale = tweened(0, { const indicatorScale = new Tween(0, {
duration: 600, duration: 600,
easing: backInOut, easing: backInOut,
}); });
let drawerOpen: boolean = false; let drawerOpen: boolean = $state(false);
afterNavigate((e) => { afterNavigate((e) => {
const routeId = e.to?.route.id; const routeId = e.to?.route.id;
@@ -91,19 +91,21 @@
} }
} }
$: user = browser ? $currentUser : pb.authStore.model; let user = $derived(browser ? $currentUser : pb.authStore.model);
</script> </script>
<Drawer bind:open={drawerOpen}> <Drawer bind:open={drawerOpen}>
<div class="flex gap-4 items-center m-4"> <div class="flex gap-4 items-center m-4">
<div class="basis-full"></div> <div class="basis-full"></div>
<button <button
aria-label="Toggle theme"
class="btn-icon fa-regular fa-{$theme === 'light' ? 'sun' : 'moon'}" class="btn-icon fa-regular fa-{$theme === 'light' ? 'sun' : 'moon'}"
on:click={() => toggleTheme()} onclick={() => toggleTheme()}
></button> ></button>
<button <button
aria-label="Toggle drawer"
class="btn-icon block fa fa-close float-right" class="btn-icon block fa fa-close float-right"
on:click={() => (drawerOpen = false)} onclick={() => (drawerOpen = false)}
></button> ></button>
</div> </div>
<div class="flex flex-col px-12 gap-8"> <div class="flex flex-col px-12 gap-8">
@@ -140,7 +142,8 @@
</p> </p>
</a> </a>
<button <button
on:click={() => { aria-label="Logout"
onclick={() => {
logout(); logout();
window.location.href = "/"; window.location.href = "/";
}} }}
@@ -168,7 +171,7 @@
<menu id="nav-bar-links" class="hidden lg:flex gap-8 relative py-1 px-2"> <menu id="nav-bar-links" class="hidden lg:flex gap-8 relative py-1 px-2">
<div <div
class="absolute h-full w-16 bg-menu-item-background-hover rounded-xl top-0 z-0" class="absolute h-full w-16 bg-menu-item-background-hover rounded-xl top-0 z-0"
style="width: {$indicatorWidth}px; left: {$indicatorPosition}px; scale: {$indicatorScale}" style="width: {indicatorWidth.current}px; left: {indicatorPosition.current}px; scale: {indicatorScale.current}"
></div> ></div>
{#each navBarItems as item} {#each navBarItems as item}
<a class="font-semibold z-10" href={item.value}>{item.text}</a> <a class="font-semibold z-10" href={item.value}>{item.text}</a>
@@ -182,26 +185,28 @@
{#if user} {#if user}
<div class="hidden lg:flex gap-6 items-center"> <div class="hidden lg:flex gap-6 items-center">
<button <button
aria-label="Toggle theme"
class="btn-icon fa-regular fa-{$theme === 'light' class="btn-icon fa-regular fa-{$theme === 'light'
? 'sun' ? 'sun'
: 'moon'}" : 'moon'}"
on:click={() => toggleTheme()} onclick={() => toggleTheme()}
></button> ></button>
<a class="btn-primary btn-large" href="/trail/edit/new" <a class="btn-primary btn-large" href="/trail/edit/new"
><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a ><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a
> >
{#if $page.data.notifications} {#if page.data.notifications}
<NotificationDropdown></NotificationDropdown> <NotificationDropdown></NotificationDropdown>
{/if} {/if}
<Dropdown <Dropdown
items={dropdownItems} items={dropdownItems}
on:change={(e) => handleDropdownClick(e.detail)} onchange={(item) => handleDropdownClick(item)}
let:toggleMenu={openDropdown}
> >
{#snippet children({ toggleMenu: openDropdown })}
<div class="flex items-center"> <div class="flex items-center">
<button <button
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 aspect-square" class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 aspect-square"
on:click={openDropdown} onclick={openDropdown}
> >
<img <img
class="rounded-full w-full h-full" class="rounded-full w-full h-full"
@@ -211,21 +216,24 @@
/> />
</button> </button>
</div> </div>
{/snippet}
</Dropdown> </Dropdown>
</div> </div>
{:else} {:else}
<div class="hidden md:flex items-center gap-8"> <div class="hidden md:flex items-center gap-8">
<button <button
aria-label="Toggle theme"
class="btn-icon fa-regular fa-{$theme === 'light' class="btn-icon fa-regular fa-{$theme === 'light'
? 'sun' ? 'sun'
: 'moon'}" : 'moon'}"
on:click={() => toggleTheme()} onclick={() => toggleTheme()}
></button> ></button>
<a class="btn-primary btn-large" href="/login">{$_("login")}</a> <a class="btn-primary btn-large" href="/login">{$_("login")}</a>
</div> </div>
{/if} {/if}
<button <button
aria-label="Toggle drawer"
class="btn-icon fa fa-bars lg:hidden" class="btn-icon fa fa-bars lg:hidden"
on:click={() => (drawerOpen = !drawerOpen)} onclick={() => (drawerOpen = !drawerOpen)}
></button> ></button>
</nav> </nav>

View File

@@ -5,12 +5,14 @@
} from "$lib/models/notification"; } from "$lib/models/notification";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { formatTimeSince } from "$lib/util/format_util"; import { formatTimeSince } from "$lib/util/format_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let notification: Notification; interface Props {
notification: Notification;
onclick?: (data: {notification: Notification, link: string | null}) => void
}
const dispatch = createEventDispatcher(); let { notification, onclick }: Props = $props();
const avatarSrc = notification.expand?.author.avatar const avatarSrc = notification.expand?.author.avatar
? getFileURL( ? getFileURL(
@@ -21,9 +23,6 @@
const timeSince = formatTimeSince(new Date(notification.created ?? "")); const timeSince = formatTimeSince(new Date(notification.created ?? ""));
$: title = getTitle(notification);
$: description = getDescription(notification);
$: link = getLink(notification);
function getTitle(n: Notification) { function getTitle(n: Notification) {
switch (n.type) { switch (n.type) {
@@ -92,14 +91,17 @@
} }
function handleItemClick() { function handleItemClick() {
dispatch("click", { notification, link }); onclick?.({ notification, link });
} }
let title = $derived(getTitle(notification));
let description = $derived(getDescription(notification));
let link = $derived(getLink(notification));
</script> </script>
<li <li
class="flex items-center gap-x-3 px-3 py-2 hover:bg-menu-item-background-hover relative cursor-pointer" class="flex items-center gap-x-3 px-3 py-2 hover:bg-menu-item-background-hover relative cursor-pointer"
role="presentation" role="presentation"
on:click={handleItemClick} onclick={handleItemClick}
> >
<img class="rounded-full w-8 aspect-square" src={avatarSrc} alt="avatar" /> <img class="rounded-full w-8 aspect-square" src={avatarSrc} alt="avatar" />
<div> <div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import type { Notification } from "$lib/models/notification"; import type { Notification } from "$lib/models/notification";
import { import {
notifications_index, notifications_index,
@@ -16,24 +16,23 @@
import emptyStateNotificationLight from "$lib/assets/svgs/empty_states/empty_state_notification_light.svg"; import emptyStateNotificationLight from "$lib/assets/svgs/empty_states/empty_state_notification_light.svg";
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
let notifications: Notification[] = []; let notifications: Notification[] = $state([]);
const pagination = { const pagination = {
page: $page.data.notifications.page, page: page.data.notifications.page,
totalPages: $page.data.notifications.totalPages, totalPages: page.data.notifications.totalPages,
}; };
let loadingNextPage: boolean = false; let loadingNextPage: boolean = $state(false);
let isOpen = false; let isOpen = $state(false);
$: unreadCount = notifications.reduce( let unreadCount = $derived(
(value, n) => (value += n.seen ? 0 : 1), notifications.reduce((value, n) => (value += n.seen ? 0 : 1), 0),
0,
); );
onMount(() => { onMount(() => {
if (!notifications.length && $page.data.notifications?.items?.length) { if (!notifications.length && page.data.notifications?.items?.length) {
notifications = $page.data.notifications.items; notifications = page.data.notifications.items;
} }
}); });
@@ -88,18 +87,21 @@
loadingNextPage = false; loadingNextPage = false;
} }
async function handleNotificationClick(e: CustomEvent) { async function handleNotificationClick(data: {
await notifications_mark_as_seen(e.detail.notification); notification: Notification;
e.detail.notification.seen = true; link: string | null;
}) {
await notifications_mark_as_seen(data.notification);
data.notification.seen = true;
notifications = notifications; notifications = notifications;
if (e.detail.link) { if (data.link) {
goto(e.detail.link); goto(data.link);
} }
} }
</script> </script>
<svelte:window on:mouseup={handleWindowClick} /> <svelte:window onmouseup={handleWindowClick} />
<div class="dropdown relative"> <div class="dropdown relative">
{#if unreadCount > 0} {#if unreadCount > 0}
@@ -110,7 +112,11 @@
</div> </div>
{/if} {/if}
<div class="dropdown-toggle"> <div class="dropdown-toggle">
<button on:click={toggleMenu} class="btn-icon"> <button
aria-label="Toggle notification dropdown"
onclick={toggleMenu}
class="btn-icon"
>
<i class="fa fa-bell"></i> <i class="fa fa-bell"></i>
</button> </button>
</div> </div>
@@ -119,7 +125,7 @@
<ul <ul
class="menu absolute bg-menu-background border border-input-border rounded-l-xl rounded-b-xl shadow-md right-0 overflow-scroll mt-4 max-h-96 w-72" class="menu absolute bg-menu-background border border-input-border rounded-l-xl rounded-b-xl shadow-md right-0 overflow-scroll mt-4 max-h-96 w-72"
class:none={isOpen} class:none={isOpen}
on:scroll={onListScroll} onscroll={onListScroll}
style="z-index: 1001" style="z-index: 1001"
in:fly={{ y: -10, duration: 150 }} in:fly={{ y: -10, duration: 150 }}
out:fly={{ y: -10, duration: 150 }} out:fly={{ y: -10, duration: 150 }}
@@ -131,13 +137,14 @@
{:else if notifications.length} {:else if notifications.length}
{#each notifications as notification} {#each notifications as notification}
<NotificationCard <NotificationCard
on:click={handleNotificationClick} onclick={(data) => handleNotificationClick(data)}
{notification} {notification}
></NotificationCard> ></NotificationCard>
{/each} {/each}
{:else} {:else}
<div class="text-center p-8"> <div class="text-center p-8">
<img class="mx-auto" <img
class="mx-auto"
src={$theme === "light" src={$theme === "light"
? emptyStateNotificationLight ? emptyStateNotificationLight
: emptyStateNotificationDark} : emptyStateNotificationDark}

View File

@@ -6,12 +6,30 @@
The progress bar will show during navigation if loading takes more than `displayThresholdMs` The progress bar will show during navigation if loading takes more than `displayThresholdMs`
and will hide when navigation is complete. and will hide when navigation is complete.
--> -->
<svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate } from "$app/navigation"; import { beforeNavigate } from "$app/navigation";
// This component is a modified version of the component from the following repo: // This component is a modified version of the component from the following repo:
// https://github.com/saibotsivad/svelte-progress-bar // https://github.com/saibotsivad/svelte-progress-bar
let {
id = undefined,
viewTransitionName = "sveltekit-progress-bar",
busy = $bindable(false),
color = "currentColor",
class: textColorClass = "",
zIndex = 1,
minimum: defaultMinimum = 0.08,
maximum = 0.994,
settleTime: defaultSettleTime = 700,
intervalTime = 700,
stepSizes = [0, 0.005, 0.01, 0.02],
displayThresholdMs = 150,
noNavigationProgress = false,
}: Props = $props();
// Towards the end of the progress bar animation, we want to shorten the increment // Towards the end of the progress bar animation, we want to shorten the increment
// step size, to give it the appearance of slowing down. This indicates to the user // step size, to give it the appearance of slowing down. This indicates to the user
// that progress is still happening, but not as fast as they might like. // that progress is still happening, but not as fast as they might like.
@@ -24,11 +42,17 @@
}; };
// Internal private state. // Internal private state.
let running: boolean = false; let running = $state(false);
let updater: ReturnType<typeof setInterval> | null = null; let updater = $state<ReturnType<typeof setInterval> | null>(null);
let completed = false; let completed = $state(false);
let width: number = 0; let width = $state(0);
// Update the busy prop when the internal `running` variable changes.
$effect.pre(() => {
busy = running;
});
interface Props {
/** If set, an ID for the progress bar on the HTML page. /** If set, an ID for the progress bar on the HTML page.
* This ID must be unique on the page to avoid conflicts. * This ID must be unique on the page to avoid conflicts.
* *
@@ -41,17 +65,25 @@
* A div that is currently loading... * A div that is currently loading...
* </div> * </div>
*/ */
export let id: string | undefined = undefined; id?: string | undefined;
/**
* The name of the CSS view transition to use for the progress bar.
* Sets the `view-transition-name` CSS property on the progress bar and leader elements.
*
* @default "sveltekit-progress-bar"
* @see https://developer.chrome.com/docs/web-platform/view-transitions/
* @see https://svelte.dev/blog/view-transitions
*/
viewTransitionName?: string;
/** Will be set to true when the progress bar is running. */ /** Will be set to true when the progress bar is running. */
export let busy: boolean = false; busy?: boolean;
$: running = busy;
/** /**
* The CSS color to use to style the progress bar. * The CSS color to use to style the progress bar.
* *
* If you're using Tailwind or Windi CSS, leave this to the default * If you're using Tailwind or Windi CSS, leave this to the default
* and set the `class` attribute to a `text-` class instead. * and set the `class` attribute to a `text-` class instead.
*/ */
export let color: string = "currentColor"; color?: string;
/** /**
* A Tailwind/Windi `text-` class to use to color the Progress Bar. * A Tailwind/Windi `text-` class to use to color the Progress Bar.
* *
@@ -62,14 +94,13 @@
* *
* @example text-green-500 * @example text-green-500
*/ */
let textColorClass: `text-${string}` | "" = ""; class?: `text-${string}` | "";
export { textColorClass as class };
/** /**
* The `z-index` CSS property value to use for the progress bar. * The `z-index` CSS property value to use for the progress bar.
* Be aware that the glowing effect on the bar will use this `zIndex` + 1. * Be aware that the glowing effect on the bar will use this `zIndex` + 1.
*/ */
export let zIndex: number = 1; zIndex?: number;
// These are defaults that you shouldn't need to change, but are exposed here in case you do. // These are defaults that you shouldn't need to change, but are exposed here in case you do.
/** /**
@@ -77,30 +108,45 @@
* Starting at 0 doesn't usually look very good. * Starting at 0 doesn't usually look very good.
* @default 0.08 * @default 0.08
*/ */
let defaultMinimum = 0.08; minimum?: number;
export { defaultMinimum as minimum };
/** /**
* The maximum percent width value to use when the bar is at the end but not marked as complete. * The maximum percent width value to use when the bar is at the end but not marked as complete.
* Letting the bar stay at 100% width for a while doesn't usually look very good either. * Letting the bar stay at 100% width for a while doesn't usually look very good either.
* @default 0.994 * @default 0.994
*/ */
export let maximum = 0.994; maximum?: number;
/** /**
* Milliseconds to wait after the complete method is called to hide the progress bar. * Milliseconds to wait after the complete method is called to hide the progress bar.
* Letting it sit at 100% width for a very short time makes it feel more fluid. * Letting it sit at 100% width for a very short time makes it feel more fluid.
* @default 700 * @default 700
*/ */
let defaultSettleTime = 700; settleTime?: number;
export { defaultSettleTime as settleTime };
/** /**
* Milliseconds to wait between incrementing bar width when using * Milliseconds to wait between incrementing bar width when using
* the `start` (auto-increment) method. * the `start` (auto-increment) method.
* @default 700 * @default 700
*/ */
export let intervalTime = 700; intervalTime?: number;
export let stepSizes = [0, 0.005, 0.01, 0.02];
/** Reset the progress bar back to the beginning, leaving it in a running state. */ /** Reset the progress bar back to the beginning, leaving it in a running state. */
stepSizes?: any;
/** When navigating, this is the threshold duration in milliseconds
* that the progress bar will wait before showing.
*
* This means that if the navigation takes less than this amount of time,
* the progress bar will not be shown. This is to prevent the progress bar
* from flashing in and out on the screen.
*
* @default 150 ms
*/
displayThresholdMs?: number;
/** Set to `true` to disable the showing of the progress bar on navigation. */
noNavigationProgress?: boolean;
}
export const reset = (minimum = defaultMinimum) => { export const reset = (minimum = defaultMinimum) => {
width = minimum; width = minimum;
running = true; running = true;
@@ -186,32 +232,21 @@
}; };
}; };
let barStyle: string; const barStyle = $derived(
$: barStyle =
(color ? `background-color: ${color};` : "") + (color ? `background-color: ${color};` : "") +
(width && width * 100 ? `width: ${width * 100}%;` : "") + (width && width * 100 ? `width: ${width * 100}%;` : "") +
`z-index: ${zIndex};`; `z-index: ${zIndex};` +
`view-transition-name: ${viewTransitionName}-bar;`,
);
// the box shadow of the leader bar uses `color` to set its shadow color // the box shadow of the leader bar uses `color` to set its shadow color
let leaderColorStyle: string; const leaderColorStyle = $derived(
$: leaderColorStyle =
(color ? `background-color: ${color}; color: ${color};` : "") + (color ? `background-color: ${color}; color: ${color};` : "") +
`z-index: ${zIndex + 1};`; `z-index: ${zIndex + 1};` +
`view-transition-name: ${viewTransitionName}-leader;`,
/** When navigating, this is the threshold duration in milliseconds );
* that the progress bar will wait before showing.
*
* This means that if the navigation takes less than this amount of time,
* the progress bar will not be shown. This is to prevent the progress bar
* from flashing in and out on the screen.
*
* @default 150 ms
*/
export let displayThresholdMs = 150;
/** Set to `true` to disable the showing of the progress bar on navigation. */
export let noNavigationProgress = false;
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null; let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
beforeNavigate((nav) => { beforeNavigate((nav) => {
if (progressBarStartTimeout) { if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout); clearTimeout(progressBarStartTimeout);
@@ -229,16 +264,16 @@
displayThresholdMs, displayThresholdMs,
); );
} else start(); } else start();
}
});
afterNavigate(() => { nav.complete.catch().finally(() => {
if (progressBarStartTimeout) { if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout); clearTimeout(progressBarStartTimeout);
progressBarStartTimeout = null; progressBarStartTimeout = null;
} }
complete(); complete();
}); });
}
});
</script> </script>
{#if running || width > 0} {#if running || width > 0}
@@ -254,7 +289,7 @@
style={barStyle} style={barStyle}
> >
{#if running} {#if running}
<div class="svelte-progress-bar-leader" style={leaderColorStyle} /> <div class="svelte-progress-bar-leader" style={leaderColorStyle}></div>
{/if} {/if}
</output> </output>
{/if} {/if}

View File

@@ -1,24 +1,36 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let src: string; interface Props {
export let isThumbnail: boolean = false; src: string;
export let showThumbnailControls: boolean = true; isThumbnail?: boolean;
export let showExifControls: boolean = false; showThumbnailControls?: boolean;
showExifControls?: boolean;
onthumbnail?: (src: string) => void;
onexif?: (src: string) => void;
ondelete?: (src: string) => void;
}
const dispatch = createEventDispatcher(); let {
src,
isThumbnail = false,
showThumbnailControls = true,
showExifControls = false,
onthumbnail,
onexif,
ondelete,
}: Props = $props();
function handleThumbnailClick() { function handleThumbnailClick() {
dispatch("thumbnail", src); onthumbnail?.(src);
} }
function handleExifClick() { function handleExifClick() {
dispatch("exif", src); onexif?.(src);
} }
function handleDeleteClick() { function handleDeleteClick() {
dispatch("delete", src); ondelete?.(src);
} }
</script> </script>
@@ -36,26 +48,30 @@
> >
{#if showThumbnailControls} {#if showThumbnailControls}
<button <button
aria-label="Make thumbnail"
type="button" type="button"
class="tooltip" class="tooltip"
data-title={$_("make-thumbnail")} data-title={$_("make-thumbnail")}
on:click={handleThumbnailClick} onclick={handleThumbnailClick}
><i class="fa fa-file-image text-content"></i></button ><i class="fa fa-file-image text-content"></i></button
> >
{/if} {/if}
{#if showExifControls} {#if showExifControls}
<button <button
aria-label="Get EXIF coordinates"
type="button" type="button"
class="tooltip" class="tooltip"
data-title={$_("get-position-from-exif")} data-title={$_("get-position-from-exif")}
on:click={handleExifClick} onclick={handleExifClick}
><i class="fa fa-magnifying-glass-location text-content"></i></button ><i class="fa fa-magnifying-glass-location text-content"
></i></button
> >
{/if} {/if}
<button <button
aria-label="Delete photo"
class="tooltip" class="tooltip"
data-title={$_("delete")} data-title={$_("delete")}
on:click={handleDeleteClick} onclick={handleDeleteClick}
type="button"><i class="fa fa-trash text-red-500"></i></button type="button"><i class="fa fa-trash text-red-500"></i></button
> >
</div> </div>

View File

@@ -3,9 +3,15 @@
import PhotoSwipeLightbox from "photoswipe/lightbox"; import PhotoSwipeLightbox from "photoswipe/lightbox";
import { onMount } from "svelte"; import { onMount } from "svelte";
export let photos: string[]; interface Props {
export function open(idx: number = 0) { photos: string[];
lightbox.loadAndOpen(idx, lightboxDataSource) open?: (idx: number) => void;
}
let { photos }: Props = $props();
export function openGallery(idx: number = 0) {
lightbox.loadAndOpen(idx, lightboxDataSource);
} }
let lightbox: PhotoSwipeLightbox; let lightbox: PhotoSwipeLightbox;
let lightboxDataSource: DataSource; let lightboxDataSource: DataSource;

View File

@@ -8,10 +8,14 @@
formatTimeHHMM, formatTimeHHMM,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let activity: Activity; interface Props {
export let user: UserAnonymous; activity: Activity;
user: UserAnonymous;
}
let fullDescription: boolean = false; let { activity, user }: Props = $props();
let fullDescription: boolean = $state(false);
</script> </script>
<div class="activity-card p-6 space-y-6 rounded-xl border border-input-border"> <div class="activity-card p-6 space-y-6 rounded-xl border border-input-border">
@@ -92,7 +96,7 @@
: activity.description} : activity.description}
{#if (activity.description?.length ?? 0) > 100 && !fullDescription} {#if (activity.description?.length ?? 0) > 100 && !fullDescription}
<button <button
on:click={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
fullDescription = true; fullDescription = true;

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/state";
import Modal from "$lib/components/base/modal.svelte"; import Modal from "$lib/components/base/modal.svelte";
import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos"; import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos";
import { onMount } from "svelte"; import { onMount } from "svelte";
@@ -7,14 +7,17 @@
import TextField from "../base/text_field.svelte"; import TextField from "../base/text_field.svelte";
import { show_toast } from "$lib/stores/toast_store"; import { show_toast } from "$lib/stores/toast_store";
export let openModal: (() => void) | undefined = undefined; let modal: Modal;
export let closeModal: (() => void) | undefined = undefined;
export function openModal() {
modal.openModal();
}
let qrcode: any | null = null; let qrcode: any | null = null;
onMount(() => { onMount(() => {
qrcode = new QrCodeWithLogo({ qrcode = new QrCodeWithLogo({
content: $page.url.href, content: page.url.href,
image: document.getElementById("qrcode") as HTMLImageElement, image: document.getElementById("qrcode") as HTMLImageElement,
logo: { logo: {
src: "/favicon.png", src: "/favicon.png",
@@ -23,7 +26,7 @@
}); });
function copyURLToClipboard() { function copyURLToClipboard() {
navigator.clipboard.writeText($page.url.href); navigator.clipboard.writeText(page.url.href);
show_toast({ show_toast({
text: $_("link-copied"), text: $_("link-copied"),
icon: "circle-info", icon: "circle-info",
@@ -43,15 +46,15 @@
id="password-modal" id="password-modal"
size="max-w-xl" size="max-w-xl"
title={$_("share-profile")} title={$_("share-profile")}
bind:openModal bind:this={modal}
bind:closeModal
> >
<div class="flex" slot="content"> {#snippet content()}
<div class="flex">
<div class="space-y-2"> <div class="space-y-2">
<img class="w-64" id="qrcode" alt="QR Code" /> <img class="w-64" id="qrcode" alt="QR Code" />
<button <button
class="btn-secondary w-full" class="btn-secondary w-full"
on:click={() => downloadQRCode()} onclick={() => downloadQRCode()}
> >
<i class="fa fa-download mr-2"></i> <i class="fa fa-download mr-2"></i>
<span>{$_("download")}</span> <span>{$_("download")}</span>
@@ -59,17 +62,20 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<p> <p>
Send this link or the QR code on the left to someone to share Send this link or the QR code on the left to someone to
your profile. share your profile.
</p> </p>
<div class="flex gap-x-2 items-center"> <div class="flex gap-x-2 items-center">
<div class="basis-full"> <div class="basis-full">
<TextField value={$page.url.href} disabled></TextField> <TextField value={page.url.href} disabled></TextField>
</div> </div>
<button class="btn-icon" on:click={copyURLToClipboard}> <button
aria-label="Copy profile URL"
class="btn-icon" onclick={copyURLToClipboard}>
<i class="fa-regular fa-copy"></i> <i class="fa-regular fa-copy"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/snippet}
</Modal> </Modal>

View File

@@ -1,28 +1,28 @@
<script lang="ts"> <script lang="ts">
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { T, useTask, useThrelte } from "@threlte/core"; import { T, useTask } from "@threlte/core";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { tweened } from "svelte/motion"; import { Tween } from "svelte/motion";
import * as THREE from "three"; import * as THREE from "three";
import Mountain from "./mountain.svelte"; import Mountain from "./mountain.svelte";
let rotation = 0; let rotation = $state(0);
useTask((delta) => { useTask((delta) => {
rotation += delta / 2; rotation += delta / 2;
}); });
const { toneMapping } = useThrelte(); const ambientIntensity = new Tween($theme == "light" ? 3 : 0, {
toneMapping.set(THREE.NoToneMapping);
const ambientIntensitiy = tweened($theme == "light" ? 3 : 0, {
duration: 500, duration: 500,
}); });
$: ambientColor = $theme == "light" ? "#ffffff" : "#4c7fe6"; let ambientColor = $derived($theme == "light" ? "#ffffff" : "#4c7fe6");
$: ambientIntensitiy.set($theme == "light" ? 3 : 0.1); $effect(() => {
ambientIntensity.set($theme == "light" ? 3 : 0.1);
});
let starsGeometry!: THREE.BufferGeometry; let starsGeometry: THREE.BufferGeometry | undefined = $state();
let starsMaterial!: THREE.PointsMaterial; let starsMaterial: THREE.PointsMaterial | undefined = $state();
onMount(() => { onMount(() => {
const stars = new Array(0); const stars = new Array(0);
for (var i = 0; i < 500; i++) { for (var i = 0; i < 500; i++) {
@@ -43,8 +43,8 @@
<T.PerspectiveCamera <T.PerspectiveCamera
makeDefault makeDefault
position={[35.5, 32, 35.5]} position={[35.5, 32, 35.5]}
on:create={({ ref }) => { oncreate={(c) => {
ref.lookAt(0, 0, 0); c.lookAt(0, 0, 0);
}} }}
/> />
@@ -58,5 +58,5 @@
<T.HemisphereLight <T.HemisphereLight
skyColor={ambientColor} skyColor={ambientColor}
groundColor="#242734" groundColor="#242734"
intensity={$ambientIntensitiy} intensity={ambientIntensity.current}
/> />

View File

@@ -2,23 +2,25 @@
import Modal from "$lib/components/base/modal.svelte"; import Modal from "$lib/components/base/modal.svelte";
import { validator } from "@felte/validator-zod"; import { validator } from "@felte/validator-zod";
import { createForm } from "felte"; import { createForm } from "felte";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { z } from "zod"; import { z } from "zod";
import TextField from "../base/text_field.svelte"; import TextField from "../base/text_field.svelte";
let _openModal: (() => void) | undefined = undefined; interface Props {
export let closeModal: (() => void) | undefined = undefined; email?: string;
onsave?: (email: string) => void;
}
let { email = "", onsave }: Props = $props();
let modal: Modal;
export function openModal() { export function openModal() {
setFields("email", email); setFields("email", email);
setErrors("email", []); setErrors("email", []);
_openModal!(); modal.openModal();
} }
export let email = "";
const dispatch = createEventDispatcher();
const { form, errors, setFields, setErrors } = createForm<{ const { form, errors, setFields, setErrors } = createForm<{
email: string; email: string;
}>({ }>({
@@ -32,8 +34,8 @@
}), }),
}), }),
onSubmit: async (form) => { onSubmit: async (form) => {
dispatch("save", form.email); onsave?.(form.email);
closeModal!(); modal.closeModal!();
}, },
}); });
</script> </script>
@@ -42,18 +44,24 @@
id="email-modal" id="email-modal"
size="max-w-xl" size="max-w-xl"
title={$_("change-email")} title={$_("change-email")}
bind:openModal={_openModal} bind:this={modal}
bind:closeModal
> >
<form id="email-form" slot="content" use:form> {#snippet content()}
<form id="email-form" use:form>
<TextField name="email" error={$errors.email}></TextField> <TextField name="email" error={$errors.email}></TextField>
</form> </form>
<div slot="footer" class="flex items-center gap-4"> {/snippet}
<button class="btn-secondary" on:click={closeModal} {#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button >{$_("cancel")}</button
> >
<button class="btn-primary" type="submit" form="email-form" name="save" <button
>{$_("save")}</button class="btn-primary"
type="submit"
form="email-form"
name="save">{$_("save")}</button
> >
</div></Modal </div>
{/snippet}</Modal
> >

View File

@@ -1,23 +1,29 @@
<script lang="ts"> <script lang="ts">
import Modal from "$lib/components/base/modal.svelte"; import Modal from "$lib/components/base/modal.svelte";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import TextField from "../base/text_field.svelte"; import TextField from "../base/text_field.svelte";
import { validator } from "@felte/validator-zod"; import { validator } from "@felte/validator-zod";
import { createForm } from "felte"; import { createForm } from "felte";
import { z } from "zod"; import { z } from "zod";
let _openModal: (() => void) | undefined = undefined; interface Props {
export let closeModal: (() => void) | undefined = undefined; onsave?: (data: {
export function openModal() { oldPassword: string;
setFields("password", "") password: string;
setFields("oldPassword", "") passwordConfirm: string;
setErrors("password", []) }) => void;
setErrors("oldPassword", [])
_openModal!();
} }
const dispatch = createEventDispatcher(); let { onsave }: Props = $props();
let modal: Modal;
export function openModal() {
setFields("password", "");
setFields("oldPassword", "");
setErrors("password", []);
setErrors("oldPassword", []);
modal.openModal!();
}
const { form, errors, setFields, setErrors } = createForm<{ const { form, errors, setFields, setErrors } = createForm<{
oldPassword: string; oldPassword: string;
@@ -53,12 +59,12 @@
}), }),
}), }),
onSubmit: async (form) => { onSubmit: async (form) => {
dispatch("save", { onsave?.({
oldPassword: form.oldPassword, oldPassword: form.oldPassword,
password: form.password, password: form.password,
passwordConfirm: form.password, passwordConfirm: form.password,
}); });
closeModal!(); modal.closeModal();
}, },
}); });
</script> </script>
@@ -67,10 +73,10 @@
id="password-modal" id="password-modal"
size="max-w-sm" size="max-w-sm"
title={$_("change-password")} title={$_("change-password")}
bind:openModal={_openModal} bind:this={modal}
bind:closeModal
> >
<form id="password-form" slot="content" use:form> {#snippet content()}
<form id="password-form" use:form>
<TextField <TextField
name="oldPassword" name="oldPassword"
label={$_("current-password")} label={$_("current-password")}
@@ -90,8 +96,10 @@
error={$errors.passwordConfirm} error={$errors.passwordConfirm}
></TextField> ></TextField>
</form> </form>
<div slot="footer" class="flex items-center gap-4"> {/snippet}
<button class="btn-secondary" on:click={closeModal} {#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button >{$_("cancel")}</button
> >
<button <button
@@ -100,5 +108,6 @@
form="password-form" form="password-form"
name="save">{$_("save")}</button name="save">{$_("save")}</button
> >
</div></Modal </div>
{/snippet}</Modal
> >

View File

@@ -8,17 +8,21 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { fly, slide } from "svelte/transition"; import { fly, slide } from "svelte/transition";
export let subject: Trail | List; interface Props {
export let large: boolean = false; subject: Trail | List;
export let type: "trail" | "list" large?: boolean;
type: "trail" | "list";
}
let { subject, large = false, type }: Props = $props();
const shareData = type == "trail" ? (subject as Trail).expand?.trail_share_via_trail : (subject as List).expand?.list_share_via_list const shareData = type == "trail" ? (subject as Trail).expand?.trail_share_via_trail : (subject as List).expand?.list_share_via_list
let showInfo: boolean = false; let showInfo: boolean = $state(false);
let loading: boolean = false; let loading: boolean = $state(false);
let infoLoaded: boolean = false; let infoLoaded: boolean = $state(false);
let subjectIsOwned: boolean = subject.author == pb.authStore.model?.id; let subjectIsOwned: boolean = subject.author == pb.authStore.model?.id;
let author: UserAnonymous; let author: UserAnonymous | undefined = $state();
async function fetchInfo() { async function fetchInfo() {
if (!infoLoaded) { if (!infoLoaded) {
@@ -47,8 +51,8 @@
class:text-xl={large} class:text-xl={large}
role="button" role="button"
tabindex="0" tabindex="0"
on:mouseenter={fetchInfo} onmouseenter={fetchInfo}
on:mouseleave={() => (showInfo = false)} onmouseleave={() => (showInfo = false)}
></i> ></i>
{#if showInfo} {#if showInfo}
<ul <ul

View File

@@ -1,23 +1,40 @@
<script lang="ts"> <script lang="ts">
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import type { SummitLog } from "$lib/models/summit_log"; import type { SummitLog } from "$lib/models/summit_log";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte"; import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import { theme } from "$lib/stores/theme_store";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { import {
formatDistance, formatDistance,
formatElevation, formatElevation,
formatTimeHHMM, formatTimeHHMM,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { theme } from "$lib/stores/theme_store";
let thumbnail: string = let thumbnail: string = $state(
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark; $theme === "light" ? emptyStateTrailLight : emptyStateTrailDark,
);
$: Promise.all( interface Props {
(log._photos ?? []).map(async (f) => { log: SummitLog;
mode?: "show" | "edit";
onchange?: (item: DropdownItem) => void;
}
let { log, mode = "show", onchange }: Props = $props();
const dropdownItems: DropdownItem[] = [
{ text: $_("edit"), value: "edit" },
{ text: $_("delete"), value: "delete" },
];
$effect(() => fetchPhotos(log._photos ?? []));
function fetchPhotos(photos: File[]) {
Promise.all(
photos.map(async (f) => {
return await readAsDataURLAsync(f); return await readAsDataURLAsync(f);
}), }),
).then((v) => { ).then((v) => {
@@ -27,17 +44,12 @@
thumbnail = v[0]; thumbnail = v[0];
} else { } else {
thumbnail = thumbnail =
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark; $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark;
} }
}); });
}
export let log: SummitLog;
export let mode: "show" | "edit" = "show";
const dropdownItems: DropdownItem[] = [
{ text: $_("edit"), value: "edit" },
{ text: $_("delete"), value: "delete" },
];
</script> </script>
<div class="p-4 my-2 border border-input-border rounded-xl"> <div class="p-4 my-2 border border-input-border rounded-xl">
@@ -65,7 +77,7 @@
</h5> </h5>
{#if mode == "edit"} {#if mode == "edit"}
<Dropdown items={dropdownItems} on:change></Dropdown> <Dropdown items={dropdownItems} {onchange}></Dropdown>
{/if} {/if}
</div> </div>
{#if log.distance || log.elevation_gain || log.elevation_loss || log.duration} {#if log.distance || log.elevation_gain || log.elevation_loss || log.duration}

View File

@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; import { type Snippet } from "svelte";
import { import { SummitLogCreateSchema } from "$lib/models/api/summit_log_schema";
SummitLogCreateSchema,
} from "$lib/models/api/summit_log_schema";
import GPX from "$lib/models/gpx/gpx"; import GPX from "$lib/models/gpx/gpx";
import { summitLog } from "$lib/stores/summit_log_store"; import { summitLog } from "$lib/stores/summit_log_store";
import { createForm } from "felte"; import { cloneDeep } from "$lib/util/deep_util";
import { validator } from "@felte/validator-zod"; import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { z } from "zod"; import { z } from "zod";
import Datepicker from "../base/datepicker.svelte"; import Datepicker from "../base/datepicker.svelte";
@@ -15,11 +14,19 @@
import Textarea from "../base/textarea.svelte"; import Textarea from "../base/textarea.svelte";
import PhotoPicker from "../trail/photo_picker.svelte"; import PhotoPicker from "../trail/photo_picker.svelte";
import TrailPicker from "../trail/trail_picker.svelte"; import TrailPicker from "../trail/trail_picker.svelte";
import { cloneDeep } from "$lib/util/deep_util"; import type { SummitLog } from "$lib/models/summit_log";
export let openModal: (() => void) | undefined = undefined; interface Props {
export let closeModal: (() => void) | undefined = undefined; children?: Snippet<[any]>;
onsave?: (summitLog: SummitLog) => void;
}
const dispatch = createEventDispatcher(); let { children, onsave }: Props = $props();
let modal: Modal;
export function openModal() {
modal.openModal();
}
const ClientSummitLogCreateSchema = SummitLogCreateSchema.extend({ const ClientSummitLogCreateSchema = SummitLogCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(), _photos: z.array(z.instanceof(File)).optional(),
@@ -56,15 +63,22 @@
form._photos = [new File([blob], "route")]; form._photos = [new File([blob], "route")];
} }
dispatch("save", form); onsave?.(form);
closeModal!(); modal.closeModal!();
}, },
}); });
$: setFields(cloneDeep($summitLog));
$: if ($summitLog._gpx) { let trailData = $derived($data.expand?.gpx_data);
$effect(() => {
setFields(cloneDeep($summitLog));
});
$effect(() => {
if ($summitLog._gpx) {
$data._gpx = $summitLog._gpx; $data._gpx = $summitLog._gpx;
} }
});
async function handleTrailSelection(trailData: string | null) { async function handleTrailSelection(trailData: string | null) {
if (!trailData) { if (!trailData) {
@@ -85,28 +99,31 @@
$data.elevation_gain = totals.elevationGain; $data.elevation_gain = totals.elevationGain;
$data.elevation_loss = totals.elevationLoss; $data.elevation_loss = totals.elevationLoss;
$data.distance = totals.distance; $data.distance = totals.distance;
$data.expand!.gpx_data = trailData;
} }
const children_render = $derived(children);
</script> </script>
<Modal <Modal
id="summit-log-modal" id="summit-log-modal"
title={$data.id ? $_("edit-entry") : $_("add-entry")} title={$data.id ? $_("edit-entry") : $_("add-entry")}
let:openModal bind:this={modal}
bind:openModal
bind:closeModal
>
<slot {openModal} />
<form
id="summit-log-form"
slot="content"
class="modal-content space-y-4"
use:form
> >
{#snippet children({ openModal })}
{@render children_render?.({ openModal })}
{/snippet}
{#snippet content()}
<form id="summit-log-form" class="modal-content space-y-4" use:form>
<div class="flex"> <div class="flex">
<Datepicker name="date" label={$_("date")} error={$errors.date} <Datepicker name="date" label={$_("date")} error={$errors.date}
></Datepicker> ></Datepicker>
</div> </div>
<div> <div>
<label for="summitlog-photo-input" class="text-sm font-medium pb-1"> <label
for="summitlog-photo-input"
class="text-sm font-medium pb-1"
>
{$_("photos")} {$_("photos")}
</label> </label>
<PhotoPicker <PhotoPicker
@@ -121,9 +138,9 @@
{#if $data.expand} {#if $data.expand}
<TrailPicker <TrailPicker
bind:trailFile={$data._gpx} bind:trailFile={$data._gpx}
bind:trailData={$data.expand.gpx_data} {trailData}
label={$_("trail", { values: { n: 1 } })} label={$_("trail", { values: { n: 1 } })}
on:change={(e) => handleTrailSelection(e.detail)} onchange={(trail) => handleTrailSelection(trail)}
></TrailPicker> ></TrailPicker>
{/if} {/if}
<div class="basis-full"> <div class="basis-full">
@@ -136,12 +153,15 @@
</div> </div>
</div> </div>
</form> </form>
<div slot="footer" class="flex items-center gap-4"> {/snippet}
<button class="btn-secondary" on:click={closeModal} {#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button >{$_("cancel")}</button
> >
<button class="btn-primary" type="submit" form="summit-log-form" <button class="btn-primary" type="submit" form="summit-log-form"
>{$_("save")}</button >{$_("save")}</button
> >
</div> </div>
{/snippet}
</Modal> </Modal>

View File

@@ -10,24 +10,32 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import MapWithElevationMaplibre from "../trail/map_with_elevation_maplibre.svelte"; import MapWithElevationMaplibre from "../trail/map_with_elevation_maplibre.svelte";
export let summitLogs: SummitLog[] = []; interface Props {
export let showCategory: boolean = false; summitLogs?: SummitLog[];
export let showTrail: boolean = false; showCategory?: boolean;
export let showAuthor: boolean = false; showTrail?: boolean;
export let showRoute: boolean = false; showAuthor?: boolean;
export let showPhotos: boolean = false; showRoute?: boolean;
showPhotos?: boolean;
}
let openMapModal: () => void; let {
let closeMapModal: () => void; summitLogs = [],
showCategory = false,
showTrail = false,
showAuthor = false,
showRoute = false,
showPhotos = false,
}: Props = $props();
let openTextModal: () => void; let mapModal: Modal;
let closeTextModal: () => void; let textModal: Modal;
let map: M.Map; let map: M.Map | undefined = $state();
let trail: Trail | null = null; let trail: Trail | null = $state(null);
let currentText: string = ""; let currentText: string = $state("");
onMount(async () => {}); onMount(async () => {});
@@ -40,21 +48,21 @@
trail.id = log.id; trail.id = log.id;
trail.expand!.gpx_data = log.expand.gpx_data; trail.expand!.gpx_data = log.expand.gpx_data;
openMapModal(); mapModal.openModal();
await tick(); await tick();
return; return;
} }
async function openText(log: SummitLog) { async function openText(log: SummitLog) {
currentText = log.text ?? ""; currentText = log.text ?? "";
openTextModal(); textModal.openModal();
} }
</script> </script>
<table class="w-full"> <table class="w-full">
<thead class="text-left text-gray-500"> <thead class="text-left text-gray-500">
<tr class="text-sm"> <tr class="text-sm">
{#if showPhotos && summitLogs.some(l => l.photos.length)} {#if showPhotos && summitLogs.some((l) => l.photos.length)}
<th class="w-24"></th> <th class="w-24"></th>
{/if} {/if}
<th>{$_("date")}</th> <th>{$_("date")}</th>
@@ -75,7 +83,7 @@
{#if summitLogs.some((l) => l.text?.length)} {#if summitLogs.some((l) => l.text?.length)}
<th>{$_("description")}</th> <th>{$_("description")}</th>
{/if} {/if}
{#if showAuthor && summitLogs.some(l => l.expand?.author)} {#if showAuthor && summitLogs.some((l) => l.expand?.author)}
<th> <th>
{$_("author", { values: { n: 1 } })} {$_("author", { values: { n: 1 } })}
</th> </th>
@@ -91,13 +99,14 @@
{#each summitLogs as log, i} {#each summitLogs as log, i}
<SummitLogTableRow <SummitLogTableRow
{log} {log}
on:open={(e) => openMap(e.detail)} onopen={(log) => openMap(log)}
on:text={(e) => openText(e.detail)} ontext={(log) => openText(log)}
{showCategory} {showCategory}
{showTrail} {showTrail}
{showAuthor} {showAuthor}
showPhotos={showPhotos && summitLogs.some(l => l.photos.length)} showPhotos={showPhotos &&
showDescription={summitLogs.some(l => l.text?.length)} summitLogs.some((l) => l.photos.length)}
showDescription={summitLogs.some((l) => l.text?.length)}
showRoute={showRoute && summitLogs.some((l) => l.gpx)} showRoute={showRoute && summitLogs.some((l) => l.gpx)}
></SummitLogTableRow> ></SummitLogTableRow>
{/each} {/each}
@@ -110,24 +119,29 @@
id="summit-log-table-map-modal" id="summit-log-table-map-modal"
size="max-w-4xl" size="max-w-4xl"
title="" title=""
bind:openModal={openMapModal} bind:this={mapModal}
bind:closeModal={closeMapModal}
> >
<div slot="content" id="summit-log-table-map" class="h-[32rem]"> {#snippet content()}
<div id="summit-log-table-map" class="h-[32rem]">
{#if trail} {#if trail}
<MapWithElevationMaplibre trails={[trail]} bind:map showTerrain <MapWithElevationMaplibre
trails={[trail]}
bind:map
showTerrain
></MapWithElevationMaplibre> ></MapWithElevationMaplibre>
{/if} {/if}
</div> </div>
{/snippet}
</Modal> </Modal>
<Modal <Modal
id="summit-log-table-text-modal" id="summit-log-table-text-modal"
size="max-w-xl" size="max-w-xl"
title={$_("description")} title={$_("description")}
bind:openModal={openTextModal} bind:this={textModal}
bind:closeModal={closeTextModal}
> >
<p slot="content" class="whitespace-pre-wrap">{currentText}</p> {#snippet content()}
<p class="whitespace-pre-wrap">{currentText}</p>
{/snippet}
</Modal> </Modal>
<style> <style>

View File

@@ -7,22 +7,38 @@
formatElevation, formatElevation,
formatTimeHHMM, formatTimeHHMM,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import PhotoGallery from "../photo_gallery.svelte"; import PhotoGallery from "../photo_gallery.svelte";
export let log: SummitLog; interface Props {
export let showCategory: boolean = false; log: SummitLog;
export let showTrail: boolean = false; showCategory?: boolean;
export let showRoute: boolean = false; showTrail?: boolean;
export let showAuthor: boolean = false; showRoute?: boolean;
export let showDescription: boolean = false; showAuthor?: boolean;
export let showPhotos: boolean = false; showDescription?: boolean;
showPhotos?: boolean;
ontext?: (summitLog: SummitLog) => void;
onopen?: (summitLog: SummitLog) => void;
}
let openGallery: (idx?: number) => void; let {
log,
showCategory = false,
showTrail = false,
showRoute = false,
showAuthor = false,
showDescription = false,
showPhotos = false,
onopen,
ontext
}: Props = $props();
let imgSrc: string[] = []; let gallery: PhotoGallery;
$: if (log.photos?.length) {
let imgSrc: string[] = $state([]);
$effect(() => {
if (log.photos?.length) {
imgSrc = log.photos imgSrc = log.photos
.filter((_, i) => i < 3) .filter((_, i) => i < 3)
.reverse() .reverse()
@@ -30,15 +46,14 @@
} else { } else {
imgSrc = []; imgSrc = [];
} }
});
const dispatch = createEventDispatcher();
function openText() { function openText() {
dispatch("text", log); ontext?.(log);
} }
function openRoute() { function openRoute() {
dispatch("open", log); onopen?.(log);
} }
function colCount() { function colCount() {
@@ -58,12 +73,12 @@
{#if imgSrc.length} {#if imgSrc.length}
<PhotoGallery <PhotoGallery
photos={log.photos.map((p) => getFileURL(log, p))} photos={log.photos.map((p) => getFileURL(log, p))}
bind:open={openGallery} bind:this={gallery}
></PhotoGallery> ></PhotoGallery>
<button <button
class="relative w-16 aspect-square ml-2 mb-3 shrink-0" class="relative w-16 aspect-square ml-2 mb-3 shrink-0"
type="button" type="button"
on:click={() => openGallery()} onclick={() => gallery.openGallery()}
> >
{#each imgSrc as img, i} {#each imgSrc as img, i}
<img <img
@@ -110,6 +125,7 @@
{#if showTrail} {#if showTrail}
<td> <td>
<a <a
aria-label="Go to trail"
class="btn-icon aspect-square" class="btn-icon aspect-square"
href="/trail/view/{log.expand?.trails_via_summit_logs?.at(0) href="/trail/view/{log.expand?.trails_via_summit_logs?.at(0)
?.id ?? ''}" ?.id ?? ''}"
@@ -120,7 +136,7 @@
{#if showDescription} {#if showDescription}
<td> <td>
{#if log.text} {#if log.text}
<button on:click={openText} <button onclick={openText}
><p ><p
class="rounded-full bg-menu-item-background-hover hover:bg-menu-item-background-focus text-ellipsis max-w-28 whitespace-nowrap overflow-hidden px-3 py-1" class="rounded-full bg-menu-item-background-hover hover:bg-menu-item-background-focus text-ellipsis max-w-28 whitespace-nowrap overflow-hidden px-3 py-1"
> >
@@ -164,7 +180,11 @@
{/if} {/if}
{#if showRoute && log.expand?.gpx_data} {#if showRoute && log.expand?.gpx_data}
<td> <td>
<button on:click={openRoute} class="btn-icon"> <button
aria-label="Open route"
onclick={openRoute}
class="btn-icon"
>
<i class="fa fa-map-location-dot px-[3px] text-xl"></i></button <i class="fa fa-map-location-dot px-[3px] text-xl"></i></button
> >
</td> </td>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/state";
import directionCaret from "$lib/assets/svgs/caret-right-solid.svg"; import directionCaret from "$lib/assets/svgs/caret-right-solid.svg";
import type { Settings } from "$lib/models/settings"; import type { Settings } from "$lib/models/settings";
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
@@ -19,34 +19,72 @@
import type { GeoJSON } from "geojson"; import type { GeoJSON } from "geojson";
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { createEventDispatcher, onDestroy, onMount } from "svelte"; import { onDestroy, onMount, untrack } from "svelte";
export let trails: Trail[] = []; interface Props {
export let waypoints: Waypoint[] = []; trails?: Trail[];
export let markers: M.Marker[] = []; waypoints?: Waypoint[];
export let map: M.Map | null = null; markers?: M.Marker[];
export let drawing: boolean = false; map?: M.Map | null;
export let showElevation: boolean = true; drawing?: boolean;
export let showInfoPopup: boolean = false; showElevation?: boolean;
export let showGrid: boolean = false; showInfoPopup?: boolean;
export let showStyleSwitcher: boolean = true; showGrid?: boolean;
export let showFullscreen: boolean = false; showStyleSwitcher?: boolean;
export let showTerrain: boolean = false; showFullscreen?: boolean;
export let fitBounds: "animate" | "instant" | "off" = "instant"; showTerrain?: boolean;
export let onMarkerDragEnd: fitBounds?: "animate" | "instant" | "off";
onmarkerdragend?:
| ((marker: M.Marker, wpId?: string) => void) | ((marker: M.Marker, wpId?: string) => void)
| undefined = undefined; | undefined;
elevationProfileContainer?: string | HTMLDivElement | undefined;
mapOptions?: Partial<M.MapOptions> | undefined;
activeTrail?: number | null;
minZoom?: number;
onsegmentdragend?: (data: {
segment: number;
event: M.MapMouseEvent;
}) => void;
onselect?: (trail: Trail) => void;
onunselect?: (trail: Trail) => void;
onfullscreen?: () => void;
onmoveend?: (map: M.Map) => void;
onzoom?: (map: M.Map) => void;
onclick?: (event: M.MapMouseEvent & Object) => void;
oninit?: (map: M.Map) => void;
}
export let elevationProfileContainer: string | HTMLDivElement | undefined = let {
undefined; trails = [],
export let mapOptions: Partial<M.MapOptions> | undefined = undefined; waypoints = [],
markers = $bindable([]),
export let activeTrail: number | null = null; map = $bindable(),
export let minZoom: number = 0; drawing = false,
showElevation = true,
showInfoPopup = false,
showGrid = false,
showStyleSwitcher = true,
showFullscreen = false,
showTerrain = false,
fitBounds = "instant",
elevationProfileContainer = undefined,
mapOptions = undefined,
activeTrail = $bindable(0),
minZoom = 0,
onmarkerdragend,
onsegmentdragend,
onselect,
onunselect,
onfullscreen,
onmoveend,
onzoom,
onclick,
oninit,
}: Props = $props();
let mapContainer: HTMLDivElement; let mapContainer: HTMLDivElement;
let epc: ElevationProfileControl | null = null; let epc: ElevationProfileControl;
let graticule: MaplibreGraticule | null = null; let graticule: MaplibreGraticule;
let layers: Record< let layers: Record<
string, string,
@@ -71,8 +109,6 @@
let hoveringTrail: boolean = false; let hoveringTrail: boolean = false;
const dispatch = createEventDispatcher();
const trailColors = [ const trailColors = [
"#3549BB", "#3549BB",
"#592E9E", "#592E9E",
@@ -86,25 +122,17 @@
"#DE7DC5", "#DE7DC5",
]; ];
$: data = getData(trails); let data = $derived(getData(trails));
$effect(() => {
$: if (data && map) { if (data && map) {
initMap(map.loaded()); untrack(() => initMap(map?.loaded() ?? false));
} }
});
$: if (activeTrail !== null && trails[activeTrail] !== undefined) { $effect(() => {
if ( adjustTrailFocus(activeTrail);
!drawing && });
fitBounds !== "off" && $effect(() => {
data.some((d) => d.bbox !== undefined) if ($theme == "dark") {
) {
focusTrail(trails[activeTrail]);
}
} else if (activeTrail === null && trails.length) {
unFocusTrail();
}
$: if ($theme == "dark") {
epc?.toggleTheme({ epc?.toggleTheme({
profileBackgroundColor: "#191b24", profileBackgroundColor: "#191b24",
elevationGridColor: "#ddd2", elevationGridColor: "#ddd2",
@@ -123,14 +151,16 @@
tooltipTextColor: "#000", tooltipTextColor: "#000",
}); });
} }
});
$: if (drawing && map) { $effect(() => {
if (drawing && map) {
startDrawing(); startDrawing();
} else if (!drawing && map) { } else if (!drawing && map) {
stopDrawing(); stopDrawing();
} }
});
$: if (showGrid) { $effect(() => {
if (showGrid) {
if (!graticule) { if (!graticule) {
graticule = new MaplibreGraticule({ graticule = new MaplibreGraticule({
minZoom: 0, minZoom: 0,
@@ -153,11 +183,15 @@
map?.removeControl(graticule); map?.removeControl(graticule);
} }
} }
});
$: if (waypoints) { $effect(() => {
if (waypoints) {
untrack(() => {
showWaypoints(); showWaypoints();
refreshElevationProfile(); refreshElevationProfile();
});
} }
});
function getData(trails: Trail[]) { function getData(trails: Trail[]) {
if (!trails.length) { if (!trails.length) {
@@ -168,7 +202,7 @@
trails.forEach((t) => { trails.forEach((t) => {
if (t.expand?.gpx_data) { if (t.expand?.gpx_data) {
r.push(toGeoJson(t.expand.gpx_data) as GeoJSON); r.push(toGeoJson(t.expand.gpx_data) as GeoJSON);
} else if (t.lat && t.lon) { } else if (t.lat !== null && t.lon !== null) {
r.push({ r.push({
id: "", id: "",
type: "Feature", type: "Feature",
@@ -212,7 +246,7 @@
fitBounds !== "off" && fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined) data.some((d) => d.bbox !== undefined)
) { ) {
if (activeTrail !== null && mapLoaded) { if (activeTrail !== null && trails[activeTrail] && mapLoaded) {
focusTrail(trails[activeTrail]); focusTrail(trails[activeTrail]);
} else { } else {
flyToBounds(); flyToBounds();
@@ -325,7 +359,7 @@
createEmptyLayer(id); createEmptyLayer(id);
if (!layers[id].source) { if (!layers[id].source || !map.getSource(id)) {
try { try {
map.addSource(id, { map.addSource(id, {
type: "geojson", type: "geojson",
@@ -339,7 +373,7 @@
layers[id].source.setData(geojson); layers[id].source.setData(geojson);
} }
if (!layers[id].layer) { if (!layers[id].layer || !map.getLayer(id)) {
map.addLayer({ map.addLayer({
id: id, id: id,
type: "line", type: "line",
@@ -407,12 +441,12 @@
function handleDragEnd(e: M.MapMouseEvent) { function handleDragEnd(e: M.MapMouseEvent) {
map?.off("mousemove", moveElevationMarkerToCursorPosition); map?.off("mousemove", moveElevationMarkerToCursorPosition);
epc?.hideCrosshair(); epc?.hideCrosshair();
dispatch("segmentDragEnd", { segment: draggingSegment, event: e }); onsegmentdragend?.({ segment: draggingSegment!, event: e });
draggingSegment = null; draggingSegment = null;
} }
function addCaretLayer(id?: string) { function addCaretLayer(id?: string) {
if (!map || !id) { if (!map || !id || !map.getSource(id)) {
return; return;
} }
if (map.getLayer("direction-carets")) { if (map.getLayer("direction-carets")) {
@@ -459,11 +493,16 @@
id: string, id: string,
showElevationMarker: boolean = false, showElevationMarker: boolean = false,
) { ) {
if (!id) {
return;
}
if (showElevationMarker) { if (showElevationMarker) {
elevationMarker.setOpacity("1"); elevationMarker.setOpacity("1");
} }
map?.setPaintProperty(id, "line-width", 7); map?.setPaintProperty(id, "line-width", 7);
if (map?.getLayer(id)) {
hoveringTrail = true; hoveringTrail = true;
}
// map?.setPaintProperty(id, "line-color", "#2766e3"); // map?.setPaintProperty(id, "line-color", "#2766e3");
} }
@@ -474,17 +513,33 @@
elevationMarker.setOpacity("0"); elevationMarker.setOpacity("0");
epc?.hideCrosshair(); epc?.hideCrosshair();
hoveringTrail = false; hoveringTrail = false;
if (map?.getLayer(id)) {
map?.setPaintProperty(id, "line-width", 5); map?.setPaintProperty(id, "line-width", 5);
}
// map?.setPaintProperty(id, "line-color", "#648ad5"); // map?.setPaintProperty(id, "line-color", "#648ad5");
} }
function adjustTrailFocus(activeTrail: number | null) {
if (activeTrail !== null && trails[activeTrail] !== undefined) {
if (
!drawing &&
fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined)
) {
untrack(() => focusTrail(trails[activeTrail]));
}
} else if (activeTrail === null && trails.length) {
untrack(() => unFocusTrail());
}
}
function focusTrail(trail: Trail) { function focusTrail(trail: Trail) {
activeTrail = trails.findIndex((t) => t.id == trail.id); activeTrail = trails.findIndex((t) => t.id == trail.id);
if (activeTrail < 0) { if (activeTrail < 0) {
activeTrail = null; activeTrail = null;
return; return;
} }
dispatch("select", trail); onselect?.(trail);
try { try {
refreshElevationProfile(); refreshElevationProfile();
@@ -501,7 +556,7 @@
function unFocusTrail(trail?: Trail) { function unFocusTrail(trail?: Trail) {
if (trail) { if (trail) {
dispatch("unselect", trail); onunselect?.(trail);
unHighlightTrail(trail.id!); unHighlightTrail(trail.id!);
} }
@@ -617,11 +672,16 @@
hideWaypoints(); hideWaypoints();
for (const waypoint of waypoints) { for (const waypoint of waypoints) {
const marker = createMarkerFromWaypoint(waypoint, onMarkerDragEnd); if (!markers.find((m) => m._element.id == waypoint.id)) {
const marker = createMarkerFromWaypoint(
waypoint,
onmarkerdragend,
);
marker.addTo(map); marker.addTo(map);
markers.push(marker); markers.push(marker);
} }
} }
}
function hideWaypoints() { function hideWaypoints() {
if (!map) { if (!map) {
@@ -630,6 +690,7 @@
for (const m of markers) { for (const m of markers) {
m.remove(); m.remove();
} }
markers = [];
} }
onMount(async () => { onMount(async () => {
@@ -646,7 +707,7 @@
const mapStyles: { text: string; value: string; thumbnail?: string }[] = const mapStyles: { text: string; value: string; thumbnail?: string }[] =
[ [
...(($page.data.settings as Settings)?.tilesets ?? []).map( ...((page.data.settings as Settings)?.tilesets ?? []).map(
(t) => ({ (t) => ({
text: t.name, text: t.name,
value: t.url, value: t.url,
@@ -683,6 +744,10 @@
preferredMapStyleIndex = 0; preferredMapStyleIndex = 0;
} }
if (!mapContainer) {
return;
}
const finalMapOptions: M.MapOptions = { const finalMapOptions: M.MapOptions = {
...{ ...{
container: mapContainer, container: mapContainer,
@@ -730,7 +795,7 @@
map.addControl( map.addControl(
new M.ScaleControl({ new M.ScaleControl({
maxWidth: 120, maxWidth: 120,
unit: $page.data.settings?.unit ?? "metric", unit: page.data.settings?.unit ?? "metric",
}), }),
"top-left", "top-left",
); );
@@ -741,7 +806,7 @@
enableHighAccuracy: true, enableHighAccuracy: true,
}, },
fitBoundsOptions: { fitBoundsOptions: {
animate: fitBounds == "animate" animate: fitBounds == "animate",
}, },
trackUserLocation: true, trackUserLocation: true,
}), }),
@@ -757,7 +822,7 @@
profileBackgroundColor: profileBackgroundColor:
$theme == "light" ? "#242734" : "#191b24", $theme == "light" ? "#242734" : "#191b24",
backgroundColor: "bg-menu-background/90", backgroundColor: "bg-menu-background/90",
unit: $page.data.settings?.unit ?? "metric", unit: page.data.settings?.unit ?? "metric",
profileLineWidth: 3, profileLineWidth: 3,
displayDistanceGrid: true, displayDistanceGrid: true,
tooltipDisplayDPlus: false, tooltipDisplayDPlus: false,
@@ -785,7 +850,7 @@
if (showFullscreen) { if (showFullscreen) {
map.addControl( map.addControl(
new FullscreenControl(() => { new FullscreenControl(() => {
dispatch("fullscreen"); onfullscreen?.();
}), }),
"bottom-right", "bottom-right",
); );
@@ -803,21 +868,21 @@
if (showTerrain) { if (showTerrain) {
try { try {
if ( if (
$page.data.settings?.terrain?.terrain && page.data.settings?.terrain?.terrain &&
!map?.getSource("terrain") !map?.getSource("terrain")
) { ) {
map!.addSource("terrain", { map!.addSource("terrain", {
type: "raster-dem", type: "raster-dem",
url: $page.data.settings?.terrain?.terrain, url: page.data.settings?.terrain?.terrain,
}); });
} }
if ( if (
$page.data.settings?.terrain?.hillshading && page.data.settings?.terrain?.hillshading &&
!map?.getSource("hillshading") !map?.getSource("hillshading")
) { ) {
map!.addSource("hillshading", { map!.addSource("hillshading", {
type: "raster-dem", type: "raster-dem",
url: $page.data.settings?.terrain?.hillshading, url: page.data.settings?.terrain?.hillshading,
}); });
map!.addLayer({ map!.addLayer({
id: "hillshading", id: "hillshading",
@@ -830,7 +895,7 @@
}); });
map.on("moveend", (e) => { map.on("moveend", (e) => {
dispatch("moveend", e.target); onmoveend?.(e.target);
}); });
map.on("zoom", (e) => { map.on("zoom", (e) => {
@@ -843,19 +908,19 @@
} }
}); });
dispatch("zoom", e.target); onzoom?.(e.target);
}); });
map.on("click", (e) => { map.on("click", (e) => {
if (hoveringTrail && drawing) { if (hoveringTrail && drawing) {
return; return;
} }
dispatch("click", e); onclick?.(e);
}); });
map.on("load", () => { map.on("load", () => {
initMap(true); initMap(true);
dispatch("init", map); oninit?.(map!);
}); });
showWaypoints(); showWaypoints();

View File

@@ -1,30 +1,48 @@
<script lang="ts"> <script lang="ts">
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { onMount } from "svelte";
import PhotoCard from "../photo_card.svelte";
import { show_toast } from "$lib/stores/toast_store"; import { show_toast } from "$lib/stores/toast_store";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import PhotoCard from "../photo_card.svelte";
export let id: string; interface Props {
export let photos: string[]; id: string;
export let photoFiles: File[] | undefined; photos: string[];
export let parent: { [key: string]: any }; photoFiles: File[] | undefined;
export let thumbnail: number = 0; parent: { [key: string]: any };
export let showThumbnailControls: boolean = true; thumbnail?: number;
export let showExifControls: boolean = false; showThumbnailControls?: boolean;
export let maxSizeBytes = 20971520; showExifControls?: boolean;
maxSizeBytes?: number;
onexif?: (src: string) => void;
}
let photoPreviews: string[] = []; let {
id,
photos = $bindable(),
photoFiles = $bindable(),
parent,
thumbnail = $bindable(0),
showThumbnailControls = true,
showExifControls = false,
maxSizeBytes = 20971520,
onexif,
}: Props = $props();
$: Promise.all( let photoPreviews: string[] = $state([]);
(photoFiles ?? []).map(async (f) => {
$effect(() => fetchPhotos(photoFiles ?? []));
function fetchPhotos(photos: File[]) {
Promise.all(
photos.map(async (f) => {
return await readAsDataURLAsync(f); return await readAsDataURLAsync(f);
}), }),
).then((v) => { ).then((v) => {
photoPreviews = v; photoPreviews = v;
}); });
}
let offerUpload: boolean = false; let offerUpload: boolean = $state(false);
function handlePhotoDragOver(e: DragEvent) { function handlePhotoDragOver(e: DragEvent) {
e.preventDefault(); e.preventDefault();
@@ -120,14 +138,15 @@
? 'outline-dashed outline-input-border' ? 'outline-dashed outline-input-border'
: ''}" : ''}"
role="dialog" role="dialog"
on:dragover={handlePhotoDragOver} ondragover={handlePhotoDragOver}
on:dragleave={handlePhotoDragLeave} ondragleave={handlePhotoDragLeave}
on:drop={handlePhotoDrop} ondrop={handlePhotoDrop}
> >
<button <button
aria-label="Open photo browser"
class="btn-secondary h-32 w-32 shrink-0 grow-0 basis-auto" class="btn-secondary h-32 w-32 shrink-0 grow-0 basis-auto"
type="button" type="button"
on:click={openPhotoBrowser}><i class="fa fa-plus"></i></button onclick={openPhotoBrowser}><i class="fa fa-plus"></i></button
> >
<input <input
type="file" type="file"
@@ -135,17 +154,17 @@
accept="image/*" accept="image/*"
multiple={true} multiple={true}
style="display: none;" style="display: none;"
on:change={() => handlePhotoSelection()} onchange={() => handlePhotoSelection()}
/> />
<div class="flex overflow-x-auto gap-x-3 w-full"> <div class="flex overflow-x-auto gap-x-3 w-full">
{#each (photos ?? []).concat(photoPreviews) as photo, i} {#each (photos ?? []).concat(photoPreviews) as photo, i}
<div class="shrink-0 grow-0 basis-auto"> <div class="shrink-0 grow-0 basis-auto">
<PhotoCard <PhotoCard
src={i >= photos.length ? photo : getFileURL(parent, photo)} src={i >= photos.length ? photo : getFileURL(parent, photo)}
on:delete={() => handlePhotoDelete(i)} ondelete={() => handlePhotoDelete(i)}
isThumbnail={thumbnail === i} isThumbnail={thumbnail === i}
on:thumbnail={() => makePhotoThumbnail(i)} onthumbnail={() => makePhotoThumbnail(i)}
on:exif {onexif}
{showThumbnailControls} {showThumbnailControls}
{showExifControls} {showExifControls}
></PhotoCard> ></PhotoCard>

View File

@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import { createBubbler } from "svelte/legacy";
const bubble = createBubbler();
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg"; import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg"; import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
@@ -12,25 +15,41 @@
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte"; import ShareInfo from "../share_info.svelte";
import type { MouseEventHandler } from "svelte/elements";
export let trail: Trail; interface Props {
export let fullWidth: boolean = false; trail: Trail;
fullWidth?: boolean;
onmouseenter?: MouseEventHandler<HTMLDivElement>;
onmouseleave?: MouseEventHandler<HTMLDivElement>;
}
$: thumbnail = trail.photos.length let {
trail,
fullWidth = false,
onmouseenter,
onmouseleave,
}: Props = $props();
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0]) ? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light" : $theme === "light"
? emptyStateTrailLight ? emptyStateTrailLight
: emptyStateTrailDark; : emptyStateTrailDark,
);
$: trailIsShared = (trail.expand?.trail_share_via_trail?.length ?? 0) > 0; let trailIsShared = $derived(
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0,
);
</script> </script>
<div <div
class="trail-card relative rounded-2xl border border-input-border min-w-72 {fullWidth class="trail-card relative rounded-2xl border border-input-border min-w-72 {fullWidth
? '' ? ''
: 'lg:w-72'} cursor-pointer" : 'lg:w-72'} cursor-pointer"
on:mouseenter {onmouseenter}
on:mouseleave {onmouseleave}
role="listitem" role="listitem"
> >
<div <div

View File

@@ -22,19 +22,25 @@
import { currentUser } from "$lib/stores/user_store"; import { currentUser } from "$lib/stores/user_store";
import { pb } from "$lib/pocketbase"; import { pb } from "$lib/pocketbase";
export let trail: Trail; interface Props {
export let mode: "overview" | "map" | "list"; trail: Trail;
mode: "overview" | "map" | "list";
}
let openConfirmModal: () => void; let { trail, mode }: Props = $props();
let openListSelectModal: () => void;
let openExportModal: () => void;
let openShareModal: () => void;
let lists: List[] = []; let confirmModal: ConfirmModal;
let listSelectModal: ListSelectModal;
let trailExportModal: TrailExportModal;
let trailShareModal: TrailShareModal;
let lists: List[] = $state([]);
const allowEdit = const allowEdit =
trail.author == $currentUser?.id || trail.author == $currentUser?.id ||
trail.expand?.trail_share_via_trail?.some((s) => s.permission == "edit"); trail.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
);
const dropdownItems: DropdownItem[] = [ const dropdownItems: DropdownItem[] = [
mode == "overview" mode == "overview"
@@ -85,7 +91,7 @@
-1, -1,
) )
).items; ).items;
openListSelectModal(); listSelectModal.openModal();
} else if (item.value == "direction") { } else if (item.value == "direction") {
window window
.open( .open(
@@ -96,13 +102,13 @@
} else if (item.value == "print") { } else if (item.value == "print") {
goto(`/map/trail/${trail.id}/print`); goto(`/map/trail/${trail.id}/print`);
} else if (item.value == "share") { } else if (item.value == "share") {
openShareModal(); trailShareModal.openModal();
} else if (item.value == "download") { } else if (item.value == "download") {
openExportModal(); trailExportModal.openModal();
} else if (item.value == "edit") { } else if (item.value == "edit") {
goto(`/trail/edit/${trail.id}`); goto(`/trail/edit/${trail.id}`);
} else if (item.value == "delete") { } else if (item.value == "delete") {
openConfirmModal(); confirmModal.openModal();
} }
} }
@@ -164,7 +170,7 @@
} }
async function deleteTrail() { async function deleteTrail() {
trails_delete(trail).then(() => history.back()); trails_delete(trail).then(() => goto('/trails'));
} }
async function handleListSelection(list: List) { async function handleListSelection(list: List) {
@@ -196,30 +202,30 @@
} }
</script> </script>
<Dropdown <Dropdown items={dropdownItems} onchange={(item) => handleDropdownClick(item)}
items={dropdownItems} >{#snippet children({ toggleMenu: openDropdown })}
on:change={(e) => handleDropdownClick(e.detail)} <button
let:toggleMenu={openDropdown} aria-label="Open dropdown"
><button
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12" class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
on:click={openDropdown} onclick={openDropdown}
> >
<i class="fa fa-ellipsis-vertical"></i> <i class="fa fa-ellipsis-vertical"></i>
</button></Dropdown </button>
> {/snippet}
</Dropdown>
<ConfirmModal <ConfirmModal
text={$_("delete-trail-confirm")} text={$_("delete-trail-confirm")}
bind:openModal={openConfirmModal} bind:this={confirmModal}
on:confirm={deleteTrail} onconfirm={deleteTrail}
></ConfirmModal> ></ConfirmModal>
<ListSelectModal <ListSelectModal
{lists} {lists}
bind:openModal={openListSelectModal} bind:this={listSelectModal}
on:change={(e) => handleListSelection(e.detail)} onchange={(list) => handleListSelection(list)}
></ListSelectModal> ></ListSelectModal>
<TrailExportModal <TrailExportModal
bind:openModal={openExportModal} bind:this={trailExportModal}
on:export={(e) => exportTrail(e.detail)} onexport={(settings) => exportTrail(settings)}
></TrailExportModal> ></TrailExportModal>
<TrailShareModal {trail} bind:openShareModal></TrailShareModal> <TrailShareModal {trail} bind:this={trailShareModal}></TrailShareModal>

View File

@@ -1,34 +1,45 @@
<script lang="ts"> <script lang="ts">
import Modal from "$lib/components/base/modal.svelte"; import Modal from "$lib/components/base/modal.svelte";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Select from "../base/select.svelte"; import Select from "../base/select.svelte";
export let openModal: (() => void) | undefined = undefined; interface Props {
export let closeModal: (() => void) | undefined = undefined; title?: string;
export let title: string = $_("export"); onexport?: (settings: typeof exportSettings) => void;
}
const dispatch = createEventDispatcher(); let { title = $_("export"), onexport }: Props = $props();
let modal: Modal;
export function openModal() {
modal.openModal();
}
const fileFormats = [ const fileFormats = [
{ text: "GPX", value: "gpx" }, { text: "GPX", value: "gpx" },
{ text: "GeoJSON", value: "json" }, { text: "GeoJSON", value: "json" },
]; ];
const exportSettings = { const exportSettings: {
fileFormat: "gpx" | "json";
photos: boolean;
summitLog: boolean;
} = $state({
fileFormat: "gpx", fileFormat: "gpx",
photos: false, photos: false,
summitLog: false, summitLog: false,
}; });
function exportTrail() { function exportTrail() {
dispatch("export", exportSettings); onexport?.(exportSettings);
closeModal!(); modal.closeModal();
} }
</script> </script>
<Modal id="export-modal" {title} size="max-w-sm" bind:openModal bind:closeModal> <Modal id="export-modal" {title} size="max-w-sm" bind:this={modal}>
<div slot="content"> {#snippet content()}
<div>
<div class="mb-3"> <div class="mb-3">
<Select <Select
bind:value={exportSettings.fileFormat} bind:value={exportSettings.fileFormat}
@@ -59,15 +70,18 @@
> >
</div> </div>
</div> </div>
<div slot="footer" class="flex items-center gap-4"> {/snippet}
<button class="btn-secondary" on:click={closeModal} {#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button >{$_("cancel")}</button
> >
<button <button
class="btn-primary" class="btn-primary"
type="button" type="button"
on:click={exportTrail} onclick={exportTrail}
name="save">{$_("export")}</button name="save">{$_("export")}</button
> >
</div></Modal </div>
{/snippet}</Modal
> >

View File

@@ -3,7 +3,6 @@
import type { TrailFilter } from "$lib/models/trail"; import type { TrailFilter } from "$lib/models/trail";
import { country_codes } from "$lib/util/country_code_util"; import { country_codes } from "$lib/util/country_code_util";
import { formatDistance, formatElevation } from "$lib/util/format_util"; import { formatDistance, formatElevation } from "$lib/util/format_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
import Datepicker from "../base/datepicker.svelte"; import Datepicker from "../base/datepicker.svelte";
@@ -17,18 +16,30 @@
import UserSearch from "../user_search.svelte"; import UserSearch from "../user_search.svelte";
import { pb } from "$lib/pocketbase"; import { pb } from "$lib/pocketbase";
export let categories: Category[]; interface Props {
export let filterExpanded: boolean = true; categories: Category[];
export let filter: TrailFilter; filterExpanded?: boolean;
export let showTrailSearch: boolean = true; filter: TrailFilter;
export let showCitySearch: boolean = true; showTrailSearch?: boolean;
showCitySearch?: boolean;
onupdate?: (filter: TrailFilter) => void;
}
$: categorySelectItems = categories.map((c) => ({ let {
categories,
filterExpanded = $bindable(true),
filter = $bindable(),
showTrailSearch = true,
showCitySearch = true,
onupdate,
}: Props = $props();
let categorySelectItems = $derived(
categories.map((c) => ({
value: c.id, value: c.id,
text: c.name, text: c.name,
})); })),
);
const dispatch = createEventDispatcher();
const radioGroupItems: RadioItem[] = [ const radioGroupItems: RadioItem[] = [
{ text: $_("completed"), value: "completed" }, { text: $_("completed"), value: "completed" },
@@ -42,12 +53,12 @@
{ text: $_("difficult"), value: "difficult" }, { text: $_("difficult"), value: "difficult" },
]; ];
let searchDropdownItems: SearchItem[] = []; let searchDropdownItems: SearchItem[] = $state([]);
let citySearchQuery: string = ""; let citySearchQuery: string = $state("");
async function update() { async function update() {
dispatch("update", filter); onupdate?.(filter);
} }
function setCategoryFilter(categories: SelectItem[]) { function setCategoryFilter(categories: SelectItem[]) {
@@ -134,13 +145,14 @@
<div class="basis-full"> <div class="basis-full">
<Search <Search
bind:value={filter.q} bind:value={filter.q}
on:update={update} onupdate={update}
placeholder="{$_('search-trails')}..." placeholder="{$_('search-trails')}..."
></Search> ></Search>
</div> </div>
<button <button
aria-label="Toggle filter"
class="btn-icon md:hidden" class="btn-icon md:hidden"
on:click={() => (filterExpanded = !filterExpanded)} onclick={() => (filterExpanded = !filterExpanded)}
><i class="fa fa-sliders"></i></button ><i class="fa fa-sliders"></i></button
> >
</div> </div>
@@ -152,8 +164,10 @@
<hr class="my-4 border-separator" /> <hr class="my-4 border-separator" />
{/if} {/if}
<MultiSelect <MultiSelect
on:change={(e) => setCategoryFilter(e.detail)} onchange={(value) => setCategoryFilter(value)}
value={categorySelectItems.filter(i => filter.category.includes(i.value))} value={categorySelectItems.filter((i) =>
filter.category.includes(i.value),
)}
label={$_("categories")} label={$_("categories")}
items={categorySelectItems} items={categorySelectItems}
placeholder={`${$_("filter-categories")}...`} placeholder={`${$_("filter-categories")}...`}
@@ -161,8 +175,8 @@
<hr class="my-4 border-separator" /> <hr class="my-4 border-separator" />
{#if pb.authStore.model} {#if pb.authStore.model}
<UserSearch <UserSearch
on:click={(e) => setAuthorFilter(e.detail)} onclick={(item) => setAuthorFilter(item)}
on:clear={() => { onclear={() => {
filter.author = ""; filter.author = "";
update(); update();
}} }}
@@ -175,7 +189,7 @@
type="checkbox" type="checkbox"
checked={filter.public} checked={filter.public}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2" class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
on:change={setPublicFilter} onchange={setPublicFilter}
/> />
<label for="public-checkbox" class="ms-2 text-sm" <label for="public-checkbox" class="ms-2 text-sm"
>{$_("public")}</label >{$_("public")}</label
@@ -187,7 +201,7 @@
type="checkbox" type="checkbox"
checked={filter.shared} checked={filter.shared}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2" class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
on:change={setSharedFilter} onchange={setSharedFilter}
/> />
<label for="shared-checkbox" class="ms-2 text-sm" <label for="shared-checkbox" class="ms-2 text-sm"
>{$_("shared")}</label >{$_("shared")}</label
@@ -196,7 +210,7 @@
<hr class="my-4 border-separator" /> <hr class="my-4 border-separator" />
{/if} {/if}
<MultiSelect <MultiSelect
on:change={(e) => setDifficultyFilter(e.detail)} onchange={(value) => setDifficultyFilter(value)}
label={$_("difficulty")} label={$_("difficulty")}
items={difficultyItems} items={difficultyItems}
placeholder={`${$_("filter-difficulty")}...`} placeholder={`${$_("filter-difficulty")}...`}
@@ -210,14 +224,14 @@
placeholder="{$_('search-cities')}..." placeholder="{$_('search-cities')}..."
clearAfterSelect={false} clearAfterSelect={false}
bind:value={citySearchQuery} bind:value={citySearchQuery}
on:update={(e) => searchCities(e.detail)} onupdate={(q) => searchCities(q)}
on:click={(e) => handleSearchClick(e.detail)} onclick={(item) => handleSearchClick(item)}
></Search> ></Search>
</div> </div>
<Slider <Slider
maxValue={10000} maxValue={10000}
bind:currentValue={filter.near.radius} bind:currentValue={filter.near.radius}
on:set={() => update()} onset={() => update()}
></Slider> ></Slider>
<p> <p>
<span class="text-gray-500 text-sm">{$_("radius")}:</span> <span class="text-gray-500 text-sm">{$_("radius")}:</span>
@@ -231,7 +245,7 @@
maxValue={filter.distanceLimit} maxValue={filter.distanceLimit}
bind:currentMin={filter.distanceMin} bind:currentMin={filter.distanceMin}
bind:currentMax={filter.distanceMax} bind:currentMax={filter.distanceMax}
on:set={() => update()} onset={() => update()}
></DoubleSlider> ></DoubleSlider>
<div class="flex justify-between"> <div class="flex justify-between">
<span>{formatDistance(filter.distanceMin)}</span> <span>{formatDistance(filter.distanceMin)}</span>
@@ -249,7 +263,7 @@
maxValue={filter.elevationGainLimit} maxValue={filter.elevationGainLimit}
bind:currentMin={filter.elevationGainMin} bind:currentMin={filter.elevationGainMin}
bind:currentMax={filter.elevationGainMax} bind:currentMax={filter.elevationGainMax}
on:set={() => update()} onset={() => update()}
></DoubleSlider> ></DoubleSlider>
<div class="flex justify-between"> <div class="flex justify-between">
<span>{formatElevation(filter.elevationGainMin)}</span> <span>{formatElevation(filter.elevationGainMin)}</span>
@@ -268,7 +282,7 @@
maxValue={filter.elevationLossLimit} maxValue={filter.elevationLossLimit}
bind:currentMin={filter.elevationLossMin} bind:currentMin={filter.elevationLossMin}
bind:currentMax={filter.elevationLossMax} bind:currentMax={filter.elevationLossMax}
on:set={() => update()} onset={() => update()}
></DoubleSlider> ></DoubleSlider>
<div class="flex justify-between"> <div class="flex justify-between">
<span>{formatElevation(filter.elevationLossMin)}</span> <span>{formatElevation(filter.elevationLossMin)}</span>
@@ -287,13 +301,13 @@
name="startDate" name="startDate"
label={$_("after")} label={$_("after")}
bind:value={filter.startDate} bind:value={filter.startDate}
on:change={update} onchange={update}
></Datepicker> ></Datepicker>
<Datepicker <Datepicker
name="endDate" name="endDate"
label={$_("before")} label={$_("before")}
bind:value={filter.endDate} bind:value={filter.endDate}
on:change={update} onchange={update}
></Datepicker> ></Datepicker>
</div> </div>
@@ -303,7 +317,7 @@
name="completed" name="completed"
items={radioGroupItems} items={radioGroupItems}
selected={2} selected={2}
on:change={(e) => setCompletedFilter(e.detail)} onchange={(item) => setCompletedFilter(item)}
></RadioGroup> ></RadioGroup>
</div> </div>
{/if} {/if}

View File

@@ -44,10 +44,14 @@
import EmptyStateDescription from "../empty_states/empty_state_description.svelte"; import EmptyStateDescription from "../empty_states/empty_state_description.svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
export let trail: Trail; interface Props {
export let mode: "overview" | "map" | "list" = "map"; trail: Trail;
export let markers: M.Marker[] = []; mode?: "overview" | "map" | "list";
export let activeTab = 0; markers?: M.Marker[];
activeTab?: number;
}
let { trail, mode = "map", markers = [], activeTab = 0 }: Props = $props();
const tabs = [ const tabs = [
$_("description"), $_("description"),
@@ -60,24 +64,19 @@
const trailIsShared = const trailIsShared =
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0; (trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
$: thumbnail = trail.photos.length let gallery: PhotoGallery;
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark;
let openGallery: (idx?: number) => void; let newComment: Comment = $state({
text: "",
rating: 0,
author: "",
trail: trail.id ?? "",
});
let newComment: Comment = new Comment("", 0, "", trail.id ?? ""); let commentsLoading: boolean = $state(activeTab == 4);
let commentCreateLoading: boolean = $state(false);
let commentsLoading: boolean = activeTab == 4;
let commentCreateLoading: boolean = false;
let commentDeleteLoading: boolean = false; let commentDeleteLoading: boolean = false;
$: if (browser && activeTab == 4) {
fetchComments();
}
onMount(async () => {}); onMount(async () => {});
function openMarkerPopup(i: number) { function openMarkerPopup(i: number) {
@@ -144,6 +143,18 @@
comments.set(newCommentList); comments.set(newCommentList);
commentDeleteLoading = false; commentDeleteLoading = false;
} }
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
);
$effect(() => {
if (browser && activeTab == 4) {
fetchComments();
}
});
</script> </script>
<div <div
@@ -180,8 +191,7 @@
</span> </span>
{/if} {/if}
{#if trailIsShared} {#if trailIsShared}
<ShareInfo type="trail" subject={trail} <ShareInfo type="trail" subject={trail}></ShareInfo>
></ShareInfo>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -316,8 +326,8 @@
<ul> <ul>
{#each trail.expand?.waypoints ?? [] as waypoint, i} {#each trail.expand?.waypoints ?? [] as waypoint, i}
<li <li
on:mouseenter={() => openMarkerPopup(i)} onmouseenter={() => openMarkerPopup(i)}
on:mouseleave={() => closeMarkerPopup(i)} onmouseleave={() => closeMarkerPopup(i)}
> >
<WaypointCard {waypoint}></WaypointCard> <WaypointCard {waypoint}></WaypointCard>
</li> </li>
@@ -339,14 +349,14 @@
photos={trail.photos.map((p) => photos={trail.photos.map((p) =>
getFileURL(trail, p), getFileURL(trail, p),
)} )}
bind:open={openGallery} bind:this={gallery}
></PhotoGallery> ></PhotoGallery>
{#each trail.photos ?? [] as photo, i} {#each trail.photos ?? [] as photo, i}
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img <img
class="rounded-xl cursor-pointer hover:scale-105 transition-transform" class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
on:click={() => openGallery(i)} onclick={() => gallery.openGallery(i)}
src={getFileURL(trail, photo)} src={getFileURL(trail, photo)}
alt="" alt=""
/> />
@@ -389,7 +399,7 @@
</div> </div>
<div class="flex justify-end mt-3"> <div class="flex justify-end mt-3">
<Button <Button
on:click={createComment} onclick={createComment}
loading={commentCreateLoading} loading={commentCreateLoading}
secondary={true} secondary={true}
disabled={commentCreateLoading || disabled={commentCreateLoading ||
@@ -415,9 +425,8 @@
mode={comment.author == $currentUser?.id mode={comment.author == $currentUser?.id
? "edit" ? "edit"
: "show"} : "show"}
on:delete={(e) => ondelete={deleteComment}
deleteComment(e.detail)} onedit={editComment}
on:edit={(e) => editComment(e.detail)}
></CommentCard> ></CommentCard>
</li> </li>
{/each} {/each}
@@ -435,7 +444,7 @@
showStyleSwitcher={false} showStyleSwitcher={false}
showFullscreen={true} showFullscreen={true}
mapOptions={{ attributionControl: false }} mapOptions={{ attributionControl: false }}
on:fullscreen={toggleMapFullScreen} onfullscreen={toggleMapFullScreen}
bind:markers bind:markers
></MapWithElevationMaplibre> ></MapWithElevationMaplibre>
</div> </div>

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Trail, TrailFilter } from "$lib/models/trail"; import type { Trail, TrailFilter } from "$lib/models/trail";
import { createEventDispatcher, onMount } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Pagination from "../base/pagination.svelte"; import Pagination from "../base/pagination.svelte";
import Select, { type SelectItem } from "../base/select.svelte"; import Select, { type SelectItem } from "../base/select.svelte";
@@ -10,15 +9,30 @@
import TrailTable from "./trail_table.svelte"; import TrailTable from "./trail_table.svelte";
import SkeletonCard from "../base/skeleton_card.svelte"; import SkeletonCard from "../base/skeleton_card.svelte";
import SkeletonListItem from "../base/skeleton_list_item.svelte"; import SkeletonListItem from "../base/skeleton_list_item.svelte";
import { onMount } from "svelte";
export let filter: TrailFilter | null = null; interface Props {
export let trails: Trail[]; filter?: TrailFilter | null;
export let pagination: { page: number; totalPages: number } = { trails: Trail[];
pagination?: { page: number; totalPages: number };
loading?: boolean;
fullWidthCards?: boolean;
onupdate?: (filter: TrailFilter | null) => void;
onpagination?: (page: number) => void;
}
let {
filter = $bindable(null),
trails,
pagination = {
page: 1, page: 1,
totalPages: 1, totalPages: 1,
}; },
export let loading: boolean = false; loading = false,
export let fullWidthCards: boolean = false; fullWidthCards = false,
onupdate,
onpagination,
}: Props = $props();
const displayOptions: SelectItem[] = [ const displayOptions: SelectItem[] = [
{ text: $_("card", { values: { n: 2 } }), value: "cards" }, { text: $_("card", { values: { n: 2 } }), value: "cards" },
@@ -26,9 +40,7 @@
{ text: $_("table"), value: "table" }, { text: $_("table"), value: "table" },
]; ];
let selectedDisplayOption = displayOptions[0].value; let selectedDisplayOption = $state(displayOptions[0].value);
let dispatch = createEventDispatcher();
const sortOptions: SelectItem[] = [ const sortOptions: SelectItem[] = [
{ text: $_("name"), value: "name" }, { text: $_("name"), value: "name" },
@@ -58,7 +70,7 @@
(storedSortOrder as typeof filter.sortOrder | null) ?? (storedSortOrder as typeof filter.sortOrder | null) ??
filter.sortOrder; filter.sortOrder;
} }
dispatch("update", filter); onupdate?.(filter);
}); });
function setDisplayOption() { function setDisplayOption() {
@@ -70,7 +82,7 @@
return; return;
} }
localStorage.setItem("sort", filter.sort); localStorage.setItem("sort", filter.sort);
dispatch("update", filter); onupdate?.(filter);
} }
function setSortOrder() { function setSortOrder() {
@@ -83,7 +95,7 @@
filter.sortOrder = "+"; filter.sortOrder = "+";
} }
localStorage.setItem("sort_order", filter.sortOrder); localStorage.setItem("sort_order", filter.sortOrder);
dispatch("update", filter); onupdate?.(filter);
} }
function handleSortUpdate(sort: any) { function handleSortUpdate(sort: any) {
@@ -106,7 +118,7 @@
<Pagination <Pagination
page={pagination.page} page={pagination.page}
totalPages={pagination.totalPages} totalPages={pagination.totalPages}
on:pagination {onpagination}
></Pagination> ></Pagination>
</div> </div>
{#if filter} {#if filter}
@@ -117,13 +129,14 @@
<Select <Select
bind:value={filter.sort} bind:value={filter.sort}
items={sortOptions} items={sortOptions}
on:change={setSort} onchange={setSort}
></Select> ></Select>
<button <button
aria-label="Change sort order"
id="sort-order-btn" id="sort-order-btn"
class="btn-icon" class="btn-icon"
class:rotated={filter.sortOrder == "-"} class:rotated={filter.sortOrder == "-"}
on:click={() => setSortOrder()} onclick={() => setSortOrder()}
><i class="fa fa-arrow-up"></i></button ><i class="fa fa-arrow-up"></i></button
> >
</div> </div>
@@ -136,7 +149,7 @@
<Select <Select
bind:value={selectedDisplayOption} bind:value={selectedDisplayOption}
items={displayOptions} items={displayOptions}
on:change={() => setDisplayOption()} onchange={() => setDisplayOption()}
></Select> ></Select>
</div> </div>
</div> </div>
@@ -170,7 +183,7 @@
(option) => option.value !== "elevation_loss", (option) => option.value !== "elevation_loss",
)} )}
{filter} {filter}
on:sort={(sort) => handleSortUpdate(sort)} onsort={handleSortUpdate}
></TrailTable> ></TrailTable>
{:else} {:else}
{#each trails as trail} {#each trails as trail}
@@ -193,7 +206,7 @@
<Pagination <Pagination
page={pagination.page} page={pagination.page}
totalPages={pagination.totalPages} totalPages={pagination.totalPages}
on:pagination {onpagination}
></Pagination> ></Pagination>
</div> </div>

View File

@@ -13,15 +13,19 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte"; import ShareInfo from "../share_info.svelte";
export let trail: Trail;
export let showDescription: boolean = true; interface Props {
trail: Trail;
showDescription?: boolean;
}
$: thumbnail = trail.photos.length let { trail, showDescription = true }: Props = $props();
let thumbnail = $derived(trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0]) ? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light" : $theme === "light"
? emptyStateTrailLight ? emptyStateTrailLight
: emptyStateTrailDark; : emptyStateTrailDark);
</script> </script>
<li <li

View File

@@ -2,23 +2,25 @@
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
import { fromFile, toGeoJson } from "$lib/util/gpx_util"; import { fromFile, toGeoJson } from "$lib/util/gpx_util";
import { createEventDispatcher, onMount } from "svelte"; import { onMount } from "svelte";
export let trailFile: File | undefined | null; interface Props {
export let trailData: string | undefined; trailFile: File | undefined | null;
export let label: string = ""; trailData: string | undefined;
label?: string;
onchange: (data: string | null) => void;
}
let {
trailFile = $bindable(),
trailData = $bindable(),
label = "",
onchange
}: Props = $props();
let map: M.Map; let map: M.Map;
let layer: M.LineLayerSpecification | undefined; let layer: M.LineLayerSpecification | undefined;
let source: M.GeoJSONSource | undefined; let source: M.GeoJSONSource | undefined;
const dispatch = createEventDispatcher();
$: if (trailData !== undefined) {
showTrailOnMap();
} else {
removeTrailFromMap();
}
onMount(async () => { onMount(async () => {
if (!map) { if (!map) {
await initMap(); await initMap();
@@ -40,7 +42,7 @@
trailFile = null; trailFile = null;
trailData = undefined; trailData = undefined;
dispatch("change", null); onchange?.(null);
} else { } else {
document.getElementById("trail-input")!.click(); document.getElementById("trail-input")!.click();
} }
@@ -64,7 +66,7 @@
trailData = parseResult.gpxData; trailData = parseResult.gpxData;
dispatch("change", trailData); onchange?.(trailData);
} }
async function showTrailOnMap() { async function showTrailOnMap() {
@@ -112,10 +114,14 @@
map?.removeSource(source.id); map?.removeSource(source.id);
source = undefined; source = undefined;
} }
trailData = undefined;
trailFile = null;
} }
$effect(() => {
if (trailData !== undefined) {
showTrailOnMap();
} else {
removeTrailFromMap();
}
});
</script> </script>
<div> <div>
@@ -131,11 +137,11 @@
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT" accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
multiple={false} multiple={false}
style="display: none;" style="display: none;"
on:change={() => handleTrailSelection()} onchange={() => handleTrailSelection()}
/> />
<button <button
type="button" type="button"
on:click={openTrailBrowser} onclick={openTrailBrowser}
class="h-28 aspect-square rounded-xl !bg-background border border-input-border-focus hover:!bg-secondary-hover group" class="h-28 aspect-square rounded-xl !bg-background border border-input-border-focus hover:!bg-secondary-hover group"
id="trail-picker-map" id="trail-picker-map"
> >

View File

@@ -2,8 +2,6 @@
import Modal from "$lib/components/base/modal.svelte"; import Modal from "$lib/components/base/modal.svelte";
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
import { TrailShare } from "$lib/models/trail_share"; import { TrailShare } from "$lib/models/trail_share";
import type { User } from "$lib/models/user";
import { show_toast } from "$lib/stores/toast_store";
import { import {
shares, shares,
trail_share_create, trail_share_create,
@@ -11,30 +9,31 @@
trail_share_index, trail_share_index,
trail_share_update, trail_share_update,
} from "$lib/stores/trail_share_store"; } from "$lib/stores/trail_share_store";
import { users_search } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Button from "../base/button.svelte"; import Button from "../base/button.svelte";
import Search, { type SearchItem } from "../base/search.svelte";
import type { SelectItem } from "../base/select.svelte"; import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte"; import Select from "../base/select.svelte";
import UserSearch from "../user_search.svelte"; import UserSearch from "../user_search.svelte";
let openModal: (() => void) | undefined = undefined; interface Props {
export let closeModal: (() => void) | undefined = undefined; trail: Trail;
export function openShareModal() { onsave?: () => void;
fetchShares();
if (openModal) {
openModal();
}
} }
export let trail: Trail; let { trail, onsave }: Props = $props();
const dispatch = createEventDispatcher(); let modal: Modal;
let copyButtonText = $_("copy-link"); export function openModal() {
openShareModalLocal();
}
async function openShareModalLocal() {
modal.openModal();
await fetchShares();
}
let copyButtonText = $state($_("copy-link"));
let sharesLoading: boolean = false; let sharesLoading: boolean = false;
@@ -51,8 +50,8 @@
} }
function close() { function close() {
dispatch("save"); onsave?.();
closeModal!(); modal.closeModal!();
} }
async function shareTrail(item: SelectItem) { async function shareTrail(item: SelectItem) {
@@ -85,11 +84,11 @@
id="share-modal" id="share-modal"
title={$_("share-this-trail")} title={$_("share-this-trail")}
size="max-w-sm overflow-visible" size="max-w-sm overflow-visible"
bind:openModal bind:this={modal}
bind:closeModal
> >
<div slot="content"> {#snippet content()}
<UserSearch includeSelf={false} on:click={(e) => shareTrail(e.detail)} <div>
<UserSearch includeSelf={false} onclick={(item) => shareTrail(item)}
></UserSearch> ></UserSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4> <h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
@@ -117,16 +116,17 @@
> >
<div class="shrink-0"> <div class="shrink-0">
<Select <Select
bind:value={share.permission} value={share.permission}
items={permissionSelectItems} items={permissionSelectItems}
on:change={(e) => onchange={(value) =>
updateSharePermission(share, e.detail)} updateSharePermission(share, value)}
></Select> ></Select>
</div> </div>
<button <button
aria-label="Delete share"
class="btn-icon text-red-500" class="btn-icon text-red-500"
on:click={() => deleteShare(share)} onclick={() => deleteShare(share)}
><i class="fa fa-trash"></i></button ><i class="fa fa-trash"></i></button
> >
</div> </div>
@@ -134,15 +134,18 @@
{/each} {/each}
{/if} {/if}
</div> </div>
<div slot="footer" class="flex justify-between items-center gap-4"> {/snippet}
{#snippet footer()}
<div class="flex justify-between items-center gap-4">
<Button <Button
secondary={true} secondary={true}
disabled={copyButtonText == $_("link-copied")} disabled={copyButtonText == $_("link-copied")}
on:click={copyURLToClipboard} onclick={copyURLToClipboard}
> >
<i class="fa fa-link mr-2"></i> <i class="fa fa-link mr-2"></i>
{copyButtonText} {copyButtonText}
</Button> </Button>
<button class="btn-primary" on:click={close}>{$_("close")}</button> <button class="btn-primary" onclick={close}>{$_("close")}</button>
</div></Modal </div>
{/snippet}</Modal
> >

View File

@@ -8,15 +8,17 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import type { SelectItem } from "../base/select.svelte"; import type { SelectItem } from "../base/select.svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { createEventDispatcher } from "svelte";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import ShareInfo from "../share_info.svelte"; import ShareInfo from "../share_info.svelte";
export let tableHeader: SelectItem[]; interface Props {
export let trails: Trail[] | null = null; tableHeader: SelectItem[];
export let filter: TrailFilter | null = null; trails?: Trail[] | null;
filter?: TrailFilter | null;
onsort?: (value: any) => void
}
let dispatch = createEventDispatcher(); let { tableHeader, trails = null, filter = null, onsort }: Props = $props();
function getColumnWidth(columnValue: string): string { function getColumnWidth(columnValue: string): string {
switch (columnValue) { switch (columnValue) {
@@ -48,7 +50,7 @@
class="p-4 text-left text-sm font-medium {getColumnWidth( class="p-4 text-left text-sm font-medium {getColumnWidth(
column.value, column.value,
)}" )}"
on:click={() => dispatch("sort", column.value)} onclick={() => onsort?.(column.value)}
> >
<div class="cursor-pointer"> <div class="cursor-pointer">
{column.text} {column.text}
@@ -69,7 +71,7 @@
{#each trails as trail} {#each trails as trail}
<tr <tr
class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors" class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors"
on:click={() => goto(`/trail/view/${trail.id}`)} onclick={() => goto(`/trail/view/${trail.id}`)}
> >
<td <td
class="flex justify-between items-center text-sm relative" class="flex justify-between items-center text-sm relative"

View File

@@ -3,23 +3,33 @@
import { show_toast } from "$lib/stores/toast_store"; import { show_toast } from "$lib/stores/toast_store";
import { users_search } from "$lib/stores/user_store"; import { users_search } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { createEventDispatcher, tick } from "svelte";
import Search, { type SearchItem } from "./base/search.svelte"; import Search, { type SearchItem } from "./base/search.svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let label: string = ""; interface Props {
export let value: string = ""; label?: string;
export let includeSelf: boolean = true; value?: string;
export let clearAfterSelect: boolean = true; includeSelf?: boolean;
clearAfterSelect?: boolean;
onclear?: () => void
onclick?: (item: SearchItem) => void
}
let searchItems: SearchItem[] = []; let {
label = "",
value = $bindable(""),
includeSelf = true,
clearAfterSelect = true,
onclear,
onclick
}: Props = $props();
const dispatch = createEventDispatcher(); let searchItems: SearchItem[] = $state([]);
async function updateUsers(q: string) { async function updateUsers(q: string) {
if (!q.length) { if (!q.length) {
searchItems = []; searchItems = [];
dispatch("clear"); onclear?.();
return; return;
} }
try { try {
@@ -39,29 +49,31 @@
} }
} }
function onClick(e: CustomEvent<SearchItem>) { function onClick(item: SearchItem) {
value = e.detail.value.username ?? value; value = item.value.username ?? value;
dispatch("click", e.detail); onclick?.(item);
searchItems = []; searchItems = [];
} }
</script> </script>
<Search <Search
on:update={(e) => updateUsers(e.detail)} onupdate={(q) => updateUsers(q)}
on:click={(e) => onClick(e)} onclick={(item) => onClick(item)}
placeholder={`${$_("username")}...`} placeholder={`${$_("username")}...`}
items={searchItems} items={searchItems}
{clearAfterSelect} {clearAfterSelect}
{label} {label}
bind:value bind:value
> >
{#snippet prepend({ item })}
<img <img
slot="item-header"
let:item
class="rounded-full w-8 aspect-square mr-2" class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(item.value, item.value.avatar) || src={getFileURL(item.value, item.value.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${item.value.username}&backgroundType=gradientLinear`} `https://api.dicebear.com/7.x/initials/svg?seed=${item.value.username}&backgroundType=gradientLinear`}
alt="avatar" alt="avatar"
/> />
{/snippet}
</Search> </Search>

View File

@@ -2,17 +2,23 @@
import type { Waypoint } from "$lib/models/waypoint"; import type { Waypoint } from "$lib/models/waypoint";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util"; import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Dropdown from "../base/dropdown.svelte"; import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import PhotoGallery from "../photo_gallery.svelte"; import PhotoGallery from "../photo_gallery.svelte";
export let waypoint: Waypoint; interface Props {
export let mode: "show" | "edit" = "show"; waypoint: Waypoint;
mode?: "show" | "edit";
onchange?: (item: DropdownItem) => void;
}
let openGallery: (idx?: number) => void; let { waypoint, mode = "show", onchange }: Props = $props();
let imgSrc: string[] = []; let gallery: PhotoGallery;
$: if (waypoint.photos?.length) {
let imgSrc: string[] = $state([]);
$effect(() => {
if (waypoint.photos?.length) {
imgSrc = waypoint.photos imgSrc = waypoint.photos
.filter((_, i) => i < 3) .filter((_, i) => i < 3)
.reverse() .reverse()
@@ -30,6 +36,7 @@
} else { } else {
imgSrc = []; imgSrc = [];
} }
});
const dropdownItems = [ const dropdownItems = [
{ text: $_("edit"), value: "edit" }, { text: $_("edit"), value: "edit" },
@@ -44,13 +51,13 @@
{#if mode == "show"} {#if mode == "show"}
<PhotoGallery <PhotoGallery
photos={waypoint.photos.map((p) => getFileURL(waypoint, p))} photos={waypoint.photos.map((p) => getFileURL(waypoint, p))}
bind:open={openGallery} bind:this={gallery}
></PhotoGallery> ></PhotoGallery>
{/if} {/if}
<button <button
class="relative basis-16 aspect-square ml-2 mb-3 shrink-0" class="relative basis-16 aspect-square ml-2 mb-3 shrink-0"
type="button" type="button"
on:click={mode == "show" ? () => openGallery() : undefined} onclick={mode == "show" ? () => gallery.openGallery() : undefined}
> >
{#each imgSrc as img, i} {#each imgSrc as img, i}
<img <img
@@ -69,7 +76,7 @@
<i class="fa fa-{waypoint.icon} mr-2"></i>{waypoint.name} <i class="fa fa-{waypoint.icon} mr-2"></i>{waypoint.name}
</h5> </h5>
{#if mode == "edit"} {#if mode == "edit"}
<Dropdown items={dropdownItems} on:change></Dropdown> <Dropdown items={dropdownItems} {onchange}></Dropdown>
{/if} {/if}
</div> </div>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; import { type Snippet } from "svelte";
import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema"; import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema";
import { convertDMSToDD } from "$lib/models/gpx/utils"; import { convertDMSToDD } from "$lib/models/gpx/utils";
@@ -17,11 +17,20 @@
import TextField from "../base/text_field.svelte"; import TextField from "../base/text_field.svelte";
import Textarea from "../base/textarea.svelte"; import Textarea from "../base/textarea.svelte";
import PhotoPicker from "../trail/photo_picker.svelte"; import PhotoPicker from "../trail/photo_picker.svelte";
import type { Waypoint } from "$lib/models/waypoint";
export let openModal: (() => void) | undefined = undefined; interface Props {
export let closeModal: (() => void) | undefined = undefined; children?: Snippet<[any]>;
onsave?: (waypoint: Waypoint) => void
}
const dispatch = createEventDispatcher(); let { children, onsave }: Props = $props();
let modal: Modal;
export function openModal() {
modal.openModal();
}
const ClientWaypointCreateSchema = WaypointCreateSchema.extend({ const ClientWaypointCreateSchema = WaypointCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(), _photos: z.array(z.instanceof(File)).optional(),
@@ -33,9 +42,9 @@
initialValues: $waypoint, initialValues: $waypoint,
extend: validator({ schema: ClientWaypointCreateSchema }), extend: validator({ schema: ClientWaypointCreateSchema }),
onSubmit: async (form) => { onSubmit: async (form) => {
dispatch("save", form); onsave?.(form);
closeModal!(); modal.closeModal!();
}, },
transform: (values: unknown) => { transform: (values: unknown) => {
const v = values as any; const v = values as any;
@@ -47,14 +56,25 @@
}, },
}); });
$: setFields(cloneDeep($waypoint)); $effect(() => {
setFields(cloneDeep($waypoint));
});
$: filteredIcons = let filteredIcons = $derived(
($data.icon?.length ?? 0) > 2 ($data.icon?.length ?? 0) > 2
? icons ? icons
.filter((i) => i.replaceAll("-", " ").includes($data.icon?.toLowerCase() ?? "")) .filter((i) =>
.map((i) => ({ text: i.replaceAll("-", " "), value: i, icon: i })) i
: []; .replaceAll("-", " ")
.includes($data.icon?.toLowerCase() ?? ""),
)
.map((i) => ({
text: i.replaceAll("-", " "),
value: i,
icon: i,
}))
: [],
);
function getCoordinatesFromPhoto(src: string) { function getCoordinatesFromPhoto(src: string) {
EXIF.getData({ src: src }, function (p) { EXIF.getData({ src: src }, function (p) {
@@ -75,25 +95,26 @@
} }
}); });
} }
const children_render = $derived(children);
</script> </script>
<Modal <Modal
id="waypoint-modal" id="waypoint-modal"
title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")} title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")}
let:openModal bind:this={modal}
bind:openModal
bind:closeModal
>
<slot {openModal} />
<form
id="waypoint-form"
slot="content"
class="modal-content space-y-4"
use:form
> >
{#snippet children({ openModal })}
{@render children_render?.({ openModal })}
{/snippet}
{#snippet content()}
<form id="waypoint-form" class="modal-content space-y-4" use:form>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="basis-2/3"> <div class="basis-2/3">
<TextField name="name" label={$_("name")} error={$errors.name} <TextField
name="name"
label={$_("name")}
error={$errors.name}
></TextField> ></TextField>
</div> </div>
@@ -114,17 +135,23 @@
<div class="flex gap-4"> <div class="flex gap-4">
<TextField name="lat" label={$_("latitude")} error={$errors.lat} <TextField name="lat" label={$_("latitude")} error={$errors.lat}
></TextField> ></TextField>
<TextField name="lon" label={$_("longitude")} error={$errors.lat} <TextField
name="lon"
label={$_("longitude")}
error={$errors.lat}
></TextField> ></TextField>
</div> </div>
<div> <div>
<label for="waypoint-photo-input" class="text-sm font-medium pb-1"> <label
for="waypoint-photo-input"
class="text-sm font-medium pb-1"
>
{$_("photos")} {$_("photos")}
</label> </label>
<PhotoPicker <PhotoPicker
id="waypoint-photo-input" id="waypoint-photo-input"
parent={$data} parent={$data}
on:exif={(e) => getCoordinatesFromPhoto(e.detail)} onexif={(src) => getCoordinatesFromPhoto(src)}
bind:photos={$data.photos} bind:photos={$data.photos}
bind:photoFiles={$data._photos} bind:photoFiles={$data._photos}
showThumbnailControls={false} showThumbnailControls={false}
@@ -132,12 +159,15 @@
></PhotoPicker> ></PhotoPicker>
</div> </div>
</form> </form>
<div slot="footer" class="flex items-center gap-4"> {/snippet}
<button class="btn-secondary" on:click={closeModal} {#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button >{$_("cancel")}</button
> >
<button class="btn-primary" type="submit" form="waypoint-form" <button class="btn-primary" type="submit" form="waypoint-form"
>{$_("save")}</button >{$_("save")}</button
> >
</div> </div>
{/snippet}
</Modal> </Modal>

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { pb } from '$lib/pocketbase'
import { currentUser } from '$lib/stores/user_store' import { currentUser } from '$lib/stores/user_store'
import { init, register } from 'svelte-i18n' import { init, register } from 'svelte-i18n'
import { get } from 'svelte/store' import { get } from 'svelte/store'
@@ -17,5 +18,5 @@ register('zh', () => import('./locales/zh.json'))
init({ init({
fallbackLocale: defaultLocale, fallbackLocale: defaultLocale,
initialLocale: browser ? get(currentUser)?.language ?? window.navigator.language : defaultLocale, initialLocale: browser ? pb.authStore.model?.language ?? window.navigator.language : defaultLocale,
}) })

View File

@@ -1,4 +1,4 @@
import { page } from "$app/stores"; import { page } from "$app/state";
import { get } from "svelte/store"; import { get } from "svelte/store";
export function formatTimeHHMM(minutes?: number) { export function formatTimeHHMM(minutes?: number) {
@@ -17,7 +17,7 @@ export function formatDistance(meters?: number) {
return "-"; return "-";
} }
const unit = get(page).data.settings?.unit ?? "metric"; const unit = page.data.settings?.unit ?? "metric";
if (unit == "metric") { if (unit == "metric") {
if (meters >= 1000) { if (meters >= 1000) {
@@ -38,7 +38,7 @@ export function formatElevation(meters?: number) {
return "-"; return "-";
} }
const unit = get(page).data.settings?.unit ?? "metric"; const unit = page.data.settings?.unit ?? "metric";
if (unit == "metric") { if (unit == "metric") {
return `${Math.round(meters)} m` return `${Math.round(meters)} m`
@@ -54,7 +54,7 @@ export function formatSpeed(speed?: number) {
return "-"; return "-";
} }
const unit = get(page).data.settings?.unit ?? "metric"; const unit = page.data.settings?.unit ?? "metric";
if (unit == "metric") { if (unit == "metric") {
return `${(speed * 3.6).toFixed(2)} km/h` return `${(speed * 3.6).toFixed(2)} km/h`

View File

@@ -29,6 +29,7 @@ export class FontawesomeMarker extends M.Marker {
export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker: M.Marker, wpId?: string) => void): FontawesomeMarker { export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker: M.Marker, wpId?: string) => void): FontawesomeMarker {
const marker = new FontawesomeMarker({ const marker = new FontawesomeMarker({
id: waypoint.id,
icon: `fa fa-${waypoint.icon}`, icon: `fa fa-${waypoint.icon}`,
}, { }, {
draggable: onDragEnd !== undefined, draggable: onDragEnd !== undefined,

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import {
Chart,
Tooltip,
BarController,
type ChartData,
type ChartOptions,
} from 'chart.js';
import type { HTMLCanvasAttributes } from 'svelte/elements';
interface Props extends HTMLCanvasAttributes {
data: ChartData<'bar', number[], string>;
options: ChartOptions<'bar'>;
}
const { data, options, ...rest }: Props = $props();
Chart.register(Tooltip, BarController);
let canvasElem: HTMLCanvasElement;
let chart: Chart;
$effect(() => {
chart = new Chart(canvasElem, {
type: 'bar',
data,
options,
});
return () => {
chart.destroy();
};
});
$effect(() => {
if (chart) {
chart.data = data;
chart.update();
}
});
</script>
<canvas bind:this={canvasElem} {...rest}></canvas>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import {
Chart,
PieController,
Tooltip,
type ChartData,
type ChartOptions,
} from 'chart.js';
import type { HTMLCanvasAttributes } from 'svelte/elements';
interface Props extends HTMLCanvasAttributes {
data: ChartData<'pie', number[], string>;
options: ChartOptions<'pie'>;
}
const { data, options, ...rest }: Props = $props();
Chart.register(Tooltip, PieController);
let canvasElem: HTMLCanvasElement;
let chart: Chart;
$effect(() => {
chart = new Chart(canvasElem, {
type: 'pie',
data,
options,
});
return () => {
chart.destroy();
};
});
$effect(() => {
if (chart) {
chart.data = data;
chart.update();
}
});
</script>
<canvas bind:this={canvasElem} {...rest}></canvas>

View File

@@ -1,12 +1,12 @@
<script> <script>
import { page } from "$app/stores"; import { page } from "$app/state";
import errorDark from "$lib/assets/svgs/empty_states/error_dark.svg"; import errorDark from "$lib/assets/svgs/empty_states/error_dark.svg";
import errorLight from "$lib/assets/svgs/empty_states/error_light.svg"; import errorLight from "$lib/assets/svgs/empty_states/error_light.svg";
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
</script> </script>
<svelte:head> <svelte:head>
<title>{$page.status} | wanderer</title> <title>{page.status} | wanderer</title>
</svelte:head> </svelte:head>
<div <div
class="m-auto text-center max-w-md" class="m-auto text-center max-w-md"
@@ -20,10 +20,10 @@
/> />
<div class="text-center mt-8"> <div class="text-center mt-8">
<h1 class="font-bold" style="line-height: 4rem;"> <h1 class="font-bold" style="line-height: 4rem;">
{$page.status} {page.status}
</h1> </h1>
<p class="text-lg text-center mt-2"> <p class="text-lg text-center mt-2">
{$page.error?.message} {page.error?.message}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate, goto } from "$app/navigation"; import { beforeNavigate, goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import Toast from "$lib/components/base/toast.svelte"; import Toast from "$lib/components/base/toast.svelte";
import Footer from "$lib/components/footer.svelte"; import Footer from "$lib/components/footer.svelte";
@@ -8,12 +8,17 @@
import { currentUser } from "$lib/stores/user_store"; import { currentUser } from "$lib/stores/user_store";
import { isRouteProtected } from "$lib/util/authorization_util"; import { isRouteProtected } from "$lib/util/authorization_util";
import "@fortawesome/fontawesome-free/css/all.min.css"; import "@fortawesome/fontawesome-free/css/all.min.css";
import { onMount } from "svelte"; import { onMount, type Snippet } from "svelte";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
import "../css/app.css"; import "../css/app.css";
import "../css/components.css"; import "../css/components.css";
import "../css/theme.css"; import "../css/theme.css";
import PageLoadingBar from "$lib/components/page_loading_bar.svelte"; import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
beforeNavigate((n) => { beforeNavigate((n) => {
if (!$currentUser && isRouteProtected(n.to?.url?.pathname ?? "")) { if (!$currentUser && isRouteProtected(n.to?.url?.pathname ?? "")) {
@@ -23,13 +28,13 @@
}); });
onMount(() => { onMount(() => {
if ($page.data.origin != location.origin) { if (page.data.origin != location.origin) {
showWarning = true; showWarning = true;
} }
}); });
let hideDemoHint = false; let hideDemoHint = $state(false);
let showWarning = false; let showWarning = $state(false);
</script> </script>
{#if env.PUBLIC_IS_DEMO === "true" && !hideDemoHint} {#if env.PUBLIC_IS_DEMO === "true" && !hideDemoHint}
@@ -42,7 +47,10 @@
>This is a demo instance. Do not store any relevant data here. You >This is a demo instance. Do not store any relevant data here. You
can use the user 'demo' and password 'password' to login. can use the user 'demo' and password 'password' to login.
</span> </span>
<button class="btn-icon self-end" on:click={() => (hideDemoHint = true)} <button
aria-label="Close"
class="btn-icon self-end"
onclick={() => (hideDemoHint = true)}
><i class="fa fa-close"></i></button ><i class="fa fa-close"></i></button
> >
</div> </div>
@@ -59,10 +67,12 @@
>{location.origin}</span >{location.origin}</span
> >
but your ORIGIN environment variable is set to but your ORIGIN environment variable is set to
<span class="font-mono bg-gray-100">{$page.data.origin}</span>. This <span class="font-mono bg-gray-100">{page.data.origin}</span>. This
may cause errors. may cause errors.
</p> </p>
<button class="btn-icon self-end" on:click={() => (showWarning = false)} <button
aria-label="Close"
class="btn-icon self-end" onclick={() => (showWarning = false)}
><i class="fa fa-close"></i></button ><i class="fa fa-close"></i></button
> >
</div> </div>
@@ -71,6 +81,6 @@
<NavBar></NavBar> <NavBar></NavBar>
<PageLoadingBar class="text-content"></PageLoadingBar> <PageLoadingBar class="text-content"></PageLoadingBar>
<Toast></Toast> <Toast></Toast>
<slot /> {@render children?.()}
<Footer></Footer> <Footer></Footer>

View File

@@ -15,9 +15,9 @@
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg"; import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
export let data; let { data } = $props();
let searchDropdownItems: SearchItem[] = []; let searchDropdownItems: SearchItem[] = $state([]);
async function search(q: string) { async function search(q: string) {
const r = await fetch("/api/v1/search/multi", { const r = await fetch("/api/v1/search/multi", {
@@ -92,15 +92,15 @@
{$_("hero_section_0_text")} {$_("hero_section_0_text")}
</h5> </h5>
<Search <Search
on:update={(e) => search(e.detail)} onupdate={search}
on:click={(e) => handleSearchClick(e.detail)} onclick={handleSearchClick}
large={true} large={true}
placeholder="{$_('search-for-trails-places')}..." placeholder="{$_('search-for-trails-places')}..."
items={searchDropdownItems} items={searchDropdownItems}
></Search> ></Search>
</div> </div>
<div class="hidden md:block"> <div class="hidden md:block">
<Canvas> <Canvas toneMapping={0}>
<Scene></Scene> <Scene></Scene>
</Canvas> </Canvas>
</div> </div>

View File

@@ -9,7 +9,9 @@ export async function GET(event: RequestEvent) {
min_distance: 0, min_distance: 0,
max_distance: 20000, max_distance: 20000,
min_elevation_gain: 0, min_elevation_gain: 0,
max_elevation_gain: 4000 max_elevation_gain: 4000,
min_elevation_loss: 0,
max_elevation_loss: 4000
}); });
} }
try { try {

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte"; import Button from "$lib/components/base/button.svelte";
import TextField from "$lib/components/base/text_field.svelte"; import TextField from "$lib/components/base/text_field.svelte";
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte"; import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
@@ -13,7 +13,7 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { z } from "zod"; import { z } from "zod";
let loading: boolean = false; let loading: boolean = $state(false);
const { form, errors } = createForm({ const { form, errors } = createForm({
initialValues: { initialValues: {
@@ -46,7 +46,7 @@
try { try {
await users_confirm_reset({ await users_confirm_reset({
...form, ...form,
token: $page.params.token, token: page.params.token,
}); });
goto("/login"); goto("/login");
show_toast({ show_toast({

View File

@@ -12,7 +12,7 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { z } from "zod"; import { z } from "zod";
let loading: boolean = false; let loading: boolean = $state(false);
const { form, errors } = createForm({ const { form, errors } = createForm({
initialValues: { initialValues: {

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import type { DropdownItem } from "$lib/components/base/dropdown.svelte"; import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
import Search, { import Search, {
type SearchItem, type SearchItem,
@@ -17,7 +17,7 @@
import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte"; import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte";
import TrailList from "$lib/components/trail/trail_list.svelte"; import TrailList from "$lib/components/trail/trail_list.svelte";
import UserSearch from "$lib/components/user_search.svelte"; import UserSearch from "$lib/components/user_search.svelte";
import { List } from "$lib/models/list"; import { List, type ListFilter } from "$lib/models/list";
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
import { lists_delete, lists_index } from "$lib/stores/list_store"; import { lists_delete, lists_index } from "$lib/stores/list_store";
import { fetchGPX } from "$lib/stores/trail_store"; import { fetchGPX } from "$lib/stores/trail_store";
@@ -28,7 +28,7 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
export let data; let { data } = $props();
const sortOptions: SelectItem[] = [ const sortOptions: SelectItem[] = [
{ text: $_("alphabetical"), value: "name" }, { text: $_("alphabetical"), value: "name" },
@@ -40,35 +40,43 @@
totalPages: data.lists.totalPages, totalPages: data.lists.totalPages,
}; };
let openConfirmModal: () => void; let lists = $state(data.lists);
let openShareModal: () => void;
let map: M.Map; let confirmModal: ConfirmModal;
let mapWithElevation: MapWithElevationMaplibre; let listShareModal: ListShareModal;
let markers: M.Marker[];
let filter: ListFilter = $state(page.data.filter);
let map: M.Map | undefined = $state();
let mapWithElevation: MapWithElevationMaplibre | undefined = $state();
let markers: M.Marker[] = $state([]);
let showMap: boolean = true; let showMap: boolean = true;
let selectedList: List | null = $page.url.searchParams.get("list") let selectedList: List | null = $state(
? data.lists.items[0] page.url.searchParams.get("list") ? lists.items[0] : null,
: null; );
let selectedTrail: Trail | null = null; let selectedTrail: Trail | null = $state(null);
let loading: boolean = false; let loading: boolean = $state(false);
let loadingNextPage: boolean = false; let loadingNextPage: boolean = false;
let filterExpanded: boolean = false; let filterExpanded: boolean = $state(false);
let loadAllListsOnNextBack = false; let loadAllListsOnNextBack = false;
let userQuery = ""; let userQuery = $state("");
$: selectedTrailIndex = selectedTrail let selectedTrailIndex = $derived(
selectedTrail
? (selectedList?.expand?.trails?.indexOf(selectedTrail) ?? null) ? (selectedList?.expand?.trails?.indexOf(selectedTrail) ?? null)
: null; : null,
);
$: selectedTrailWaypoints = selectedTrail?.expand?.waypoints; let selectedTrailWaypoints = $derived(
(selectedTrail as Trail | null)?.expand?.waypoints,
);
onMount(() => { onMount(() => {
if ($page.url.searchParams.get("list") && selectedList) { if (page.url.searchParams.get("list") && selectedList) {
setCurrentList(selectedList); setCurrentList(selectedList);
// only the requested list has been loaded at this point // only the requested list has been loaded at this point
@@ -77,18 +85,16 @@
} }
}); });
async function handleDropdownClick(e: CustomEvent<DropdownItem>) { async function handleDropdownClick(item: DropdownItem) {
const item = e.detail;
if (!selectedList) { if (!selectedList) {
return; return;
} }
if (item.value == "share") { if (item.value == "share") {
openShareModal(); listShareModal.openModal();
} else if (item.value == "edit") { } else if (item.value == "edit") {
goto("/lists/edit/" + selectedList.id); goto("/lists/edit/" + selectedList.id);
} else if (item.value == "delete") { } else if (item.value == "delete") {
openConfirmModal(); confirmModal.openModal();
} }
} }
@@ -128,7 +134,7 @@
selectedTrail = null; selectedTrail = null;
} else if (selectedList) { } else if (selectedList) {
selectedList = null; selectedList = null;
map.flyTo({ map?.flyTo({
animate: true, animate: true,
zoom: 1, zoom: 1,
center: [0, 0], center: [0, 0],
@@ -138,16 +144,16 @@
function selectTrail(trail: Trail) { function selectTrail(trail: Trail) {
selectedTrail = trail; selectedTrail = trail;
mapWithElevation.unHighlightTrail(trail.id!); mapWithElevation?.unHighlightTrail(trail.id!);
window.scrollTo({ top: 0 }); window.scrollTo({ top: 0 });
} }
function highlightTrail(trail: Trail) { function highlightTrail(trail: Trail) {
mapWithElevation.highlightTrail(trail.id!); mapWithElevation?.highlightTrail(trail.id!);
} }
function unHighlightTrail(trail: Trail) { function unHighlightTrail(trail: Trail) {
mapWithElevation.unHighlightTrail(trail.id!); mapWithElevation?.unHighlightTrail(trail.id!);
} }
async function onListScroll(e: Event) { async function onListScroll(e: Event) {
@@ -169,7 +175,7 @@
async function loadNextPage() { async function loadNextPage() {
pagination.page += 1; pagination.page += 1;
data.lists = await lists_index(data.filter, pagination.page); lists = await lists_index(filter, pagination.page);
} }
async function updateFilter() { async function updateFilter() {
@@ -178,7 +184,7 @@
if (selectedList || selectedTrail) { if (selectedList || selectedTrail) {
selectedList = null; selectedList = null;
selectedTrail = null; selectedTrail = null;
map.flyTo({ map?.flyTo({
animate: true, animate: true,
zoom: 1, zoom: 1,
center: [0, 0], center: [0, 0],
@@ -186,41 +192,41 @@
} }
pagination.page = 0; pagination.page = 0;
data.lists = await lists_index(data.filter, pagination.page); lists = await lists_index(filter, pagination.page);
loading = false; loading = false;
} }
async function setSort(value: "name" | "created") { async function setSort(value: "name" | "created") {
data.filter.sort = value; filter.sort = value;
await updateFilter(); await updateFilter();
} }
async function setSortOrder() { async function setSortOrder() {
if (data.filter.sortOrder === "+") { if (filter.sortOrder === "+") {
data.filter.sortOrder = "-"; filter.sortOrder = "-";
} else { } else {
data.filter.sortOrder = "+"; filter.sortOrder = "+";
} }
await updateFilter(); await updateFilter();
} }
async function setPublicFilter(e: Event) { async function setPublicFilter(e: Event) {
data.filter.public = (e.target as HTMLInputElement).checked; filter.public = (e.target as HTMLInputElement).checked;
updateFilter(); updateFilter();
} }
async function setAuthorFilter(item: SearchItem) { async function setAuthorFilter(item: SearchItem) {
data.filter.author = item.value.id; filter.author = item.value.id;
await updateFilter(); await updateFilter();
} }
async function clearAuthorFilter() { async function clearAuthorFilter() {
data.filter.author = ""; filter.author = "";
await updateFilter(); await updateFilter();
} }
async function setSharedFilter(e: Event) { async function setSharedFilter(e: Event) {
data.filter.shared = (e.target as HTMLInputElement).checked; filter.shared = (e.target as HTMLInputElement).checked;
updateFilter(); updateFilter();
} }
</script> </script>
@@ -236,19 +242,21 @@
class="flex gap-x-3 items-center px-3 py-4 bg-background z-50 rounded-xl" class="flex gap-x-3 items-center px-3 py-4 bg-background z-50 rounded-xl"
> >
<button <button
aria-label="Back"
class="btn-icon" class="btn-icon"
class:btn-disabled={!selectedList} class:btn-disabled={!selectedList}
disabled={!selectedList} disabled={!selectedList}
on:click={back}><i class="fa fa-arrow-left"></i></button onclick={back}><i class="fa fa-arrow-left"></i></button
> >
<Search bind:value={data.filter.q} on:update={updateFilter} <Search bind:value={filter.q} onupdate={updateFilter}></Search>
></Search>
<button <button
aria-label="Toggle filter"
class="btn-icon" class="btn-icon"
on:click={() => (filterExpanded = !filterExpanded)} onclick={() => (filterExpanded = !filterExpanded)}
><i class="fa fa-sliders"></i></button ><i class="fa fa-sliders"></i></button
> >
<a <a
aria-label="New list"
class="btn-primary tooltip" class="btn-primary tooltip"
data-title={$_("new-list")} data-title={$_("new-list")}
href="/lists/edit/new"><i class="fa fa-plus"></i></a href="/lists/edit/new"><i class="fa fa-plus"></i></a
@@ -263,15 +271,16 @@
<p class="text-sm font-medium pb-1">{$_("sort")}</p> <p class="text-sm font-medium pb-1">{$_("sort")}</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Select <Select
bind:value={data.filter.sort} bind:value={filter.sort}
items={sortOptions} items={sortOptions}
on:change={(e) => setSort(e.detail)} onchange={setSort}
></Select> ></Select>
<button <button
aria-label="Set sort order"
id="sort-order-btn" id="sort-order-btn"
class="btn-icon" class="btn-icon"
class:rotated={data.filter.sortOrder == "-"} class:rotated={filter.sortOrder == "-"}
on:click={() => setSortOrder()} onclick={() => setSortOrder()}
><i class="fa fa-arrow-up"></i></button ><i class="fa fa-arrow-up"></i></button
> >
</div> </div>
@@ -279,8 +288,8 @@
<hr class="my-4 border-separator" /> <hr class="my-4 border-separator" />
<UserSearch <UserSearch
on:click={(e) => setAuthorFilter(e.detail)} onclick={setAuthorFilter}
on:clear={clearAuthorFilter} onclear={clearAuthorFilter}
bind:value={userQuery} bind:value={userQuery}
clearAfterSelect={false} clearAfterSelect={false}
label={$_("author")} label={$_("author")}
@@ -289,9 +298,9 @@
<input <input
id="public-checkbox" id="public-checkbox"
type="checkbox" type="checkbox"
checked={data.filter.public} checked={filter.public}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2" class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
on:change={setPublicFilter} onchange={setPublicFilter}
/> />
<label for="public-checkbox" class="ms-2 text-sm" <label for="public-checkbox" class="ms-2 text-sm"
>{$_("public")}</label >{$_("public")}</label
@@ -301,9 +310,9 @@
<input <input
id="shared-checkbox" id="shared-checkbox"
type="checkbox" type="checkbox"
checked={data.filter.shared} checked={filter.shared}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2" class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
on:change={setSharedFilter} onchange={setSharedFilter}
/> />
<label for="shared-checkbox" class="ms-2 text-sm" <label for="shared-checkbox" class="ms-2 text-sm"
>{$_("shared")}</label >{$_("shared")}</label
@@ -317,7 +326,7 @@
<div <div
id="list-container" id="list-container"
class="overflow-y-scroll overflow-x-clip max-h-full" class="overflow-y-scroll overflow-x-clip max-h-full"
on:scroll={onListScroll} onscroll={onListScroll}
> >
{#if !selectedList} {#if !selectedList}
<div class="px-4 mt-2" class:space-y-3={loading}> <div class="px-4 mt-2" class:space-y-3={loading}>
@@ -325,13 +334,13 @@
{#each { length: 3 } as _, index} {#each { length: 3 } as _, index}
<SkeletonCard></SkeletonCard> <SkeletonCard></SkeletonCard>
{/each} {/each}
{:else if data.lists.items.length == 0} {:else if lists.items.length == 0}
<EmptyStateSearch width={356}></EmptyStateSearch> <EmptyStateSearch width={356}></EmptyStateSearch>
{:else} {:else}
{#each data.lists.items as item, i} {#each lists.items as item, i}
<div <div
class="list-list-item" class="list-list-item"
on:click={() => setCurrentList(item)} onclick={() => setCurrentList(item)}
role="presentation" role="presentation"
> >
<ListCard list={item}></ListCard> <ListCard list={item}></ListCard>
@@ -342,10 +351,10 @@
{:else if selectedList && !selectedTrail} {:else if selectedList && !selectedTrail}
<ListPanel <ListPanel
list={selectedList} list={selectedList}
on:mouseenter={(e) => highlightTrail(e.detail.trail)} onmouseenter={(data) => highlightTrail(data.trail)}
on:mouseleave={(e) => unHighlightTrail(e.detail.trail)} onmouseleave={(data) => unHighlightTrail(data.trail)}
on:change={(e) => handleDropdownClick(e)} onchange={(item) => handleDropdownClick(item)}
on:click={(e) => selectTrail(e.detail.trail)} onclick={(data) => selectTrail(data.trail)}
></ListPanel> ></ListPanel>
{:else if selectedList && selectedTrail} {:else if selectedList && selectedTrail}
<TrailInfoPanel trail={selectedTrail} mode="list" {markers} <TrailInfoPanel trail={selectedTrail} mode="list" {markers}
@@ -362,8 +371,8 @@
bind:markers bind:markers
activeTrail={selectedTrailIndex} activeTrail={selectedTrailIndex}
fitBounds="animate" fitBounds="animate"
on:select={(e) => { onselect={(trail) => {
selectedTrail = e.detail; selectedTrail = trail;
}} }}
showInfoPopup={true} showInfoPopup={true}
showTerrain={true} showTerrain={true}
@@ -375,17 +384,11 @@
<ConfirmModal <ConfirmModal
text={$_("delete-list-confirm")} text={$_("delete-list-confirm")}
bind:openModal={openConfirmModal} bind:this={confirmModal}
on:confirm={deleteList} onconfirm={deleteList}
></ConfirmModal> ></ConfirmModal>
{#if selectedList} {#if selectedList}
<ListShareModal <ListShareModal bind:this={listShareModal} list={selectedList}
list={selectedList}
bind:openShareModal
on:update={() => {
data.lists.items = data.lists.items;
selectedList = selectedList;
}}
></ListShareModal> ></ListShareModal>
{/if} {/if}
</main> </main>

View File

@@ -2,7 +2,7 @@
import { type List } from "$lib/models/list"; import { type List } from "$lib/models/list";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { page } from "$app/stores"; import { page } from "$app/state";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg"; import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg"; import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import Button from "$lib/components/base/button.svelte"; import Button from "$lib/components/base/button.svelte";
@@ -38,21 +38,21 @@
import { createForm } from "felte"; import { createForm } from "felte";
import { z } from "zod"; import { z } from "zod";
export let data; let { data } = $props();
let previewURL = data.previewUrl ?? ""; let previewURL = $state(data.previewUrl ?? "");
let searchDropdownItems: SearchItem[] = []; let searchDropdownItems: SearchItem[] = $state([]);
let activeTrailIndex: number = -1; let activeTrailIndex: number = $state(-1);
let map: MapWithElevationMaplibre; let map: MapWithElevationMaplibre | undefined = $state();
let loading: boolean = false; let loading: boolean = $state(false);
let newShares: TrailShare[] = []; let newShares: TrailShare[] = [];
let openConfirmModal: () => void; let shareConfirmModal: ConfirmModal;
let openPublishConfirmModal: () => void; let publishConfirmModal: ConfirmModal;
const ClientListCreateSchema = ListCreateSchema.extend({ const ClientListCreateSchema = ListCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(), _photos: z.array(z.instanceof(File)).optional(),
@@ -74,7 +74,7 @@
...data.list, ...data.list,
public: data.list.id public: data.list.id
? data.list.public ? data.list.public
: $page.data.settings?.privacy?.lists === "public", : page.data.settings?.privacy?.lists === "public",
}, },
extend: validator({ extend: validator({
schema: ClientListCreateSchema, schema: ClientListCreateSchema,
@@ -93,10 +93,10 @@
data.list?.expand?.trails?.length !== data.list?.expand?.trails?.length !==
$formData.expand?.trails?.length) $formData.expand?.trails?.length)
) { ) {
openPublishConfirmModal(); publishConfirmModal.openModal();
return false; return false;
} else if (await findNewTrailShares()) { } else if (await findNewTrailShares()) {
openConfirmModal(); shareConfirmModal.openModal();
return false; return false;
} }
@@ -273,7 +273,7 @@
use:form use:form
> >
<h2 class="text-2xl font-semibold"> <h2 class="text-2xl font-semibold">
{$page.params.id === "new" ? $_("new-list") : $_("edit-list")} {page.params.id === "new" ? $_("new-list") : $_("edit-list")}
</h2> </h2>
<label for="avatar" class="text-sm font-medium block"> <label for="avatar" class="text-sm font-medium block">
{$_("avatar")} {$_("avatar")}
@@ -284,7 +284,7 @@
id="avatar" id="avatar"
accept="image/*" accept="image/*"
style="display: none;" style="display: none;"
on:change={handleAvatarSelection} onchange={handleAvatarSelection}
/> />
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{#if previewURL.length > 0} {#if previewURL.length > 0}
@@ -301,7 +301,7 @@
<button <button
class="btn-secondary" class="btn-secondary"
type="button" type="button"
on:click={openAvatarBrowser}>{$_("change")}...</button onclick={openAvatarBrowser}>{$_("change")}...</button
> >
</div> </div>
@@ -318,8 +318,8 @@
{$_("trail", { values: { n: 2 } })} {$_("trail", { values: { n: 2 } })}
</h3> </h3>
<Search <Search
on:update={(e) => search(e.detail)} onupdate={search}
on:click={(e) => handleSearchClick(e.detail)} onclick={handleSearchClick}
placeholder="{$_('search-trails')}..." placeholder="{$_('search-trails')}..."
items={searchDropdownItems} items={searchDropdownItems}
></Search> ></Search>
@@ -329,9 +329,8 @@
class="flex gap-4 p-4 rounded-xl border border-input-border cursor-pointer hover:bg-secondary-hover transition-colors items-center" class="flex gap-4 p-4 rounded-xl border border-input-border cursor-pointer hover:bg-secondary-hover transition-colors items-center"
class:bg-secondary-hover={i == activeTrailIndex} class:bg-secondary-hover={i == activeTrailIndex}
role="presentation" role="presentation"
on:mouseenter={() => map.highlightTrail(trail.id ?? "")} onmouseenter={() => map?.highlightTrail(trail.id ?? "")}
on:mouseleave={() => map.unHighlightTrail(trail.id ?? "")} onmouseleave={() => map?.unHighlightTrail(trail.id ?? "")}
on:click={() => map.selectTrail(trail.id ?? "")}
> >
<div class="shrink-0"> <div class="shrink-0">
<img <img
@@ -384,8 +383,8 @@
<div class="basis-0"> <div class="basis-0">
{#if i > 0} {#if i > 0}
<button <button
on:click|stopPropagation={() => aria-label="Move up"
moveTrail(trail, i, -1)} onclick={() => moveTrail(trail, i, -1)}
type="button" type="button"
class="btn-icon" class="btn-icon"
> >
@@ -394,8 +393,8 @@
{/if} {/if}
{#if i < $formData.expand?.trails?.length - 1} {#if i < $formData.expand?.trails?.length - 1}
<button <button
on:click|stopPropagation={() => aria-label="Move down"
moveTrail(trail, i, 1)} onclick={() => moveTrail(trail, i, 1)}
type="button" type="button"
class="btn-icon" class="btn-icon"
> >
@@ -403,9 +402,10 @@
</button> </button>
{/if} {/if}
<button <button
aria-label="Remove trail"
type="button" type="button"
class="btn-icon text-red-500" class="btn-icon text-red-500"
on:click={() => deleteTrail(trail)} onclick={() => deleteTrail(trail)}
><i class="fa fa-trash"></i></button ><i class="fa fa-trash"></i></button
> >
</div> </div>
@@ -426,6 +426,7 @@
</form> </form>
<div id="trail-map" class="max-h-full"> <div id="trail-map" class="max-h-full">
<MapWithElevationMaplibre <MapWithElevationMaplibre
fitBounds="animate"
trails={$formData.expand?.trails ?? []} trails={$formData.expand?.trails ?? []}
bind:activeTrail={activeTrailIndex} bind:activeTrail={activeTrailIndex}
bind:this={map} bind:this={map}
@@ -438,8 +439,8 @@
text={$_("list-share-warning-update")} text={$_("list-share-warning-update")}
title={$_("confirm-share")} title={$_("confirm-share")}
action="confirm" action="confirm"
bind:openModal={openConfirmModal} bind:this={shareConfirmModal}
on:confirm={async () => { onconfirm={async () => {
await updateTrailShares(); await updateTrailShares();
await saveList(); await saveList();
}} }}
@@ -450,8 +451,8 @@
text={$_("list-public-warning")} text={$_("list-public-warning")}
title={$_("confirm-publish")} title={$_("confirm-publish")}
action="confirm" action="confirm"
bind:openModal={openPublishConfirmModal} bind:this={publishConfirmModal}
on:confirm={async () => { onconfirm={async () => {
await publishTrails(); await publishTrails();
await saveList(); await saveList();
}} }}

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/state";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import Button from "$lib/components/base/button.svelte"; import Button from "$lib/components/base/button.svelte";
import TextField from "$lib/components/base/text_field.svelte"; import TextField from "$lib/components/base/text_field.svelte";
@@ -16,19 +16,19 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { z } from "zod"; import { z } from "zod";
let loading: boolean = false; let loading: boolean = $state(false);
const authProviders = $page.data.authMethods.authProviders; const authProviders = page.data.authMethods.authProviders;
let loginLabel = ""; let loginLabel = $state("");
if ( if (
$page.data.authMethods.usernamePassword && page.data.authMethods.usernamePassword &&
$page.data.authMethods.emailPassword page.data.authMethods.emailPassword
) { ) {
loginLabel = `${$_("username")}/${$_("email")}`; loginLabel = `${$_("username")}/${$_("email")}`;
} else if ($page.data.authMethods.usernamePassword) { } else if (page.data.authMethods.usernamePassword) {
loginLabel = `${$_("username")}`; loginLabel = `${$_("username")}`;
} else if ($page.data.authMethods.emailPassword) { } else if (page.data.authMethods.emailPassword) {
loginLabel = `${$_("email")}`; loginLabel = `${$_("email")}`;
} }
@@ -49,7 +49,7 @@
try { try {
await login(newUser); await login(newUser);
window.location.href = $page.url.searchParams.get("r") ?? "/"; window.location.href = page.url.searchParams.get("r") ?? "/";
} catch (e) { } catch (e) {
if ( if (
e instanceof APIError && e instanceof APIError &&
@@ -92,7 +92,7 @@
<LogoTextTwoLineLight></LogoTextTwoLineLight> <LogoTextTwoLineLight></LogoTextTwoLineLight>
{/if} {/if}
<h4 class="text-xl font-semibold">{$_("slogan")}</h4> <h4 class="text-xl font-semibold">{$_("slogan")}</h4>
{#if $page.data.authMethods.usernamePassword || $page.data.authMethods.emailPassword} {#if page.data.authMethods.usernamePassword || page.data.authMethods.emailPassword}
<div class="space-y-6 w-80"> <div class="space-y-6 w-80">
<TextField <TextField
name="username" name="username"
@@ -129,7 +129,7 @@
{/if} {/if}
{#if authProviders.length} {#if authProviders.length}
{#if $page.data.authMethods.usernamePassword || $page.data.authMethods.emailPassword} {#if page.data.authMethods.usernamePassword || page.data.authMethods.emailPassword}
<div class="flex gap-4 items-center w-full"> <div class="flex gap-4 items-center w-full">
<hr class="basis-full border-input-border" /> <hr class="basis-full border-input-border" />
<span class="text-gray-500 uppercase">{$_("or")}</span> <span class="text-gray-500 uppercase">{$_("or")}</span>
@@ -141,7 +141,7 @@
<a <a
href={provider.url} href={provider.url}
class="btn-secondary inline-flex min-w-full justify-center" class="btn-secondary inline-flex min-w-full justify-center"
on:click={() => setProvider(provider)} onclick={() => setProvider(provider)}
> >
<img <img
class="w-5 aspect-square mr-4" class="w-5 aspect-square mr-4"

View File

@@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import { oauth_login } from "$lib/stores/user_store"; import { oauth_login } from "$lib/stores/user_store";
import type { AuthProviderInfo } from "pocketbase"; import type { AuthProviderInfo } from "pocketbase";
import { onMount } from "svelte"; import { onMount } from "svelte";
let error = $page.url.searchParams.get("error"); let error = $state(page.url.searchParams.get("error"));
let errorDescription = $page.url.searchParams.get("error_description"); let errorDescription = $state(page.url.searchParams.get("error_description"));
const errorURI = $page.url.searchParams.get("error_uri"); const errorURI = page.url.searchParams.get("error_uri");
const state = $page.url.searchParams.get("state"); const oauthState = page.url.searchParams.get("state");
const code = $page.url.searchParams.get("code"); const code = page.url.searchParams.get("code");
onMount(async () => { onMount(async () => {
if (error || !state || !code) { if (error || !oauthState || !code) {
return; return;
} }
@@ -27,7 +27,7 @@
const provider: AuthProviderInfo = JSON.parse(providerData); const provider: AuthProviderInfo = JSON.parse(providerData);
if (provider.state !== state) { if (provider.state !== oauthState) {
error = "mismacthed_provider"; error = "mismacthed_provider";
errorDescription = errorDescription =
"OAuth provider does not match the one defined in local storage."; "OAuth provider does not match the one defined in local storage.";

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import Search, { import Search, {
type SearchItem, type SearchItem,
} from "$lib/components/base/search.svelte"; } from "$lib/components/base/search.svelte";
@@ -24,22 +24,22 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
let trails: Trail[] = []; let trails: Trail[] = $state([]);
let map: M.Map; let map: M.Map | undefined = $state();
let mapWithElevation: MapWithElevationMaplibre; let mapWithElevation: MapWithElevationMaplibre | undefined = $state();
let searchDropdownItems: SearchItem[] = []; let searchDropdownItems: SearchItem[] = $state([]);
let showFilter: boolean = false; let showFilter: boolean = $state(false);
let showMap: boolean = true; let showMap: boolean = $state(true);
const filter: TrailFilter = $page.data.filter; let filter: TrailFilter = $state(page.data.filter);
const maxBoundingBox: TrailBoundingBox = $page.data.boundingBox; const maxBoundingBox: TrailBoundingBox = page.data.boundingBox;
const settings: Settings = $page.data.settings; const settings: Settings = page.data.settings;
const MIN_ZOOM = 6; const MIN_ZOOM = 6;
let loading: boolean = true; let loading: boolean = $state(true);
onMount(async () => {}); onMount(async () => {});
@@ -89,8 +89,8 @@
} }
function handleSearchClick(item: SearchItem) { function handleSearchClick(item: SearchItem) {
map.setCenter([item.value._geo.lng, item.value._geo.lat]); map?.setCenter([item.value._geo.lng, item.value._geo.lat]);
map.setZoom(14); map?.setZoom(14);
} }
async function searchTrails(northEast: M.LngLat, southWest: M.LngLat) { async function searchTrails(northEast: M.LngLat, southWest: M.LngLat) {
@@ -99,26 +99,32 @@
northEast, northEast,
southWest, southWest,
filter, filter,
map.getZoom() > MIN_ZOOM, (map?.getZoom() ?? 0) > MIN_ZOOM,
); );
trails = changes.trails; trails = changes.trails;
loading = false; loading = false;
} }
function handleTrailCardMouseEnter(trail: Trail) { function handleTrailCardMouseEnter(trail: Trail) {
mapWithElevation.togglePopup(trail.id!, false); mapWithElevation?.togglePopup(trail.id!, false);
} }
function handleTrailCardMouseLeave(trail: Trail) { function handleTrailCardMouseLeave(trail: Trail) {
mapWithElevation.togglePopup(trail.id!, true); mapWithElevation?.togglePopup(trail.id!, true);
} }
async function handleFilterUpdate(filter: TrailFilter) { async function handleFilterUpdate(filter: TrailFilter) {
if (!map) {
return;
}
const bounds = map.getBounds(); const bounds = map.getBounds();
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest()); await searchTrails(bounds.getNorthEast(), bounds.getSouthWest());
} }
async function handleMapMove() { async function handleMapMove() {
if (!map) {
return;
}
const bounds = map.getBounds(); const bounds = map.getBounds();
const normalizedBounds = { const normalizedBounds = {
@@ -137,60 +143,60 @@
normalizedBounds.southWest, normalizedBounds.southWest,
); );
$page.url.searchParams.set("tl_lat", bounds.getNorth().toString()); page.url.searchParams.set("tl_lat", bounds.getNorth().toString());
$page.url.searchParams.set("tl_lon", bounds.getEast().toString()); page.url.searchParams.set("tl_lon", bounds.getEast().toString());
$page.url.searchParams.set("br_lat", bounds.getSouth().toString()); page.url.searchParams.set("br_lat", bounds.getSouth().toString());
$page.url.searchParams.set("br_lon", bounds.getWest().toString()); page.url.searchParams.set("br_lon", bounds.getWest().toString());
goto(`?${$page.url.searchParams.toString()}`); goto(`?${page.url.searchParams.toString()}`);
} }
function handleMapInit() { function handleMapInit() {
if ( if (
$page.url.searchParams.has("tl_lat") && page.url.searchParams.has("tl_lat") &&
$page.url.searchParams.has("tl_lon") && page.url.searchParams.has("tl_lon") &&
$page.url.searchParams.has("br_lat") && page.url.searchParams.has("br_lat") &&
$page.url.searchParams.has("br_lon") page.url.searchParams.has("br_lon")
) { ) {
const boundingBox: M.LngLatBoundsLike = [ const boundingBox: M.LngLatBoundsLike = [
[ [
parseFloat($page.url.searchParams.get("br_lon")!), parseFloat(page.url.searchParams.get("br_lon")!),
parseFloat($page.url.searchParams.get("tl_lat")!), parseFloat(page.url.searchParams.get("tl_lat")!),
], ],
[ [
parseFloat($page.url.searchParams.get("tl_lon")!), parseFloat(page.url.searchParams.get("tl_lon")!),
parseFloat($page.url.searchParams.get("br_lat")!), parseFloat(page.url.searchParams.get("br_lat")!),
], ],
]; ];
map.fitBounds(boundingBox, { animate: false }); map?.fitBounds(boundingBox, { animate: false });
} else if ( } else if (
$page.url.searchParams.has("lat") && page.url.searchParams.has("lat") &&
$page.url.searchParams.has("lon") page.url.searchParams.has("lon")
) { ) {
const lat = $page.url.searchParams.get("lat"); const lat = page.url.searchParams.get("lat");
const lon = $page.url.searchParams.get("lon"); const lon = page.url.searchParams.get("lon");
map.setZoom(14); map?.setZoom(14);
map.setCenter([parseFloat(lon!), parseFloat(lat!)]); map?.setCenter([parseFloat(lon!), parseFloat(lat!)]);
} else if (settings && settings.mapFocus == "trails") { } else if (settings && settings.mapFocus == "trails") {
const boundingBox: M.LngLatBoundsLike = [ const boundingBox: M.LngLatBoundsLike = [
[maxBoundingBox.min_lon, maxBoundingBox.max_lat], [maxBoundingBox.min_lon, maxBoundingBox.max_lat],
[maxBoundingBox.max_lon, maxBoundingBox.min_lat], [maxBoundingBox.max_lon, maxBoundingBox.min_lat],
]; ];
map.fitBounds(boundingBox, { animate: false, padding: 32 }); map?.fitBounds(boundingBox, { animate: false, padding: 32 });
} else if ( } else if (
settings && settings &&
settings.mapFocus == "location" && settings.mapFocus == "location" &&
settings.location settings.location
) { ) {
map.setZoom(12); map?.setZoom(12);
map.setCenter([settings.location.lon, settings.location.lat]); map?.setCenter([settings.location.lon, settings.location.lat]);
} else { } else {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
const lat = position.coords.latitude; const lat = position.coords.latitude;
const lon = position.coords.longitude; const lon = position.coords.longitude;
map.setZoom(12); map?.setZoom(12);
map.setCenter([lon, lat]); map?.setCenter([lon, lat]);
}, },
(error) => { (error) => {
console.error("Error getting user location:", error); console.error("Error getting user location:", error);
@@ -212,19 +218,21 @@
<div class="flex items-center gap-2 md:gap-4"> <div class="flex items-center gap-2 md:gap-4">
<Search <Search
extraClasses="w-full" extraClasses="w-full"
on:update={(e) => search(e.detail)} onupdate={search}
on:click={(e) => handleSearchClick(e.detail)} onclick={handleSearchClick}
placeholder="{$_('search-for-trails-places')}..." placeholder="{$_('search-for-trails-places')}..."
items={searchDropdownItems} items={searchDropdownItems}
></Search> ></Search>
<button <button
aria-label="Open filter"
class="btn-icon" class="btn-icon"
on:click={() => (showFilter = !showFilter)} onclick={() => (showFilter = !showFilter)}
><i class="fa fa-sliders"></i></button ><i class="fa fa-sliders"></i></button
> >
<button <button
aria-label="Toggle map"
class="btn-icon md:hidden" class="btn-icon md:hidden"
on:click={() => (showMap = !showMap)} onclick={() => (showMap = !showMap)}
><i ><i
class="fa-regular fa-{showMap class="fa-regular fa-{showMap
? 'rectangle-list' ? 'rectangle-list'
@@ -238,8 +246,8 @@
categories={$categories} categories={$categories}
showTrailSearch={false} showTrailSearch={false}
showCitySearch={false} showCitySearch={false}
{filter} bind:filter
on:update={(e) => handleFilterUpdate(e.detail)} onupdate={handleFilterUpdate}
></TrailFilterPanel> ></TrailFilterPanel>
</div> </div>
{/if} {/if}
@@ -255,14 +263,12 @@
<EmptyStateSearch></EmptyStateSearch> <EmptyStateSearch></EmptyStateSearch>
{/if} {/if}
{#each trails as trail, i} {#each trails as trail, i}
<a <a href="map/trail/{trail.id}">
href="map/trail/{trail.id}"
>
<TrailCard <TrailCard
{trail} {trail}
on:mouseenter={() => onmouseenter={() =>
handleTrailCardMouseEnter(trail)} handleTrailCardMouseEnter(trail)}
on:mouseleave={() => onmouseleave={() =>
handleTrailCardMouseLeave(trail)} handleTrailCardMouseLeave(trail)}
></TrailCard> ></TrailCard>
</a> </a>
@@ -275,8 +281,8 @@
class:hidden={!showMap && browser && window.innerWidth < 768} class:hidden={!showMap && browser && window.innerWidth < 768}
> >
<MapWithElevationMaplibre <MapWithElevationMaplibre
on:moveend={handleMapMove} onmoveend={handleMapMove}
on:init={handleMapInit} oninit={handleMapInit}
{trails} {trails}
showElevation={false} showElevation={false}
showInfoPopup={true} showInfoPopup={true}

View File

@@ -6,7 +6,7 @@
import "photoswipe/style.css"; import "photoswipe/style.css";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
let markers: M.Marker[]; let markers: M.Marker[] = $state([]);
</script> </script>
<svelte:head> <svelte:head>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/state";
import "$lib/assets/fonts/IBMPlexSans-Regular-normal"; import "$lib/assets/fonts/IBMPlexSans-Regular-normal";
import "$lib/assets/fonts/IBMPlexSans-SemiBold-bold"; import "$lib/assets/fonts/IBMPlexSans-SemiBold-bold";
import "$lib/assets/fonts/fa-solid-900-normal"; import "$lib/assets/fonts/fa-solid-900-normal";
@@ -15,7 +15,10 @@
formatElevation, formatElevation,
formatTimeHHMM, formatTimeHHMM,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import { calculatePixelPerMeter, calculateScaleFactor } from "$lib/util/maplibre_util"; import {
calculatePixelPerMeter,
calculateScaleFactor,
} from "$lib/util/maplibre_util";
import { createRect, createText } from "$lib/util/svg_util"; import { createRect, createText } from "$lib/util/svg_util";
import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos/index"; import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos/index";
@@ -24,10 +27,10 @@
import { onMount, tick } from "svelte"; import { onMount, tick } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
let map: M.Map; let map: M.Map | undefined = $state();
let showGrid: boolean = true; let showGrid: boolean = $state(true);
const settings: Settings = $page.data.settings; const settings: Settings = page.data.settings;
const paperSizes: { text: string; value: keyof typeof paperDimensions }[] = const paperSizes: { text: string; value: keyof typeof paperDimensions }[] =
[ [
@@ -56,29 +59,31 @@
}, },
}, },
}; };
let selectedPaperSize = paperSizes[0].value; let selectedPaperSize = $state(paperSizes[0].value);
const orientations: { text: string; value: "portrait" | "landscape" }[] = [ const orientations: { text: string; value: "portrait" | "landscape" }[] = [
{ text: "Portrait", value: "portrait" }, { text: "Portrait", value: "portrait" },
{ text: "Landscape", value: "landscape" }, { text: "Landscape", value: "landscape" },
]; ];
let selectedOrientation: "portrait" | "landscape" = orientations[0].value; let selectedOrientation: "portrait" | "landscape" = $state(
orientations[0].value,
);
const gridOptions = [ const gridOptions = [
{ text: $_("degrees"), value: "degree" }, { text: $_("degrees"), value: "degree" },
{ text: $_("no-grid"), value: "off" }, { text: $_("no-grid"), value: "off" },
]; ];
let selectedGrid = gridOptions[0].value; let selectedGrid = $state(gridOptions[0].value);
let scale: number = 1; let scale: number = $state(1);
let printLoading: boolean = false; let printLoading: boolean = $state(false);
let includeDescription: boolean = false; let includeDescription: boolean = $state(false);
onMount(() => { onMount(() => {
let qrcode = new QrCodeWithLogo({ let qrcode = new QrCodeWithLogo({
content: $page.url.href.replace("/print", ""), content: page.url.href.replace("/print", ""),
image: document.getElementById("qrcode") as HTMLImageElement, image: document.getElementById("qrcode") as HTMLImageElement,
logo: { logo: {
src: "/favicon.png", src: "/favicon.png",
@@ -87,6 +92,9 @@
}); });
async function print() { async function print() {
if (!map) {
return;
}
printLoading = true; printLoading = true;
const doc = new jsPDF(selectedOrientation, "mm", [ const doc = new jsPDF(selectedOrientation, "mm", [
paperDimensions[selectedPaperSize].pdf[ paperDimensions[selectedPaperSize].pdf[
@@ -110,7 +118,7 @@
try { try {
var img = document.createElement("img"); var img = document.createElement("img");
var dimensions = map.getCanvas().getBoundingClientRect(); let dimensions = map.getCanvas().getBoundingClientRect();
img.width = dimensions.width; img.width = dimensions.width;
img.height = dimensions.height; img.height = dimensions.height;
let ratio = img.height / img.width; let ratio = img.height / img.width;
@@ -289,7 +297,7 @@
const elevationChartCanvas = document.getElementById( const elevationChartCanvas = document.getElementById(
"elevation-profile-chart", "elevation-profile-chart",
) as HTMLCanvasElement; ) as HTMLCanvasElement;
var dimensions = elevationChartCanvas.getBoundingClientRect(); dimensions = elevationChartCanvas.getBoundingClientRect();
img.width = dimensions.width; img.width = dimensions.width;
img.height = dimensions.height; img.height = dimensions.height;
ratio = img.height / img.width; ratio = img.height / img.width;
@@ -469,25 +477,25 @@
bind:value={selectedPaperSize} bind:value={selectedPaperSize}
items={paperSizes} items={paperSizes}
label={$_("paper-size")} label={$_("paper-size")}
on:change={updateMapSize} onchange={updateMapSize}
></Select> ></Select>
<Select <Select
bind:value={selectedOrientation} bind:value={selectedOrientation}
items={orientations} items={orientations}
label={$_("orientation")} label={$_("orientation")}
on:change={updateMapSize} onchange={updateMapSize}
></Select> ></Select>
<Select <Select
bind:value={selectedGrid} bind:value={selectedGrid}
items={gridOptions} items={gridOptions}
label={$_("grid")} label={$_("grid")}
on:change={toggleGrid} onchange={toggleGrid}
></Select> ></Select>
<div> <div>
<input <input
id="description-checkbox" id="description-checkbox"
type="checkbox" type="checkbox"
bind:value={includeDescription} bind:checked={includeDescription}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2" class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/> />
<label for="description-checkbox" class="ms-2 text-sm" <label for="description-checkbox" class="ms-2 text-sm"
@@ -499,7 +507,7 @@
loading={printLoading} loading={printLoading}
extraClasses="mt-2" extraClasses="mt-2"
primary={true} primary={true}
on:click={print}>{$_("print")}!</Button onclick={print}>{$_("print")}!</Button
> >
</div> </div>
@@ -518,7 +526,7 @@
trails={[$trail]} trails={[$trail]}
waypoints={$trail.expand?.waypoints} waypoints={$trail.expand?.waypoints}
activeTrail={0} activeTrail={0}
on:zoom={(e) => updateScale(e.detail)} onzoom={updateScale}
bind:map bind:map
{showGrid} {showGrid}
elevationProfileContainer="elevation-profile" elevationProfileContainer="elevation-profile"

View File

@@ -1,41 +1,42 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from "$app/navigation"; import { invalidateAll } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte"; import Button from "$lib/components/base/button.svelte";
import type { DropdownItem } from "$lib/components/base/dropdown.svelte"; import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
import type { SelectItem } from "$lib/components/base/select.svelte"; import type { SelectItem } from "$lib/components/base/select.svelte";
import ProfileShareModal from "$lib/components/profile/profile_share_modal.svelte"; import ProfileShareModal from "$lib/components/profile/profile_share_modal.svelte";
import TrailShareModal from "$lib/components/trail/trail_share_modal.svelte";
import { follows_create, follows_delete } from "$lib/stores/follow_store"; import { follows_create, follows_delete } from "$lib/stores/follow_store";
import { currentUser } from "$lib/stores/user_store"; import { currentUser } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let data; let { data, children } = $props();
let openShareModal: () => void; let profileShareModal: ProfileShareModal;
let followLoading: boolean = false; let followLoading: boolean = $state(false);
const profileLinks: DropdownItem[] = [ const profileLinks: DropdownItem[] = [
{ {
text: $_("profile"), text: $_("profile"),
value: `/profile/${$page.params.id}`, value: `/profile/${page.params.id}`,
icon: "user", icon: "user",
}, },
{ {
text: $_("trail", { values: { n: 2 } }), text: $_("trail", { values: { n: 2 } }),
value: `/profile/${$page.params.id}/trails`, value: `/profile/${page.params.id}/trails`,
icon: "route", icon: "route",
}, },
{ {
text: $_("statistics"), text: $_("statistics"),
value: `/profile/${$page.params.id}/stats`, value: `/profile/${page.params.id}/stats`,
icon: "chart-pie", icon: "chart-pie",
}, },
]; ];
$: activeIndex = profileLinks.findIndex( let activeIndex = $derived(
(l) => l.value === $page.url.pathname, profileLinks.findIndex((l) => l.value === page.url.pathname),
); );
async function follow() { async function follow() {
@@ -90,14 +91,14 @@
{/if} {/if}
<div class="flex gap-x-6 text-sm px-6"> <div class="flex gap-x-6 text-sm px-6">
<a <a
class:font-bold={$page.url.pathname.endsWith("followers")} class:font-bold={page.url.pathname.endsWith("followers")}
href="/profile/{data.user.id}/users/followers" href="/profile/{data.user.id}/users/followers"
> >
<p class="font-semibold">{data.followers}</p> <p class="font-semibold">{data.followers}</p>
<p>{$_("followers")}</p> <p>{$_("followers")}</p>
</a> </a>
<a <a
class:font-bold={$page.url.pathname.endsWith("following")} class:font-bold={page.url.pathname.endsWith("following")}
href="/profile/{data.user.id}/users/following" href="/profile/{data.user.id}/users/following"
> >
<p class="font-semibold">{data.following}</p> <p class="font-semibold">{data.following}</p>
@@ -112,7 +113,7 @@
primary={!data.follow} primary={!data.follow}
secondary={!!data.follow} secondary={!!data.follow}
icon={data.follow ? "check" : ""} icon={data.follow ? "check" : ""}
on:click={() => follow()} onclick={() => follow()}
> >
{data.follow ? $_("following") : $_("follow")}</Button {data.follow ? $_("following") : $_("follow")}</Button
> >
@@ -132,7 +133,7 @@
<div class="px-6 mb-4 flex flex-col gap-2"> <div class="px-6 mb-4 flex flex-col gap-2">
<button <button
class="btn-secondary basis-full" class="btn-secondary basis-full"
on:click={() => openShareModal()} onclick={() => profileShareModal.openModal()}
>{$_("share-profile")}</button >{$_("share-profile")}</button
> >
<a <a
@@ -140,11 +141,11 @@
href="/settings/profile">{$_("settings")}</a href="/settings/profile">{$_("settings")}</a
> >
</div> </div>
<ProfileShareModal bind:openModal={openShareModal} <ProfileShareModal bind:this={profileShareModal}
></ProfileShareModal> ></ProfileShareModal>
{/if} {/if}
</div> </div>
<div class="md:mr-6"> <div class="md:mr-6">
<slot></slot> {@render children?.()}
</div> </div>
</div> </div>

View File

@@ -1,20 +1,23 @@
<script lang="ts"> <script lang="ts">
import ActivityCard from "$lib/components/profile/activity_card.svelte"; import { goto } from "$app/navigation";
import { activities_index } from "$lib/stores/activity_store.js";
import { getFileURL } from "$lib/util/file_util.js";
import { _ } from "svelte-i18n";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg"; import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg"; import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import ActivityCard from "$lib/components/profile/activity_card.svelte";
import { activities_index } from "$lib/stores/activity_store.js";
import { theme } from "$lib/stores/theme_store.js"; import { theme } from "$lib/stores/theme_store.js";
import { getFileURL } from "$lib/util/file_util.js";
import { _ } from "svelte-i18n";
export let data; let { data } = $props();
let activities = $state(data.activities);
let loading: boolean = false; let loading: boolean = false;
$: pagination = { let pagination = $derived({
page: data.activities.page, page: data.activities.page,
totalPages: data.activities.totalPages, totalPages: data.activities.totalPages,
}; });
async function onListScroll(e: Event) { async function onListScroll(e: Event) {
if ( if (
@@ -31,11 +34,11 @@
async function loadNextPage() { async function loadNextPage() {
pagination.page += 1; pagination.page += 1;
data.activities = await activities_index(data.user.id, pagination.page); activities = await activities_index(data.user.id, pagination.page);
} }
</script> </script>
<svelte:window on:scroll={onListScroll} /> <svelte:window onscroll={onListScroll} />
<svelte:head> <svelte:head>
<title>{$_("profile")} | wanderer</title> <title>{$_("profile")} | wanderer</title>
@@ -47,7 +50,7 @@
{$_("about")} {$_("about")}
{data.user.username} {data.user.username}
{#if data.isOwnProfile && data.settings.bio?.length} {#if data.isOwnProfile && data.settings.bio?.length}
<a class="ml-4" href="/settings/profile" <a aria-label="Edit bio" class="ml-4" href="/settings/profile"
><i class="fa fa-pen text-base"></i></a ><i class="fa fa-pen text-base"></i></a
> >
{/if} {/if}
@@ -109,26 +112,31 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<h4 class="text-xl font-semibold">Timeline</h4> <h4 class="text-xl font-semibold">Timeline</h4>
{#if !data.activities.items.length && data.isOwnProfile} {#if !activities.items.length && data.isOwnProfile}
<a class="btn-primary inline-block" href="/trails/edit/new" <a class="btn-primary inline-block" href="/trails/edit/new"
>+ {$_("new-trail")}</a >+ {$_("new-trail")}</a
> >
{:else if !data.activities.items.length} {:else if !activities.items.length}
<p class="w-full text-center text-gray-500 text-sm"> <p class="w-full text-center text-gray-500 text-sm">
{$_("empty-activities", { {$_("empty-activities", {
values: { username: data.user.username }, values: { username: data.user.username },
})} })}
</p> </p>
{/if} {/if}
{#each data.activities.items as activity} {#each activities.items as activity}
<a <div
class="block" role="presentation"
href={activity.type == "trail" class="cursor-pointer"
onclick={() => {
goto(
activity.type == "trail"
? `/trail/view/${activity.id}` ? `/trail/view/${activity.id}`
: `/trail/view/${activity.trail_id}?t=3`} : `/trail/view/${activity.trail_id}?t=3`,
);
}}
> >
<ActivityCard {activity} user={data.user}></ActivityCard> <ActivityCard {activity} user={data.user}></ActivityCard>
</a> </div>
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/state";
import Calendar from "$lib/components/base/calendar.svelte"; import Calendar from "$lib/components/base/calendar.svelte";
import Datepicker from "$lib/components/base/datepicker.svelte"; import Datepicker from "$lib/components/base/datepicker.svelte";
import MultiSelect from "$lib/components/base/multi_select.svelte"; import MultiSelect from "$lib/components/base/multi_select.svelte";
@@ -18,6 +18,8 @@
formatSpeed, formatSpeed,
formatTimeHHMM, formatTimeHHMM,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import Bar from "$lib/vendor/svelte-chartjs/bar.svelte";
import Pie from "$lib/vendor/svelte-chartjs/pie.svelte";
import { import {
ArcElement, ArcElement,
BarElement, BarElement,
@@ -28,10 +30,9 @@
Title, Title,
Tooltip, Tooltip,
} from "chart.js"; } from "chart.js";
import { Bar, Pie } from "svelte-chartjs";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let data; let { data } = $props();
ChartJS.register( ChartJS.register(
Title, Title,
@@ -43,7 +44,7 @@
BarElement, BarElement,
); );
const filter = data.filter; const filter = $state(data.filter);
const categorySelectItems: SelectItem[] = $categories.map((c) => ({ const categorySelectItems: SelectItem[] = $categories.map((c) => ({
value: c.id, value: c.id,
@@ -69,7 +70,7 @@
}, },
]; ];
let barChartSelectedValue = barChartSelectItems[0].value; let barChartSelectedValue = $state(barChartSelectItems[0].value);
const categoryColors = [ const categoryColors = [
"#fb8500", "#fb8500",
@@ -86,7 +87,7 @@
elevation_loss: 3.28084, elevation_loss: 3.28084,
}; };
$: logCategories = $summitLogs.reduce( let logCategories = $derived($summitLogs.reduce(
(acc, log) => { (acc, log) => {
const cat = const cat =
log.expand?.trails_via_summit_logs?.at(0)?.expand?.category log.expand?.trails_via_summit_logs?.at(0)?.expand?.category
@@ -95,18 +96,18 @@
return acc; return acc;
}, },
{} as Record<string, number>, {} as Record<string, number>,
); ));
$: categoryLabels = Object.keys(logCategories).sort(); let categoryLabels = $derived(Object.keys(logCategories).sort());
$: categoryValues = Object.values(logCategories); let categoryValues = $derived(Object.values(logCategories));
$: categoryColorMap = Object.fromEntries( let categoryColorMap = $derived(Object.fromEntries(
categoryLabels.map((label, index) => [ categoryLabels.map((label, index) => [
label, label,
categoryColors[index % categoryColors.length], categoryColors[index % categoryColors.length],
]), ]),
); ));
$: categoryChartData = { let categoryChartData = $derived({
labels: categoryLabels, labels: categoryLabels,
datasets: [ datasets: [
{ {
@@ -114,10 +115,10 @@
backgroundColor: categoryColors, backgroundColor: categoryColors,
}, },
], ],
}; });
function barChartUnit() { function barChartUnit() {
const unit = $page.data.settings?.unit ?? "metric"; const unit = page.data.settings?.unit ?? "metric";
switch (barChartSelectedValue) { switch (barChartSelectedValue) {
case "duration": case "duration":
@@ -130,7 +131,7 @@
} }
} }
$: barChartDataByDate = $summitLogs.reduce( let barChartDataByDate = $derived($summitLogs.reduce(
(acc, log) => { (acc, log) => {
const date = new Date(log.date).toLocaleDateString(undefined, { const date = new Date(log.date).toLocaleDateString(undefined, {
month: "2-digit", month: "2-digit",
@@ -145,7 +146,7 @@
} else if (barChartSelectedValue === "duration") { } else if (barChartSelectedValue === "duration") {
acc[date] = acc[date] / 60 / 60; acc[date] = acc[date] / 60 / 60;
} }
if ($page.data.settings?.unit !== "metric") { if (page.data.settings?.unit !== "metric") {
acc[date] = acc[date] =
acc[date] * acc[date] *
(conversionFactors as any)[barChartSelectedValue]; (conversionFactors as any)[barChartSelectedValue];
@@ -153,12 +154,12 @@
return acc; return acc;
}, },
{} as Record<string, number>, {} as Record<string, number>,
); ));
$: barChartLabels = Object.keys(barChartDataByDate); // Dates as labels let barChartLabels = $derived(Object.keys(barChartDataByDate)); // Dates as labels
$: barChartValues = Object.values(barChartDataByDate); let barChartValues = $derived(Object.values(barChartDataByDate));
$: barChartData = { let barChartData = $derived({
labels: barChartLabels, labels: barChartLabels,
datasets: [ datasets: [
{ {
@@ -170,36 +171,36 @@
borderRadius: 15, borderRadius: 15,
}, },
], ],
}; });
$: totalDistance = $summitLogs.reduce( let totalDistance = $derived($summitLogs.reduce(
(sum, log) => sum + (log.distance ?? 0), (sum, log) => sum + (log.distance ?? 0),
0, 0,
); ));
$: totalDuration = $summitLogs.reduce( let totalDuration = $derived($summitLogs.reduce(
(sum, log) => sum + (log.duration ?? 0), (sum, log) => sum + (log.duration ?? 0),
0, 0,
); ));
$: totalElevationGain = $summitLogs.reduce( let totalElevationGain = $derived($summitLogs.reduce(
(sum, log) => sum + (log.elevation_gain ?? 0), (sum, log) => sum + (log.elevation_gain ?? 0),
0, 0,
); ));
$: totalElevationLoss = $summitLogs.reduce( let totalElevationLoss = $derived($summitLogs.reduce(
(sum, log) => sum + (log.elevation_loss ?? 0), (sum, log) => sum + (log.elevation_loss ?? 0),
0, 0,
); ));
$: averageSpeed = let averageSpeed =
totalDuration > 0 $derived(totalDuration > 0
? $summitLogs.reduce( ? $summitLogs.reduce(
(sum, log) => (sum, log) =>
sum + (log.distance && log.duration ? log.distance : 0), sum + (log.distance && log.duration ? log.distance : 0),
0, 0,
) / totalDuration ) / totalDuration
: undefined; : undefined);
function updateFilterCategory(categories: SelectItem[]) { function updateFilterCategory(categories: SelectItem[]) {
filter.category = categories.map((c) => c.value); filter.category = categories.map((c) => c.value);
@@ -216,7 +217,7 @@
} }
async function loadSummitLogs() { async function loadSummitLogs() {
const logs = await summit_logs_index($page.params.id, filter); const logs = await summit_logs_index(page.params.id, filter);
summitLogs.set(logs.items); summitLogs.set(logs.items);
} }
</script> </script>
@@ -230,18 +231,18 @@
class="flex flex-wrap lg:flex-nowrap col-start-1 lg:col-start-2 gap-x-4 justify-end" class="flex flex-wrap lg:flex-nowrap col-start-1 lg:col-start-2 gap-x-4 justify-end"
> >
<MultiSelect <MultiSelect
on:change={(e) => updateFilterCategory(e.detail)} onchange={updateFilterCategory}
label={$_("categories")} label={$_("categories")}
items={categorySelectItems} items={categorySelectItems}
placeholder={`${$_("filter-categories")}...`} placeholder={`${$_("filter-categories")}...`}
></MultiSelect> ></MultiSelect>
<Datepicker <Datepicker
on:change={loadSummitLogs} onchange={loadSummitLogs}
bind:value={filter.startDate} bind:value={filter.startDate}
label={$_("after")} label={$_("after")}
></Datepicker> ></Datepicker>
<Datepicker <Datepicker
on:change={loadSummitLogs} onchange={loadSummitLogs}
bind:value={filter.endDate} bind:value={filter.endDate}
label={$_("before")} label={$_("before")}
></Datepicker> ></Datepicker>
@@ -249,7 +250,7 @@
<div class="space-y-4 grow-0 lg:mr-4"> <div class="space-y-4 grow-0 lg:mr-4">
<div class="border border-input-border rounded-xl p-6"> <div class="border border-input-border rounded-xl p-6">
<Calendar <Calendar
on:click={(e) => handleDateClick(e.detail)} onclick={handleDateClick}
logs={$summitLogs} logs={$summitLogs}
colorMap={categoryColorMap} colorMap={categoryColorMap}
></Calendar> ></Calendar>

View File

@@ -1,29 +1,35 @@
<script lang="ts"> <script lang="ts">
import TrailList from "$lib/components/trail/trail_list.svelte"; import TrailList from "$lib/components/trail/trail_list.svelte";
import type { Trail, TrailFilter } from "$lib/models/trail.js";
import { trails_search_filter } from "$lib/stores/trail_store"; import { trails_search_filter } from "$lib/stores/trail_store";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let data; let { data } = $props();
let loading = true; let loading = $state(true);
$: pagination = { let pagination = $state({
page: data.trails.page, page: data.trails.page,
totalPages: data.trails.totalPages, totalPages: data.trails.totalPages,
}; });
let trails = $state(data.trails);
let filter: TrailFilter = $state(data.filter);
async function handleFilterUpdate() { async function handleFilterUpdate() {
loading = true; loading = true;
data.trails = await trails_search_filter(data.filter, pagination.page); trails = await trails_search_filter(filter, pagination.page);
loading = false; loading = false;
} }
async function paginate(page: number) { async function paginate(page: number) {
pagination.page = page; pagination.page = page;
data.trails = await trails_search_filter(data.filter, page); trails = await trails_search_filter(filter, page);
} }
</script> </script>
<svelte:head> <svelte:head>
<title>{$_("profile")} | wanderer</title> <title>{$_("profile")} | wanderer</title>
</svelte:head> </svelte:head>
@@ -31,8 +37,8 @@
{pagination} {pagination}
{loading} {loading}
fullWidthCards={true} fullWidthCards={true}
trails={data.trails.items} trails={trails.items}
filter={data.filter} {filter}
on:update={() => handleFilterUpdate()} onupdate={handleFilterUpdate}
on:pagination={(e) => paginate(e.detail)} onpagination={paginate}
></TrailList> ></TrailList>

View File

@@ -1,18 +1,24 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/state";
import { follows_index } from "$lib/stores/follow_store.js"; import { follows_index } from "$lib/stores/follow_store.js";
import { getFileURL } from "$lib/util/file_util.js"; import { getFileURL } from "$lib/util/file_util.js";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export let data; let { data } = $props();
let follows = $state(data.follows);
let loading: boolean = false; let loading: boolean = false;
$: key = ($page.params.type == "following" ? "followee" : "follower") as "follower" | "followee"; let key = $derived(
(page.params.type == "following" ? "followee" : "follower") as
| "follower"
| "followee",
);
$: pagination = { let pagination = $derived({
page: data.follows.page, page: data.follows.page,
totalPages: data.follows.totalPages, totalPages: data.follows.totalPages,
}; });
async function onListScroll(e: Event) { async function onListScroll(e: Event) {
if ( if (
@@ -29,18 +35,18 @@
async function loadNextPage() { async function loadNextPage() {
pagination.page += 1; pagination.page += 1;
data.follows = await follows_index( follows = await follows_index(
{ followee: $page.params.id }, { followee: page.params.id },
pagination.page, pagination.page,
); );
} }
</script> </script>
<svelte:window on:scroll={onListScroll} /> <svelte:window onscroll={onListScroll} />
<div> <div>
<h1 class="text-3xl font-semibold mb-4">{$_($page.params.type)}</h1> <h1 class="text-3xl font-semibold mb-4">{$_(page.params.type)}</h1>
<ul class="space-y-4"> <ul class="space-y-4">
{#each data.follows.items as follow} {#each follows.items as follow}
{#if !follow.expand?.[key].private} {#if !follow.expand?.[key].private}
<a href="/profile/{follow.expand?.[key].id}"> <a href="/profile/{follow.expand?.[key].id}">
<li <li

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto, invalidateAll } from "$app/navigation"; import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte"; import Button from "$lib/components/base/button.svelte";
import TextField from "$lib/components/base/text_field.svelte"; import TextField from "$lib/components/base/text_field.svelte";
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte"; import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
@@ -16,7 +16,7 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { INVALID, z } from "zod"; import { INVALID, z } from "zod";
let loading: boolean = false; let loading: boolean = $state(false);
const { form, errors } = createForm<User>({ const { form, errors } = createForm<User>({
initialValues: { initialValues: {
id: "", id: "",
@@ -77,7 +77,7 @@
) )
? (window.navigator.language as Language) ? (window.navigator.language as Language)
: Language.en; : Language.en;
await settings_update({ id: $page.data.settings.id, language }); await settings_update({ id: page.data.settings.id, language });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {

View File

@@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import type { SelectItem } from "$lib/components/base/select.svelte"; import type { SelectItem } from "$lib/components/base/select.svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import type { Snippet } from "svelte";
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
const settingsLinks: SelectItem[] = [ const settingsLinks: SelectItem[] = [
{ text: $_("profile"), value: "/settings/profile" }, { text: $_("profile"), value: "/settings/profile" },
@@ -22,11 +28,12 @@
}, },
]; ];
$: activeIndex = settingsLinks.findIndex( let activeIndex = $derived(
(l) => l.value === $page.url.pathname, settingsLinks.findIndex((l) => l.value === page.url.pathname),
); );
function handleItemClick(item: SelectItem) { function handleItemClick(e: Event, item: SelectItem) {
e.stopPropagation();
if (item.value != settingsLinks.at(-1)?.value) { if (item.value != settingsLinks.at(-1)?.value) {
goto(item.value); goto(item.value);
} else { } else {
@@ -44,13 +51,13 @@
class="menu-item flex items-center px-4 py-3 my-1 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors rounded-md" class="menu-item flex items-center px-4 py-3 my-1 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors rounded-md"
class:bg-menu-item-background-hover={i == activeIndex} class:bg-menu-item-background-hover={i == activeIndex}
role="presentation" role="presentation"
on:mousedown|stopPropagation={() => handleItemClick(item)} onmousedown={(e) => handleItemClick(e, item)}
> >
{item.text} {item.text}
</li> </li>
{/each} {/each}
</menu> </menu>
<div class="settings-content"> <div class="settings-content">
<slot></slot> {@render children?.()}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/state";
import ConfirmModal from "$lib/components/confirm_modal.svelte"; import ConfirmModal from "$lib/components/confirm_modal.svelte";
import EmailModal from "$lib/components/settings/email_modal.svelte"; import EmailModal from "$lib/components/settings/email_modal.svelte";
import PasswordModal from "$lib/components/settings/password_modal.svelte"; import PasswordModal from "$lib/components/settings/password_modal.svelte";
@@ -14,16 +14,16 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
const settings = $page.data.settings; const settings = page.data.settings;
let selectedLanguage = "en"; let selectedLanguage = "en";
let selectedMapFocus = "trails"; let selectedMapFocus = "trails";
let citySearchQuery: string = ""; let citySearchQuery: string = "";
let openConfirmModal: () => void; let confirmModal: ConfirmModal;
let openEmailModal: () => void; let emailModal: EmailModal;
let openPasswordModal: () => void; let passwordModal: PasswordModal;
onMount(() => { onMount(() => {
citySearchQuery = settings?.location?.name ?? ""; citySearchQuery = settings?.location?.name ?? "";
@@ -84,33 +84,37 @@
<hr class="mt-4 mb-6 border-input-border" /> <hr class="mt-4 mb-6 border-input-border" />
<div class="space-y-6"> <div class="space-y-6">
<h4 class="text-xl font-medium">{$_("login-details")}</h4> <h4 class="text-xl font-medium">{$_("login-details")}</h4>
<button class="btn-secondary block" on:click={openEmailModal} <button
>{$_("change-email")}</button class="btn-secondary block"
onclick={() => emailModal.openModal()}>{$_("change-email")}</button
> >
<button class="btn-secondary" on:click={openPasswordModal} <button class="btn-secondary" onclick={() => passwordModal.openModal()}
>{$_("change-password")}</button >{$_("change-password")}</button
> >
<div class="space-y-4"> <div class="space-y-4">
<h4 class="text-xl text-red-400 font-medium"> <h4 class="text-xl text-red-400 font-medium">
{$_("danger-zone")} {$_("danger-zone")}
</h4> </h4>
<button id="delete-account" class="btn-danger" on:click={openConfirmModal} <button
id="delete-account"
class="btn-danger"
onclick={() => confirmModal.openModal()}
>{$_("delete-account")}</button >{$_("delete-account")}</button
> >
</div> </div>
</div> </div>
<EmailModal <EmailModal
email={$currentUser.email} email={$currentUser.email}
on:save={(e) => updateEmail(e.detail)} onsave={updateEmail}
bind:openModal={openEmailModal} bind:this={emailModal}
></EmailModal> ></EmailModal>
<PasswordModal <PasswordModal
on:save={(e) => updatePassword(e.detail)} onsave={updatePassword}
bind:openModal={openPasswordModal} bind:this={passwordModal}
></PasswordModal> ></PasswordModal>
{/if} {/if}
<ConfirmModal <ConfirmModal
text={$_("account-delete-confirm")} text={$_("account-delete-confirm")}
bind:openModal={openConfirmModal} bind:this={confirmModal}
on:confirm={deleteAccount} onconfirm={deleteAccount}
></ConfirmModal> ></ConfirmModal>

View File

@@ -13,22 +13,22 @@
import JSZip from "jszip"; import JSZip from "jszip";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { linear } from "svelte/easing"; import { linear } from "svelte/easing";
import { tweened } from "svelte/motion"; import { Tween } from "svelte/motion";
import emptyStateUploadDark from "$lib/assets/svgs/empty_states/empty_state_upload_dark.svg"; import emptyStateUploadDark from "$lib/assets/svgs/empty_states/empty_state_upload_dark.svg";
import emptyStateUploadLight from "$lib/assets/svgs/empty_states/empty_state_upload_light.svg"; import emptyStateUploadLight from "$lib/assets/svgs/empty_states/empty_state_upload_light.svg";
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { onMount } from "svelte"; import { onMount } from "svelte";
import JSConfetti from "js-confetti"; import JSConfetti from "js-confetti";
const uploadProgress = tweened(0, { const uploadProgress = new Tween(0, {
duration: 300, duration: 300,
easing: linear, easing: linear,
}); });
let openExportModal: () => void; let exportModal: TrailExportModal;
let offerUpload: boolean = false; let offerUpload: boolean = $state(false);
let uploading: boolean = false; let uploading: boolean = $state(false);
let jsConfetti: JSConfetti; let jsConfetti: JSConfetti;
@@ -98,7 +98,7 @@
uploading = false; uploading = false;
jsConfetti.addConfetti({ jsConfetti.addConfetti({
confettiRadius: 4, confettiRadius: 4,
emojis: ['🍃', '🍁'], emojis: ["🍃", "🍁"],
}); });
}, 500); }, 500);
@@ -191,13 +191,18 @@
: 'border border-content border-dashed'} rounded-xl flex items-center justify-center text-gray-500 bg-background cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors" : 'border border-content border-dashed'} rounded-xl flex items-center justify-center text-gray-500 bg-background cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
class:bg-menu-item-background-hover={uploading} class:bg-menu-item-background-hover={uploading}
class:border-2={offerUpload && !uploading} class:border-2={offerUpload && !uploading}
style="--progress: {$uploadProgress}%" style="--progress: {uploadProgress.current}%"
on:click={openFileBrowser} onclick={openFileBrowser}
on:dragover={handleDragOver} ondragover={handleDragOver}
on:dragleave={handleDragLeave} ondragleave={handleDragLeave}
on:drop={handleDrop} ondrop={handleDrop}
> >
<canvas id="confetti-canvas" class="absolute" style="width: 100%; height: 200%"> </canvas> <canvas
id="confetti-canvas"
class="absolute"
style="width: 100%; height: 200%"
>
</canvas>
<div class=""> <div class="">
<img <img
class="rounded-full aspect-square mx-auto" class="rounded-full aspect-square mx-auto"
@@ -216,18 +221,18 @@
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT" accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
multiple={true} multiple={true}
style="display: none;" style="display: none;"
on:change={() => handleFileSelection()} onchange={() => handleFileSelection()}
/> />
<h3 class="text-2xl font-semibold">{$_("export")}</h3> <h3 class="text-2xl font-semibold">{$_("export")}</h3>
<hr class="mt-4 mb-6 border-input-border" /> <hr class="mt-4 mb-6 border-input-border" />
<Button secondary={true} on:click={() => openExportModal()} <Button secondary={true} onclick={() => exportModal.openModal()}
>{$_("export-all-trails")}</Button >{$_("export-all-trails")}</Button
> >
</div> </div>
<TrailExportModal <TrailExportModal
bind:openModal={openExportModal} bind:this={exportModal}
on:export={(e) => exportTrails(e.detail)} onexport={exportTrails}
></TrailExportModal> ></TrailExportModal>
<style> <style>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/state";
import RadioGroup, { import RadioGroup, {
type RadioItem, type RadioItem,
} from "$lib/components/base/radio_group.svelte"; } from "$lib/components/base/radio_group.svelte";
@@ -10,7 +10,7 @@
import { settings_update } from "$lib/stores/settings_store"; import { settings_update } from "$lib/stores/settings_store";
import { _, locale } from "svelte-i18n"; import { _, locale } from "svelte-i18n";
const settings = $page.data.settings; const settings = page.data.settings;
const languages: SelectItem[] = [ const languages: SelectItem[] = [
{ text: $_("chinese"), value: "zh" }, { text: $_("chinese"), value: "zh" },
@@ -30,7 +30,7 @@
{ text: $_("imperial"), value: "imperial" }, { text: $_("imperial"), value: "imperial" },
]; ];
let selectedLanguage = settings?.language ?? "en"; let selectedLanguage = $state(settings?.language ?? "en");
async function handleLanguageSelection( async function handleLanguageSelection(
value: Language, value: Language,
@@ -59,12 +59,12 @@
<Select <Select
items={languages} items={languages}
bind:value={selectedLanguage} bind:value={selectedLanguage}
on:change={(e) => handleLanguageSelection(e.detail)} onchange={handleLanguageSelection}
></Select> ></Select>
<h4 class="text-xl font-medium mt-6 mb-2">{$_("units")}</h4> <h4 class="text-xl font-medium mt-6 mb-2">{$_("units")}</h4>
<RadioGroup <RadioGroup
name="unit" name="unit"
items={units} items={units}
selected={settings?.unit == "metric" ? 0 : 1} selected={settings?.unit == "metric" ? 0 : 1}
on:change={(e) => handleUnitSelection(e.detail)} onchange={handleUnitSelection}
></RadioGroup> ></RadioGroup>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/state";
import Search, { import Search, {
type SearchItem, type SearchItem,
} from "$lib/components/base/search.svelte"; } from "$lib/components/base/search.svelte";
@@ -14,7 +14,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
$: settings = $page.data.settings; let settings = $derived(page.data.settings);
const mapFocus: SelectItem[] = [ const mapFocus: SelectItem[] = [
{ text: $_("trail", { values: { n: 2 } }), value: "trails" }, { text: $_("trail", { values: { n: 2 } }), value: "trails" },
@@ -22,15 +22,15 @@
]; ];
let selectedLanguage = "en"; let selectedLanguage = "en";
let selectedMapFocus = "trails"; let selectedMapFocus = $state("trails");
let searchDropdownItems: SearchItem[] = []; let searchDropdownItems: SearchItem[] = $state([]);
let citySearchQuery: string = ""; let citySearchQuery: string = $state("");
let customTilesetName: string = ""; let customTilesetName: string = $state("");
let customTilesetURL: string = ""; let customTilesetURL: string = $state("");
let terrainURL: string = ""; let terrainURL: string = $state("");
let hillshadingURL: string = ""; let hillshadingURL: string = $state("");
onMount(() => { onMount(() => {
citySearchQuery = settings?.location?.name ?? ""; citySearchQuery = settings?.location?.name ?? "";
@@ -106,9 +106,10 @@
}); });
} }
$: terrainSaveEnabled = let terrainSaveEnabled = $derived(
terrainURL !== settings?.terrain?.terrain || terrainURL !== settings?.terrain?.terrain ||
hillshadingURL !== settings?.terrain?.hillshading; hillshadingURL !== settings?.terrain?.hillshading,
);
</script> </script>
<svelte:head> <svelte:head>
@@ -123,7 +124,7 @@
<Select <Select
items={mapFocus} items={mapFocus}
bind:value={selectedMapFocus} bind:value={selectedMapFocus}
on:change={(e) => handleMapFocusSelection(e.detail)} onchange={handleMapFocusSelection}
></Select> ></Select>
{#if selectedMapFocus == "location"} {#if selectedMapFocus == "location"}
<div class="mt-3"> <div class="mt-3">
@@ -132,8 +133,8 @@
placeholder="{$_('search-cities')}..." placeholder="{$_('search-cities')}..."
clearAfterSelect={false} clearAfterSelect={false}
bind:value={citySearchQuery} bind:value={citySearchQuery}
on:update={(e) => searchCities(e.detail)} onupdate={searchCities}
on:click={(e) => handleSearchClick(e.detail)} onclick={handleSearchClick}
></Search> ></Search>
</div> </div>
{/if} {/if}
@@ -150,8 +151,9 @@
<p class="text-sm text-gray-500">{tileset.url}</p> <p class="text-sm text-gray-500">{tileset.url}</p>
</div> </div>
<button <button
aria-label="Delete tileset"
class="btn-icon" class="btn-icon"
on:click={() => handleTilesetDelete(i)} onclick={() => handleTilesetDelete(i)}
><i class="fa fa-trash text-red-500"></i></button ><i class="fa fa-trash text-red-500"></i></button
> >
</div> </div>
@@ -170,7 +172,10 @@
placeholder="https://.../style.json" placeholder="https://.../style.json"
></TextField> ></TextField>
</div> </div>
<button class="btn-icon mt-6" on:click={handleTilesetAdd} <button
aria-label="Add tileset"
class="btn-icon mt-6"
onclick={handleTilesetAdd}
><i class="fa fa-plus"></i></button ><i class="fa fa-plus"></i></button
> >
</div> </div>
@@ -195,10 +200,11 @@
></TextField> ></TextField>
</div> </div>
<button <button
aria-label="Save terrain settings"
disabled={!terrainSaveEnabled} disabled={!terrainSaveEnabled}
class="btn-icon mt-6" class="btn-icon mt-6"
class:hover:!bg-background={!terrainSaveEnabled} class:hover:!bg-background={!terrainSaveEnabled}
on:click={handleTerrainAdd} onclick={handleTerrainAdd}
class:text-gray-500={!terrainSaveEnabled} class:text-gray-500={!terrainSaveEnabled}
><i class="fa fa-save"></i></button ><i class="fa fa-save"></i></button
> >

Some files were not shown because too many files have changed in this diff Show More