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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,21 @@
<script lang="ts">
export let label: string = "";
export let name: string = "";
export let accept: string = "*";
import { createBubbler } from 'svelte/legacy';
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>
<div>
@@ -18,6 +30,6 @@
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"
type="file"
on:change
onchange={bubble('change')}
/>
</div>

View File

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

View File

@@ -1,45 +1,58 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import type { SelectItem } from "./select.svelte";
export let items: SelectItem[] = [];
export let value: SelectItem[] = [];
export let label: string = "";
export let name: string = "";
export let placeholder: string = "";
let showDropdown = false;
let dropdownRef;
const dispatch = createEventDispatcher();
function toggleItem(item: SelectItem) {
if (value.includes(item)) {
value = value.filter((i) => i !== item);
} else {
value = [...value, item];
}
dispatch("change", value);
interface Props {
items?: SelectItem[];
value?: SelectItem[];
label?: string;
name?: string;
placeholder?: string;
onchange?: (value: SelectItem[]) => void
}
function removeItem(item: SelectItem) {
let {
items = [],
value = $bindable([]),
label = "",
name = "",
placeholder = "",
onchange
}: Props = $props();
let showDropdown = $state(false);
let dropdownRef = $state();
function toggleItem(item: SelectItem) {
const itemIndex = value.findIndex((i) => i.value == item.value);
if (itemIndex >= 0) {
value.splice(itemIndex, 1);
} else {
value.push(item);
}
onchange?.(value);
}
function removeItem(e: Event, item: SelectItem) {
e.stopPropagation();
value = value.filter((i) => i !== item);
dispatch("change", value);
onchange?.(value);
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="relative max-w-full">
{#if label.length}
<label for={name} class="text-sm font-medium pb-1 block">
{label}
</label>
{/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"
on:click={() => (showDropdown = !showDropdown)}
onclick={() => (showDropdown = !showDropdown)}
>
{#if value.length === 0}
<span class="text-gray-400">{placeholder}</span>
@@ -50,14 +63,15 @@
>
<span class="text-sm">{$_(item.text)}</span>
<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"
>
<i class="fa fa-close"></i>
</button>
</div>
{/each}
</button>
</div>
<!-- Dropdown menu -->
{#if showDropdown}
@@ -67,11 +81,11 @@
>
{#each items as item}
<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"
>
<span>{$_(item.text)}</span>
{#if value.includes(item)}
{#if value.findIndex((i) => i.value == item.value) >= 0}
<div class="ml-auto">
<i class="fa fa-check"></i>
</div>

View File

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

View File

@@ -1,4 +1,4 @@
<script context="module" lang="ts">
<script module lang="ts">
export type RadioItem = {
text: string;
value: string;
@@ -8,17 +8,19 @@
</script>
<script lang="ts">
import { createEventDispatcher } from "svelte";
interface Props {
items: RadioItem[];
name: string;
selected?: number;
onchange?: (item: RadioItem) => void
}
export let items: RadioItem[];
export let name: string;
export let selected: number = 0;
let { items, name, selected = $bindable(0), onchange }: Props = $props();
const dispatch = createEventDispatcher();
function handleRadioChange(radioIndex: number) {
selected = radioIndex;
dispatch("change", items[selected]);
onchange?.(items[selected]);
}
</script>
@@ -31,7 +33,7 @@
checked={i == selected}
value={item.value}
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">
<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 = {
text: string;
description?: string;
@@ -8,26 +9,43 @@
</script>
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { fade } from "svelte/transition";
import TextField from "./text_field.svelte";
import type { Snippet } from "svelte";
export let maxSearchLength: number = 5;
export let value: string = "";
export let items: SearchItem[] = [];
export let placeholder: string = "Search...";
export let large: boolean = false;
export let extraClasses: string = "";
export let label: string = "";
export let clearAfterSelect: boolean = true;
interface Props {
maxSearchLength?: number;
value?: string;
items?: SearchItem[];
placeholder?: string;
large?: boolean;
extraClasses?: string;
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 searching: boolean = false;
let searching: boolean = $state(false);
let typingTimer!: any;
$: dropDownOpen = value.length > 0 && items.length > 0 && searching;
let dropDownOpen = $derived(value.length > 0 && items.length > 0 && searching);
function onSearchType() {
clearTimeout(typingTimer);
@@ -42,12 +60,13 @@
function update(q: string) {
lastSearch = q;
dispatch("update", q);
onupdate?.(q);
}
function handleItemClick(item: SearchItem) {
function handleItemClick(e: Event, item: SearchItem) {
e.stopPropagation();
searching = false;
dispatch("click", item);
onclick?.(item);
if (clearAfterSelect) {
clear();
}
@@ -70,11 +89,12 @@
</span>
{#if value.length > 0}
<button
aria-label="Clear"
type="button"
class="btn-icon absolute {large
? 'bottom-[31px]'
: 'bottom-[25px]'} translate-y-1/2 right-0 mr-2"
on:click={clear}
onclick={clear}
in:fade={{ duration: 150 }}
out:fade={{ duration: 150 }}
>
@@ -91,9 +111,9 @@
: "px-10"}
{placeholder}
bind:value
on:input={onSearchType}
on:focusin={() => (searching = true)}
on:focusout={() => (searching = false)}
oninput={onSearchType}
onfocusin={() => (searching = true)}
onfocusout={() => (searching = false)}
></TextField>
{#if dropDownOpen}
@@ -103,17 +123,17 @@
style="z-index: 1001"
>
{#each items as item}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<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"
tabindex="0"
on:mousedown|stopPropagation={() => handleItemClick(item)}
on:keydown|stopPropagation={() => handleItemClick(item)}
onmousedown={(e) => handleItemClick(e, 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>
</slot>
{/if}
<div>
<p>{item.text}</p>

View File

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

View File

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

View File

@@ -1,18 +1,22 @@
<script lang="ts">
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { tweened } from "svelte/motion";
import { Tween } from "svelte/motion";
export let tabs: string[];
export let activeTab: number;
export let extraClasses: string = "";
interface Props {
tabs: string[];
activeTab: number;
extraClasses?: string;
}
const indicatorPosition = tweened(0, {
let { tabs, activeTab = $bindable(), extraClasses = "" }: Props = $props();
const indicatorPosition = new Tween(0, {
duration: 300,
easing: cubicOut,
});
const indicatorWidth = tweened(0, {
const indicatorWidth = new Tween(0, {
duration: 300,
easing: cubicOut,
});
@@ -37,13 +41,13 @@
<div id="tabs" class="flex gap-2 overflow-x-auto relative {extraClasses}">
<div
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>
{#each tabs as tab, i}
<button
class="tab z-10"
class:tab-active={activeTab == i}
on:click={() => switchTabs(i)}>{tab}</button
onclick={() => switchTabs(i)}>{tab}</button
>
{/each}
</div>

View File

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

View File

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

View File

@@ -25,7 +25,7 @@
<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"
on:click={closeToast}
onclick={closeToast}
>
<i class="fa fa-close"></i>
<span class="sr-only">Close modal</span>

View File

@@ -1,15 +1,24 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
export let name: string = "";
export let value: boolean = false;
export let label: string = "";
export let error: string = "";
interface Props {
name?: string;
value?: boolean;
label?: string;
error?: string;
onchange?: (value: boolean) => void
}
let {
name = "",
value = $bindable(false),
label = "",
error = "",
onchange
}: Props = $props();
const dispatch = createEventDispatcher();
function handleToggleChange() {
dispatch("change", value);
onchange?.(value);
}
</script>
@@ -20,7 +29,7 @@
type="checkbox"
class="sr-only peer"
value="1"
on:change={handleToggleChange}
onchange={handleToggleChange}
/>
<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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { type Snippet } from "svelte";
import type { List } from "$lib/models/list";
import { trail } from "$lib/stores/trail_store";
@@ -10,58 +10,68 @@
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";
export let lists: List[];
interface Props {
lists: List[];
children?: Snippet<[any]>;
onchange?: (list: List) => void
}
export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
let { lists, children, onchange }: Props = $props();
const dispatch = createEventDispatcher();
let modal: Modal;
export function openModal() {
modal.openModal();
}
function handleSelect(list: List) {
dispatch("change", list);
closeModal!();
onchange?.(list);
modal.closeModal!();
}
function listContainsCurrentTrail(list: List) {
return list.trails?.includes($trail.id!);
}
const children_render = $derived(children);
</script>
<Modal
id="list-modal"
title={$_("select-list")}
size="max-w-sm"
let:openModal
bind:openModal
bind:closeModal
bind:this={modal}
>
<slot {openModal} />
{#snippet children({ openModal })}
{@render children_render?.({ openModal })}
{/snippet}
{#snippet content()}
<ul>
{#each lists as list}
<li
class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer"
onclick={() => handleSelect(list)}
role="presentation"
>
<img
class="w-12 aspect-square rounded-full"
src={list.avatar
? getFileURL(list, list.avatar)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark}
alt="avatar"
/>
<ul slot="content">
{#each lists as list}
<li
class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer"
on:click={() => handleSelect(list)}
role="presentation"
>
<img
class="w-12 aspect-square rounded-full"
src={list.avatar
? getFileURL(list, list.avatar)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark}
alt="avatar"
/>
<h5 class="text-md font-semibold">{list.name}</h5>
<h5 class="text-md font-semibold">{list.name}</h5>
<i
class="fa fa-{listContainsCurrentTrail(list)
? 'minus'
: 'plus'} rounded-full border border-input-border p-2"
></i>
</li>
{/each}
</ul>
<i
class="fa fa-{listContainsCurrentTrail(list)
? 'minus'
: 'plus'} rounded-full border border-input-border p-2"
></i>
</li>
{/each}
</ul>
{/snippet}
</Modal>

View File

@@ -16,28 +16,36 @@
trail_share_index,
} from "$lib/stores/trail_share_store";
import { getFileURL } from "$lib/util/file_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import Button from "../base/button.svelte";
import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte";
import UserSearch from "../user_search.svelte";
let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
export function openShareModal() {
shares.set([]);
fetchShares();
if (openModal) {
openModal();
}
interface Props {
list: List;
onsave?: () => void,
onupdate?: (list: List) => void
}
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;
@@ -56,8 +64,8 @@
}
function close() {
dispatch("save");
closeModal!();
onsave?.()
modal.closeModal!();
}
async function shareTrails(userId: string) {
@@ -109,10 +117,10 @@
const fetchedShares = await list_share_index(list.id!);
list.expand = {
trails: list.expand?.trails ?? [],
list_share_via_list: fetchedShares,
list_share_via_list: fetchedShares.items,
};
sharesLoading = false;
dispatch("update", list)
sharesLoading = false;
onupdate?.(list);
}
</script>
@@ -120,66 +128,73 @@
id="share-modal"
title={$_("share-this-list")}
size="max-w-sm overflow-visible"
bind:openModal
bind:closeModal
bind:this={modal}
>
<div slot="content">
<p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500">
{$_("list-share-warning")}
</p>
<UserSearch includeSelf={false} on:click={(e) => shareList(e.detail)}></UserSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#if $shares.length == 0}
<p class="text-gray-500 text-center mt-2 text-sm">
{$_("list-not-shared")}
{#snippet content()}
<div>
<p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500">
{$_("list-share-warning")}
</p>
{:else}
{#each $shares as share}
{#if share.expand}
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<span
class="basis-full text-sm text-center text-gray-500"
>{$_("can")}</span
>
<div class="shrink-0">
<Select
bind:value={share.permission}
items={permissionSelectItems}
on:change={(e) =>
updateSharePermission(share, e.detail)}
></Select>
</div>
<UserSearch
includeSelf={false}
onclick={(item) => shareList(item)}
></UserSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
<button
class="btn-icon text-red-500"
on:click={() => deleteShare(share)}
><i class="fa fa-trash"></i></button
>
</div>
{/if}
{/each}
{/if}
</div>
<div slot="footer" class="flex justify-between items-center gap-4">
<Button
secondary={true}
disabled={copyButtonText == $_("link-copied")}
on:click={copyURLToClipboard}
>
<i class="fa fa-link mr-2"></i>
{copyButtonText}
</Button>
<button class="btn-primary" on:click={close}>{$_("close")}</button>
</div></Modal
{#if $shares.length == 0}
<p class="text-gray-500 text-center mt-2 text-sm">
{$_("list-not-shared")}
</p>
{:else}
{#each $shares as share}
{#if share.expand}
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<span
class="basis-full text-sm text-center text-gray-500"
>{$_("can")}</span
>
<div class="shrink-0">
<Select
bind:value={share.permission}
items={permissionSelectItems}
onchange={(value) =>
updateSharePermission(share, value)}
></Select>
</div>
<button
aria-label="Delete share"
class="btn-icon text-red-500"
onclick={() => deleteShare(share)}
><i class="fa fa-trash"></i></button
>
</div>
{/if}
{/each}
{/if}
</div>
{/snippet}
{#snippet footer()}
<div class="flex justify-between items-center gap-4">
<Button
secondary={true}
disabled={copyButtonText == $_("link-copied")}
onclick={copyURLToClipboard}
>
<i class="fa fa-link mr-2"></i>
{copyButtonText}
</Button>
<button class="btn-primary" onclick={close}>{$_("close")}</button>
</div>
{/snippet}</Modal
>

View File

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

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { afterNavigate, goto } from "$app/navigation";
import { page } from "$app/stores";
import LogoText from "$lib/components/logo/logo_text.svelte";
import { pb } from "$lib/pocketbase";
import { theme, toggleTheme } from "$lib/stores/theme_store";
@@ -8,12 +7,13 @@
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import { backInOut, cubicOut } from "svelte/easing";
import { tweened } from "svelte/motion";
import Drawer from "./base/drawer.svelte";
import Dropdown from "./base/dropdown.svelte";
import LogoTextLight from "./logo/logo_text_light.svelte";
import NotificationDropdown from "./notification/notification_dropdown.svelte";
import { browser } from "$app/environment";
import { page } from "$app/state";
import { Tween } from "svelte/motion";
let navBarItems = [
{ text: "Home", value: "/" },
@@ -27,22 +27,22 @@
{ text: $_("logout"), value: "logout", icon: "right-from-bracket" },
];
const indicatorPosition = tweened(0, {
const indicatorPosition = new Tween(0, {
duration: 300,
easing: cubicOut,
});
const indicatorWidth = tweened(0, {
const indicatorWidth = new Tween(0, {
duration: 300,
easing: cubicOut,
});
const indicatorScale = tweened(0, {
const indicatorScale = new Tween(0, {
duration: 600,
easing: backInOut,
});
let drawerOpen: boolean = false;
let drawerOpen: boolean = $state(false);
afterNavigate((e) => {
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>
<Drawer bind:open={drawerOpen}>
<div class="flex gap-4 items-center m-4">
<div class="basis-full"></div>
<button
aria-label="Toggle theme"
class="btn-icon fa-regular fa-{$theme === 'light' ? 'sun' : 'moon'}"
on:click={() => toggleTheme()}
onclick={() => toggleTheme()}
></button>
<button
aria-label="Toggle drawer"
class="btn-icon block fa fa-close float-right"
on:click={() => (drawerOpen = false)}
onclick={() => (drawerOpen = false)}
></button>
</div>
<div class="flex flex-col px-12 gap-8">
@@ -140,7 +142,8 @@
</p>
</a>
<button
on:click={() => {
aria-label="Logout"
onclick={() => {
logout();
window.location.href = "/";
}}
@@ -168,7 +171,7 @@
<menu id="nav-bar-links" class="hidden lg:flex gap-8 relative py-1 px-2">
<div
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>
{#each navBarItems as item}
<a class="font-semibold z-10" href={item.value}>{item.text}</a>
@@ -182,50 +185,55 @@
{#if user}
<div class="hidden lg:flex gap-6 items-center">
<button
aria-label="Toggle theme"
class="btn-icon fa-regular fa-{$theme === 'light'
? 'sun'
: 'moon'}"
on:click={() => toggleTheme()}
onclick={() => toggleTheme()}
></button>
<a class="btn-primary btn-large" href="/trail/edit/new"
><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a
>
{#if $page.data.notifications}
{#if page.data.notifications}
<NotificationDropdown></NotificationDropdown>
{/if}
<Dropdown
items={dropdownItems}
on:change={(e) => handleDropdownClick(e.detail)}
let:toggleMenu={openDropdown}
onchange={(item) => handleDropdownClick(item)}
>
<div class="flex items-center">
<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"
on:click={openDropdown}
>
<img
class="rounded-full w-full h-full"
src={getFileURL(user, user.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
</button>
</div>
</Dropdown>
{#snippet children({ toggleMenu: openDropdown })}
<div class="flex items-center">
<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"
onclick={openDropdown}
>
<img
class="rounded-full w-full h-full"
src={getFileURL(user, user.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
</button>
</div>
{/snippet}
</Dropdown>
</div>
{:else}
<div class="hidden md:flex items-center gap-8">
<button
aria-label="Toggle theme"
class="btn-icon fa-regular fa-{$theme === 'light'
? 'sun'
: 'moon'}"
on:click={() => toggleTheme()}
onclick={() => toggleTheme()}
></button>
<a class="btn-primary btn-large" href="/login">{$_("login")}</a>
</div>
{/if}
<button
aria-label="Toggle drawer"
class="btn-icon fa fa-bars lg:hidden"
on:click={() => (drawerOpen = !drawerOpen)}
onclick={() => (drawerOpen = !drawerOpen)}
></button>
</nav>

View File

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

View File

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

View File

@@ -6,12 +6,30 @@
The progress bar will show during navigation if loading takes more than `displayThresholdMs`
and will hide when navigation is complete.
-->
<svelte:options runes={true} />
<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:
// 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
// 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.
@@ -24,83 +42,111 @@
};
// Internal private state.
let running: boolean = false;
let updater: ReturnType<typeof setInterval> | null = null;
let completed = false;
let width: number = 0;
let running = $state(false);
let updater = $state<ReturnType<typeof setInterval> | null>(null);
let completed = $state(false);
let width = $state(0);
/** If set, an ID for the progress bar on the HTML page.
* This ID must be unique on the page to avoid conflicts.
*
* Might be used with another element to signal to assistive technologies that
* progress is ongoing.
*
* @example
* <ProgressBar id="my-progress-bar" bind:busy />
* <div aria-busy={busy} aria-describedby="my-progress-bar">
* A div that is currently loading...
* </div>
*/
export let id: string | undefined = undefined;
/** Will be set to true when the progress bar is running. */
export let busy: boolean = false;
$: running = busy;
/**
* The CSS color to use to style the progress bar.
*
* If you're using Tailwind or Windi CSS, leave this to the default
* and set the `class` attribute to a `text-` class instead.
*/
export let color: string = "currentColor";
/**
* A Tailwind/Windi `text-` class to use to color the Progress Bar.
*
* This prop will be ignored if the `color` prop is set to something other than `currentColor`.
*
* **WARNING**: Do not set this prop with something other than a `text-` class,
* as it could interfere with the styling of the Progress Bar.
*
* @example text-green-500
*/
let textColorClass: `text-${string}` | "" = "";
export { textColorClass as class };
// Update the busy prop when the internal `running` variable changes.
$effect.pre(() => {
busy = running;
});
/**
* 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.
*/
export let zIndex: number = 1;
interface Props {
/** If set, an ID for the progress bar on the HTML page.
* This ID must be unique on the page to avoid conflicts.
*
* Might be used with another element to signal to assistive technologies that
* progress is ongoing.
*
* @example
* <ProgressBar id="my-progress-bar" bind:busy />
* <div aria-busy={busy} aria-describedby="my-progress-bar">
* A div that is currently loading...
* </div>
*/
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. */
busy?: boolean;
/**
* The CSS color to use to style the progress bar.
*
* If you're using Tailwind or Windi CSS, leave this to the default
* and set the `class` attribute to a `text-` class instead.
*/
color?: string;
/**
* A Tailwind/Windi `text-` class to use to color the Progress Bar.
*
* This prop will be ignored if the `color` prop is set to something other than `currentColor`.
*
* **WARNING**: Do not set this prop with something other than a `text-` class,
* as it could interfere with the styling of the Progress Bar.
*
* @example text-green-500
*/
class?: `text-${string}` | "";
// These are defaults that you shouldn't need to change, but are exposed here in case you do.
/**
* The starting percent width use when the bar starts.
* Starting at 0 doesn't usually look very good.
* @default 0.08
*/
let defaultMinimum = 0.08;
export { defaultMinimum as minimum };
/**
* 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.
* @default 0.994
*/
export let maximum = 0.994;
/**
* 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.
* @default 700
*/
let defaultSettleTime = 700;
export { defaultSettleTime as settleTime };
/**
* Milliseconds to wait between incrementing bar width when using
* the `start` (auto-increment) method.
* @default 700
*/
export let intervalTime = 700;
export let stepSizes = [0, 0.005, 0.01, 0.02];
/**
* 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.
*/
zIndex?: number;
// These are defaults that you shouldn't need to change, but are exposed here in case you do.
/**
* The starting percent width use when the bar starts.
* Starting at 0 doesn't usually look very good.
* @default 0.08
*/
minimum?: number;
/**
* 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.
* @default 0.994
*/
maximum?: number;
/**
* 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.
* @default 700
*/
settleTime?: number;
/**
* Milliseconds to wait between incrementing bar width when using
* the `start` (auto-increment) method.
* @default 700
*/
intervalTime?: number;
/** 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;
}
/** Reset the progress bar back to the beginning, leaving it in a running state. */
export const reset = (minimum = defaultMinimum) => {
width = minimum;
running = true;
@@ -186,32 +232,21 @@
};
};
let barStyle: string;
$: barStyle =
const barStyle = $derived(
(color ? `background-color: ${color};` : "") +
(width && width * 100 ? `width: ${width * 100}%;` : "") +
`z-index: ${zIndex};`;
(width && width * 100 ? `width: ${width * 100}%;` : "") +
`z-index: ${zIndex};` +
`view-transition-name: ${viewTransitionName}-bar;`,
);
// the box shadow of the leader bar uses `color` to set its shadow color
let leaderColorStyle: string;
$: leaderColorStyle =
const leaderColorStyle = $derived(
(color ? `background-color: ${color}; color: ${color};` : "") +
`z-index: ${zIndex + 1};`;
/** 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;
`z-index: ${zIndex + 1};` +
`view-transition-name: ${viewTransitionName}-leader;`,
);
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
beforeNavigate((nav) => {
if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout);
@@ -229,15 +264,15 @@
displayThresholdMs,
);
} else start();
}
});
afterNavigate(() => {
if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout);
progressBarStartTimeout = null;
nav.complete.catch().finally(() => {
if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout);
progressBarStartTimeout = null;
}
complete();
});
}
complete();
});
</script>
@@ -254,7 +289,7 @@
style={barStyle}
>
{#if running}
<div class="svelte-progress-bar-leader" style={leaderColorStyle} />
<div class="svelte-progress-bar-leader" style={leaderColorStyle}></div>
{/if}
</output>
{/if}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +1,55 @@
<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 { _ } from "svelte-i18n";
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 {
formatDistance,
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { theme } from "$lib/stores/theme_store";
let thumbnail: string =
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark;
let thumbnail: string = $state(
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark,
);
$: Promise.all(
(log._photos ?? []).map(async (f) => {
return await readAsDataURLAsync(f);
}),
).then((v) => {
if (log.photos.length) {
thumbnail = getFileURL(log, log.photos[0]);
} else if (v.length) {
thumbnail = v[0];
} else {
thumbnail =
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark;
}
});
interface Props {
log: SummitLog;
mode?: "show" | "edit";
onchange?: (item: DropdownItem) => void;
}
export let log: SummitLog;
export let mode: "show" | "edit" = "show";
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);
}),
).then((v) => {
if (log.photos.length) {
thumbnail = getFileURL(log, log.photos[0]);
} else if (v.length) {
thumbnail = v[0];
} else {
thumbnail =
$theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark;
}
});
}
</script>
<div class="p-4 my-2 border border-input-border rounded-xl">
@@ -65,7 +77,7 @@
</h5>
{#if mode == "edit"}
<Dropdown items={dropdownItems} on:change></Dropdown>
<Dropdown items={dropdownItems} {onchange}></Dropdown>
{/if}
</div>
{#if log.distance || log.elevation_gain || log.elevation_loss || log.duration}

View File

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

View File

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

View File

@@ -7,38 +7,53 @@
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import PhotoGallery from "../photo_gallery.svelte";
export let log: SummitLog;
export let showCategory: boolean = false;
export let showTrail: boolean = false;
export let showRoute: boolean = false;
export let showAuthor: boolean = false;
export let showDescription: boolean = false;
export let showPhotos: boolean = false;
let openGallery: (idx?: number) => void;
let imgSrc: string[] = [];
$: if (log.photos?.length) {
imgSrc = log.photos
.filter((_, i) => i < 3)
.reverse()
.map((p) => getFileURL(log, p));
} else {
imgSrc = [];
interface Props {
log: SummitLog;
showCategory?: boolean;
showTrail?: boolean;
showRoute?: boolean;
showAuthor?: boolean;
showDescription?: boolean;
showPhotos?: boolean;
ontext?: (summitLog: SummitLog) => void;
onopen?: (summitLog: SummitLog) => void;
}
const dispatch = createEventDispatcher();
let {
log,
showCategory = false,
showTrail = false,
showRoute = false,
showAuthor = false,
showDescription = false,
showPhotos = false,
onopen,
ontext
}: Props = $props();
let gallery: PhotoGallery;
let imgSrc: string[] = $state([]);
$effect(() => {
if (log.photos?.length) {
imgSrc = log.photos
.filter((_, i) => i < 3)
.reverse()
.map((p) => getFileURL(log, p));
} else {
imgSrc = [];
}
});
function openText() {
dispatch("text", log);
ontext?.(log);
}
function openRoute() {
dispatch("open", log);
onopen?.(log);
}
function colCount() {
@@ -58,12 +73,12 @@
{#if imgSrc.length}
<PhotoGallery
photos={log.photos.map((p) => getFileURL(log, p))}
bind:open={openGallery}
bind:this={gallery}
></PhotoGallery>
<button
class="relative w-16 aspect-square ml-2 mb-3 shrink-0"
type="button"
on:click={() => openGallery()}
onclick={() => gallery.openGallery()}
>
{#each imgSrc as img, i}
<img
@@ -110,6 +125,7 @@
{#if showTrail}
<td>
<a
aria-label="Go to trail"
class="btn-icon aspect-square"
href="/trail/view/{log.expand?.trails_via_summit_logs?.at(0)
?.id ?? ''}"
@@ -120,7 +136,7 @@
{#if showDescription}
<td>
{#if log.text}
<button on:click={openText}
<button onclick={openText}
><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"
>
@@ -164,7 +180,11 @@
{/if}
{#if showRoute && log.expand?.gpx_data}
<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
>
</td>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,73 +1,87 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import Select from "../base/select.svelte";
export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
export let title: string = $_("export");
interface Props {
title?: string;
onexport?: (settings: typeof exportSettings) => void;
}
const dispatch = createEventDispatcher();
let { title = $_("export"), onexport }: Props = $props();
let modal: Modal;
export function openModal() {
modal.openModal();
}
const fileFormats = [
{ text: "GPX", value: "gpx" },
{ text: "GeoJSON", value: "json" },
];
const exportSettings = {
const exportSettings: {
fileFormat: "gpx" | "json";
photos: boolean;
summitLog: boolean;
} = $state({
fileFormat: "gpx",
photos: false,
summitLog: false,
};
});
function exportTrail() {
dispatch("export", exportSettings);
closeModal!();
onexport?.(exportSettings);
modal.closeModal();
}
</script>
<Modal id="export-modal" {title} size="max-w-sm" bind:openModal bind:closeModal>
<div slot="content">
<div class="mb-3">
<Select
bind:value={exportSettings.fileFormat}
items={fileFormats}
label={$_("file-format")}
></Select>
<Modal id="export-modal" {title} size="max-w-sm" bind:this={modal}>
{#snippet content()}
<div>
<div class="mb-3">
<Select
bind:value={exportSettings.fileFormat}
items={fileFormats}
label={$_("file-format")}
></Select>
</div>
<div class="mb-2">
<input
id="include-photos-checkbox"
type="checkbox"
bind:checked={exportSettings.photos}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-photos-checkbox" class="ms-2 text-sm"
>{$_("photos")}</label
>
</div>
<div class="mb-2">
<input
id="include-summit-log-checkbox"
type="checkbox"
bind:checked={exportSettings.summitLog}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
>{$_("summit-book")}</label
>
</div>
</div>
<div class="mb-2">
<input
id="include-photos-checkbox"
type="checkbox"
bind:checked={exportSettings.photos}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-photos-checkbox" class="ms-2 text-sm"
>{$_("photos")}</label
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
type="button"
onclick={exportTrail}
name="save">{$_("export")}</button
>
</div>
<div class="mb-2">
<input
id="include-summit-log-checkbox"
type="checkbox"
bind:checked={exportSettings.summitLog}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
>{$_("summit-book")}</label
>
</div>
</div>
<div slot="footer" class="flex items-center gap-4">
<button class="btn-secondary" on:click={closeModal}
>{$_("cancel")}</button
>
<button
class="btn-primary"
type="button"
on:click={exportTrail}
name="save">{$_("export")}</button
>
</div></Modal
{/snippet}</Modal
>

View File

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

View File

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

View File

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

View File

@@ -13,15 +13,19 @@
import { _ } from "svelte-i18n";
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])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark;
: emptyStateTrailDark);
</script>
<li

View File

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

View File

@@ -2,8 +2,6 @@
import Modal from "$lib/components/base/modal.svelte";
import type { Trail } from "$lib/models/trail";
import { TrailShare } from "$lib/models/trail_share";
import type { User } from "$lib/models/user";
import { show_toast } from "$lib/stores/toast_store";
import {
shares,
trail_share_create,
@@ -11,30 +9,31 @@
trail_share_index,
trail_share_update,
} from "$lib/stores/trail_share_store";
import { users_search } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import Button from "../base/button.svelte";
import Search, { type SearchItem } from "../base/search.svelte";
import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte";
import UserSearch from "../user_search.svelte";
let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
export function openShareModal() {
fetchShares();
if (openModal) {
openModal();
}
interface Props {
trail: Trail;
onsave?: () => void;
}
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;
@@ -51,8 +50,8 @@
}
function close() {
dispatch("save");
closeModal!();
onsave?.();
modal.closeModal!();
}
async function shareTrail(item: SelectItem) {
@@ -85,64 +84,68 @@
id="share-modal"
title={$_("share-this-trail")}
size="max-w-sm overflow-visible"
bind:openModal
bind:closeModal
bind:this={modal}
>
<div slot="content">
<UserSearch includeSelf={false} on:click={(e) => shareTrail(e.detail)}
></UserSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#snippet content()}
<div>
<UserSearch includeSelf={false} onclick={(item) => shareTrail(item)}
></UserSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#if $shares.length == 0}
<p class="text-gray-500 text-center mt-2 text-sm">
{$_("trail-not-shared")}
</p>
{:else}
{#each $shares as share}
{#if share.expand}
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<span
class="basis-full text-sm text-center text-gray-500"
>{$_("can")}</span
>
<div class="shrink-0">
<Select
bind:value={share.permission}
items={permissionSelectItems}
on:change={(e) =>
updateSharePermission(share, e.detail)}
></Select>
{#if $shares.length == 0}
<p class="text-gray-500 text-center mt-2 text-sm">
{$_("trail-not-shared")}
</p>
{:else}
{#each $shares as share}
{#if share.expand}
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<span
class="basis-full text-sm text-center text-gray-500"
>{$_("can")}</span
>
<div class="shrink-0">
<Select
value={share.permission}
items={permissionSelectItems}
onchange={(value) =>
updateSharePermission(share, value)}
></Select>
</div>
<button
aria-label="Delete share"
class="btn-icon text-red-500"
onclick={() => deleteShare(share)}
><i class="fa fa-trash"></i></button
>
</div>
<button
class="btn-icon text-red-500"
on:click={() => deleteShare(share)}
><i class="fa fa-trash"></i></button
>
</div>
{/if}
{/each}
{/if}
</div>
<div slot="footer" class="flex justify-between items-center gap-4">
<Button
secondary={true}
disabled={copyButtonText == $_("link-copied")}
on:click={copyURLToClipboard}
>
<i class="fa fa-link mr-2"></i>
{copyButtonText}
</Button>
<button class="btn-primary" on:click={close}>{$_("close")}</button>
</div></Modal
{/if}
{/each}
{/if}
</div>
{/snippet}
{#snippet footer()}
<div class="flex justify-between items-center gap-4">
<Button
secondary={true}
disabled={copyButtonText == $_("link-copied")}
onclick={copyURLToClipboard}
>
<i class="fa fa-link mr-2"></i>
{copyButtonText}
</Button>
<button class="btn-primary" onclick={close}>{$_("close")}</button>
</div>
{/snippet}</Modal
>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment'
import { pb } from '$lib/pocketbase'
import { currentUser } from '$lib/stores/user_store'
import { init, register } from 'svelte-i18n'
import { get } from 'svelte/store'
@@ -17,5 +18,5 @@ register('zh', () => import('./locales/zh.json'))
init({
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";
export function formatTimeHHMM(minutes?: number) {
@@ -17,7 +17,7 @@ export function formatDistance(meters?: number) {
return "-";
}
const unit = get(page).data.settings?.unit ?? "metric";
const unit = page.data.settings?.unit ?? "metric";
if (unit == "metric") {
if (meters >= 1000) {
@@ -38,7 +38,7 @@ export function formatElevation(meters?: number) {
return "-";
}
const unit = get(page).data.settings?.unit ?? "metric";
const unit = page.data.settings?.unit ?? "metric";
if (unit == "metric") {
return `${Math.round(meters)} m`
@@ -54,7 +54,7 @@ export function formatSpeed(speed?: number) {
return "-";
}
const unit = get(page).data.settings?.unit ?? "metric";
const unit = page.data.settings?.unit ?? "metric";
if (unit == "metric") {
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 {
const marker = new FontawesomeMarker({
id: waypoint.id,
icon: `fa fa-${waypoint.icon}`,
}, {
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>