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

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>