Migrates to svelte 5
This commit is contained in:
6
db/package-lock.json
generated
Normal file
6
db/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "db",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
4782
web/package-lock.json
generated
4782
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,17 +15,17 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.42.1",
|
"@playwright/test": "^1.42.1",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.5.27",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/node": "^20.11.25",
|
"@types/node": "^20.11.25",
|
||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"svelte": "^4.2.7",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^3.6.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tslib": "^2.4.1",
|
"tslib": "^2.4.1",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.0.3",
|
"vite": "^5.4.4",
|
||||||
"vitest": "^1.2.0"
|
"vitest": "^1.2.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
"@felte/validator-zod": "^1.0.18",
|
"@felte/validator-zod": "^1.0.18",
|
||||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||||
"@sveltejs/adapter-node": "^4.0.1",
|
"@sveltejs/adapter-node": "^4.0.1",
|
||||||
"@threlte/core": "^7.1.0",
|
"@threlte/core": "^8.0.0-next.41",
|
||||||
"@threlte/extras": "^8.7.5",
|
"@threlte/extras": "^9.0.0-next.55",
|
||||||
"@turf/destination": "^7.1.0",
|
"@turf/destination": "^7.1.0",
|
||||||
"@turf/distance": "^7.1.0",
|
"@turf/distance": "^7.1.0",
|
||||||
"@types/chart.js": "^2.9.41",
|
"@types/chart.js": "^2.9.41",
|
||||||
@@ -59,7 +59,6 @@
|
|||||||
"photoswipe": "^5.4.3",
|
"photoswipe": "^5.4.3",
|
||||||
"pocketbase": "^0.21.0",
|
"pocketbase": "^0.21.0",
|
||||||
"qrcode": "^1.4.4",
|
"qrcode": "^1.4.4",
|
||||||
"svelte-chartjs": "^3.1.5",
|
|
||||||
"svelte-i18n": "^4.0.0",
|
"svelte-i18n": "^4.0.0",
|
||||||
"three": "^0.161.0",
|
"three": "^0.161.0",
|
||||||
"xmldom": "^0.6.0",
|
"xmldom": "^0.6.0",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let type: "button" | "submit" | "reset" | null | undefined =
|
import type { Snippet } from "svelte";
|
||||||
undefined;
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
export let icon: string = "";
|
import { createBubbler } from "svelte/legacy";
|
||||||
export let extraClasses: string = "";
|
|
||||||
export let primary: boolean = false;
|
const bubble = createBubbler();
|
||||||
export let secondary: boolean = false;
|
interface Props {
|
||||||
export let large: boolean = false;
|
type?: "button" | "submit" | "reset" | null | undefined;
|
||||||
export let loading: boolean = false;
|
icon?: string;
|
||||||
export let disabled: boolean = false;
|
extraClasses?: string;
|
||||||
export let tooltip: string | undefined = undefined;
|
primary?: boolean;
|
||||||
export let id: string | undefined = undefined
|
secondary?: boolean;
|
||||||
|
large?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
tooltip?: string | undefined;
|
||||||
|
id?: string | undefined;
|
||||||
|
children?: Snippet;
|
||||||
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
type = undefined,
|
||||||
|
icon = "",
|
||||||
|
extraClasses = "",
|
||||||
|
primary = false,
|
||||||
|
secondary = false,
|
||||||
|
large = false,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
tooltip = undefined,
|
||||||
|
id = undefined,
|
||||||
|
children,
|
||||||
|
onclick = undefined,
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -21,7 +44,7 @@
|
|||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
class:tooltip={(tooltip?.length ?? 0) > 0}
|
class:tooltip={(tooltip?.length ?? 0) > 0}
|
||||||
data-title={tooltip}
|
data-title={tooltip}
|
||||||
on:click
|
{onclick}
|
||||||
{type}
|
{type}
|
||||||
{id}
|
{id}
|
||||||
>
|
>
|
||||||
@@ -29,7 +52,7 @@
|
|||||||
{#if icon}
|
{#if icon}
|
||||||
<i class="fa fa-{icon} mr-2"></i>
|
<i class="fa fa-{icon} mr-2"></i>
|
||||||
{/if}
|
{/if}
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="absolute aspect-square spinner"></div>
|
<div class="absolute aspect-square spinner"></div>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { SummitLog } from "$lib/models/summit_log";
|
import type { SummitLog } from "$lib/models/summit_log";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { isSameDay, isToday } from "../../util/date_util";
|
import { isSameDay, isToday } from "../../util/date_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let logs: SummitLog[] = [];
|
interface Props {
|
||||||
export let colorMap: Record<string, string> = {};
|
logs?: SummitLog[];
|
||||||
|
colorMap?: Record<string, string>;
|
||||||
|
onforward?: (data: { start: Date; end: Date }) => void;
|
||||||
|
onbackward?: (data: { start: Date; end: Date }) => void;
|
||||||
|
onclick?: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
let {
|
||||||
forward: {
|
logs = [],
|
||||||
start: Date;
|
colorMap = {},
|
||||||
end: Date;
|
onforward,
|
||||||
};
|
onbackward,
|
||||||
backward: {
|
onclick,
|
||||||
start: Date;
|
}: Props = $props();
|
||||||
end: Date;
|
|
||||||
};
|
|
||||||
click: Date;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||||
const months = [
|
const months = [
|
||||||
@@ -34,14 +34,13 @@
|
|||||||
"Dezember",
|
"Dezember",
|
||||||
];
|
];
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
let currentMonth = today.getMonth();
|
let currentMonth = $state(today.getMonth());
|
||||||
let currentYear = today.getFullYear();
|
let currentYear = $state(today.getFullYear());
|
||||||
let currentMonthArray: ({
|
let currentMonthArray: ({
|
||||||
date: Date | undefined;
|
date: Date | undefined;
|
||||||
today: boolean;
|
today: boolean;
|
||||||
log?: SummitLog;
|
log?: SummitLog;
|
||||||
} | null)[];
|
} | null)[] = $derived(generateMonthArray(currentYear, currentMonth, logs));
|
||||||
$: currentMonthArray = generateMonthArray(currentYear, currentMonth, logs);
|
|
||||||
|
|
||||||
function calculateFirstDayOfMonthDayOfWeek(year: number, month: number) {
|
function calculateFirstDayOfMonthDayOfWeek(year: number, month: number) {
|
||||||
const date = new Date(year, month, 1);
|
const date = new Date(year, month, 1);
|
||||||
@@ -94,7 +93,7 @@
|
|||||||
} else {
|
} else {
|
||||||
currentMonth++;
|
currentMonth++;
|
||||||
}
|
}
|
||||||
dispatch("forward", {
|
onforward?.({
|
||||||
start: new Date(currentYear, currentMonth, 1),
|
start: new Date(currentYear, currentMonth, 1),
|
||||||
end: new Date(currentYear, currentMonth + 1, 0),
|
end: new Date(currentYear, currentMonth + 1, 0),
|
||||||
});
|
});
|
||||||
@@ -107,7 +106,7 @@
|
|||||||
} else {
|
} else {
|
||||||
currentMonth--;
|
currentMonth--;
|
||||||
}
|
}
|
||||||
dispatch("backward", {
|
onbackward?.({
|
||||||
start: new Date(currentYear, currentMonth, 1),
|
start: new Date(currentYear, currentMonth, 1),
|
||||||
end: new Date(currentYear, currentMonth + 1, 0),
|
end: new Date(currentYear, currentMonth + 1, 0),
|
||||||
});
|
});
|
||||||
@@ -124,7 +123,7 @@
|
|||||||
if (!date) {
|
if (!date) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch("click", date);
|
onclick?.(date);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -133,10 +132,12 @@
|
|||||||
<span class="text-lg">{months[currentMonth]}</span>
|
<span class="text-lg">{months[currentMonth]}</span>
|
||||||
<span>{currentYear}</span>
|
<span>{currentYear}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon mr-2" on:click={monthMinus}
|
<button
|
||||||
><i class="fa fa-caret-left"></i></button
|
aria-label="Previous month"
|
||||||
|
class="btn-icon mr-2"
|
||||||
|
onclick={monthMinus}><i class="fa fa-caret-left"></i></button
|
||||||
>
|
>
|
||||||
<button class="btn-icon" on:click={monthPlus}
|
<button aria-label="Next month" class="btn-icon" onclick={monthPlus}
|
||||||
><i class="fa fa-caret-right"></i></button
|
><i class="fa fa-caret-right"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,7 +158,7 @@
|
|||||||
{#each { length: 42 } as _, i}
|
{#each { length: 42 } as _, i}
|
||||||
<button
|
<button
|
||||||
class="calendar-day flex items-center justify-center rounded-xl"
|
class="calendar-day flex items-center justify-center rounded-xl"
|
||||||
on:click={() => handleDateClick(currentMonthArray[i]?.date)}
|
onclick={() => handleDateClick(currentMonthArray[i]?.date)}
|
||||||
class:today={currentMonthArray[i]?.today}
|
class:today={currentMonthArray[i]?.today}
|
||||||
style="background-color: {colorMap[
|
style="background-color: {colorMap[
|
||||||
colorKey(currentMonthArray, i)
|
colorKey(currentMonthArray, i)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script context="module" lang="ts">
|
<script module lang="ts">
|
||||||
export type ComboboxItem = {
|
export type ComboboxItem = {
|
||||||
text: string;
|
text: string;
|
||||||
value: any;
|
value: any;
|
||||||
@@ -7,22 +7,41 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, tick } from "svelte";
|
import type { ChangeEventHandler } from "svelte/elements";
|
||||||
import TextField from "./text_field.svelte";
|
import TextField from "./text_field.svelte";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
|
||||||
export let name: string = "";
|
interface Props {
|
||||||
export let icon: string = "";
|
name?: string;
|
||||||
export let label: string = "";
|
icon?: string;
|
||||||
export let value: string = "";
|
label?: string;
|
||||||
export let items: ComboboxItem[] = [];
|
value?: string;
|
||||||
export let placeholder: string = "";
|
items?: ComboboxItem[];
|
||||||
export let extraClasses: string = "";
|
placeholder?: string;
|
||||||
|
extraClasses?: string;
|
||||||
|
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
onupdate?: (q: string) => void;
|
||||||
|
onclick?: (item: ComboboxItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let {
|
||||||
|
name = "",
|
||||||
|
icon = "",
|
||||||
|
label = "",
|
||||||
|
value = $bindable(""),
|
||||||
|
items = [],
|
||||||
|
placeholder = "",
|
||||||
|
extraClasses = "",
|
||||||
|
onchange,
|
||||||
|
onupdate,
|
||||||
|
onclick,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let searching: boolean = false;
|
let searching: boolean = $state(false);
|
||||||
|
|
||||||
$: dropDownOpen = value.length > 0 && items.length > 0 && searching;
|
let dropDownOpen = $derived(
|
||||||
|
value.length > 0 && items.length > 0 && searching,
|
||||||
|
);
|
||||||
|
|
||||||
async function onSearchType() {
|
async function onSearchType() {
|
||||||
await tick();
|
await tick();
|
||||||
@@ -45,12 +64,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function update(q: string) {
|
function update(q: string) {
|
||||||
dispatch("update", q);
|
onupdate?.(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleItemClick(item: ComboboxItem) {
|
function handleItemClick(e: Event, item: ComboboxItem) {
|
||||||
|
e.stopPropagation();
|
||||||
value = item.value;
|
value = item.value;
|
||||||
dispatch("click", item);
|
onclick?.(item);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,10 +83,10 @@
|
|||||||
{label}
|
{label}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
bind:value
|
bind:value
|
||||||
on:change
|
{onchange}
|
||||||
on:input={onSearchType}
|
oninput={onSearchType}
|
||||||
on:focusin={() => (searching = true)}
|
onfocusin={() => (searching = true)}
|
||||||
on:focusout={() => (searching = false)}
|
onfocusout={() => (searching = false)}
|
||||||
></TextField>
|
></TextField>
|
||||||
|
|
||||||
{#if dropDownOpen}
|
{#if dropDownOpen}
|
||||||
@@ -76,13 +96,13 @@
|
|||||||
style="z-index: 1001"
|
style="z-index: 1001"
|
||||||
>
|
>
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
<li
|
<li
|
||||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
onmousedown={(e) => handleItemClick(e, item)}
|
||||||
on:keydown|stopPropagation={() => handleItemClick(item)}
|
onkeydown={(e) => handleItemClick(e, item)}
|
||||||
>
|
>
|
||||||
<i class="fa fa-{item.icon} mr-6"></i>
|
<i class="fa fa-{item.icon} mr-6"></i>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
import type { ChangeEventHandler } from "svelte/elements";
|
||||||
|
|
||||||
export let name: string = "";
|
interface Props {
|
||||||
export let value: string | Date = "";
|
name?: string;
|
||||||
export let label: string = "";
|
value?: string | Date;
|
||||||
export let error: string | string[] | null = "";
|
label?: string;
|
||||||
|
error?: string | string[] | null;
|
||||||
|
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
name = "",
|
||||||
|
value = $bindable(),
|
||||||
|
label = "",
|
||||||
|
error = "",
|
||||||
|
onchange
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -21,7 +33,7 @@
|
|||||||
class:bg-input-background-error={(error?.length ?? 0) > 0}
|
class:bg-input-background-error={(error?.length ?? 0) > 0}
|
||||||
type="date"
|
type="date"
|
||||||
bind:value
|
bind:value
|
||||||
on:change
|
{onchange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as noUiSlider from "nouislider";
|
import * as noUiSlider from "nouislider";
|
||||||
import "nouislider/dist/nouislider.css";
|
import "nouislider/dist/nouislider.css";
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
export let minValue = 0;
|
interface Props {
|
||||||
export let maxValue = 100;
|
minValue?: number;
|
||||||
|
maxValue?: number;
|
||||||
|
currentMin?: any;
|
||||||
|
currentMax?: any;
|
||||||
|
onset?: (data: [number, number]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export let currentMin = minValue;
|
let {
|
||||||
export let currentMax = maxValue;
|
minValue = 0,
|
||||||
|
maxValue = 100,
|
||||||
|
currentMin = $bindable(minValue),
|
||||||
|
currentMax = $bindable(maxValue),
|
||||||
|
onset,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let sliderContainer: any;
|
let sliderContainer: any = $state();
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const updateValues = (values: string[]) => {
|
const updateValues = (values: string[]) => {
|
||||||
@@ -31,7 +39,7 @@
|
|||||||
sliderContainer.noUiSlider.on("update", updateValues);
|
sliderContainer.noUiSlider.on("update", updateValues);
|
||||||
|
|
||||||
sliderContainer.noUiSlider.on("set", () => {
|
sliderContainer.noUiSlider.on("set", () => {
|
||||||
dispatch("set", [currentMin, currentMax]);
|
onset?.([currentMin, currentMax]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { beforeNavigate } from "$app/navigation";
|
import { beforeNavigate } from "$app/navigation";
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import { backIn, backOut } from "svelte/easing";
|
import { backIn, backOut } from "svelte/easing";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
|
|
||||||
export let open: boolean = false;
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
$: if (open && browser) {
|
let { open = $bindable(false), children }: Props = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open && browser) {
|
||||||
document.body.style.position = "fixed";
|
document.body.style.position = "fixed";
|
||||||
document.body.style.top = `-${window.scrollY}px`;
|
document.body.style.top = `-${window.scrollY}px`;
|
||||||
} else if (browser) {
|
} else if (browser) {
|
||||||
@@ -15,6 +23,7 @@
|
|||||||
document.body.style.top = "";
|
document.body.style.top = "";
|
||||||
window.scrollTo(0, parseInt(scrollY || "0") * -1);
|
window.scrollTo(0, parseInt(scrollY || "0") * -1);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
beforeNavigate(() => {
|
beforeNavigate(() => {
|
||||||
open = false;
|
open = false;
|
||||||
@@ -24,8 +33,8 @@
|
|||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500/50 cursor-pointer"
|
class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500/50 cursor-pointer"
|
||||||
on:click={() => (open = false)}
|
onclick={() => (open = false)}
|
||||||
on:keydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key == "Escape") open = false;
|
if (e.key == "Escape") open = false;
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -36,6 +45,6 @@
|
|||||||
in:slide={{ axis: "x", easing: backOut }}
|
in:slide={{ axis: "x", easing: backOut }}
|
||||||
out:slide={{ axis: "x", easing: backIn }}
|
out:slide={{ axis: "x", easing: backIn }}
|
||||||
>
|
>
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script context="module" lang="ts">
|
<script module lang="ts">
|
||||||
export type DropdownItem = {
|
export type DropdownItem = {
|
||||||
text: string;
|
text: string;
|
||||||
value: any;
|
value: any;
|
||||||
@@ -7,15 +7,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
import { type Snippet } from "svelte";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
|
|
||||||
export let items: DropdownItem[] = [];
|
interface Props {
|
||||||
export let size: string = "regular";
|
items?: DropdownItem[];
|
||||||
|
size?: string;
|
||||||
|
children?: Snippet<[any]>;
|
||||||
|
onchange?: (item: DropdownItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { items = [], size = "regular", children, onchange }: Props = $props();
|
||||||
|
|
||||||
let isOpen = false;
|
let isOpen = $state(false);
|
||||||
|
|
||||||
export function toggleMenu(e: MouseEvent) {
|
export function toggleMenu(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -27,13 +31,18 @@
|
|||||||
isOpen = false;
|
isOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleItemClick(item: { text: string; value: any }) {
|
function handleItemClick(e: Event, item: { text: string; value: any }) {
|
||||||
dispatch("change", item);
|
e.stopPropagation();
|
||||||
|
onchange?.(item);
|
||||||
closeMenu();
|
closeMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWindowClick(e: MouseEvent) {
|
function handleWindowClick(e: MouseEvent) {
|
||||||
if ((e.target as HTMLElement).parentElement?.classList.contains("dropdown-toggle")) {
|
if (
|
||||||
|
(e.target as HTMLElement).parentElement?.classList.contains(
|
||||||
|
"dropdown-toggle",
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,19 +50,20 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:mouseup={handleWindowClick} />
|
<svelte:window onmouseup={handleWindowClick} />
|
||||||
|
|
||||||
<div class="dropdown relative">
|
<div class="dropdown relative">
|
||||||
<div class="dropdown-toggle">
|
<div class="dropdown-toggle">
|
||||||
<slot {toggleMenu}>
|
{#if children}{@render children({ toggleMenu })}{:else}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle menu"
|
||||||
class="btn-icon flex items-center justify-center"
|
class="btn-icon flex items-center justify-center"
|
||||||
on:click={toggleMenu}
|
onclick={toggleMenu}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<i class="fa fa-ellipsis-vertical text-{size}"></i>
|
<i class="fa fa-ellipsis-vertical text-{size}"></i>
|
||||||
</button>
|
</button>
|
||||||
</slot>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
@@ -68,7 +78,7 @@
|
|||||||
<li
|
<li
|
||||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
onmousedown={(e) => handleItemClick(e, item)}
|
||||||
>
|
>
|
||||||
{#if item.icon}
|
{#if item.icon}
|
||||||
<i class="fa fa-{item.icon} mr-3"></i>
|
<i class="fa fa-{item.icon} mr-3"></i>
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let label: string = "";
|
import { createBubbler } from 'svelte/legacy';
|
||||||
export let name: string = "";
|
|
||||||
export let accept: string = "*";
|
|
||||||
|
|
||||||
export let files: FileList | null = null;
|
const bubble = createBubbler();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
name?: string;
|
||||||
|
accept?: string;
|
||||||
|
files?: FileList | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label = "",
|
||||||
|
name = "",
|
||||||
|
accept = "*",
|
||||||
|
files = $bindable(null)
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -18,6 +30,6 @@
|
|||||||
bind:files
|
bind:files
|
||||||
class="cursor-pointer bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full file:rounded-lg file:px-4 file:py-2 file:border file:border-input-border-focus file:font-medium file:mr-4"
|
class="cursor-pointer bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full file:rounded-lg file:px-4 file:py-2 file:border file:border-input-border-focus file:font-medium file:mr-4"
|
||||||
type="file"
|
type="file"
|
||||||
on:change
|
onchange={bubble('change')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let id: string;
|
import type { Snippet } from "svelte";
|
||||||
export let title: string;
|
|
||||||
export let size: string = "max-w-2xl";
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
size?: string;
|
||||||
|
children?: Snippet<[any]>;
|
||||||
|
content?: Snippet;
|
||||||
|
footer?: Snippet<[any]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
size = "max-w-2xl",
|
||||||
|
children,
|
||||||
|
content,
|
||||||
|
footer,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
export function openModal() {
|
export function openModal() {
|
||||||
document.body.style.top = `-${window.scrollY}px`;
|
document.body.style.top = `-${window.scrollY}px`;
|
||||||
@@ -21,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<slot {openModal} />
|
{@render children?.({ openModal })}
|
||||||
<dialog
|
<dialog
|
||||||
{id}
|
{id}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -40,7 +56,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full btn-icon"
|
class="rounded-full btn-icon"
|
||||||
on:click={closeModal}
|
onclick={closeModal}
|
||||||
>
|
>
|
||||||
<i class="fa fa-close"></i>
|
<i class="fa fa-close"></i>
|
||||||
<span class="sr-only">Close modal</span>
|
<span class="sr-only">Close modal</span>
|
||||||
@@ -48,11 +64,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Modal body -->
|
<!-- Modal body -->
|
||||||
<div class="p-4 md:p-5 space-y-4">
|
<div class="p-4 md:p-5 space-y-4">
|
||||||
<slot name="content" />
|
{@render content?.()}
|
||||||
</div>
|
</div>
|
||||||
<!-- Modal footer -->
|
<!-- Modal footer -->
|
||||||
<div class="p-4 md:p-5 border-t border-separator rounded-b">
|
<div class="p-4 md:p-5 border-t border-separator rounded-b">
|
||||||
<slot name="footer" {closeModal} />
|
{@render footer?.({ closeModal })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -1,45 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import type { SelectItem } from "./select.svelte";
|
import type { SelectItem } from "./select.svelte";
|
||||||
|
|
||||||
export let items: SelectItem[] = [];
|
interface Props {
|
||||||
export let value: SelectItem[] = [];
|
items?: SelectItem[];
|
||||||
export let label: string = "";
|
value?: SelectItem[];
|
||||||
export let name: string = "";
|
label?: string;
|
||||||
export let placeholder: string = "";
|
name?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onchange?: (value: SelectItem[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
let showDropdown = false;
|
let {
|
||||||
let dropdownRef;
|
items = [],
|
||||||
|
value = $bindable([]),
|
||||||
|
label = "",
|
||||||
|
name = "",
|
||||||
|
placeholder = "",
|
||||||
|
onchange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let showDropdown = $state(false);
|
||||||
|
let dropdownRef = $state();
|
||||||
|
|
||||||
function toggleItem(item: SelectItem) {
|
function toggleItem(item: SelectItem) {
|
||||||
if (value.includes(item)) {
|
const itemIndex = value.findIndex((i) => i.value == item.value);
|
||||||
value = value.filter((i) => i !== item);
|
|
||||||
|
if (itemIndex >= 0) {
|
||||||
|
value.splice(itemIndex, 1);
|
||||||
} else {
|
} else {
|
||||||
value = [...value, item];
|
value.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch("change", value);
|
onchange?.(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(item: SelectItem) {
|
function removeItem(e: Event, item: SelectItem) {
|
||||||
|
e.stopPropagation();
|
||||||
value = value.filter((i) => i !== item);
|
value = value.filter((i) => i !== item);
|
||||||
dispatch("change", value);
|
onchange?.(value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="relative max-w-full">
|
<div class="relative max-w-full">
|
||||||
{#if label.length}
|
{#if label.length}
|
||||||
<label for={name} class="text-sm font-medium pb-1 block">
|
<label for={name} class="text-sm font-medium pb-1 block">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<div
|
||||||
|
role="presentation"
|
||||||
class="min-w-44 w-full flex flex-wrap items-center gap-2 border border-input-border bg-input-background min-h-[50px] p-3 rounded-md transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
|
class="min-w-44 w-full flex flex-wrap items-center gap-2 border border-input-border bg-input-background min-h-[50px] p-3 rounded-md transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
|
||||||
on:click={() => (showDropdown = !showDropdown)}
|
onclick={() => (showDropdown = !showDropdown)}
|
||||||
>
|
>
|
||||||
{#if value.length === 0}
|
{#if value.length === 0}
|
||||||
<span class="text-gray-400">{placeholder}</span>
|
<span class="text-gray-400">{placeholder}</span>
|
||||||
@@ -50,14 +63,15 @@
|
|||||||
>
|
>
|
||||||
<span class="text-sm">{$_(item.text)}</span>
|
<span class="text-sm">{$_(item.text)}</span>
|
||||||
<button
|
<button
|
||||||
on:click|stopPropagation={() => removeItem(item)}
|
aria-label="Close"
|
||||||
|
onclick={(e) => removeItem(e, item)}
|
||||||
class="text-white hover:bg-primary-hover rounded-full w-4 h-4 flex items-center justify-center"
|
class="text-white hover:bg-primary-hover rounded-full w-4 h-4 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<i class="fa fa-close"></i>
|
<i class="fa fa-close"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<!-- Dropdown menu -->
|
<!-- Dropdown menu -->
|
||||||
{#if showDropdown}
|
{#if showDropdown}
|
||||||
@@ -67,11 +81,11 @@
|
|||||||
>
|
>
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
<button
|
<button
|
||||||
on:click={() => toggleItem(item)}
|
onclick={() => toggleItem(item)}
|
||||||
class="px-3 py-2 hover:bg-menu-item-background-hover cursor-pointer flex justify-between items-center w-full"
|
class="px-3 py-2 hover:bg-menu-item-background-hover cursor-pointer flex justify-between items-center w-full"
|
||||||
>
|
>
|
||||||
<span>{$_(item.text)}</span>
|
<span>{$_(item.text)}</span>
|
||||||
{#if value.includes(item)}
|
{#if value.findIndex((i) => i.value == item.value) >= 0}
|
||||||
<div class="ml-auto">
|
<div class="ml-auto">
|
||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { range } from "$lib/util/array_util";
|
import { range } from "$lib/util/array_util";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
|
|
||||||
export let page: number;
|
interface Props {
|
||||||
export let totalPages: number;
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onpagination?: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { page, totalPages, onpagination }: Props = $props();
|
||||||
|
|
||||||
const maxPagesToRender = 6;
|
const maxPagesToRender = 6;
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
function update(clickedPage: number) {
|
function update(clickedPage: number) {
|
||||||
if (page !== clickedPage) {
|
if (page !== clickedPage) {
|
||||||
dispatch("pagination", clickedPage);
|
onpagination?.(clickedPage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: numberGenerator = () => {
|
let numberGenerator = $derived(() => {
|
||||||
if (totalPages < 4) {
|
if (totalPages < 4) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -31,21 +34,22 @@
|
|||||||
return range(page + Math.max(1, maxPagesToRender - 5), page - 2);
|
return range(page + Math.max(1, maxPagesToRender - 5), page - 2);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex gap-x-4 justify-center">
|
<div class="flex gap-x-4 justify-center">
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Next page"
|
||||||
class:text-gray-500={page == 1}
|
class:text-gray-500={page == 1}
|
||||||
disabled={page == 1}
|
disabled={page == 1}
|
||||||
on:click={() => update(page - 1)}
|
onclick={() => update(page - 1)}
|
||||||
><i class="fa fa-caret-left"></i></button
|
><i class="fa fa-caret-left"></i></button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||||
class:border={page == 1}
|
class:border={page == 1}
|
||||||
on:click={() => update(1)}>1</button
|
onclick={() => update(1)}>1</button
|
||||||
>
|
>
|
||||||
{#if totalPages > maxPagesToRender && page >= maxPagesToRender - 2 && page > 2}
|
{#if totalPages > maxPagesToRender && page >= maxPagesToRender - 2 && page > 2}
|
||||||
<span>...</span>
|
<span>...</span>
|
||||||
@@ -53,14 +57,14 @@
|
|||||||
<button
|
<button
|
||||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||||
class:border={page == 2}
|
class:border={page == 2}
|
||||||
on:click={() => update(2)}>2</button
|
onclick={() => update(2)}>2</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#each numberGenerator() as i}
|
{#each numberGenerator() as i}
|
||||||
<button
|
<button
|
||||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||||
class:border={page == i + 1}
|
class:border={page == i + 1}
|
||||||
on:click={() => update(i + 1)}>{i + 1}</button
|
onclick={() => update(i + 1)}>{i + 1}</button
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
{#if totalPages > maxPagesToRender && page < totalPages - 3}
|
{#if totalPages > maxPagesToRender && page < totalPages - 3}
|
||||||
@@ -69,18 +73,19 @@
|
|||||||
<button
|
<button
|
||||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||||
class:border={page == totalPages - 1}
|
class:border={page == totalPages - 1}
|
||||||
on:click={() => update(totalPages - 1)}>{totalPages - 1}</button
|
onclick={() => update(totalPages - 1)}>{totalPages - 1}</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||||
class:border={page == totalPages}
|
class:border={page == totalPages}
|
||||||
on:click={() => update(totalPages)}>{totalPages}</button
|
onclick={() => update(totalPages)}>{totalPages}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Previous page"
|
||||||
class:text-gray-500={page == totalPages}
|
class:text-gray-500={page == totalPages}
|
||||||
disabled={page == totalPages}
|
disabled={page == totalPages}
|
||||||
on:click={() => update(page + 1)}
|
onclick={() => update(page + 1)}
|
||||||
><i class="fa fa-caret-right"></i></button
|
><i class="fa fa-caret-right"></i></button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script context="module" lang="ts">
|
<script module lang="ts">
|
||||||
export type RadioItem = {
|
export type RadioItem = {
|
||||||
text: string;
|
text: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -8,17 +8,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
interface Props {
|
||||||
|
items: RadioItem[];
|
||||||
|
name: string;
|
||||||
|
selected?: number;
|
||||||
|
onchange?: (item: RadioItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
export let items: RadioItem[];
|
let { items, name, selected = $bindable(0), onchange }: Props = $props();
|
||||||
export let name: string;
|
|
||||||
export let selected: number = 0;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
function handleRadioChange(radioIndex: number) {
|
function handleRadioChange(radioIndex: number) {
|
||||||
selected = radioIndex;
|
selected = radioIndex;
|
||||||
dispatch("change", items[selected]);
|
onchange?.(items[selected]);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@
|
|||||||
checked={i == selected}
|
checked={i == selected}
|
||||||
value={item.value}
|
value={item.value}
|
||||||
class="w-4 h-4 accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
class="w-4 h-4 accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||||
on:change={() => handleRadioChange(i)}
|
onchange={() => handleRadioChange(i)}
|
||||||
/>
|
/>
|
||||||
<div class="ms-2 text-sm">
|
<div class="ms-2 text-sm">
|
||||||
<label for="{name}-radio-{i}"
|
<label for="{name}-radio-{i}"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script context="module" lang="ts">
|
<!-- @migration-task Error while migrating Svelte code: This migration would change the name of a slot making the component unusable -->
|
||||||
|
<script module lang="ts">
|
||||||
export type SearchItem = {
|
export type SearchItem = {
|
||||||
text: string;
|
text: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -8,26 +9,43 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import TextField from "./text_field.svelte";
|
import TextField from "./text_field.svelte";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
export let maxSearchLength: number = 5;
|
interface Props {
|
||||||
export let value: string = "";
|
maxSearchLength?: number;
|
||||||
export let items: SearchItem[] = [];
|
value?: string;
|
||||||
export let placeholder: string = "Search...";
|
items?: SearchItem[];
|
||||||
export let large: boolean = false;
|
placeholder?: string;
|
||||||
export let extraClasses: string = "";
|
large?: boolean;
|
||||||
export let label: string = "";
|
extraClasses?: string;
|
||||||
export let clearAfterSelect: boolean = true;
|
label?: string;
|
||||||
|
clearAfterSelect?: boolean;
|
||||||
|
prepend?: Snippet<[any]>;
|
||||||
|
onupdate?: (q: string) => void;
|
||||||
|
onclick?: (item: SearchItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let {
|
||||||
|
maxSearchLength = 5,
|
||||||
|
value = $bindable(""),
|
||||||
|
items = $bindable([]),
|
||||||
|
placeholder = "Search...",
|
||||||
|
large = false,
|
||||||
|
extraClasses = "",
|
||||||
|
label = "",
|
||||||
|
clearAfterSelect = true,
|
||||||
|
prepend,
|
||||||
|
onupdate,
|
||||||
|
onclick
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let lastSearch: string = "";
|
let lastSearch: string = "";
|
||||||
let searching: boolean = false;
|
let searching: boolean = $state(false);
|
||||||
let typingTimer!: any;
|
let typingTimer!: any;
|
||||||
|
|
||||||
$: dropDownOpen = value.length > 0 && items.length > 0 && searching;
|
let dropDownOpen = $derived(value.length > 0 && items.length > 0 && searching);
|
||||||
|
|
||||||
function onSearchType() {
|
function onSearchType() {
|
||||||
clearTimeout(typingTimer);
|
clearTimeout(typingTimer);
|
||||||
@@ -42,12 +60,13 @@
|
|||||||
|
|
||||||
function update(q: string) {
|
function update(q: string) {
|
||||||
lastSearch = q;
|
lastSearch = q;
|
||||||
dispatch("update", q);
|
onupdate?.(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleItemClick(item: SearchItem) {
|
function handleItemClick(e: Event, item: SearchItem) {
|
||||||
|
e.stopPropagation();
|
||||||
searching = false;
|
searching = false;
|
||||||
dispatch("click", item);
|
onclick?.(item);
|
||||||
if (clearAfterSelect) {
|
if (clearAfterSelect) {
|
||||||
clear();
|
clear();
|
||||||
}
|
}
|
||||||
@@ -70,11 +89,12 @@
|
|||||||
</span>
|
</span>
|
||||||
{#if value.length > 0}
|
{#if value.length > 0}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Clear"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-icon absolute {large
|
class="btn-icon absolute {large
|
||||||
? 'bottom-[31px]'
|
? 'bottom-[31px]'
|
||||||
: 'bottom-[25px]'} translate-y-1/2 right-0 mr-2"
|
: 'bottom-[25px]'} translate-y-1/2 right-0 mr-2"
|
||||||
on:click={clear}
|
onclick={clear}
|
||||||
in:fade={{ duration: 150 }}
|
in:fade={{ duration: 150 }}
|
||||||
out:fade={{ duration: 150 }}
|
out:fade={{ duration: 150 }}
|
||||||
>
|
>
|
||||||
@@ -91,9 +111,9 @@
|
|||||||
: "px-10"}
|
: "px-10"}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
bind:value
|
bind:value
|
||||||
on:input={onSearchType}
|
oninput={onSearchType}
|
||||||
on:focusin={() => (searching = true)}
|
onfocusin={() => (searching = true)}
|
||||||
on:focusout={() => (searching = false)}
|
onfocusout={() => (searching = false)}
|
||||||
></TextField>
|
></TextField>
|
||||||
|
|
||||||
{#if dropDownOpen}
|
{#if dropDownOpen}
|
||||||
@@ -103,17 +123,17 @@
|
|||||||
style="z-index: 1001"
|
style="z-index: 1001"
|
||||||
>
|
>
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
<li
|
<li
|
||||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
onmousedown={(e) => handleItemClick(e, item)}
|
||||||
on:keydown|stopPropagation={() => handleItemClick(item)}
|
onkeydown={(e) => handleItemClick(e, item)}
|
||||||
>
|
>
|
||||||
<slot name="item-header" {item}>
|
{#if prepend}{@render prepend({ item, })}{:else}
|
||||||
<i class="fa fa-{item.icon} mr-6"></i>
|
<i class="fa fa-{item.icon} mr-6"></i>
|
||||||
</slot>
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>{item.text}</p>
|
<p>{item.text}</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script context="module" lang="ts">
|
<script module lang="ts">
|
||||||
export type SelectItem = {
|
export type SelectItem = {
|
||||||
text: string;
|
text: string;
|
||||||
value: any;
|
value: any;
|
||||||
@@ -6,18 +6,26 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
interface Props {
|
||||||
|
name?: string;
|
||||||
|
items?: SelectItem[];
|
||||||
|
value?: any;
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onchange?: (value: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
export let name: string = "";
|
let {
|
||||||
export let items: SelectItem[] = [];
|
name = "",
|
||||||
export let value: any = items.at(0)?.value ?? "";
|
items = [],
|
||||||
export let label: string = "";
|
value = $bindable(items.at(0)?.value ?? ""),
|
||||||
export let disabled: boolean = false;
|
label = "",
|
||||||
|
disabled = false,
|
||||||
const dispatch = createEventDispatcher();
|
onchange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
function onChange(target: any) {
|
function onChange(target: any) {
|
||||||
dispatch("change", target?.value);
|
onchange?.(target?.value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,7 +41,7 @@
|
|||||||
class:text-gray-500={disabled}
|
class:text-gray-500={disabled}
|
||||||
{disabled}
|
{disabled}
|
||||||
bind:value
|
bind:value
|
||||||
on:change={(e) => onChange(e.target)}
|
onchange={(e) => onChange(e.target)}
|
||||||
>
|
>
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
<option value={item.value}>{item.text}</option>
|
<option value={item.value}>{item.text}</option>
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import noUiSlider from "nouislider";
|
import noUiSlider from "nouislider";
|
||||||
import "nouislider/dist/nouislider.css";
|
import "nouislider/dist/nouislider.css";
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
export let minValue = 0;
|
interface Props {
|
||||||
export let maxValue = 100;
|
minValue?: number;
|
||||||
|
maxValue?: number;
|
||||||
|
currentValue?: any;
|
||||||
|
onset?: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export let currentValue = maxValue / 2;
|
let {
|
||||||
|
minValue = 0,
|
||||||
|
maxValue = 100,
|
||||||
|
currentValue = $bindable(maxValue / 2),
|
||||||
|
onset,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let sliderContainer: any;
|
let sliderContainer: any = $state();
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const updateValues = (values: string[]) => {
|
const updateValues = (values: string[]) => {
|
||||||
@@ -29,7 +36,7 @@
|
|||||||
sliderContainer.noUiSlider.on("update", updateValues);
|
sliderContainer.noUiSlider.on("update", updateValues);
|
||||||
|
|
||||||
sliderContainer.noUiSlider.on("set", () => {
|
sliderContainer.noUiSlider.on("set", () => {
|
||||||
dispatch("set", currentValue);
|
onset?.(currentValue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { cubicOut } from "svelte/easing";
|
import { cubicOut } from "svelte/easing";
|
||||||
import { tweened } from "svelte/motion";
|
import { Tween } from "svelte/motion";
|
||||||
|
|
||||||
export let tabs: string[];
|
interface Props {
|
||||||
export let activeTab: number;
|
tabs: string[];
|
||||||
export let extraClasses: string = "";
|
activeTab: number;
|
||||||
|
extraClasses?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const indicatorPosition = tweened(0, {
|
let { tabs, activeTab = $bindable(), extraClasses = "" }: Props = $props();
|
||||||
|
|
||||||
|
const indicatorPosition = new Tween(0, {
|
||||||
duration: 300,
|
duration: 300,
|
||||||
easing: cubicOut,
|
easing: cubicOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
const indicatorWidth = tweened(0, {
|
const indicatorWidth = new Tween(0, {
|
||||||
duration: 300,
|
duration: 300,
|
||||||
easing: cubicOut,
|
easing: cubicOut,
|
||||||
});
|
});
|
||||||
@@ -37,13 +41,13 @@
|
|||||||
<div id="tabs" class="flex gap-2 overflow-x-auto relative {extraClasses}">
|
<div id="tabs" class="flex gap-2 overflow-x-auto relative {extraClasses}">
|
||||||
<div
|
<div
|
||||||
class="absolute h-full bg-menu-item-background-hover rounded-t-lg top-0 z-0"
|
class="absolute h-full bg-menu-item-background-hover rounded-t-lg top-0 z-0"
|
||||||
style="width: {$indicatorWidth}px; left: {$indicatorPosition}px;"
|
style="width: {indicatorWidth.current}px; left: {indicatorPosition.current}px;"
|
||||||
></div>
|
></div>
|
||||||
{#each tabs as tab, i}
|
{#each tabs as tab, i}
|
||||||
<button
|
<button
|
||||||
class="tab z-10"
|
class="tab z-10"
|
||||||
class:tab-active={activeTab == i}
|
class:tab-active={activeTab == i}
|
||||||
on:click={() => switchTabs(i)}>{tab}</button
|
onclick={() => switchTabs(i)}>{tab}</button
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
import type { ChangeEventHandler, FocusEventHandler, FormEventHandler } from "svelte/elements";
|
||||||
|
|
||||||
export let name: string = "";
|
interface Props {
|
||||||
export let value: string | number = "";
|
name?: string;
|
||||||
export let placeholder: string = "";
|
value?: string | number;
|
||||||
export let disabled: boolean = false;
|
placeholder?: string;
|
||||||
export let label: string = "";
|
disabled?: boolean;
|
||||||
export let error: string | string[] | null = "";
|
label?: string;
|
||||||
export let icon: string = "";
|
error?: string | string[] | null;
|
||||||
export let extraClasses: string = "";
|
icon?: string;
|
||||||
export let type: "text" | "password" | "search" = "text";
|
extraClasses?: string;
|
||||||
export let autocomplete: "on" | "off" = "on";
|
type?: "text" | "password" | "search";
|
||||||
|
autocomplete?: "on" | "off";
|
||||||
|
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
oninput?: FormEventHandler<HTMLInputElement>;
|
||||||
|
onfocusin?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
onfocusout?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
name = "",
|
||||||
|
value = $bindable(""),
|
||||||
|
placeholder = "",
|
||||||
|
disabled = false,
|
||||||
|
label = "",
|
||||||
|
error = "",
|
||||||
|
icon = "",
|
||||||
|
extraClasses = "",
|
||||||
|
type = "text",
|
||||||
|
autocomplete = "on",
|
||||||
|
onchange,
|
||||||
|
oninput,
|
||||||
|
onfocusin,
|
||||||
|
onfocusout
|
||||||
|
}: Props = $props();
|
||||||
function typeAction(node: HTMLInputElement) {
|
function typeAction(node: HTMLInputElement) {
|
||||||
node.type = type;
|
node.type = type;
|
||||||
}
|
}
|
||||||
@@ -38,10 +62,10 @@
|
|||||||
{autocomplete}
|
{autocomplete}
|
||||||
use:typeAction
|
use:typeAction
|
||||||
bind:value
|
bind:value
|
||||||
on:change
|
{onchange}
|
||||||
on:input
|
{oninput}
|
||||||
on:focusin
|
{onfocusin}
|
||||||
on:focusout
|
{onfocusout}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createBubbler } from 'svelte/legacy';
|
||||||
|
|
||||||
|
const bubble = createBubbler();
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let name: string = "";
|
interface Props {
|
||||||
export let value: string | number = "";
|
name?: string;
|
||||||
export let placeholder: string = "";
|
value?: string | number;
|
||||||
export let rows: number = 3;
|
placeholder?: string;
|
||||||
export let label: string = "";
|
rows?: number;
|
||||||
export let error: string | string[] | null = "";
|
label?: string;
|
||||||
export let extraClasses: string = "";
|
error?: string | string[] | null;
|
||||||
|
extraClasses?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
name = "",
|
||||||
|
value = $bindable(""),
|
||||||
|
placeholder = "",
|
||||||
|
rows = 3,
|
||||||
|
label = "",
|
||||||
|
error = "",
|
||||||
|
extraClasses = ""
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -24,8 +39,8 @@
|
|||||||
class:border-red-400={(error?.length ?? 0) > 0}
|
class:border-red-400={(error?.length ?? 0) > 0}
|
||||||
class:bg-input-background-error={(error?.length ?? 0) > 0}
|
class:bg-input-background-error={(error?.length ?? 0) > 0}
|
||||||
bind:value
|
bind:value
|
||||||
on:change
|
onchange={bubble('change')}
|
||||||
/>
|
></textarea>
|
||||||
{#if error}
|
{#if error}
|
||||||
<span class="textfield-error text-xs text-red-400">
|
<span class="textfield-error text-xs text-red-400">
|
||||||
{error instanceof Array ? $_(error[0]) : error}
|
{error instanceof Array ? $_(error[0]) : error}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white ml-6"
|
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white ml-6"
|
||||||
on:click={closeToast}
|
onclick={closeToast}
|
||||||
>
|
>
|
||||||
<i class="fa fa-close"></i>
|
<i class="fa fa-close"></i>
|
||||||
<span class="sr-only">Close modal</span>
|
<span class="sr-only">Close modal</span>
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
|
|
||||||
export let name: string = "";
|
interface Props {
|
||||||
export let value: boolean = false;
|
name?: string;
|
||||||
export let label: string = "";
|
value?: boolean;
|
||||||
export let error: string = "";
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
onchange?: (value: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
name = "",
|
||||||
|
value = $bindable(false),
|
||||||
|
label = "",
|
||||||
|
error = "",
|
||||||
|
onchange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
function handleToggleChange() {
|
function handleToggleChange() {
|
||||||
dispatch("change", value);
|
onchange?.(value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -20,7 +29,7 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="sr-only peer"
|
class="sr-only peer"
|
||||||
value="1"
|
value="1"
|
||||||
on:change={handleToggleChange}
|
onchange={handleToggleChange}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"
|
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import type { Category } from "$lib/models/category";
|
import type { Category } from "$lib/models/category";
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let category: Category;
|
interface Props {
|
||||||
|
category: Category;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { category }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,34 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Comment } from "$lib/models/comment";
|
import type { Comment } from "$lib/models/comment";
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import TextField from "../base/text_field.svelte";
|
import TextField from "../base/text_field.svelte";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { formatTimeSince } from "$lib/util/format_util";
|
import { formatTimeSince } from "$lib/util/format_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let comment: Comment;
|
interface Props {
|
||||||
export let mode: "show" | "edit" = "show";
|
comment: Comment;
|
||||||
|
mode?: "show" | "edit";
|
||||||
|
ondelete?: (comment: Comment) => void
|
||||||
|
onedit?: (data: {comment: Comment, text: string}) => void
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { comment, mode = "show", ondelete, onedit }: Props = $props();
|
||||||
|
|
||||||
let editing: boolean = false;
|
let editing: boolean = $state(false);
|
||||||
|
|
||||||
let editedComment = comment.text;
|
let editedComment = $state(comment.text);
|
||||||
|
|
||||||
$: avatarSrc = comment.expand?.author.avatar
|
$effect(() => {
|
||||||
|
editedComment = comment.text;
|
||||||
|
});
|
||||||
|
|
||||||
|
let avatarSrc = $derived(
|
||||||
|
comment.expand?.author.avatar
|
||||||
? getFileURL(comment.expand.author, comment.expand.author.avatar)
|
? getFileURL(comment.expand.author, comment.expand.author.avatar)
|
||||||
: `https://api.dicebear.com/7.x/initials/svg?seed=${comment.expand?.author.username ?? ""}&backgroundType=gradientLinear`;
|
: `https://api.dicebear.com/7.x/initials/svg?seed=${comment.expand?.author.username ?? ""}&backgroundType=gradientLinear`,
|
||||||
|
);
|
||||||
|
|
||||||
$: timeSince = formatTimeSince(new Date(comment.created ?? ""));
|
let timeSince = $derived(formatTimeSince(new Date(comment.created ?? "")));
|
||||||
|
|
||||||
function deleteComment() {
|
function deleteComment() {
|
||||||
dispatch("delete", comment);
|
ondelete?.(comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEdit() {
|
function toggleEdit() {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
dispatch("edit", { comment: comment, text: editedComment });
|
onedit?.({ comment: comment, text: editedComment });
|
||||||
}
|
}
|
||||||
editing = !editing;
|
editing = !editing;
|
||||||
}
|
}
|
||||||
@@ -74,22 +83,27 @@
|
|||||||
<span class="text-xs text-gray-500 ml-2"
|
<span class="text-xs text-gray-500 ml-2"
|
||||||
>{$_(`n-${timeSince.unit}-ago`, {
|
>{$_(`n-${timeSince.unit}-ago`, {
|
||||||
values: { n: timeSince.value },
|
values: { n: timeSince.value },
|
||||||
})} {comment.updated != comment.created ? `(${$_("edited")})` : ""}</span
|
})}
|
||||||
|
{comment.updated != comment.created
|
||||||
|
? `(${$_("edited")})`
|
||||||
|
: ""}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
{#if mode == "edit"}
|
{#if mode == "edit"}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Edit comment"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-icon ml-2"
|
class="btn-icon ml-2"
|
||||||
style="font-size: 0.75rem;"
|
style="font-size: 0.75rem;"
|
||||||
on:click={toggleEdit}
|
onclick={toggleEdit}
|
||||||
><i class="fa fa-{editing ? 'check' : 'pen'}"></i></button
|
><i class="fa fa-{editing ? 'check' : 'pen'}"></i></button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Delete comment"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-icon text-xs"
|
class="btn-icon text-xs"
|
||||||
style="font-size: 0.75rem;"
|
style="font-size: 0.75rem;"
|
||||||
on:click={deleteComment}><i class="fa fa-trash"></i></button
|
onclick={deleteComment}><i class="fa fa-trash"></i></button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from "$lib/components/base/modal.svelte";
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let openModal: (() => void) | undefined = undefined;
|
interface Props {
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
title?: string;
|
||||||
|
text: string;
|
||||||
|
action?: string;
|
||||||
|
id?: string;
|
||||||
|
onconfirm?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export let title: string = $_('confirm-deletion');
|
let {
|
||||||
export let text: string;
|
title = $_("confirm-deletion"),
|
||||||
export let action: string = "delete";
|
text,
|
||||||
export let id: string = "confirm-modal";
|
action = "delete",
|
||||||
|
id = "confirm-modal",
|
||||||
|
onconfirm
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let modal: Modal;
|
||||||
|
|
||||||
|
export function openModal() {
|
||||||
|
modal.openModal();
|
||||||
|
}
|
||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
dispatch("confirm");
|
onconfirm?.()
|
||||||
closeModal!();
|
modal.closeModal!();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal {id} {title} bind:openModal bind:closeModal>
|
<Modal {id} {title} bind:this={modal}>
|
||||||
<p slot="content">{text}</p>
|
{#snippet content()}
|
||||||
<div slot="footer" class="flex items-center gap-4">
|
<p>{text}</p>
|
||||||
<button class="btn-secondary" on:click={closeModal}
|
{/snippet}
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||||
>{$_("cancel")}</button
|
>{$_("cancel")}</button
|
||||||
>
|
>
|
||||||
<button id="confirm" class={action === "delete" ? "btn-danger" : "btn-primary"} type="button" on:click={confirm} name="delete"
|
<button
|
||||||
>{$_(action)}</button
|
id="confirm"
|
||||||
|
class={action === "delete" ? "btn-danger" : "btn-primary"}
|
||||||
|
type="button"
|
||||||
|
onclick={confirm}
|
||||||
|
name="delete">{$_(action)}</button
|
||||||
>
|
>
|
||||||
</div></Modal
|
</div>
|
||||||
|
{/snippet}</Modal
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let width: number = 192;
|
interface Props {
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { width = 192 }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let width: number = 192;
|
interface Props {
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { width = 192 }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let width: number = 192;
|
interface Props {
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { width = 192 }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let width: number = 256;
|
interface Props {
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { width = 256 }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let width: number = 192;
|
interface Props {
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { width = 192 }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||||
|
|||||||
@@ -12,30 +12,34 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import ShareInfo from "../share_info.svelte";
|
import ShareInfo from "../share_info.svelte";
|
||||||
|
|
||||||
export let list: List;
|
interface Props {
|
||||||
export let active: boolean = false;
|
list: List;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
$: cumulativeDistance = list.expand?.trails?.reduce(
|
let { list, active = false }: Props = $props();
|
||||||
|
|
||||||
|
let cumulativeDistance = $derived(list.expand?.trails?.reduce(
|
||||||
(s, b) => s + b.distance!,
|
(s, b) => s + b.distance!,
|
||||||
0,
|
0,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: cumulativeElevationGain = list.expand?.trails?.reduce(
|
let cumulativeElevationGain = $derived(list.expand?.trails?.reduce(
|
||||||
(s, b) => s + b.elevation_gain!,
|
(s, b) => s + b.elevation_gain!,
|
||||||
0,
|
0,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: cumulativeElevationLoss = list.expand?.trails?.reduce(
|
let cumulativeElevationLoss = $derived(list.expand?.trails?.reduce(
|
||||||
(s, b) => s + b.elevation_loss!,
|
(s, b) => s + b.elevation_loss!,
|
||||||
0,
|
0,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: cumulativeDuration = list.expand?.trails?.reduce(
|
let cumulativeDuration = $derived(list.expand?.trails?.reduce(
|
||||||
(s, b) => s + b.duration!,
|
(s, b) => s + b.duration!,
|
||||||
0,
|
0,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: listIsShared = (list.expand?.list_share_via_list?.length ?? 0) > 0;
|
let listIsShared = $derived((list.expand?.list_share_via_list?.length ?? 0) > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -11,44 +11,50 @@
|
|||||||
formatElevation,
|
formatElevation,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import Dropdown from "../base/dropdown.svelte";
|
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||||
import ShareInfo from "../share_info.svelte";
|
import ShareInfo from "../share_info.svelte";
|
||||||
import TrailListItem from "../trail/trail_list_item.svelte";
|
import TrailListItem from "../trail/trail_list_item.svelte";
|
||||||
export let list: List;
|
interface Props {
|
||||||
|
list: List;
|
||||||
|
onclick?: (data: { trail: Trail; index: number }) => void;
|
||||||
|
onmouseenter?: (data: { trail: Trail; index: number }) => void;
|
||||||
|
onmouseleave?: (data: { trail: Trail; index: number }) => void;
|
||||||
|
onchange?: (item: DropdownItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { list, onclick, onmouseenter, onmouseleave, onchange }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
$: cumulativeDistance = list.expand?.trails?.reduce(
|
let cumulativeDistance = $derived(
|
||||||
(s, b) => s + b.distance!,
|
list.expand?.trails?.reduce((s, b) => s + b.distance!, 0),
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$: cumulativeElevationGain = list.expand?.trails?.reduce(
|
let cumulativeElevationGain = $derived(
|
||||||
(s, b) => s + b.elevation_gain!,
|
list.expand?.trails?.reduce((s, b) => s + b.elevation_gain!, 0),
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$: cumulativeElevationLoss = list.expand?.trails?.reduce(
|
let cumulativeElevationLoss = $derived(
|
||||||
(s, b) => s + b.elevation_loss!,
|
list.expand?.trails?.reduce((s, b) => s + b.elevation_loss!, 0),
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$: cumulativeDuration = list.expand?.trails?.reduce(
|
let cumulativeDuration = $derived(
|
||||||
(s, b) => s + b.duration!,
|
list.expand?.trails?.reduce((s, b) => s + b.duration!, 0),
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$: allowEdit =
|
let allowEdit = $derived(
|
||||||
list.author == $currentUser?.id ||
|
list.author == $currentUser?.id ||
|
||||||
list.expand?.list_share_via_list?.some((s) => s.permission == "edit");
|
list.expand?.list_share_via_list?.some(
|
||||||
|
(s) => s.permission == "edit",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
$: allowShare =
|
let allowShare = $derived(
|
||||||
list.author == $currentUser?.id &&
|
list.author == $currentUser?.id &&
|
||||||
!list.expand?.trails?.some((t) => t.author !== $currentUser?.id);
|
!list.expand?.trails?.some((t) => t.author !== $currentUser?.id),
|
||||||
|
);
|
||||||
|
|
||||||
$: dropdownItems = [
|
let dropdownItems = $derived([
|
||||||
...(allowShare
|
...(allowShare
|
||||||
? [{ text: $_("share"), value: "share", icon: "share" }]
|
? [{ text: $_("share"), value: "share", icon: "share" }]
|
||||||
: []),
|
: []),
|
||||||
@@ -58,22 +64,24 @@
|
|||||||
...(list.author == $currentUser?.id
|
...(list.author == $currentUser?.id
|
||||||
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
|
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
|
||||||
: []),
|
: []),
|
||||||
];
|
]);
|
||||||
|
|
||||||
$: listIsShared = (list.expand?.list_share_via_list?.length ?? 0) > 0;
|
let listIsShared = $derived(
|
||||||
|
(list.expand?.list_share_via_list?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
let fullDescription: boolean = false;
|
let fullDescription: boolean = $state(false);
|
||||||
|
|
||||||
function handleTrailSelect(trail: Trail, index: number) {
|
function handleTrailSelect(trail: Trail, index: number) {
|
||||||
dispatch("click", { trail, index });
|
onclick?.({ trail, index });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrailMouseEnter(trail: Trail, index: number) {
|
function handleTrailMouseEnter(trail: Trail, index: number) {
|
||||||
dispatch("mouseenter", { trail, index });
|
onmouseenter?.({ trail, index });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrailMouseLeave(trail: Trail, index: number) {
|
function handleTrailMouseLeave(trail: Trail, index: number) {
|
||||||
dispatch("mouseleave", { trail, index });
|
onmouseleave?.({ trail, index });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -100,17 +108,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if dropdownItems.length}
|
{#if dropdownItems.length}
|
||||||
<div class="absolute bottom-8 right-8">
|
<div class="absolute bottom-8 right-8">
|
||||||
<Dropdown
|
<Dropdown items={dropdownItems} {onchange}
|
||||||
items={dropdownItems}
|
>{#snippet children({ toggleMenu: openDropdown })}
|
||||||
on:change
|
<button
|
||||||
let:toggleMenu={openDropdown}
|
aria-label="Open dropdown"
|
||||||
><button
|
|
||||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
||||||
on:click={openDropdown}
|
onclick={openDropdown}
|
||||||
>
|
>
|
||||||
<i class="fa fa-ellipsis-vertical"></i>
|
<i class="fa fa-ellipsis-vertical"></i>
|
||||||
</button></Dropdown
|
</button>
|
||||||
>
|
{/snippet}
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<img
|
<img
|
||||||
@@ -182,7 +190,7 @@
|
|||||||
? list.description?.substring(0, 100)
|
? list.description?.substring(0, 100)
|
||||||
: list.description}
|
: list.description}
|
||||||
{#if (list.description?.length ?? 0) > 100 && !fullDescription}
|
{#if (list.description?.length ?? 0) > 100 && !fullDescription}
|
||||||
<button on:click={() => (fullDescription = true)}>
|
<button onclick={() => (fullDescription = true)}>
|
||||||
... <span class="underline">{$_("read-more")}</span></button
|
... <span class="underline">{$_("read-more")}</span></button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -195,9 +203,9 @@
|
|||||||
{#each list.expand?.trails ?? [] as trail, i}
|
{#each list.expand?.trails ?? [] as trail, i}
|
||||||
<div
|
<div
|
||||||
role="presentation"
|
role="presentation"
|
||||||
on:click={() => handleTrailSelect(trail, i)}
|
onclick={() => handleTrailSelect(trail, i)}
|
||||||
on:mouseenter={() => handleTrailMouseEnter(trail, i)}
|
onmouseenter={() => handleTrailMouseEnter(trail, i)}
|
||||||
on:mouseleave={() => handleTrailMouseLeave(trail, i)}
|
onmouseleave={() => handleTrailMouseLeave(trail, i)}
|
||||||
>
|
>
|
||||||
<TrailListItem {trail} showDescription={false}></TrailListItem>
|
<TrailListItem {trail} showDescription={false}></TrailListItem>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
import { type Snippet } from "svelte";
|
||||||
|
|
||||||
import type { List } from "$lib/models/list";
|
import type { List } from "$lib/models/list";
|
||||||
import { trail } from "$lib/stores/trail_store";
|
import { trail } from "$lib/stores/trail_store";
|
||||||
@@ -10,38 +10,47 @@
|
|||||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||||
|
|
||||||
export let lists: List[];
|
interface Props {
|
||||||
|
lists: List[];
|
||||||
|
children?: Snippet<[any]>;
|
||||||
|
onchange?: (list: List) => void
|
||||||
|
}
|
||||||
|
|
||||||
export let openModal: (() => void) | undefined = undefined;
|
let { lists, children, onchange }: Props = $props();
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let modal: Modal;
|
||||||
|
|
||||||
|
export function openModal() {
|
||||||
|
modal.openModal();
|
||||||
|
}
|
||||||
|
|
||||||
function handleSelect(list: List) {
|
function handleSelect(list: List) {
|
||||||
dispatch("change", list);
|
onchange?.(list);
|
||||||
closeModal!();
|
modal.closeModal!();
|
||||||
}
|
}
|
||||||
|
|
||||||
function listContainsCurrentTrail(list: List) {
|
function listContainsCurrentTrail(list: List) {
|
||||||
return list.trails?.includes($trail.id!);
|
return list.trails?.includes($trail.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const children_render = $derived(children);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
id="list-modal"
|
id="list-modal"
|
||||||
title={$_("select-list")}
|
title={$_("select-list")}
|
||||||
size="max-w-sm"
|
size="max-w-sm"
|
||||||
let:openModal
|
bind:this={modal}
|
||||||
bind:openModal
|
|
||||||
bind:closeModal
|
|
||||||
>
|
>
|
||||||
<slot {openModal} />
|
{#snippet children({ openModal })}
|
||||||
|
{@render children_render?.({ openModal })}
|
||||||
<ul slot="content">
|
{/snippet}
|
||||||
|
{#snippet content()}
|
||||||
|
<ul>
|
||||||
{#each lists as list}
|
{#each lists as list}
|
||||||
<li
|
<li
|
||||||
class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer"
|
class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer"
|
||||||
on:click={() => handleSelect(list)}
|
onclick={() => handleSelect(list)}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -64,4 +73,5 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -16,28 +16,36 @@
|
|||||||
trail_share_index,
|
trail_share_index,
|
||||||
} from "$lib/stores/trail_share_store";
|
} from "$lib/stores/trail_share_store";
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import Button from "../base/button.svelte";
|
import Button from "../base/button.svelte";
|
||||||
import type { SelectItem } from "../base/select.svelte";
|
import type { SelectItem } from "../base/select.svelte";
|
||||||
import Select from "../base/select.svelte";
|
import Select from "../base/select.svelte";
|
||||||
import UserSearch from "../user_search.svelte";
|
import UserSearch from "../user_search.svelte";
|
||||||
|
|
||||||
let openModal: (() => void) | undefined = undefined;
|
interface Props {
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
list: List;
|
||||||
export function openShareModal() {
|
onsave?: () => void,
|
||||||
shares.set([]);
|
onupdate?: (list: List) => void
|
||||||
fetchShares();
|
|
||||||
if (openModal) {
|
|
||||||
openModal();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export let list: List;
|
let {
|
||||||
|
list,
|
||||||
|
onsave,
|
||||||
|
onupdate
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let modal: Modal;
|
||||||
|
|
||||||
let copyButtonText = $_("copy-link");
|
export function openModal() {
|
||||||
|
openShareModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openShareModal() {
|
||||||
|
await fetchShares();
|
||||||
|
modal.openModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
let copyButtonText = $state($_("copy-link"));
|
||||||
|
|
||||||
let sharesLoading: boolean = false;
|
let sharesLoading: boolean = false;
|
||||||
|
|
||||||
@@ -56,8 +64,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
dispatch("save");
|
onsave?.()
|
||||||
closeModal!();
|
modal.closeModal!();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareTrails(userId: string) {
|
async function shareTrails(userId: string) {
|
||||||
@@ -109,10 +117,10 @@
|
|||||||
const fetchedShares = await list_share_index(list.id!);
|
const fetchedShares = await list_share_index(list.id!);
|
||||||
list.expand = {
|
list.expand = {
|
||||||
trails: list.expand?.trails ?? [],
|
trails: list.expand?.trails ?? [],
|
||||||
list_share_via_list: fetchedShares,
|
list_share_via_list: fetchedShares.items,
|
||||||
};
|
};
|
||||||
sharesLoading = false;
|
sharesLoading = false;
|
||||||
dispatch("update", list)
|
onupdate?.(list);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -120,14 +128,17 @@
|
|||||||
id="share-modal"
|
id="share-modal"
|
||||||
title={$_("share-this-list")}
|
title={$_("share-this-list")}
|
||||||
size="max-w-sm overflow-visible"
|
size="max-w-sm overflow-visible"
|
||||||
bind:openModal
|
bind:this={modal}
|
||||||
bind:closeModal
|
|
||||||
>
|
>
|
||||||
<div slot="content">
|
{#snippet content()}
|
||||||
|
<div>
|
||||||
<p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500">
|
<p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500">
|
||||||
{$_("list-share-warning")}
|
{$_("list-share-warning")}
|
||||||
</p>
|
</p>
|
||||||
<UserSearch includeSelf={false} on:click={(e) => shareList(e.detail)}></UserSearch>
|
<UserSearch
|
||||||
|
includeSelf={false}
|
||||||
|
onclick={(item) => shareList(item)}
|
||||||
|
></UserSearch>
|
||||||
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
|
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
|
||||||
|
|
||||||
{#if $shares.length == 0}
|
{#if $shares.length == 0}
|
||||||
@@ -156,14 +167,15 @@
|
|||||||
<Select
|
<Select
|
||||||
bind:value={share.permission}
|
bind:value={share.permission}
|
||||||
items={permissionSelectItems}
|
items={permissionSelectItems}
|
||||||
on:change={(e) =>
|
onchange={(value) =>
|
||||||
updateSharePermission(share, e.detail)}
|
updateSharePermission(share, value)}
|
||||||
></Select>
|
></Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
aria-label="Delete share"
|
||||||
class="btn-icon text-red-500"
|
class="btn-icon text-red-500"
|
||||||
on:click={() => deleteShare(share)}
|
onclick={() => deleteShare(share)}
|
||||||
><i class="fa fa-trash"></i></button
|
><i class="fa fa-trash"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,15 +183,18 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div slot="footer" class="flex justify-between items-center gap-4">
|
{/snippet}
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="flex justify-between items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
secondary={true}
|
secondary={true}
|
||||||
disabled={copyButtonText == $_("link-copied")}
|
disabled={copyButtonText == $_("link-copied")}
|
||||||
on:click={copyURLToClipboard}
|
onclick={copyURLToClipboard}
|
||||||
>
|
>
|
||||||
<i class="fa fa-link mr-2"></i>
|
<i class="fa fa-link mr-2"></i>
|
||||||
{copyButtonText}
|
{copyButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
<button class="btn-primary" on:click={close}>{$_("close")}</button>
|
<button class="btn-primary" onclick={close}>{$_("close")}</button>
|
||||||
</div></Modal
|
</div>
|
||||||
|
{/snippet}</Modal
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let height: number = 64;
|
interface Props {
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { height = 64 }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,67 +1,82 @@
|
|||||||
<!--
|
|
||||||
Auto-generated by: https://github.com/threlte/threlte/tree/main/packages/gltf
|
|
||||||
Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|
||||||
-->
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
import { T, forwardEventHandlers } from "@threlte/core";
|
import { T } from "@threlte/core";
|
||||||
import { useGltf } from "@threlte/extras";
|
import { useDraco, useGltf } from "@threlte/extras";
|
||||||
import { backInOut } from "svelte/easing";
|
import { backInOut } from "svelte/easing";
|
||||||
import { tweened } from "svelte/motion";
|
import { Tween } from "svelte/motion";
|
||||||
import { Group, Mesh } from "three";
|
import { Group, Mesh } from "three";
|
||||||
|
|
||||||
import { interactivity } from "@threlte/extras";
|
import { interactivity } from "@threlte/extras";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
interactivity();
|
interactivity();
|
||||||
|
|
||||||
export const ref = new Group();
|
export const ref = new Group();
|
||||||
export let rotation: number = 0;
|
interface Props {
|
||||||
|
rotation?: number;
|
||||||
|
fallback?: Snippet;
|
||||||
|
errorSlot?: Snippet<[any]>;
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
let dog: Mesh;
|
let { rotation = 0, fallback, errorSlot, ...rest }: Props = $props();
|
||||||
|
|
||||||
const gltf = useGltf("/models/mountain.glb", { useDraco: true });
|
let dog: Mesh | undefined = $state();
|
||||||
|
|
||||||
const component = forwardEventHandlers();
|
const dracoLoader = useDraco()
|
||||||
|
const gltf = useGltf("/models/mountain.glb", { dracoLoader });
|
||||||
|
|
||||||
const bark = new Audio("/audio/sophie.mp3");
|
const bark = $state(new Audio("/audio/sophie.mp3"));
|
||||||
bark.volume = 0.5
|
bark.volume = 0.5
|
||||||
|
|
||||||
const sunZPosition = tweened($theme == "light" ? 11.7 : -20, {
|
const sunZPosition = new Tween($theme == "light" ? 11.7 : -20, {
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
easing: backInOut,
|
easing: backInOut,
|
||||||
});
|
});
|
||||||
$: sunZPosition.set($theme == "light" ? 11.7 : -20);
|
$effect(() => {
|
||||||
|
sunZPosition.set($theme == "light" ? 11.7 : -20);
|
||||||
|
});
|
||||||
|
|
||||||
const moonZPosition = tweened($theme == "dark" ? 14 : -20, {
|
const moonZPosition = new Tween($theme == "dark" ? 14 : -20, {
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
easing: backInOut,
|
easing: backInOut,
|
||||||
});
|
});
|
||||||
$: moonZPosition.set($theme == "dark" ? 14 : -20);
|
$effect(() => {
|
||||||
|
moonZPosition.set($theme == "dark" ? 14 : -20);
|
||||||
|
});
|
||||||
|
|
||||||
const campfireLightIntensity = tweened($theme == "dark" ? 2 : 0, {
|
const campfireLightIntensity = new Tween($theme == "dark" ? 2 : 0, {
|
||||||
duration: 500,
|
duration: 500,
|
||||||
});
|
});
|
||||||
$: campfireLightIntensity.set($theme == "dark" ? 2 : 0);
|
$effect(() => {
|
||||||
|
campfireLightIntensity.set($theme == "dark" ? 2 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
const cabinLightIntensity = tweened($theme == "dark" ? 4 : 0, {
|
const cabinLightIntensity = new Tween($theme == "dark" ? 4 : 0, {
|
||||||
duration: 400,
|
duration: 400,
|
||||||
});
|
});
|
||||||
$: cabinLightIntensity.set($theme == "dark" ? 4 : 0);
|
$effect(() => {
|
||||||
|
cabinLightIntensity.set($theme == "dark" ? 4 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
const sunLightIntensity = tweened($theme == "light" ? 5 : 0, {
|
const sunLightIntensity = new Tween($theme == "light" ? 5 : 0, {
|
||||||
duration: 500,
|
duration: 500,
|
||||||
});
|
});
|
||||||
$: sunLightIntensity.set($theme == "light" ? 5 : 0);
|
$effect(() => {
|
||||||
|
sunLightIntensity.set($theme == "light" ? 5 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
const moonLightIntensity = tweened($theme == "dark" ? 0.8 : 0, {
|
const moonLightIntensity = new Tween($theme == "dark" ? 0.8 : 0, {
|
||||||
duration: 500,
|
duration: 500,
|
||||||
});
|
});
|
||||||
$: moonLightIntensity.set($theme == "dark" ? 0.8 : 0);
|
$effect(() => {
|
||||||
|
moonLightIntensity.set($theme == "dark" ? 0.8 : 0);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<T is={ref} dispose={false} {...$$restProps} bind:this={$component}>
|
<T is={ref} dispose={false} {...rest}>
|
||||||
{#await gltf}
|
{#await gltf}
|
||||||
<slot name="fallback" />
|
{@render fallback?.()}
|
||||||
{:then gltf}
|
{:then gltf}
|
||||||
<T.Group
|
<T.Group
|
||||||
position={[3.98, 4.61, 3.15]}
|
position={[3.98, 4.61, 3.15]}
|
||||||
@@ -135,7 +150,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
<T.PointLight
|
<T.PointLight
|
||||||
position={[14.37, 3.8, 4.11]}
|
position={[14.37, 3.8, 4.11]}
|
||||||
color="#C98A76"
|
color="#C98A76"
|
||||||
intensity={$campfireLightIntensity}
|
intensity={campfireLightIntensity.current}
|
||||||
></T.PointLight>
|
></T.PointLight>
|
||||||
<T.Mesh
|
<T.Mesh
|
||||||
geometry={gltf.nodes.campfire.geometry}
|
geometry={gltf.nodes.campfire.geometry}
|
||||||
@@ -153,7 +168,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
<T.Mesh
|
<T.Mesh
|
||||||
geometry={gltf.nodes.Moon.geometry}
|
geometry={gltf.nodes.Moon.geometry}
|
||||||
material={gltf.materials.DD9944}
|
material={gltf.materials.DD9944}
|
||||||
position={[-23.68, $moonZPosition, -6.37]}
|
position={[-23.68, moonZPosition.current, -6.37]}
|
||||||
rotation={[0, -0.81, 0]}
|
rotation={[0, -0.81, 0]}
|
||||||
scale={0.53}
|
scale={0.53}
|
||||||
><T.PointLight position={[0.5, 5, 0.5]} color="#a2cbf5" intensity={4}
|
><T.PointLight position={[0.5, 5, 0.5]} color="#a2cbf5" intensity={4}
|
||||||
@@ -161,12 +176,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
<T.DirectionalLight
|
<T.DirectionalLight
|
||||||
position={[20, 20, 40]}
|
position={[20, 20, 40]}
|
||||||
color="#a2cbf5"
|
color="#a2cbf5"
|
||||||
intensity={$moonLightIntensity}
|
intensity={moonLightIntensity.current}
|
||||||
castShadow
|
castShadow
|
||||||
></T.DirectionalLight></T.Mesh
|
></T.DirectionalLight></T.Mesh
|
||||||
>
|
>
|
||||||
<T.Group
|
<T.Group
|
||||||
position={[-5.61, $sunZPosition, -26.34]}
|
position={[-5.61, sunZPosition.current, -26.34]}
|
||||||
rotation={[0, 0.75, 0]}
|
rotation={[0, 0.75, 0]}
|
||||||
scale={0.11}
|
scale={0.11}
|
||||||
>
|
>
|
||||||
@@ -189,7 +204,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
<T.DirectionalLight
|
<T.DirectionalLight
|
||||||
position={[20, 40, 20]}
|
position={[20, 40, 20]}
|
||||||
color="#FCF9D9"
|
color="#FCF9D9"
|
||||||
intensity={$sunLightIntensity}
|
intensity={sunLightIntensity.current}
|
||||||
castShadow
|
castShadow
|
||||||
/></T.Mesh
|
/></T.Mesh
|
||||||
>
|
>
|
||||||
@@ -198,7 +213,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
position={[-4.58, 4.85, 13.62]}
|
position={[-4.58, 4.85, 13.62]}
|
||||||
angle={Math.PI / 5}
|
angle={Math.PI / 5}
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
intensity={$campfireLightIntensity * 1.5}
|
intensity={campfireLightIntensity.current * 1.5}
|
||||||
target={dog}
|
target={dog}
|
||||||
></T.SpotLight>
|
></T.SpotLight>
|
||||||
<T.Mesh
|
<T.Mesh
|
||||||
@@ -215,7 +230,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
rotation={[-2.94, 0.25, Math.PI]}
|
rotation={[-2.94, 0.25, Math.PI]}
|
||||||
scale={1.5}
|
scale={1.5}
|
||||||
bind:ref={dog}
|
bind:ref={dog}
|
||||||
on:click={() => bark.play()}
|
onclick={() => bark.play()}
|
||||||
/>
|
/>
|
||||||
<T.Mesh
|
<T.Mesh
|
||||||
geometry={gltf.nodes.ground.geometry}
|
geometry={gltf.nodes.ground.geometry}
|
||||||
@@ -769,7 +784,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
<T.PointLight
|
<T.PointLight
|
||||||
position={[-8.6, 9.3, -1]}
|
position={[-8.6, 9.3, -1]}
|
||||||
color="#FCF9D9"
|
color="#FCF9D9"
|
||||||
intensity={$cabinLightIntensity}
|
intensity={cabinLightIntensity.current}
|
||||||
></T.PointLight>
|
></T.PointLight>
|
||||||
<T.Mesh
|
<T.Mesh
|
||||||
geometry={gltf.nodes.Cabin.geometry}
|
geometry={gltf.nodes.Cabin.geometry}
|
||||||
@@ -804,12 +819,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
<T.PointLight
|
<T.PointLight
|
||||||
position={[-1, 0.8, 0]}
|
position={[-1, 0.8, 0]}
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
intensity={$campfireLightIntensity}
|
intensity={campfireLightIntensity.current}
|
||||||
></T.PointLight>
|
></T.PointLight>
|
||||||
<T.PointLight
|
<T.PointLight
|
||||||
position={[1, 0.8, 0]}
|
position={[1, 0.8, 0]}
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
intensity={$campfireLightIntensity}
|
intensity={campfireLightIntensity.current}
|
||||||
></T.PointLight></T.Mesh
|
></T.PointLight></T.Mesh
|
||||||
>
|
>
|
||||||
</T.Group>
|
</T.Group>
|
||||||
@@ -828,12 +843,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
<T.PointLight
|
<T.PointLight
|
||||||
position={[-1, 0.8, 0]}
|
position={[-1, 0.8, 0]}
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
intensity={$campfireLightIntensity}
|
intensity={campfireLightIntensity.current}
|
||||||
></T.PointLight>
|
></T.PointLight>
|
||||||
<T.PointLight
|
<T.PointLight
|
||||||
position={[1, 0.8, 0]}
|
position={[1, 0.8, 0]}
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
intensity={$campfireLightIntensity}
|
intensity={campfireLightIntensity.current}
|
||||||
></T.PointLight></T.Mesh
|
></T.PointLight></T.Mesh
|
||||||
>
|
>
|
||||||
</T.Group>
|
</T.Group>
|
||||||
@@ -849,18 +864,16 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
|||||||
<T.PointLight
|
<T.PointLight
|
||||||
position={[-1, 0.8, 0]}
|
position={[-1, 0.8, 0]}
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
intensity={$campfireLightIntensity}
|
intensity={campfireLightIntensity.current}
|
||||||
></T.PointLight>
|
></T.PointLight>
|
||||||
<T.PointLight
|
<T.PointLight
|
||||||
position={[1, 0.8, 0]}
|
position={[1, 0.8, 0]}
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
intensity={$campfireLightIntensity}
|
intensity={campfireLightIntensity.current}
|
||||||
></T.PointLight></T.Mesh
|
></T.PointLight></T.Mesh
|
||||||
>
|
>
|
||||||
</T.Group>
|
</T.Group>
|
||||||
{:catch error}
|
{:catch error}
|
||||||
<slot name="error" {error} />
|
{@render errorSlot?.({ error, })}
|
||||||
{/await}
|
{/await}
|
||||||
|
|
||||||
<slot {ref} />
|
|
||||||
</T>
|
</T>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, goto } from "$app/navigation";
|
import { afterNavigate, goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
|
||||||
import LogoText from "$lib/components/logo/logo_text.svelte";
|
import LogoText from "$lib/components/logo/logo_text.svelte";
|
||||||
import { pb } from "$lib/pocketbase";
|
import { pb } from "$lib/pocketbase";
|
||||||
import { theme, toggleTheme } from "$lib/stores/theme_store";
|
import { theme, toggleTheme } from "$lib/stores/theme_store";
|
||||||
@@ -8,12 +7,13 @@
|
|||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { backInOut, cubicOut } from "svelte/easing";
|
import { backInOut, cubicOut } from "svelte/easing";
|
||||||
import { tweened } from "svelte/motion";
|
|
||||||
import Drawer from "./base/drawer.svelte";
|
import Drawer from "./base/drawer.svelte";
|
||||||
import Dropdown from "./base/dropdown.svelte";
|
import Dropdown from "./base/dropdown.svelte";
|
||||||
import LogoTextLight from "./logo/logo_text_light.svelte";
|
import LogoTextLight from "./logo/logo_text_light.svelte";
|
||||||
import NotificationDropdown from "./notification/notification_dropdown.svelte";
|
import NotificationDropdown from "./notification/notification_dropdown.svelte";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { Tween } from "svelte/motion";
|
||||||
|
|
||||||
let navBarItems = [
|
let navBarItems = [
|
||||||
{ text: "Home", value: "/" },
|
{ text: "Home", value: "/" },
|
||||||
@@ -27,22 +27,22 @@
|
|||||||
{ text: $_("logout"), value: "logout", icon: "right-from-bracket" },
|
{ text: $_("logout"), value: "logout", icon: "right-from-bracket" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const indicatorPosition = tweened(0, {
|
const indicatorPosition = new Tween(0, {
|
||||||
duration: 300,
|
duration: 300,
|
||||||
easing: cubicOut,
|
easing: cubicOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
const indicatorWidth = tweened(0, {
|
const indicatorWidth = new Tween(0, {
|
||||||
duration: 300,
|
duration: 300,
|
||||||
easing: cubicOut,
|
easing: cubicOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
const indicatorScale = tweened(0, {
|
const indicatorScale = new Tween(0, {
|
||||||
duration: 600,
|
duration: 600,
|
||||||
easing: backInOut,
|
easing: backInOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
let drawerOpen: boolean = false;
|
let drawerOpen: boolean = $state(false);
|
||||||
|
|
||||||
afterNavigate((e) => {
|
afterNavigate((e) => {
|
||||||
const routeId = e.to?.route.id;
|
const routeId = e.to?.route.id;
|
||||||
@@ -91,19 +91,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: user = browser ? $currentUser : pb.authStore.model;
|
let user = $derived(browser ? $currentUser : pb.authStore.model);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Drawer bind:open={drawerOpen}>
|
<Drawer bind:open={drawerOpen}>
|
||||||
<div class="flex gap-4 items-center m-4">
|
<div class="flex gap-4 items-center m-4">
|
||||||
<div class="basis-full"></div>
|
<div class="basis-full"></div>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle theme"
|
||||||
class="btn-icon fa-regular fa-{$theme === 'light' ? 'sun' : 'moon'}"
|
class="btn-icon fa-regular fa-{$theme === 'light' ? 'sun' : 'moon'}"
|
||||||
on:click={() => toggleTheme()}
|
onclick={() => toggleTheme()}
|
||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle drawer"
|
||||||
class="btn-icon block fa fa-close float-right"
|
class="btn-icon block fa fa-close float-right"
|
||||||
on:click={() => (drawerOpen = false)}
|
onclick={() => (drawerOpen = false)}
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col px-12 gap-8">
|
<div class="flex flex-col px-12 gap-8">
|
||||||
@@ -140,7 +142,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
on:click={() => {
|
aria-label="Logout"
|
||||||
|
onclick={() => {
|
||||||
logout();
|
logout();
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}}
|
}}
|
||||||
@@ -168,7 +171,7 @@
|
|||||||
<menu id="nav-bar-links" class="hidden lg:flex gap-8 relative py-1 px-2">
|
<menu id="nav-bar-links" class="hidden lg:flex gap-8 relative py-1 px-2">
|
||||||
<div
|
<div
|
||||||
class="absolute h-full w-16 bg-menu-item-background-hover rounded-xl top-0 z-0"
|
class="absolute h-full w-16 bg-menu-item-background-hover rounded-xl top-0 z-0"
|
||||||
style="width: {$indicatorWidth}px; left: {$indicatorPosition}px; scale: {$indicatorScale}"
|
style="width: {indicatorWidth.current}px; left: {indicatorPosition.current}px; scale: {indicatorScale.current}"
|
||||||
></div>
|
></div>
|
||||||
{#each navBarItems as item}
|
{#each navBarItems as item}
|
||||||
<a class="font-semibold z-10" href={item.value}>{item.text}</a>
|
<a class="font-semibold z-10" href={item.value}>{item.text}</a>
|
||||||
@@ -182,26 +185,28 @@
|
|||||||
{#if user}
|
{#if user}
|
||||||
<div class="hidden lg:flex gap-6 items-center">
|
<div class="hidden lg:flex gap-6 items-center">
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle theme"
|
||||||
class="btn-icon fa-regular fa-{$theme === 'light'
|
class="btn-icon fa-regular fa-{$theme === 'light'
|
||||||
? 'sun'
|
? 'sun'
|
||||||
: 'moon'}"
|
: 'moon'}"
|
||||||
on:click={() => toggleTheme()}
|
onclick={() => toggleTheme()}
|
||||||
></button>
|
></button>
|
||||||
<a class="btn-primary btn-large" href="/trail/edit/new"
|
<a class="btn-primary btn-large" href="/trail/edit/new"
|
||||||
><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a
|
><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a
|
||||||
>
|
>
|
||||||
{#if $page.data.notifications}
|
{#if page.data.notifications}
|
||||||
<NotificationDropdown></NotificationDropdown>
|
<NotificationDropdown></NotificationDropdown>
|
||||||
{/if}
|
{/if}
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={dropdownItems}
|
items={dropdownItems}
|
||||||
on:change={(e) => handleDropdownClick(e.detail)}
|
onchange={(item) => handleDropdownClick(item)}
|
||||||
let:toggleMenu={openDropdown}
|
|
||||||
>
|
>
|
||||||
|
{#snippet children({ toggleMenu: openDropdown })}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<button
|
<button
|
||||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 aspect-square"
|
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 aspect-square"
|
||||||
on:click={openDropdown}
|
onclick={openDropdown}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="rounded-full w-full h-full"
|
class="rounded-full w-full h-full"
|
||||||
@@ -211,21 +216,24 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="hidden md:flex items-center gap-8">
|
<div class="hidden md:flex items-center gap-8">
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle theme"
|
||||||
class="btn-icon fa-regular fa-{$theme === 'light'
|
class="btn-icon fa-regular fa-{$theme === 'light'
|
||||||
? 'sun'
|
? 'sun'
|
||||||
: 'moon'}"
|
: 'moon'}"
|
||||||
on:click={() => toggleTheme()}
|
onclick={() => toggleTheme()}
|
||||||
></button>
|
></button>
|
||||||
<a class="btn-primary btn-large" href="/login">{$_("login")}</a>
|
<a class="btn-primary btn-large" href="/login">{$_("login")}</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle drawer"
|
||||||
class="btn-icon fa fa-bars lg:hidden"
|
class="btn-icon fa fa-bars lg:hidden"
|
||||||
on:click={() => (drawerOpen = !drawerOpen)}
|
onclick={() => (drawerOpen = !drawerOpen)}
|
||||||
></button>
|
></button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -5,12 +5,14 @@
|
|||||||
} from "$lib/models/notification";
|
} from "$lib/models/notification";
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import { formatTimeSince } from "$lib/util/format_util";
|
import { formatTimeSince } from "$lib/util/format_util";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let notification: Notification;
|
interface Props {
|
||||||
|
notification: Notification;
|
||||||
|
onclick?: (data: {notification: Notification, link: string | null}) => void
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { notification, onclick }: Props = $props();
|
||||||
|
|
||||||
const avatarSrc = notification.expand?.author.avatar
|
const avatarSrc = notification.expand?.author.avatar
|
||||||
? getFileURL(
|
? getFileURL(
|
||||||
@@ -21,9 +23,6 @@
|
|||||||
|
|
||||||
const timeSince = formatTimeSince(new Date(notification.created ?? ""));
|
const timeSince = formatTimeSince(new Date(notification.created ?? ""));
|
||||||
|
|
||||||
$: title = getTitle(notification);
|
|
||||||
$: description = getDescription(notification);
|
|
||||||
$: link = getLink(notification);
|
|
||||||
|
|
||||||
function getTitle(n: Notification) {
|
function getTitle(n: Notification) {
|
||||||
switch (n.type) {
|
switch (n.type) {
|
||||||
@@ -92,14 +91,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleItemClick() {
|
function handleItemClick() {
|
||||||
dispatch("click", { notification, link });
|
onclick?.({ notification, link });
|
||||||
}
|
}
|
||||||
|
let title = $derived(getTitle(notification));
|
||||||
|
let description = $derived(getDescription(notification));
|
||||||
|
let link = $derived(getLink(notification));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li
|
<li
|
||||||
class="flex items-center gap-x-3 px-3 py-2 hover:bg-menu-item-background-hover relative cursor-pointer"
|
class="flex items-center gap-x-3 px-3 py-2 hover:bg-menu-item-background-hover relative cursor-pointer"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
on:click={handleItemClick}
|
onclick={handleItemClick}
|
||||||
>
|
>
|
||||||
<img class="rounded-full w-8 aspect-square" src={avatarSrc} alt="avatar" />
|
<img class="rounded-full w-8 aspect-square" src={avatarSrc} alt="avatar" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import type { Notification } from "$lib/models/notification";
|
import type { Notification } from "$lib/models/notification";
|
||||||
import {
|
import {
|
||||||
notifications_index,
|
notifications_index,
|
||||||
@@ -16,24 +16,23 @@
|
|||||||
import emptyStateNotificationLight from "$lib/assets/svgs/empty_states/empty_state_notification_light.svg";
|
import emptyStateNotificationLight from "$lib/assets/svgs/empty_states/empty_state_notification_light.svg";
|
||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
|
|
||||||
let notifications: Notification[] = [];
|
let notifications: Notification[] = $state([]);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
page: $page.data.notifications.page,
|
page: page.data.notifications.page,
|
||||||
totalPages: $page.data.notifications.totalPages,
|
totalPages: page.data.notifications.totalPages,
|
||||||
};
|
};
|
||||||
|
|
||||||
let loadingNextPage: boolean = false;
|
let loadingNextPage: boolean = $state(false);
|
||||||
let isOpen = false;
|
let isOpen = $state(false);
|
||||||
|
|
||||||
$: unreadCount = notifications.reduce(
|
let unreadCount = $derived(
|
||||||
(value, n) => (value += n.seen ? 0 : 1),
|
notifications.reduce((value, n) => (value += n.seen ? 0 : 1), 0),
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!notifications.length && $page.data.notifications?.items?.length) {
|
if (!notifications.length && page.data.notifications?.items?.length) {
|
||||||
notifications = $page.data.notifications.items;
|
notifications = page.data.notifications.items;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,18 +87,21 @@
|
|||||||
loadingNextPage = false;
|
loadingNextPage = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNotificationClick(e: CustomEvent) {
|
async function handleNotificationClick(data: {
|
||||||
await notifications_mark_as_seen(e.detail.notification);
|
notification: Notification;
|
||||||
e.detail.notification.seen = true;
|
link: string | null;
|
||||||
|
}) {
|
||||||
|
await notifications_mark_as_seen(data.notification);
|
||||||
|
data.notification.seen = true;
|
||||||
notifications = notifications;
|
notifications = notifications;
|
||||||
|
|
||||||
if (e.detail.link) {
|
if (data.link) {
|
||||||
goto(e.detail.link);
|
goto(data.link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:mouseup={handleWindowClick} />
|
<svelte:window onmouseup={handleWindowClick} />
|
||||||
|
|
||||||
<div class="dropdown relative">
|
<div class="dropdown relative">
|
||||||
{#if unreadCount > 0}
|
{#if unreadCount > 0}
|
||||||
@@ -110,7 +112,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="dropdown-toggle">
|
<div class="dropdown-toggle">
|
||||||
<button on:click={toggleMenu} class="btn-icon">
|
<button
|
||||||
|
aria-label="Toggle notification dropdown"
|
||||||
|
onclick={toggleMenu}
|
||||||
|
class="btn-icon"
|
||||||
|
>
|
||||||
<i class="fa fa-bell"></i>
|
<i class="fa fa-bell"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +125,7 @@
|
|||||||
<ul
|
<ul
|
||||||
class="menu absolute bg-menu-background border border-input-border rounded-l-xl rounded-b-xl shadow-md right-0 overflow-scroll mt-4 max-h-96 w-72"
|
class="menu absolute bg-menu-background border border-input-border rounded-l-xl rounded-b-xl shadow-md right-0 overflow-scroll mt-4 max-h-96 w-72"
|
||||||
class:none={isOpen}
|
class:none={isOpen}
|
||||||
on:scroll={onListScroll}
|
onscroll={onListScroll}
|
||||||
style="z-index: 1001"
|
style="z-index: 1001"
|
||||||
in:fly={{ y: -10, duration: 150 }}
|
in:fly={{ y: -10, duration: 150 }}
|
||||||
out:fly={{ y: -10, duration: 150 }}
|
out:fly={{ y: -10, duration: 150 }}
|
||||||
@@ -131,13 +137,14 @@
|
|||||||
{:else if notifications.length}
|
{:else if notifications.length}
|
||||||
{#each notifications as notification}
|
{#each notifications as notification}
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
on:click={handleNotificationClick}
|
onclick={(data) => handleNotificationClick(data)}
|
||||||
{notification}
|
{notification}
|
||||||
></NotificationCard>
|
></NotificationCard>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-center p-8">
|
<div class="text-center p-8">
|
||||||
<img class="mx-auto"
|
<img
|
||||||
|
class="mx-auto"
|
||||||
src={$theme === "light"
|
src={$theme === "light"
|
||||||
? emptyStateNotificationLight
|
? emptyStateNotificationLight
|
||||||
: emptyStateNotificationDark}
|
: emptyStateNotificationDark}
|
||||||
|
|||||||
@@ -6,12 +6,30 @@
|
|||||||
The progress bar will show during navigation if loading takes more than `displayThresholdMs`
|
The progress bar will show during navigation if loading takes more than `displayThresholdMs`
|
||||||
and will hide when navigation is complete.
|
and will hide when navigation is complete.
|
||||||
-->
|
-->
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, beforeNavigate } from "$app/navigation";
|
import { beforeNavigate } from "$app/navigation";
|
||||||
|
|
||||||
// This component is a modified version of the component from the following repo:
|
// This component is a modified version of the component from the following repo:
|
||||||
// https://github.com/saibotsivad/svelte-progress-bar
|
// https://github.com/saibotsivad/svelte-progress-bar
|
||||||
|
|
||||||
|
let {
|
||||||
|
id = undefined,
|
||||||
|
viewTransitionName = "sveltekit-progress-bar",
|
||||||
|
busy = $bindable(false),
|
||||||
|
color = "currentColor",
|
||||||
|
class: textColorClass = "",
|
||||||
|
zIndex = 1,
|
||||||
|
minimum: defaultMinimum = 0.08,
|
||||||
|
maximum = 0.994,
|
||||||
|
settleTime: defaultSettleTime = 700,
|
||||||
|
intervalTime = 700,
|
||||||
|
stepSizes = [0, 0.005, 0.01, 0.02],
|
||||||
|
displayThresholdMs = 150,
|
||||||
|
noNavigationProgress = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// Towards the end of the progress bar animation, we want to shorten the increment
|
// Towards the end of the progress bar animation, we want to shorten the increment
|
||||||
// step size, to give it the appearance of slowing down. This indicates to the user
|
// step size, to give it the appearance of slowing down. This indicates to the user
|
||||||
// that progress is still happening, but not as fast as they might like.
|
// that progress is still happening, but not as fast as they might like.
|
||||||
@@ -24,11 +42,17 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Internal private state.
|
// Internal private state.
|
||||||
let running: boolean = false;
|
let running = $state(false);
|
||||||
let updater: ReturnType<typeof setInterval> | null = null;
|
let updater = $state<ReturnType<typeof setInterval> | null>(null);
|
||||||
let completed = false;
|
let completed = $state(false);
|
||||||
let width: number = 0;
|
let width = $state(0);
|
||||||
|
|
||||||
|
// Update the busy prop when the internal `running` variable changes.
|
||||||
|
$effect.pre(() => {
|
||||||
|
busy = running;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
/** If set, an ID for the progress bar on the HTML page.
|
/** If set, an ID for the progress bar on the HTML page.
|
||||||
* This ID must be unique on the page to avoid conflicts.
|
* This ID must be unique on the page to avoid conflicts.
|
||||||
*
|
*
|
||||||
@@ -41,17 +65,25 @@
|
|||||||
* A div that is currently loading...
|
* A div that is currently loading...
|
||||||
* </div>
|
* </div>
|
||||||
*/
|
*/
|
||||||
export let id: string | undefined = undefined;
|
id?: string | undefined;
|
||||||
|
/**
|
||||||
|
* The name of the CSS view transition to use for the progress bar.
|
||||||
|
* Sets the `view-transition-name` CSS property on the progress bar and leader elements.
|
||||||
|
*
|
||||||
|
* @default "sveltekit-progress-bar"
|
||||||
|
* @see https://developer.chrome.com/docs/web-platform/view-transitions/
|
||||||
|
* @see https://svelte.dev/blog/view-transitions
|
||||||
|
*/
|
||||||
|
viewTransitionName?: string;
|
||||||
/** Will be set to true when the progress bar is running. */
|
/** Will be set to true when the progress bar is running. */
|
||||||
export let busy: boolean = false;
|
busy?: boolean;
|
||||||
$: running = busy;
|
|
||||||
/**
|
/**
|
||||||
* The CSS color to use to style the progress bar.
|
* The CSS color to use to style the progress bar.
|
||||||
*
|
*
|
||||||
* If you're using Tailwind or Windi CSS, leave this to the default
|
* If you're using Tailwind or Windi CSS, leave this to the default
|
||||||
* and set the `class` attribute to a `text-` class instead.
|
* and set the `class` attribute to a `text-` class instead.
|
||||||
*/
|
*/
|
||||||
export let color: string = "currentColor";
|
color?: string;
|
||||||
/**
|
/**
|
||||||
* A Tailwind/Windi `text-` class to use to color the Progress Bar.
|
* A Tailwind/Windi `text-` class to use to color the Progress Bar.
|
||||||
*
|
*
|
||||||
@@ -62,14 +94,13 @@
|
|||||||
*
|
*
|
||||||
* @example text-green-500
|
* @example text-green-500
|
||||||
*/
|
*/
|
||||||
let textColorClass: `text-${string}` | "" = "";
|
class?: `text-${string}` | "";
|
||||||
export { textColorClass as class };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `z-index` CSS property value to use for the progress bar.
|
* The `z-index` CSS property value to use for the progress bar.
|
||||||
* Be aware that the glowing effect on the bar will use this `zIndex` + 1.
|
* Be aware that the glowing effect on the bar will use this `zIndex` + 1.
|
||||||
*/
|
*/
|
||||||
export let zIndex: number = 1;
|
zIndex?: number;
|
||||||
|
|
||||||
// These are defaults that you shouldn't need to change, but are exposed here in case you do.
|
// These are defaults that you shouldn't need to change, but are exposed here in case you do.
|
||||||
/**
|
/**
|
||||||
@@ -77,30 +108,45 @@
|
|||||||
* Starting at 0 doesn't usually look very good.
|
* Starting at 0 doesn't usually look very good.
|
||||||
* @default 0.08
|
* @default 0.08
|
||||||
*/
|
*/
|
||||||
let defaultMinimum = 0.08;
|
minimum?: number;
|
||||||
export { defaultMinimum as minimum };
|
|
||||||
/**
|
/**
|
||||||
* The maximum percent width value to use when the bar is at the end but not marked as complete.
|
* The maximum percent width value to use when the bar is at the end but not marked as complete.
|
||||||
* Letting the bar stay at 100% width for a while doesn't usually look very good either.
|
* Letting the bar stay at 100% width for a while doesn't usually look very good either.
|
||||||
* @default 0.994
|
* @default 0.994
|
||||||
*/
|
*/
|
||||||
export let maximum = 0.994;
|
maximum?: number;
|
||||||
/**
|
/**
|
||||||
* Milliseconds to wait after the complete method is called to hide the progress bar.
|
* Milliseconds to wait after the complete method is called to hide the progress bar.
|
||||||
* Letting it sit at 100% width for a very short time makes it feel more fluid.
|
* Letting it sit at 100% width for a very short time makes it feel more fluid.
|
||||||
* @default 700
|
* @default 700
|
||||||
*/
|
*/
|
||||||
let defaultSettleTime = 700;
|
settleTime?: number;
|
||||||
export { defaultSettleTime as settleTime };
|
|
||||||
/**
|
/**
|
||||||
* Milliseconds to wait between incrementing bar width when using
|
* Milliseconds to wait between incrementing bar width when using
|
||||||
* the `start` (auto-increment) method.
|
* the `start` (auto-increment) method.
|
||||||
* @default 700
|
* @default 700
|
||||||
*/
|
*/
|
||||||
export let intervalTime = 700;
|
intervalTime?: number;
|
||||||
export let stepSizes = [0, 0.005, 0.01, 0.02];
|
|
||||||
|
|
||||||
/** Reset the progress bar back to the beginning, leaving it in a running state. */
|
/** Reset the progress bar back to the beginning, leaving it in a running state. */
|
||||||
|
stepSizes?: any;
|
||||||
|
|
||||||
|
/** When navigating, this is the threshold duration in milliseconds
|
||||||
|
* that the progress bar will wait before showing.
|
||||||
|
*
|
||||||
|
* This means that if the navigation takes less than this amount of time,
|
||||||
|
* the progress bar will not be shown. This is to prevent the progress bar
|
||||||
|
* from flashing in and out on the screen.
|
||||||
|
*
|
||||||
|
* @default 150 ms
|
||||||
|
*/
|
||||||
|
displayThresholdMs?: number;
|
||||||
|
/** Set to `true` to disable the showing of the progress bar on navigation. */
|
||||||
|
noNavigationProgress?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const reset = (minimum = defaultMinimum) => {
|
export const reset = (minimum = defaultMinimum) => {
|
||||||
width = minimum;
|
width = minimum;
|
||||||
running = true;
|
running = true;
|
||||||
@@ -186,32 +232,21 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
let barStyle: string;
|
const barStyle = $derived(
|
||||||
$: barStyle =
|
|
||||||
(color ? `background-color: ${color};` : "") +
|
(color ? `background-color: ${color};` : "") +
|
||||||
(width && width * 100 ? `width: ${width * 100}%;` : "") +
|
(width && width * 100 ? `width: ${width * 100}%;` : "") +
|
||||||
`z-index: ${zIndex};`;
|
`z-index: ${zIndex};` +
|
||||||
|
`view-transition-name: ${viewTransitionName}-bar;`,
|
||||||
|
);
|
||||||
// the box shadow of the leader bar uses `color` to set its shadow color
|
// the box shadow of the leader bar uses `color` to set its shadow color
|
||||||
let leaderColorStyle: string;
|
const leaderColorStyle = $derived(
|
||||||
$: leaderColorStyle =
|
|
||||||
(color ? `background-color: ${color}; color: ${color};` : "") +
|
(color ? `background-color: ${color}; color: ${color};` : "") +
|
||||||
`z-index: ${zIndex + 1};`;
|
`z-index: ${zIndex + 1};` +
|
||||||
|
`view-transition-name: ${viewTransitionName}-leader;`,
|
||||||
/** When navigating, this is the threshold duration in milliseconds
|
);
|
||||||
* that the progress bar will wait before showing.
|
|
||||||
*
|
|
||||||
* This means that if the navigation takes less than this amount of time,
|
|
||||||
* the progress bar will not be shown. This is to prevent the progress bar
|
|
||||||
* from flashing in and out on the screen.
|
|
||||||
*
|
|
||||||
* @default 150 ms
|
|
||||||
*/
|
|
||||||
export let displayThresholdMs = 150;
|
|
||||||
|
|
||||||
/** Set to `true` to disable the showing of the progress bar on navigation. */
|
|
||||||
export let noNavigationProgress = false;
|
|
||||||
|
|
||||||
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
|
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
beforeNavigate((nav) => {
|
beforeNavigate((nav) => {
|
||||||
if (progressBarStartTimeout) {
|
if (progressBarStartTimeout) {
|
||||||
clearTimeout(progressBarStartTimeout);
|
clearTimeout(progressBarStartTimeout);
|
||||||
@@ -229,16 +264,16 @@
|
|||||||
displayThresholdMs,
|
displayThresholdMs,
|
||||||
);
|
);
|
||||||
} else start();
|
} else start();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterNavigate(() => {
|
nav.complete.catch().finally(() => {
|
||||||
if (progressBarStartTimeout) {
|
if (progressBarStartTimeout) {
|
||||||
clearTimeout(progressBarStartTimeout);
|
clearTimeout(progressBarStartTimeout);
|
||||||
progressBarStartTimeout = null;
|
progressBarStartTimeout = null;
|
||||||
}
|
}
|
||||||
complete();
|
complete();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if running || width > 0}
|
{#if running || width > 0}
|
||||||
@@ -254,7 +289,7 @@
|
|||||||
style={barStyle}
|
style={barStyle}
|
||||||
>
|
>
|
||||||
{#if running}
|
{#if running}
|
||||||
<div class="svelte-progress-bar-leader" style={leaderColorStyle} />
|
<div class="svelte-progress-bar-leader" style={leaderColorStyle}></div>
|
||||||
{/if}
|
{/if}
|
||||||
</output>
|
</output>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let src: string;
|
interface Props {
|
||||||
export let isThumbnail: boolean = false;
|
src: string;
|
||||||
export let showThumbnailControls: boolean = true;
|
isThumbnail?: boolean;
|
||||||
export let showExifControls: boolean = false;
|
showThumbnailControls?: boolean;
|
||||||
|
showExifControls?: boolean;
|
||||||
|
onthumbnail?: (src: string) => void;
|
||||||
|
onexif?: (src: string) => void;
|
||||||
|
ondelete?: (src: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let {
|
||||||
|
src,
|
||||||
|
isThumbnail = false,
|
||||||
|
showThumbnailControls = true,
|
||||||
|
showExifControls = false,
|
||||||
|
onthumbnail,
|
||||||
|
onexif,
|
||||||
|
ondelete,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
function handleThumbnailClick() {
|
function handleThumbnailClick() {
|
||||||
dispatch("thumbnail", src);
|
onthumbnail?.(src);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExifClick() {
|
function handleExifClick() {
|
||||||
dispatch("exif", src);
|
onexif?.(src);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteClick() {
|
function handleDeleteClick() {
|
||||||
dispatch("delete", src);
|
ondelete?.(src);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -36,26 +48,30 @@
|
|||||||
>
|
>
|
||||||
{#if showThumbnailControls}
|
{#if showThumbnailControls}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Make thumbnail"
|
||||||
type="button"
|
type="button"
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
data-title={$_("make-thumbnail")}
|
data-title={$_("make-thumbnail")}
|
||||||
on:click={handleThumbnailClick}
|
onclick={handleThumbnailClick}
|
||||||
><i class="fa fa-file-image text-content"></i></button
|
><i class="fa fa-file-image text-content"></i></button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showExifControls}
|
{#if showExifControls}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Get EXIF coordinates"
|
||||||
type="button"
|
type="button"
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
data-title={$_("get-position-from-exif")}
|
data-title={$_("get-position-from-exif")}
|
||||||
on:click={handleExifClick}
|
onclick={handleExifClick}
|
||||||
><i class="fa fa-magnifying-glass-location text-content"></i></button
|
><i class="fa fa-magnifying-glass-location text-content"
|
||||||
|
></i></button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Delete photo"
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
data-title={$_("delete")}
|
data-title={$_("delete")}
|
||||||
on:click={handleDeleteClick}
|
onclick={handleDeleteClick}
|
||||||
type="button"><i class="fa fa-trash text-red-500"></i></button
|
type="button"><i class="fa fa-trash text-red-500"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,15 @@
|
|||||||
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
export let photos: string[];
|
interface Props {
|
||||||
export function open(idx: number = 0) {
|
photos: string[];
|
||||||
lightbox.loadAndOpen(idx, lightboxDataSource)
|
open?: (idx: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { photos }: Props = $props();
|
||||||
|
|
||||||
|
export function openGallery(idx: number = 0) {
|
||||||
|
lightbox.loadAndOpen(idx, lightboxDataSource);
|
||||||
}
|
}
|
||||||
let lightbox: PhotoSwipeLightbox;
|
let lightbox: PhotoSwipeLightbox;
|
||||||
let lightboxDataSource: DataSource;
|
let lightboxDataSource: DataSource;
|
||||||
|
|||||||
@@ -8,10 +8,14 @@
|
|||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let activity: Activity;
|
interface Props {
|
||||||
export let user: UserAnonymous;
|
activity: Activity;
|
||||||
|
user: UserAnonymous;
|
||||||
|
}
|
||||||
|
|
||||||
let fullDescription: boolean = false;
|
let { activity, user }: Props = $props();
|
||||||
|
|
||||||
|
let fullDescription: boolean = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="activity-card p-6 space-y-6 rounded-xl border border-input-border">
|
<div class="activity-card p-6 space-y-6 rounded-xl border border-input-border">
|
||||||
@@ -92,7 +96,7 @@
|
|||||||
: activity.description}
|
: activity.description}
|
||||||
{#if (activity.description?.length ?? 0) > 100 && !fullDescription}
|
{#if (activity.description?.length ?? 0) > 100 && !fullDescription}
|
||||||
<button
|
<button
|
||||||
on:click={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fullDescription = true;
|
fullDescription = true;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import Modal from "$lib/components/base/modal.svelte";
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos";
|
import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
@@ -7,14 +7,17 @@
|
|||||||
import TextField from "../base/text_field.svelte";
|
import TextField from "../base/text_field.svelte";
|
||||||
import { show_toast } from "$lib/stores/toast_store";
|
import { show_toast } from "$lib/stores/toast_store";
|
||||||
|
|
||||||
export let openModal: (() => void) | undefined = undefined;
|
let modal: Modal;
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
|
||||||
|
export function openModal() {
|
||||||
|
modal.openModal();
|
||||||
|
}
|
||||||
|
|
||||||
let qrcode: any | null = null;
|
let qrcode: any | null = null;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
qrcode = new QrCodeWithLogo({
|
qrcode = new QrCodeWithLogo({
|
||||||
content: $page.url.href,
|
content: page.url.href,
|
||||||
image: document.getElementById("qrcode") as HTMLImageElement,
|
image: document.getElementById("qrcode") as HTMLImageElement,
|
||||||
logo: {
|
logo: {
|
||||||
src: "/favicon.png",
|
src: "/favicon.png",
|
||||||
@@ -23,7 +26,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function copyURLToClipboard() {
|
function copyURLToClipboard() {
|
||||||
navigator.clipboard.writeText($page.url.href);
|
navigator.clipboard.writeText(page.url.href);
|
||||||
show_toast({
|
show_toast({
|
||||||
text: $_("link-copied"),
|
text: $_("link-copied"),
|
||||||
icon: "circle-info",
|
icon: "circle-info",
|
||||||
@@ -43,15 +46,15 @@
|
|||||||
id="password-modal"
|
id="password-modal"
|
||||||
size="max-w-xl"
|
size="max-w-xl"
|
||||||
title={$_("share-profile")}
|
title={$_("share-profile")}
|
||||||
bind:openModal
|
bind:this={modal}
|
||||||
bind:closeModal
|
|
||||||
>
|
>
|
||||||
<div class="flex" slot="content">
|
{#snippet content()}
|
||||||
|
<div class="flex">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<img class="w-64" id="qrcode" alt="QR Code" />
|
<img class="w-64" id="qrcode" alt="QR Code" />
|
||||||
<button
|
<button
|
||||||
class="btn-secondary w-full"
|
class="btn-secondary w-full"
|
||||||
on:click={() => downloadQRCode()}
|
onclick={() => downloadQRCode()}
|
||||||
>
|
>
|
||||||
<i class="fa fa-download mr-2"></i>
|
<i class="fa fa-download mr-2"></i>
|
||||||
<span>{$_("download")}</span>
|
<span>{$_("download")}</span>
|
||||||
@@ -59,17 +62,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Send this link or the QR code on the left to someone to share
|
Send this link or the QR code on the left to someone to
|
||||||
your profile.
|
share your profile.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-x-2 items-center">
|
<div class="flex gap-x-2 items-center">
|
||||||
<div class="basis-full">
|
<div class="basis-full">
|
||||||
<TextField value={$page.url.href} disabled></TextField>
|
<TextField value={page.url.href} disabled></TextField>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon" on:click={copyURLToClipboard}>
|
<button
|
||||||
|
aria-label="Copy profile URL"
|
||||||
|
class="btn-icon" onclick={copyURLToClipboard}>
|
||||||
<i class="fa-regular fa-copy"></i>
|
<i class="fa-regular fa-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
import { T, useTask, useThrelte } from "@threlte/core";
|
import { T, useTask } from "@threlte/core";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { tweened } from "svelte/motion";
|
import { Tween } from "svelte/motion";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import Mountain from "./mountain.svelte";
|
import Mountain from "./mountain.svelte";
|
||||||
|
|
||||||
let rotation = 0;
|
let rotation = $state(0);
|
||||||
useTask((delta) => {
|
useTask((delta) => {
|
||||||
rotation += delta / 2;
|
rotation += delta / 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { toneMapping } = useThrelte();
|
const ambientIntensity = new Tween($theme == "light" ? 3 : 0, {
|
||||||
toneMapping.set(THREE.NoToneMapping);
|
|
||||||
|
|
||||||
const ambientIntensitiy = tweened($theme == "light" ? 3 : 0, {
|
|
||||||
duration: 500,
|
duration: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
$: ambientColor = $theme == "light" ? "#ffffff" : "#4c7fe6";
|
let ambientColor = $derived($theme == "light" ? "#ffffff" : "#4c7fe6");
|
||||||
$: ambientIntensitiy.set($theme == "light" ? 3 : 0.1);
|
$effect(() => {
|
||||||
|
ambientIntensity.set($theme == "light" ? 3 : 0.1);
|
||||||
|
});
|
||||||
|
|
||||||
let starsGeometry!: THREE.BufferGeometry;
|
let starsGeometry: THREE.BufferGeometry | undefined = $state();
|
||||||
let starsMaterial!: THREE.PointsMaterial;
|
let starsMaterial: THREE.PointsMaterial | undefined = $state();
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const stars = new Array(0);
|
const stars = new Array(0);
|
||||||
for (var i = 0; i < 500; i++) {
|
for (var i = 0; i < 500; i++) {
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
<T.PerspectiveCamera
|
<T.PerspectiveCamera
|
||||||
makeDefault
|
makeDefault
|
||||||
position={[35.5, 32, 35.5]}
|
position={[35.5, 32, 35.5]}
|
||||||
on:create={({ ref }) => {
|
oncreate={(c) => {
|
||||||
ref.lookAt(0, 0, 0);
|
c.lookAt(0, 0, 0);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -58,5 +58,5 @@
|
|||||||
<T.HemisphereLight
|
<T.HemisphereLight
|
||||||
skyColor={ambientColor}
|
skyColor={ambientColor}
|
||||||
groundColor="#242734"
|
groundColor="#242734"
|
||||||
intensity={$ambientIntensitiy}
|
intensity={ambientIntensity.current}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,23 +2,25 @@
|
|||||||
import Modal from "$lib/components/base/modal.svelte";
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
import { validator } from "@felte/validator-zod";
|
import { validator } from "@felte/validator-zod";
|
||||||
import { createForm } from "felte";
|
import { createForm } from "felte";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import TextField from "../base/text_field.svelte";
|
import TextField from "../base/text_field.svelte";
|
||||||
|
|
||||||
let _openModal: (() => void) | undefined = undefined;
|
interface Props {
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
email?: string;
|
||||||
|
onsave?: (email: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { email = "", onsave }: Props = $props();
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
|
||||||
export function openModal() {
|
export function openModal() {
|
||||||
setFields("email", email);
|
setFields("email", email);
|
||||||
setErrors("email", []);
|
setErrors("email", []);
|
||||||
_openModal!();
|
modal.openModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
export let email = "";
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
const { form, errors, setFields, setErrors } = createForm<{
|
const { form, errors, setFields, setErrors } = createForm<{
|
||||||
email: string;
|
email: string;
|
||||||
}>({
|
}>({
|
||||||
@@ -32,8 +34,8 @@
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
onSubmit: async (form) => {
|
onSubmit: async (form) => {
|
||||||
dispatch("save", form.email);
|
onsave?.(form.email);
|
||||||
closeModal!();
|
modal.closeModal!();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -42,18 +44,24 @@
|
|||||||
id="email-modal"
|
id="email-modal"
|
||||||
size="max-w-xl"
|
size="max-w-xl"
|
||||||
title={$_("change-email")}
|
title={$_("change-email")}
|
||||||
bind:openModal={_openModal}
|
bind:this={modal}
|
||||||
bind:closeModal
|
|
||||||
>
|
>
|
||||||
<form id="email-form" slot="content" use:form>
|
{#snippet content()}
|
||||||
|
<form id="email-form" use:form>
|
||||||
<TextField name="email" error={$errors.email}></TextField>
|
<TextField name="email" error={$errors.email}></TextField>
|
||||||
</form>
|
</form>
|
||||||
<div slot="footer" class="flex items-center gap-4">
|
{/snippet}
|
||||||
<button class="btn-secondary" on:click={closeModal}
|
{#snippet footer()}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||||
>{$_("cancel")}</button
|
>{$_("cancel")}</button
|
||||||
>
|
>
|
||||||
<button class="btn-primary" type="submit" form="email-form" name="save"
|
<button
|
||||||
>{$_("save")}</button
|
class="btn-primary"
|
||||||
|
type="submit"
|
||||||
|
form="email-form"
|
||||||
|
name="save">{$_("save")}</button
|
||||||
>
|
>
|
||||||
</div></Modal
|
</div>
|
||||||
|
{/snippet}</Modal
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from "$lib/components/base/modal.svelte";
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import TextField from "../base/text_field.svelte";
|
import TextField from "../base/text_field.svelte";
|
||||||
import { validator } from "@felte/validator-zod";
|
import { validator } from "@felte/validator-zod";
|
||||||
import { createForm } from "felte";
|
import { createForm } from "felte";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
let _openModal: (() => void) | undefined = undefined;
|
interface Props {
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
onsave?: (data: {
|
||||||
export function openModal() {
|
oldPassword: string;
|
||||||
setFields("password", "")
|
password: string;
|
||||||
setFields("oldPassword", "")
|
passwordConfirm: string;
|
||||||
setErrors("password", [])
|
}) => void;
|
||||||
setErrors("oldPassword", [])
|
|
||||||
_openModal!();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { onsave }: Props = $props();
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
export function openModal() {
|
||||||
|
setFields("password", "");
|
||||||
|
setFields("oldPassword", "");
|
||||||
|
setErrors("password", []);
|
||||||
|
setErrors("oldPassword", []);
|
||||||
|
modal.openModal!();
|
||||||
|
}
|
||||||
|
|
||||||
const { form, errors, setFields, setErrors } = createForm<{
|
const { form, errors, setFields, setErrors } = createForm<{
|
||||||
oldPassword: string;
|
oldPassword: string;
|
||||||
@@ -53,12 +59,12 @@
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
onSubmit: async (form) => {
|
onSubmit: async (form) => {
|
||||||
dispatch("save", {
|
onsave?.({
|
||||||
oldPassword: form.oldPassword,
|
oldPassword: form.oldPassword,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
passwordConfirm: form.password,
|
passwordConfirm: form.password,
|
||||||
});
|
});
|
||||||
closeModal!();
|
modal.closeModal();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -67,10 +73,10 @@
|
|||||||
id="password-modal"
|
id="password-modal"
|
||||||
size="max-w-sm"
|
size="max-w-sm"
|
||||||
title={$_("change-password")}
|
title={$_("change-password")}
|
||||||
bind:openModal={_openModal}
|
bind:this={modal}
|
||||||
bind:closeModal
|
|
||||||
>
|
>
|
||||||
<form id="password-form" slot="content" use:form>
|
{#snippet content()}
|
||||||
|
<form id="password-form" use:form>
|
||||||
<TextField
|
<TextField
|
||||||
name="oldPassword"
|
name="oldPassword"
|
||||||
label={$_("current-password")}
|
label={$_("current-password")}
|
||||||
@@ -90,8 +96,10 @@
|
|||||||
error={$errors.passwordConfirm}
|
error={$errors.passwordConfirm}
|
||||||
></TextField>
|
></TextField>
|
||||||
</form>
|
</form>
|
||||||
<div slot="footer" class="flex items-center gap-4">
|
{/snippet}
|
||||||
<button class="btn-secondary" on:click={closeModal}
|
{#snippet footer()}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||||
>{$_("cancel")}</button
|
>{$_("cancel")}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -100,5 +108,6 @@
|
|||||||
form="password-form"
|
form="password-form"
|
||||||
name="save">{$_("save")}</button
|
name="save">{$_("save")}</button
|
||||||
>
|
>
|
||||||
</div></Modal
|
</div>
|
||||||
|
{/snippet}</Modal
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,17 +8,21 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { fly, slide } from "svelte/transition";
|
import { fly, slide } from "svelte/transition";
|
||||||
|
|
||||||
export let subject: Trail | List;
|
interface Props {
|
||||||
export let large: boolean = false;
|
subject: Trail | List;
|
||||||
export let type: "trail" | "list"
|
large?: boolean;
|
||||||
|
type: "trail" | "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
let { subject, large = false, type }: Props = $props();
|
||||||
|
|
||||||
const shareData = type == "trail" ? (subject as Trail).expand?.trail_share_via_trail : (subject as List).expand?.list_share_via_list
|
const shareData = type == "trail" ? (subject as Trail).expand?.trail_share_via_trail : (subject as List).expand?.list_share_via_list
|
||||||
|
|
||||||
let showInfo: boolean = false;
|
let showInfo: boolean = $state(false);
|
||||||
let loading: boolean = false;
|
let loading: boolean = $state(false);
|
||||||
let infoLoaded: boolean = false;
|
let infoLoaded: boolean = $state(false);
|
||||||
let subjectIsOwned: boolean = subject.author == pb.authStore.model?.id;
|
let subjectIsOwned: boolean = subject.author == pb.authStore.model?.id;
|
||||||
let author: UserAnonymous;
|
let author: UserAnonymous | undefined = $state();
|
||||||
|
|
||||||
async function fetchInfo() {
|
async function fetchInfo() {
|
||||||
if (!infoLoaded) {
|
if (!infoLoaded) {
|
||||||
@@ -47,8 +51,8 @@
|
|||||||
class:text-xl={large}
|
class:text-xl={large}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:mouseenter={fetchInfo}
|
onmouseenter={fetchInfo}
|
||||||
on:mouseleave={() => (showInfo = false)}
|
onmouseleave={() => (showInfo = false)}
|
||||||
></i>
|
></i>
|
||||||
{#if showInfo}
|
{#if showInfo}
|
||||||
<ul
|
<ul
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
|
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||||
|
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||||
import type { SummitLog } from "$lib/models/summit_log";
|
import type { SummitLog } from "$lib/models/summit_log";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
|
||||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
|
||||||
|
|
||||||
|
import { theme } from "$lib/stores/theme_store";
|
||||||
|
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||||
import {
|
import {
|
||||||
formatDistance,
|
formatDistance,
|
||||||
formatElevation,
|
formatElevation,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
|
||||||
import { theme } from "$lib/stores/theme_store";
|
|
||||||
|
|
||||||
let thumbnail: string =
|
let thumbnail: string = $state(
|
||||||
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark;
|
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark,
|
||||||
|
);
|
||||||
|
|
||||||
$: Promise.all(
|
interface Props {
|
||||||
(log._photos ?? []).map(async (f) => {
|
log: SummitLog;
|
||||||
|
mode?: "show" | "edit";
|
||||||
|
onchange?: (item: DropdownItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { log, mode = "show", onchange }: Props = $props();
|
||||||
|
|
||||||
|
const dropdownItems: DropdownItem[] = [
|
||||||
|
{ text: $_("edit"), value: "edit" },
|
||||||
|
{ text: $_("delete"), value: "delete" },
|
||||||
|
];
|
||||||
|
$effect(() => fetchPhotos(log._photos ?? []));
|
||||||
|
|
||||||
|
function fetchPhotos(photos: File[]) {
|
||||||
|
Promise.all(
|
||||||
|
photos.map(async (f) => {
|
||||||
return await readAsDataURLAsync(f);
|
return await readAsDataURLAsync(f);
|
||||||
}),
|
}),
|
||||||
).then((v) => {
|
).then((v) => {
|
||||||
@@ -27,17 +44,12 @@
|
|||||||
thumbnail = v[0];
|
thumbnail = v[0];
|
||||||
} else {
|
} else {
|
||||||
thumbnail =
|
thumbnail =
|
||||||
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark;
|
$theme === "light"
|
||||||
|
? emptyStateTrailLight
|
||||||
|
: emptyStateTrailDark;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
export let log: SummitLog;
|
|
||||||
export let mode: "show" | "edit" = "show";
|
|
||||||
|
|
||||||
const dropdownItems: DropdownItem[] = [
|
|
||||||
{ text: $_("edit"), value: "edit" },
|
|
||||||
{ text: $_("delete"), value: "delete" },
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4 my-2 border border-input-border rounded-xl">
|
<div class="p-4 my-2 border border-input-border rounded-xl">
|
||||||
@@ -65,7 +77,7 @@
|
|||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
{#if mode == "edit"}
|
{#if mode == "edit"}
|
||||||
<Dropdown items={dropdownItems} on:change></Dropdown>
|
<Dropdown items={dropdownItems} {onchange}></Dropdown>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if log.distance || log.elevation_gain || log.elevation_loss || log.duration}
|
{#if log.distance || log.elevation_gain || log.elevation_loss || log.duration}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
import { type Snippet } from "svelte";
|
||||||
|
|
||||||
import {
|
import { SummitLogCreateSchema } from "$lib/models/api/summit_log_schema";
|
||||||
SummitLogCreateSchema,
|
|
||||||
} from "$lib/models/api/summit_log_schema";
|
|
||||||
import GPX from "$lib/models/gpx/gpx";
|
import GPX from "$lib/models/gpx/gpx";
|
||||||
import { summitLog } from "$lib/stores/summit_log_store";
|
import { summitLog } from "$lib/stores/summit_log_store";
|
||||||
import { createForm } from "felte";
|
import { cloneDeep } from "$lib/util/deep_util";
|
||||||
import { validator } from "@felte/validator-zod";
|
import { validator } from "@felte/validator-zod";
|
||||||
|
import { createForm } from "felte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import Datepicker from "../base/datepicker.svelte";
|
import Datepicker from "../base/datepicker.svelte";
|
||||||
@@ -15,11 +14,19 @@
|
|||||||
import Textarea from "../base/textarea.svelte";
|
import Textarea from "../base/textarea.svelte";
|
||||||
import PhotoPicker from "../trail/photo_picker.svelte";
|
import PhotoPicker from "../trail/photo_picker.svelte";
|
||||||
import TrailPicker from "../trail/trail_picker.svelte";
|
import TrailPicker from "../trail/trail_picker.svelte";
|
||||||
import { cloneDeep } from "$lib/util/deep_util";
|
import type { SummitLog } from "$lib/models/summit_log";
|
||||||
export let openModal: (() => void) | undefined = undefined;
|
interface Props {
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
children?: Snippet<[any]>;
|
||||||
|
onsave?: (summitLog: SummitLog) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { children, onsave }: Props = $props();
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
|
||||||
|
export function openModal() {
|
||||||
|
modal.openModal();
|
||||||
|
}
|
||||||
|
|
||||||
const ClientSummitLogCreateSchema = SummitLogCreateSchema.extend({
|
const ClientSummitLogCreateSchema = SummitLogCreateSchema.extend({
|
||||||
_photos: z.array(z.instanceof(File)).optional(),
|
_photos: z.array(z.instanceof(File)).optional(),
|
||||||
@@ -56,15 +63,22 @@
|
|||||||
form._photos = [new File([blob], "route")];
|
form._photos = [new File([blob], "route")];
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch("save", form);
|
onsave?.(form);
|
||||||
closeModal!();
|
modal.closeModal!();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
$: setFields(cloneDeep($summitLog));
|
|
||||||
|
|
||||||
$: if ($summitLog._gpx) {
|
let trailData = $derived($data.expand?.gpx_data);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
setFields(cloneDeep($summitLog));
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($summitLog._gpx) {
|
||||||
$data._gpx = $summitLog._gpx;
|
$data._gpx = $summitLog._gpx;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function handleTrailSelection(trailData: string | null) {
|
async function handleTrailSelection(trailData: string | null) {
|
||||||
if (!trailData) {
|
if (!trailData) {
|
||||||
@@ -85,28 +99,31 @@
|
|||||||
$data.elevation_gain = totals.elevationGain;
|
$data.elevation_gain = totals.elevationGain;
|
||||||
$data.elevation_loss = totals.elevationLoss;
|
$data.elevation_loss = totals.elevationLoss;
|
||||||
$data.distance = totals.distance;
|
$data.distance = totals.distance;
|
||||||
|
$data.expand!.gpx_data = trailData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const children_render = $derived(children);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
id="summit-log-modal"
|
id="summit-log-modal"
|
||||||
title={$data.id ? $_("edit-entry") : $_("add-entry")}
|
title={$data.id ? $_("edit-entry") : $_("add-entry")}
|
||||||
let:openModal
|
bind:this={modal}
|
||||||
bind:openModal
|
|
||||||
bind:closeModal
|
|
||||||
>
|
|
||||||
<slot {openModal} />
|
|
||||||
<form
|
|
||||||
id="summit-log-form"
|
|
||||||
slot="content"
|
|
||||||
class="modal-content space-y-4"
|
|
||||||
use:form
|
|
||||||
>
|
>
|
||||||
|
{#snippet children({ openModal })}
|
||||||
|
{@render children_render?.({ openModal })}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content()}
|
||||||
|
<form id="summit-log-form" class="modal-content space-y-4" use:form>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<Datepicker name="date" label={$_("date")} error={$errors.date}
|
<Datepicker name="date" label={$_("date")} error={$errors.date}
|
||||||
></Datepicker>
|
></Datepicker>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="summitlog-photo-input" class="text-sm font-medium pb-1">
|
<label
|
||||||
|
for="summitlog-photo-input"
|
||||||
|
class="text-sm font-medium pb-1"
|
||||||
|
>
|
||||||
{$_("photos")}
|
{$_("photos")}
|
||||||
</label>
|
</label>
|
||||||
<PhotoPicker
|
<PhotoPicker
|
||||||
@@ -121,9 +138,9 @@
|
|||||||
{#if $data.expand}
|
{#if $data.expand}
|
||||||
<TrailPicker
|
<TrailPicker
|
||||||
bind:trailFile={$data._gpx}
|
bind:trailFile={$data._gpx}
|
||||||
bind:trailData={$data.expand.gpx_data}
|
{trailData}
|
||||||
label={$_("trail", { values: { n: 1 } })}
|
label={$_("trail", { values: { n: 1 } })}
|
||||||
on:change={(e) => handleTrailSelection(e.detail)}
|
onchange={(trail) => handleTrailSelection(trail)}
|
||||||
></TrailPicker>
|
></TrailPicker>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="basis-full">
|
<div class="basis-full">
|
||||||
@@ -136,12 +153,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div slot="footer" class="flex items-center gap-4">
|
{/snippet}
|
||||||
<button class="btn-secondary" on:click={closeModal}
|
{#snippet footer()}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||||
>{$_("cancel")}</button
|
>{$_("cancel")}</button
|
||||||
>
|
>
|
||||||
<button class="btn-primary" type="submit" form="summit-log-form"
|
<button class="btn-primary" type="submit" form="summit-log-form"
|
||||||
>{$_("save")}</button
|
>{$_("save")}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -10,24 +10,32 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import MapWithElevationMaplibre from "../trail/map_with_elevation_maplibre.svelte";
|
import MapWithElevationMaplibre from "../trail/map_with_elevation_maplibre.svelte";
|
||||||
|
|
||||||
export let summitLogs: SummitLog[] = [];
|
interface Props {
|
||||||
export let showCategory: boolean = false;
|
summitLogs?: SummitLog[];
|
||||||
export let showTrail: boolean = false;
|
showCategory?: boolean;
|
||||||
export let showAuthor: boolean = false;
|
showTrail?: boolean;
|
||||||
export let showRoute: boolean = false;
|
showAuthor?: boolean;
|
||||||
export let showPhotos: boolean = false;
|
showRoute?: boolean;
|
||||||
|
showPhotos?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
let openMapModal: () => void;
|
let {
|
||||||
let closeMapModal: () => void;
|
summitLogs = [],
|
||||||
|
showCategory = false,
|
||||||
|
showTrail = false,
|
||||||
|
showAuthor = false,
|
||||||
|
showRoute = false,
|
||||||
|
showPhotos = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let openTextModal: () => void;
|
let mapModal: Modal;
|
||||||
let closeTextModal: () => void;
|
let textModal: Modal;
|
||||||
|
|
||||||
let map: M.Map;
|
let map: M.Map | undefined = $state();
|
||||||
|
|
||||||
let trail: Trail | null = null;
|
let trail: Trail | null = $state(null);
|
||||||
|
|
||||||
let currentText: string = "";
|
let currentText: string = $state("");
|
||||||
|
|
||||||
onMount(async () => {});
|
onMount(async () => {});
|
||||||
|
|
||||||
@@ -40,21 +48,21 @@
|
|||||||
trail.id = log.id;
|
trail.id = log.id;
|
||||||
trail.expand!.gpx_data = log.expand.gpx_data;
|
trail.expand!.gpx_data = log.expand.gpx_data;
|
||||||
|
|
||||||
openMapModal();
|
mapModal.openModal();
|
||||||
await tick();
|
await tick();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openText(log: SummitLog) {
|
async function openText(log: SummitLog) {
|
||||||
currentText = log.text ?? "";
|
currentText = log.text ?? "";
|
||||||
openTextModal();
|
textModal.openModal();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="text-left text-gray-500">
|
<thead class="text-left text-gray-500">
|
||||||
<tr class="text-sm">
|
<tr class="text-sm">
|
||||||
{#if showPhotos && summitLogs.some(l => l.photos.length)}
|
{#if showPhotos && summitLogs.some((l) => l.photos.length)}
|
||||||
<th class="w-24"></th>
|
<th class="w-24"></th>
|
||||||
{/if}
|
{/if}
|
||||||
<th>{$_("date")}</th>
|
<th>{$_("date")}</th>
|
||||||
@@ -75,7 +83,7 @@
|
|||||||
{#if summitLogs.some((l) => l.text?.length)}
|
{#if summitLogs.some((l) => l.text?.length)}
|
||||||
<th>{$_("description")}</th>
|
<th>{$_("description")}</th>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showAuthor && summitLogs.some(l => l.expand?.author)}
|
{#if showAuthor && summitLogs.some((l) => l.expand?.author)}
|
||||||
<th>
|
<th>
|
||||||
{$_("author", { values: { n: 1 } })}
|
{$_("author", { values: { n: 1 } })}
|
||||||
</th>
|
</th>
|
||||||
@@ -91,13 +99,14 @@
|
|||||||
{#each summitLogs as log, i}
|
{#each summitLogs as log, i}
|
||||||
<SummitLogTableRow
|
<SummitLogTableRow
|
||||||
{log}
|
{log}
|
||||||
on:open={(e) => openMap(e.detail)}
|
onopen={(log) => openMap(log)}
|
||||||
on:text={(e) => openText(e.detail)}
|
ontext={(log) => openText(log)}
|
||||||
{showCategory}
|
{showCategory}
|
||||||
{showTrail}
|
{showTrail}
|
||||||
{showAuthor}
|
{showAuthor}
|
||||||
showPhotos={showPhotos && summitLogs.some(l => l.photos.length)}
|
showPhotos={showPhotos &&
|
||||||
showDescription={summitLogs.some(l => l.text?.length)}
|
summitLogs.some((l) => l.photos.length)}
|
||||||
|
showDescription={summitLogs.some((l) => l.text?.length)}
|
||||||
showRoute={showRoute && summitLogs.some((l) => l.gpx)}
|
showRoute={showRoute && summitLogs.some((l) => l.gpx)}
|
||||||
></SummitLogTableRow>
|
></SummitLogTableRow>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -110,24 +119,29 @@
|
|||||||
id="summit-log-table-map-modal"
|
id="summit-log-table-map-modal"
|
||||||
size="max-w-4xl"
|
size="max-w-4xl"
|
||||||
title=""
|
title=""
|
||||||
bind:openModal={openMapModal}
|
bind:this={mapModal}
|
||||||
bind:closeModal={closeMapModal}
|
|
||||||
>
|
>
|
||||||
<div slot="content" id="summit-log-table-map" class="h-[32rem]">
|
{#snippet content()}
|
||||||
|
<div id="summit-log-table-map" class="h-[32rem]">
|
||||||
{#if trail}
|
{#if trail}
|
||||||
<MapWithElevationMaplibre trails={[trail]} bind:map showTerrain
|
<MapWithElevationMaplibre
|
||||||
|
trails={[trail]}
|
||||||
|
bind:map
|
||||||
|
showTerrain
|
||||||
></MapWithElevationMaplibre>
|
></MapWithElevationMaplibre>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
id="summit-log-table-text-modal"
|
id="summit-log-table-text-modal"
|
||||||
size="max-w-xl"
|
size="max-w-xl"
|
||||||
title={$_("description")}
|
title={$_("description")}
|
||||||
bind:openModal={openTextModal}
|
bind:this={textModal}
|
||||||
bind:closeModal={closeTextModal}
|
|
||||||
>
|
>
|
||||||
<p slot="content" class="whitespace-pre-wrap">{currentText}</p>
|
{#snippet content()}
|
||||||
|
<p class="whitespace-pre-wrap">{currentText}</p>
|
||||||
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -7,22 +7,38 @@
|
|||||||
formatElevation,
|
formatElevation,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import PhotoGallery from "../photo_gallery.svelte";
|
import PhotoGallery from "../photo_gallery.svelte";
|
||||||
|
|
||||||
export let log: SummitLog;
|
interface Props {
|
||||||
export let showCategory: boolean = false;
|
log: SummitLog;
|
||||||
export let showTrail: boolean = false;
|
showCategory?: boolean;
|
||||||
export let showRoute: boolean = false;
|
showTrail?: boolean;
|
||||||
export let showAuthor: boolean = false;
|
showRoute?: boolean;
|
||||||
export let showDescription: boolean = false;
|
showAuthor?: boolean;
|
||||||
export let showPhotos: boolean = false;
|
showDescription?: boolean;
|
||||||
|
showPhotos?: boolean;
|
||||||
|
ontext?: (summitLog: SummitLog) => void;
|
||||||
|
onopen?: (summitLog: SummitLog) => void;
|
||||||
|
}
|
||||||
|
|
||||||
let openGallery: (idx?: number) => void;
|
let {
|
||||||
|
log,
|
||||||
|
showCategory = false,
|
||||||
|
showTrail = false,
|
||||||
|
showRoute = false,
|
||||||
|
showAuthor = false,
|
||||||
|
showDescription = false,
|
||||||
|
showPhotos = false,
|
||||||
|
onopen,
|
||||||
|
ontext
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let imgSrc: string[] = [];
|
let gallery: PhotoGallery;
|
||||||
$: if (log.photos?.length) {
|
|
||||||
|
let imgSrc: string[] = $state([]);
|
||||||
|
$effect(() => {
|
||||||
|
if (log.photos?.length) {
|
||||||
imgSrc = log.photos
|
imgSrc = log.photos
|
||||||
.filter((_, i) => i < 3)
|
.filter((_, i) => i < 3)
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -30,15 +46,14 @@
|
|||||||
} else {
|
} else {
|
||||||
imgSrc = [];
|
imgSrc = [];
|
||||||
}
|
}
|
||||||
|
});
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
function openText() {
|
function openText() {
|
||||||
dispatch("text", log);
|
ontext?.(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRoute() {
|
function openRoute() {
|
||||||
dispatch("open", log);
|
onopen?.(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
function colCount() {
|
function colCount() {
|
||||||
@@ -58,12 +73,12 @@
|
|||||||
{#if imgSrc.length}
|
{#if imgSrc.length}
|
||||||
<PhotoGallery
|
<PhotoGallery
|
||||||
photos={log.photos.map((p) => getFileURL(log, p))}
|
photos={log.photos.map((p) => getFileURL(log, p))}
|
||||||
bind:open={openGallery}
|
bind:this={gallery}
|
||||||
></PhotoGallery>
|
></PhotoGallery>
|
||||||
<button
|
<button
|
||||||
class="relative w-16 aspect-square ml-2 mb-3 shrink-0"
|
class="relative w-16 aspect-square ml-2 mb-3 shrink-0"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => openGallery()}
|
onclick={() => gallery.openGallery()}
|
||||||
>
|
>
|
||||||
{#each imgSrc as img, i}
|
{#each imgSrc as img, i}
|
||||||
<img
|
<img
|
||||||
@@ -110,6 +125,7 @@
|
|||||||
{#if showTrail}
|
{#if showTrail}
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
|
aria-label="Go to trail"
|
||||||
class="btn-icon aspect-square"
|
class="btn-icon aspect-square"
|
||||||
href="/trail/view/{log.expand?.trails_via_summit_logs?.at(0)
|
href="/trail/view/{log.expand?.trails_via_summit_logs?.at(0)
|
||||||
?.id ?? ''}"
|
?.id ?? ''}"
|
||||||
@@ -120,7 +136,7 @@
|
|||||||
{#if showDescription}
|
{#if showDescription}
|
||||||
<td>
|
<td>
|
||||||
{#if log.text}
|
{#if log.text}
|
||||||
<button on:click={openText}
|
<button onclick={openText}
|
||||||
><p
|
><p
|
||||||
class="rounded-full bg-menu-item-background-hover hover:bg-menu-item-background-focus text-ellipsis max-w-28 whitespace-nowrap overflow-hidden px-3 py-1"
|
class="rounded-full bg-menu-item-background-hover hover:bg-menu-item-background-focus text-ellipsis max-w-28 whitespace-nowrap overflow-hidden px-3 py-1"
|
||||||
>
|
>
|
||||||
@@ -164,7 +180,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showRoute && log.expand?.gpx_data}
|
{#if showRoute && log.expand?.gpx_data}
|
||||||
<td>
|
<td>
|
||||||
<button on:click={openRoute} class="btn-icon">
|
<button
|
||||||
|
aria-label="Open route"
|
||||||
|
onclick={openRoute}
|
||||||
|
class="btn-icon"
|
||||||
|
>
|
||||||
<i class="fa fa-map-location-dot px-[3px] text-xl"></i></button
|
<i class="fa fa-map-location-dot px-[3px] text-xl"></i></button
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import directionCaret from "$lib/assets/svgs/caret-right-solid.svg";
|
import directionCaret from "$lib/assets/svgs/caret-right-solid.svg";
|
||||||
import type { Settings } from "$lib/models/settings";
|
import type { Settings } from "$lib/models/settings";
|
||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
@@ -19,34 +19,72 @@
|
|||||||
import type { GeoJSON } from "geojson";
|
import type { GeoJSON } from "geojson";
|
||||||
import * as M from "maplibre-gl";
|
import * as M from "maplibre-gl";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
import { createEventDispatcher, onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount, untrack } from "svelte";
|
||||||
|
|
||||||
export let trails: Trail[] = [];
|
interface Props {
|
||||||
export let waypoints: Waypoint[] = [];
|
trails?: Trail[];
|
||||||
export let markers: M.Marker[] = [];
|
waypoints?: Waypoint[];
|
||||||
export let map: M.Map | null = null;
|
markers?: M.Marker[];
|
||||||
export let drawing: boolean = false;
|
map?: M.Map | null;
|
||||||
export let showElevation: boolean = true;
|
drawing?: boolean;
|
||||||
export let showInfoPopup: boolean = false;
|
showElevation?: boolean;
|
||||||
export let showGrid: boolean = false;
|
showInfoPopup?: boolean;
|
||||||
export let showStyleSwitcher: boolean = true;
|
showGrid?: boolean;
|
||||||
export let showFullscreen: boolean = false;
|
showStyleSwitcher?: boolean;
|
||||||
export let showTerrain: boolean = false;
|
showFullscreen?: boolean;
|
||||||
export let fitBounds: "animate" | "instant" | "off" = "instant";
|
showTerrain?: boolean;
|
||||||
export let onMarkerDragEnd:
|
fitBounds?: "animate" | "instant" | "off";
|
||||||
|
onmarkerdragend?:
|
||||||
| ((marker: M.Marker, wpId?: string) => void)
|
| ((marker: M.Marker, wpId?: string) => void)
|
||||||
| undefined = undefined;
|
| undefined;
|
||||||
|
elevationProfileContainer?: string | HTMLDivElement | undefined;
|
||||||
|
mapOptions?: Partial<M.MapOptions> | undefined;
|
||||||
|
activeTrail?: number | null;
|
||||||
|
minZoom?: number;
|
||||||
|
onsegmentdragend?: (data: {
|
||||||
|
segment: number;
|
||||||
|
event: M.MapMouseEvent;
|
||||||
|
}) => void;
|
||||||
|
onselect?: (trail: Trail) => void;
|
||||||
|
onunselect?: (trail: Trail) => void;
|
||||||
|
onfullscreen?: () => void;
|
||||||
|
onmoveend?: (map: M.Map) => void;
|
||||||
|
onzoom?: (map: M.Map) => void;
|
||||||
|
onclick?: (event: M.MapMouseEvent & Object) => void;
|
||||||
|
oninit?: (map: M.Map) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export let elevationProfileContainer: string | HTMLDivElement | undefined =
|
let {
|
||||||
undefined;
|
trails = [],
|
||||||
export let mapOptions: Partial<M.MapOptions> | undefined = undefined;
|
waypoints = [],
|
||||||
|
markers = $bindable([]),
|
||||||
export let activeTrail: number | null = null;
|
map = $bindable(),
|
||||||
export let minZoom: number = 0;
|
drawing = false,
|
||||||
|
showElevation = true,
|
||||||
|
showInfoPopup = false,
|
||||||
|
showGrid = false,
|
||||||
|
showStyleSwitcher = true,
|
||||||
|
showFullscreen = false,
|
||||||
|
showTerrain = false,
|
||||||
|
fitBounds = "instant",
|
||||||
|
elevationProfileContainer = undefined,
|
||||||
|
mapOptions = undefined,
|
||||||
|
activeTrail = $bindable(0),
|
||||||
|
minZoom = 0,
|
||||||
|
onmarkerdragend,
|
||||||
|
onsegmentdragend,
|
||||||
|
onselect,
|
||||||
|
onunselect,
|
||||||
|
onfullscreen,
|
||||||
|
onmoveend,
|
||||||
|
onzoom,
|
||||||
|
onclick,
|
||||||
|
oninit,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let mapContainer: HTMLDivElement;
|
let mapContainer: HTMLDivElement;
|
||||||
let epc: ElevationProfileControl | null = null;
|
let epc: ElevationProfileControl;
|
||||||
let graticule: MaplibreGraticule | null = null;
|
let graticule: MaplibreGraticule;
|
||||||
|
|
||||||
let layers: Record<
|
let layers: Record<
|
||||||
string,
|
string,
|
||||||
@@ -71,8 +109,6 @@
|
|||||||
|
|
||||||
let hoveringTrail: boolean = false;
|
let hoveringTrail: boolean = false;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
const trailColors = [
|
const trailColors = [
|
||||||
"#3549BB",
|
"#3549BB",
|
||||||
"#592E9E",
|
"#592E9E",
|
||||||
@@ -86,25 +122,17 @@
|
|||||||
"#DE7DC5",
|
"#DE7DC5",
|
||||||
];
|
];
|
||||||
|
|
||||||
$: data = getData(trails);
|
let data = $derived(getData(trails));
|
||||||
|
$effect(() => {
|
||||||
$: if (data && map) {
|
if (data && map) {
|
||||||
initMap(map.loaded());
|
untrack(() => initMap(map?.loaded() ?? false));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
$: if (activeTrail !== null && trails[activeTrail] !== undefined) {
|
$effect(() => {
|
||||||
if (
|
adjustTrailFocus(activeTrail);
|
||||||
!drawing &&
|
});
|
||||||
fitBounds !== "off" &&
|
$effect(() => {
|
||||||
data.some((d) => d.bbox !== undefined)
|
if ($theme == "dark") {
|
||||||
) {
|
|
||||||
focusTrail(trails[activeTrail]);
|
|
||||||
}
|
|
||||||
} else if (activeTrail === null && trails.length) {
|
|
||||||
unFocusTrail();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($theme == "dark") {
|
|
||||||
epc?.toggleTheme({
|
epc?.toggleTheme({
|
||||||
profileBackgroundColor: "#191b24",
|
profileBackgroundColor: "#191b24",
|
||||||
elevationGridColor: "#ddd2",
|
elevationGridColor: "#ddd2",
|
||||||
@@ -123,14 +151,16 @@
|
|||||||
tooltipTextColor: "#000",
|
tooltipTextColor: "#000",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
$: if (drawing && map) {
|
$effect(() => {
|
||||||
|
if (drawing && map) {
|
||||||
startDrawing();
|
startDrawing();
|
||||||
} else if (!drawing && map) {
|
} else if (!drawing && map) {
|
||||||
stopDrawing();
|
stopDrawing();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
$: if (showGrid) {
|
$effect(() => {
|
||||||
|
if (showGrid) {
|
||||||
if (!graticule) {
|
if (!graticule) {
|
||||||
graticule = new MaplibreGraticule({
|
graticule = new MaplibreGraticule({
|
||||||
minZoom: 0,
|
minZoom: 0,
|
||||||
@@ -153,11 +183,15 @@
|
|||||||
map?.removeControl(graticule);
|
map?.removeControl(graticule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
$: if (waypoints) {
|
$effect(() => {
|
||||||
|
if (waypoints) {
|
||||||
|
untrack(() => {
|
||||||
showWaypoints();
|
showWaypoints();
|
||||||
refreshElevationProfile();
|
refreshElevationProfile();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function getData(trails: Trail[]) {
|
function getData(trails: Trail[]) {
|
||||||
if (!trails.length) {
|
if (!trails.length) {
|
||||||
@@ -168,7 +202,7 @@
|
|||||||
trails.forEach((t) => {
|
trails.forEach((t) => {
|
||||||
if (t.expand?.gpx_data) {
|
if (t.expand?.gpx_data) {
|
||||||
r.push(toGeoJson(t.expand.gpx_data) as GeoJSON);
|
r.push(toGeoJson(t.expand.gpx_data) as GeoJSON);
|
||||||
} else if (t.lat && t.lon) {
|
} else if (t.lat !== null && t.lon !== null) {
|
||||||
r.push({
|
r.push({
|
||||||
id: "",
|
id: "",
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
@@ -212,7 +246,7 @@
|
|||||||
fitBounds !== "off" &&
|
fitBounds !== "off" &&
|
||||||
data.some((d) => d.bbox !== undefined)
|
data.some((d) => d.bbox !== undefined)
|
||||||
) {
|
) {
|
||||||
if (activeTrail !== null && mapLoaded) {
|
if (activeTrail !== null && trails[activeTrail] && mapLoaded) {
|
||||||
focusTrail(trails[activeTrail]);
|
focusTrail(trails[activeTrail]);
|
||||||
} else {
|
} else {
|
||||||
flyToBounds();
|
flyToBounds();
|
||||||
@@ -325,7 +359,7 @@
|
|||||||
|
|
||||||
createEmptyLayer(id);
|
createEmptyLayer(id);
|
||||||
|
|
||||||
if (!layers[id].source) {
|
if (!layers[id].source || !map.getSource(id)) {
|
||||||
try {
|
try {
|
||||||
map.addSource(id, {
|
map.addSource(id, {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
@@ -339,7 +373,7 @@
|
|||||||
layers[id].source.setData(geojson);
|
layers[id].source.setData(geojson);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!layers[id].layer) {
|
if (!layers[id].layer || !map.getLayer(id)) {
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: id,
|
id: id,
|
||||||
type: "line",
|
type: "line",
|
||||||
@@ -407,12 +441,12 @@
|
|||||||
function handleDragEnd(e: M.MapMouseEvent) {
|
function handleDragEnd(e: M.MapMouseEvent) {
|
||||||
map?.off("mousemove", moveElevationMarkerToCursorPosition);
|
map?.off("mousemove", moveElevationMarkerToCursorPosition);
|
||||||
epc?.hideCrosshair();
|
epc?.hideCrosshair();
|
||||||
dispatch("segmentDragEnd", { segment: draggingSegment, event: e });
|
onsegmentdragend?.({ segment: draggingSegment!, event: e });
|
||||||
draggingSegment = null;
|
draggingSegment = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCaretLayer(id?: string) {
|
function addCaretLayer(id?: string) {
|
||||||
if (!map || !id) {
|
if (!map || !id || !map.getSource(id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (map.getLayer("direction-carets")) {
|
if (map.getLayer("direction-carets")) {
|
||||||
@@ -459,11 +493,16 @@
|
|||||||
id: string,
|
id: string,
|
||||||
showElevationMarker: boolean = false,
|
showElevationMarker: boolean = false,
|
||||||
) {
|
) {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (showElevationMarker) {
|
if (showElevationMarker) {
|
||||||
elevationMarker.setOpacity("1");
|
elevationMarker.setOpacity("1");
|
||||||
}
|
}
|
||||||
map?.setPaintProperty(id, "line-width", 7);
|
map?.setPaintProperty(id, "line-width", 7);
|
||||||
|
if (map?.getLayer(id)) {
|
||||||
hoveringTrail = true;
|
hoveringTrail = true;
|
||||||
|
}
|
||||||
// map?.setPaintProperty(id, "line-color", "#2766e3");
|
// map?.setPaintProperty(id, "line-color", "#2766e3");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,17 +513,33 @@
|
|||||||
elevationMarker.setOpacity("0");
|
elevationMarker.setOpacity("0");
|
||||||
epc?.hideCrosshair();
|
epc?.hideCrosshair();
|
||||||
hoveringTrail = false;
|
hoveringTrail = false;
|
||||||
|
if (map?.getLayer(id)) {
|
||||||
map?.setPaintProperty(id, "line-width", 5);
|
map?.setPaintProperty(id, "line-width", 5);
|
||||||
|
}
|
||||||
// map?.setPaintProperty(id, "line-color", "#648ad5");
|
// map?.setPaintProperty(id, "line-color", "#648ad5");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adjustTrailFocus(activeTrail: number | null) {
|
||||||
|
if (activeTrail !== null && trails[activeTrail] !== undefined) {
|
||||||
|
if (
|
||||||
|
!drawing &&
|
||||||
|
fitBounds !== "off" &&
|
||||||
|
data.some((d) => d.bbox !== undefined)
|
||||||
|
) {
|
||||||
|
untrack(() => focusTrail(trails[activeTrail]));
|
||||||
|
}
|
||||||
|
} else if (activeTrail === null && trails.length) {
|
||||||
|
untrack(() => unFocusTrail());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function focusTrail(trail: Trail) {
|
function focusTrail(trail: Trail) {
|
||||||
activeTrail = trails.findIndex((t) => t.id == trail.id);
|
activeTrail = trails.findIndex((t) => t.id == trail.id);
|
||||||
if (activeTrail < 0) {
|
if (activeTrail < 0) {
|
||||||
activeTrail = null;
|
activeTrail = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch("select", trail);
|
onselect?.(trail);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
refreshElevationProfile();
|
refreshElevationProfile();
|
||||||
@@ -501,7 +556,7 @@
|
|||||||
|
|
||||||
function unFocusTrail(trail?: Trail) {
|
function unFocusTrail(trail?: Trail) {
|
||||||
if (trail) {
|
if (trail) {
|
||||||
dispatch("unselect", trail);
|
onunselect?.(trail);
|
||||||
unHighlightTrail(trail.id!);
|
unHighlightTrail(trail.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,11 +672,16 @@
|
|||||||
|
|
||||||
hideWaypoints();
|
hideWaypoints();
|
||||||
for (const waypoint of waypoints) {
|
for (const waypoint of waypoints) {
|
||||||
const marker = createMarkerFromWaypoint(waypoint, onMarkerDragEnd);
|
if (!markers.find((m) => m._element.id == waypoint.id)) {
|
||||||
|
const marker = createMarkerFromWaypoint(
|
||||||
|
waypoint,
|
||||||
|
onmarkerdragend,
|
||||||
|
);
|
||||||
marker.addTo(map);
|
marker.addTo(map);
|
||||||
markers.push(marker);
|
markers.push(marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hideWaypoints() {
|
function hideWaypoints() {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
@@ -630,6 +690,7 @@
|
|||||||
for (const m of markers) {
|
for (const m of markers) {
|
||||||
m.remove();
|
m.remove();
|
||||||
}
|
}
|
||||||
|
markers = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -646,7 +707,7 @@
|
|||||||
|
|
||||||
const mapStyles: { text: string; value: string; thumbnail?: string }[] =
|
const mapStyles: { text: string; value: string; thumbnail?: string }[] =
|
||||||
[
|
[
|
||||||
...(($page.data.settings as Settings)?.tilesets ?? []).map(
|
...((page.data.settings as Settings)?.tilesets ?? []).map(
|
||||||
(t) => ({
|
(t) => ({
|
||||||
text: t.name,
|
text: t.name,
|
||||||
value: t.url,
|
value: t.url,
|
||||||
@@ -683,6 +744,10 @@
|
|||||||
preferredMapStyleIndex = 0;
|
preferredMapStyleIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mapContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const finalMapOptions: M.MapOptions = {
|
const finalMapOptions: M.MapOptions = {
|
||||||
...{
|
...{
|
||||||
container: mapContainer,
|
container: mapContainer,
|
||||||
@@ -730,7 +795,7 @@
|
|||||||
map.addControl(
|
map.addControl(
|
||||||
new M.ScaleControl({
|
new M.ScaleControl({
|
||||||
maxWidth: 120,
|
maxWidth: 120,
|
||||||
unit: $page.data.settings?.unit ?? "metric",
|
unit: page.data.settings?.unit ?? "metric",
|
||||||
}),
|
}),
|
||||||
"top-left",
|
"top-left",
|
||||||
);
|
);
|
||||||
@@ -741,7 +806,7 @@
|
|||||||
enableHighAccuracy: true,
|
enableHighAccuracy: true,
|
||||||
},
|
},
|
||||||
fitBoundsOptions: {
|
fitBoundsOptions: {
|
||||||
animate: fitBounds == "animate"
|
animate: fitBounds == "animate",
|
||||||
},
|
},
|
||||||
trackUserLocation: true,
|
trackUserLocation: true,
|
||||||
}),
|
}),
|
||||||
@@ -757,7 +822,7 @@
|
|||||||
profileBackgroundColor:
|
profileBackgroundColor:
|
||||||
$theme == "light" ? "#242734" : "#191b24",
|
$theme == "light" ? "#242734" : "#191b24",
|
||||||
backgroundColor: "bg-menu-background/90",
|
backgroundColor: "bg-menu-background/90",
|
||||||
unit: $page.data.settings?.unit ?? "metric",
|
unit: page.data.settings?.unit ?? "metric",
|
||||||
profileLineWidth: 3,
|
profileLineWidth: 3,
|
||||||
displayDistanceGrid: true,
|
displayDistanceGrid: true,
|
||||||
tooltipDisplayDPlus: false,
|
tooltipDisplayDPlus: false,
|
||||||
@@ -785,7 +850,7 @@
|
|||||||
if (showFullscreen) {
|
if (showFullscreen) {
|
||||||
map.addControl(
|
map.addControl(
|
||||||
new FullscreenControl(() => {
|
new FullscreenControl(() => {
|
||||||
dispatch("fullscreen");
|
onfullscreen?.();
|
||||||
}),
|
}),
|
||||||
"bottom-right",
|
"bottom-right",
|
||||||
);
|
);
|
||||||
@@ -803,21 +868,21 @@
|
|||||||
if (showTerrain) {
|
if (showTerrain) {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
$page.data.settings?.terrain?.terrain &&
|
page.data.settings?.terrain?.terrain &&
|
||||||
!map?.getSource("terrain")
|
!map?.getSource("terrain")
|
||||||
) {
|
) {
|
||||||
map!.addSource("terrain", {
|
map!.addSource("terrain", {
|
||||||
type: "raster-dem",
|
type: "raster-dem",
|
||||||
url: $page.data.settings?.terrain?.terrain,
|
url: page.data.settings?.terrain?.terrain,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
$page.data.settings?.terrain?.hillshading &&
|
page.data.settings?.terrain?.hillshading &&
|
||||||
!map?.getSource("hillshading")
|
!map?.getSource("hillshading")
|
||||||
) {
|
) {
|
||||||
map!.addSource("hillshading", {
|
map!.addSource("hillshading", {
|
||||||
type: "raster-dem",
|
type: "raster-dem",
|
||||||
url: $page.data.settings?.terrain?.hillshading,
|
url: page.data.settings?.terrain?.hillshading,
|
||||||
});
|
});
|
||||||
map!.addLayer({
|
map!.addLayer({
|
||||||
id: "hillshading",
|
id: "hillshading",
|
||||||
@@ -830,7 +895,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.on("moveend", (e) => {
|
map.on("moveend", (e) => {
|
||||||
dispatch("moveend", e.target);
|
onmoveend?.(e.target);
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on("zoom", (e) => {
|
map.on("zoom", (e) => {
|
||||||
@@ -843,19 +908,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch("zoom", e.target);
|
onzoom?.(e.target);
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on("click", (e) => {
|
map.on("click", (e) => {
|
||||||
if (hoveringTrail && drawing) {
|
if (hoveringTrail && drawing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch("click", e);
|
onclick?.(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on("load", () => {
|
map.on("load", () => {
|
||||||
initMap(true);
|
initMap(true);
|
||||||
dispatch("init", map);
|
oninit?.(map!);
|
||||||
});
|
});
|
||||||
|
|
||||||
showWaypoints();
|
showWaypoints();
|
||||||
|
|||||||
@@ -1,30 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import PhotoCard from "../photo_card.svelte";
|
|
||||||
import { show_toast } from "$lib/stores/toast_store";
|
import { show_toast } from "$lib/stores/toast_store";
|
||||||
|
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
import PhotoCard from "../photo_card.svelte";
|
||||||
|
|
||||||
export let id: string;
|
interface Props {
|
||||||
export let photos: string[];
|
id: string;
|
||||||
export let photoFiles: File[] | undefined;
|
photos: string[];
|
||||||
export let parent: { [key: string]: any };
|
photoFiles: File[] | undefined;
|
||||||
export let thumbnail: number = 0;
|
parent: { [key: string]: any };
|
||||||
export let showThumbnailControls: boolean = true;
|
thumbnail?: number;
|
||||||
export let showExifControls: boolean = false;
|
showThumbnailControls?: boolean;
|
||||||
export let maxSizeBytes = 20971520;
|
showExifControls?: boolean;
|
||||||
|
maxSizeBytes?: number;
|
||||||
|
onexif?: (src: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
let photoPreviews: string[] = [];
|
let {
|
||||||
|
id,
|
||||||
|
photos = $bindable(),
|
||||||
|
photoFiles = $bindable(),
|
||||||
|
parent,
|
||||||
|
thumbnail = $bindable(0),
|
||||||
|
showThumbnailControls = true,
|
||||||
|
showExifControls = false,
|
||||||
|
maxSizeBytes = 20971520,
|
||||||
|
onexif,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
$: Promise.all(
|
let photoPreviews: string[] = $state([]);
|
||||||
(photoFiles ?? []).map(async (f) => {
|
|
||||||
|
$effect(() => fetchPhotos(photoFiles ?? []));
|
||||||
|
|
||||||
|
function fetchPhotos(photos: File[]) {
|
||||||
|
Promise.all(
|
||||||
|
photos.map(async (f) => {
|
||||||
return await readAsDataURLAsync(f);
|
return await readAsDataURLAsync(f);
|
||||||
}),
|
}),
|
||||||
).then((v) => {
|
).then((v) => {
|
||||||
photoPreviews = v;
|
photoPreviews = v;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let offerUpload: boolean = false;
|
let offerUpload: boolean = $state(false);
|
||||||
|
|
||||||
function handlePhotoDragOver(e: DragEvent) {
|
function handlePhotoDragOver(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -120,14 +138,15 @@
|
|||||||
? 'outline-dashed outline-input-border'
|
? 'outline-dashed outline-input-border'
|
||||||
: ''}"
|
: ''}"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
on:dragover={handlePhotoDragOver}
|
ondragover={handlePhotoDragOver}
|
||||||
on:dragleave={handlePhotoDragLeave}
|
ondragleave={handlePhotoDragLeave}
|
||||||
on:drop={handlePhotoDrop}
|
ondrop={handlePhotoDrop}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Open photo browser"
|
||||||
class="btn-secondary h-32 w-32 shrink-0 grow-0 basis-auto"
|
class="btn-secondary h-32 w-32 shrink-0 grow-0 basis-auto"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={openPhotoBrowser}><i class="fa fa-plus"></i></button
|
onclick={openPhotoBrowser}><i class="fa fa-plus"></i></button
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -135,17 +154,17 @@
|
|||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
on:change={() => handlePhotoSelection()}
|
onchange={() => handlePhotoSelection()}
|
||||||
/>
|
/>
|
||||||
<div class="flex overflow-x-auto gap-x-3 w-full">
|
<div class="flex overflow-x-auto gap-x-3 w-full">
|
||||||
{#each (photos ?? []).concat(photoPreviews) as photo, i}
|
{#each (photos ?? []).concat(photoPreviews) as photo, i}
|
||||||
<div class="shrink-0 grow-0 basis-auto">
|
<div class="shrink-0 grow-0 basis-auto">
|
||||||
<PhotoCard
|
<PhotoCard
|
||||||
src={i >= photos.length ? photo : getFileURL(parent, photo)}
|
src={i >= photos.length ? photo : getFileURL(parent, photo)}
|
||||||
on:delete={() => handlePhotoDelete(i)}
|
ondelete={() => handlePhotoDelete(i)}
|
||||||
isThumbnail={thumbnail === i}
|
isThumbnail={thumbnail === i}
|
||||||
on:thumbnail={() => makePhotoThumbnail(i)}
|
onthumbnail={() => makePhotoThumbnail(i)}
|
||||||
on:exif
|
{onexif}
|
||||||
{showThumbnailControls}
|
{showThumbnailControls}
|
||||||
{showExifControls}
|
{showExifControls}
|
||||||
></PhotoCard>
|
></PhotoCard>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createBubbler } from "svelte/legacy";
|
||||||
|
|
||||||
|
const bubble = createBubbler();
|
||||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
@@ -12,25 +15,41 @@
|
|||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import ShareInfo from "../share_info.svelte";
|
import ShareInfo from "../share_info.svelte";
|
||||||
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
|
|
||||||
export let trail: Trail;
|
interface Props {
|
||||||
export let fullWidth: boolean = false;
|
trail: Trail;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
onmouseenter?: MouseEventHandler<HTMLDivElement>;
|
||||||
|
onmouseleave?: MouseEventHandler<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
$: thumbnail = trail.photos.length
|
let {
|
||||||
|
trail,
|
||||||
|
fullWidth = false,
|
||||||
|
onmouseenter,
|
||||||
|
onmouseleave,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let thumbnail = $derived(
|
||||||
|
trail.photos.length
|
||||||
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
||||||
: $theme === "light"
|
: $theme === "light"
|
||||||
? emptyStateTrailLight
|
? emptyStateTrailLight
|
||||||
: emptyStateTrailDark;
|
: emptyStateTrailDark,
|
||||||
|
);
|
||||||
|
|
||||||
$: trailIsShared = (trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
|
let trailIsShared = $derived(
|
||||||
|
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="trail-card relative rounded-2xl border border-input-border min-w-72 {fullWidth
|
class="trail-card relative rounded-2xl border border-input-border min-w-72 {fullWidth
|
||||||
? ''
|
? ''
|
||||||
: 'lg:w-72'} cursor-pointer"
|
: 'lg:w-72'} cursor-pointer"
|
||||||
on:mouseenter
|
{onmouseenter}
|
||||||
on:mouseleave
|
{onmouseleave}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -22,19 +22,25 @@
|
|||||||
import { currentUser } from "$lib/stores/user_store";
|
import { currentUser } from "$lib/stores/user_store";
|
||||||
import { pb } from "$lib/pocketbase";
|
import { pb } from "$lib/pocketbase";
|
||||||
|
|
||||||
export let trail: Trail;
|
interface Props {
|
||||||
export let mode: "overview" | "map" | "list";
|
trail: Trail;
|
||||||
|
mode: "overview" | "map" | "list";
|
||||||
|
}
|
||||||
|
|
||||||
let openConfirmModal: () => void;
|
let { trail, mode }: Props = $props();
|
||||||
let openListSelectModal: () => void;
|
|
||||||
let openExportModal: () => void;
|
|
||||||
let openShareModal: () => void;
|
|
||||||
|
|
||||||
let lists: List[] = [];
|
let confirmModal: ConfirmModal;
|
||||||
|
let listSelectModal: ListSelectModal;
|
||||||
|
let trailExportModal: TrailExportModal;
|
||||||
|
let trailShareModal: TrailShareModal;
|
||||||
|
|
||||||
|
let lists: List[] = $state([]);
|
||||||
|
|
||||||
const allowEdit =
|
const allowEdit =
|
||||||
trail.author == $currentUser?.id ||
|
trail.author == $currentUser?.id ||
|
||||||
trail.expand?.trail_share_via_trail?.some((s) => s.permission == "edit");
|
trail.expand?.trail_share_via_trail?.some(
|
||||||
|
(s) => s.permission == "edit",
|
||||||
|
);
|
||||||
|
|
||||||
const dropdownItems: DropdownItem[] = [
|
const dropdownItems: DropdownItem[] = [
|
||||||
mode == "overview"
|
mode == "overview"
|
||||||
@@ -85,7 +91,7 @@
|
|||||||
-1,
|
-1,
|
||||||
)
|
)
|
||||||
).items;
|
).items;
|
||||||
openListSelectModal();
|
listSelectModal.openModal();
|
||||||
} else if (item.value == "direction") {
|
} else if (item.value == "direction") {
|
||||||
window
|
window
|
||||||
.open(
|
.open(
|
||||||
@@ -96,13 +102,13 @@
|
|||||||
} else if (item.value == "print") {
|
} else if (item.value == "print") {
|
||||||
goto(`/map/trail/${trail.id}/print`);
|
goto(`/map/trail/${trail.id}/print`);
|
||||||
} else if (item.value == "share") {
|
} else if (item.value == "share") {
|
||||||
openShareModal();
|
trailShareModal.openModal();
|
||||||
} else if (item.value == "download") {
|
} else if (item.value == "download") {
|
||||||
openExportModal();
|
trailExportModal.openModal();
|
||||||
} else if (item.value == "edit") {
|
} else if (item.value == "edit") {
|
||||||
goto(`/trail/edit/${trail.id}`);
|
goto(`/trail/edit/${trail.id}`);
|
||||||
} else if (item.value == "delete") {
|
} else if (item.value == "delete") {
|
||||||
openConfirmModal();
|
confirmModal.openModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +170,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTrail() {
|
async function deleteTrail() {
|
||||||
trails_delete(trail).then(() => history.back());
|
trails_delete(trail).then(() => goto('/trails'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleListSelection(list: List) {
|
async function handleListSelection(list: List) {
|
||||||
@@ -196,30 +202,30 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown items={dropdownItems} onchange={(item) => handleDropdownClick(item)}
|
||||||
items={dropdownItems}
|
>{#snippet children({ toggleMenu: openDropdown })}
|
||||||
on:change={(e) => handleDropdownClick(e.detail)}
|
<button
|
||||||
let:toggleMenu={openDropdown}
|
aria-label="Open dropdown"
|
||||||
><button
|
|
||||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
||||||
on:click={openDropdown}
|
onclick={openDropdown}
|
||||||
>
|
>
|
||||||
<i class="fa fa-ellipsis-vertical"></i>
|
<i class="fa fa-ellipsis-vertical"></i>
|
||||||
</button></Dropdown
|
</button>
|
||||||
>
|
{/snippet}
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
text={$_("delete-trail-confirm")}
|
text={$_("delete-trail-confirm")}
|
||||||
bind:openModal={openConfirmModal}
|
bind:this={confirmModal}
|
||||||
on:confirm={deleteTrail}
|
onconfirm={deleteTrail}
|
||||||
></ConfirmModal>
|
></ConfirmModal>
|
||||||
<ListSelectModal
|
<ListSelectModal
|
||||||
{lists}
|
{lists}
|
||||||
bind:openModal={openListSelectModal}
|
bind:this={listSelectModal}
|
||||||
on:change={(e) => handleListSelection(e.detail)}
|
onchange={(list) => handleListSelection(list)}
|
||||||
></ListSelectModal>
|
></ListSelectModal>
|
||||||
<TrailExportModal
|
<TrailExportModal
|
||||||
bind:openModal={openExportModal}
|
bind:this={trailExportModal}
|
||||||
on:export={(e) => exportTrail(e.detail)}
|
onexport={(settings) => exportTrail(settings)}
|
||||||
></TrailExportModal>
|
></TrailExportModal>
|
||||||
<TrailShareModal {trail} bind:openShareModal></TrailShareModal>
|
<TrailShareModal {trail} bind:this={trailShareModal}></TrailShareModal>
|
||||||
|
|||||||
@@ -1,34 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from "$lib/components/base/modal.svelte";
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import Select from "../base/select.svelte";
|
import Select from "../base/select.svelte";
|
||||||
|
|
||||||
export let openModal: (() => void) | undefined = undefined;
|
interface Props {
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
title?: string;
|
||||||
export let title: string = $_("export");
|
onexport?: (settings: typeof exportSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { title = $_("export"), onexport }: Props = $props();
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
|
||||||
|
export function openModal() {
|
||||||
|
modal.openModal();
|
||||||
|
}
|
||||||
|
|
||||||
const fileFormats = [
|
const fileFormats = [
|
||||||
{ text: "GPX", value: "gpx" },
|
{ text: "GPX", value: "gpx" },
|
||||||
{ text: "GeoJSON", value: "json" },
|
{ text: "GeoJSON", value: "json" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const exportSettings = {
|
const exportSettings: {
|
||||||
|
fileFormat: "gpx" | "json";
|
||||||
|
photos: boolean;
|
||||||
|
summitLog: boolean;
|
||||||
|
} = $state({
|
||||||
fileFormat: "gpx",
|
fileFormat: "gpx",
|
||||||
photos: false,
|
photos: false,
|
||||||
summitLog: false,
|
summitLog: false,
|
||||||
};
|
});
|
||||||
|
|
||||||
function exportTrail() {
|
function exportTrail() {
|
||||||
dispatch("export", exportSettings);
|
onexport?.(exportSettings);
|
||||||
closeModal!();
|
modal.closeModal();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal id="export-modal" {title} size="max-w-sm" bind:openModal bind:closeModal>
|
<Modal id="export-modal" {title} size="max-w-sm" bind:this={modal}>
|
||||||
<div slot="content">
|
{#snippet content()}
|
||||||
|
<div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<Select
|
<Select
|
||||||
bind:value={exportSettings.fileFormat}
|
bind:value={exportSettings.fileFormat}
|
||||||
@@ -59,15 +70,18 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div slot="footer" class="flex items-center gap-4">
|
{/snippet}
|
||||||
<button class="btn-secondary" on:click={closeModal}
|
{#snippet footer()}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||||
>{$_("cancel")}</button
|
>{$_("cancel")}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={exportTrail}
|
onclick={exportTrail}
|
||||||
name="save">{$_("export")}</button
|
name="save">{$_("export")}</button
|
||||||
>
|
>
|
||||||
</div></Modal
|
</div>
|
||||||
|
{/snippet}</Modal
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { TrailFilter } from "$lib/models/trail";
|
import type { TrailFilter } from "$lib/models/trail";
|
||||||
import { country_codes } from "$lib/util/country_code_util";
|
import { country_codes } from "$lib/util/country_code_util";
|
||||||
import { formatDistance, formatElevation } from "$lib/util/format_util";
|
import { formatDistance, formatElevation } from "$lib/util/format_util";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
import Datepicker from "../base/datepicker.svelte";
|
import Datepicker from "../base/datepicker.svelte";
|
||||||
@@ -17,18 +16,30 @@
|
|||||||
import UserSearch from "../user_search.svelte";
|
import UserSearch from "../user_search.svelte";
|
||||||
import { pb } from "$lib/pocketbase";
|
import { pb } from "$lib/pocketbase";
|
||||||
|
|
||||||
export let categories: Category[];
|
interface Props {
|
||||||
export let filterExpanded: boolean = true;
|
categories: Category[];
|
||||||
export let filter: TrailFilter;
|
filterExpanded?: boolean;
|
||||||
export let showTrailSearch: boolean = true;
|
filter: TrailFilter;
|
||||||
export let showCitySearch: boolean = true;
|
showTrailSearch?: boolean;
|
||||||
|
showCitySearch?: boolean;
|
||||||
|
onupdate?: (filter: TrailFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
$: categorySelectItems = categories.map((c) => ({
|
let {
|
||||||
|
categories,
|
||||||
|
filterExpanded = $bindable(true),
|
||||||
|
filter = $bindable(),
|
||||||
|
showTrailSearch = true,
|
||||||
|
showCitySearch = true,
|
||||||
|
onupdate,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let categorySelectItems = $derived(
|
||||||
|
categories.map((c) => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
text: c.name,
|
text: c.name,
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
const radioGroupItems: RadioItem[] = [
|
const radioGroupItems: RadioItem[] = [
|
||||||
{ text: $_("completed"), value: "completed" },
|
{ text: $_("completed"), value: "completed" },
|
||||||
@@ -42,12 +53,12 @@
|
|||||||
{ text: $_("difficult"), value: "difficult" },
|
{ text: $_("difficult"), value: "difficult" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let searchDropdownItems: SearchItem[] = [];
|
let searchDropdownItems: SearchItem[] = $state([]);
|
||||||
|
|
||||||
let citySearchQuery: string = "";
|
let citySearchQuery: string = $state("");
|
||||||
|
|
||||||
async function update() {
|
async function update() {
|
||||||
dispatch("update", filter);
|
onupdate?.(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCategoryFilter(categories: SelectItem[]) {
|
function setCategoryFilter(categories: SelectItem[]) {
|
||||||
@@ -134,13 +145,14 @@
|
|||||||
<div class="basis-full">
|
<div class="basis-full">
|
||||||
<Search
|
<Search
|
||||||
bind:value={filter.q}
|
bind:value={filter.q}
|
||||||
on:update={update}
|
onupdate={update}
|
||||||
placeholder="{$_('search-trails')}..."
|
placeholder="{$_('search-trails')}..."
|
||||||
></Search>
|
></Search>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle filter"
|
||||||
class="btn-icon md:hidden"
|
class="btn-icon md:hidden"
|
||||||
on:click={() => (filterExpanded = !filterExpanded)}
|
onclick={() => (filterExpanded = !filterExpanded)}
|
||||||
><i class="fa fa-sliders"></i></button
|
><i class="fa fa-sliders"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,8 +164,10 @@
|
|||||||
<hr class="my-4 border-separator" />
|
<hr class="my-4 border-separator" />
|
||||||
{/if}
|
{/if}
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
on:change={(e) => setCategoryFilter(e.detail)}
|
onchange={(value) => setCategoryFilter(value)}
|
||||||
value={categorySelectItems.filter(i => filter.category.includes(i.value))}
|
value={categorySelectItems.filter((i) =>
|
||||||
|
filter.category.includes(i.value),
|
||||||
|
)}
|
||||||
label={$_("categories")}
|
label={$_("categories")}
|
||||||
items={categorySelectItems}
|
items={categorySelectItems}
|
||||||
placeholder={`${$_("filter-categories")}...`}
|
placeholder={`${$_("filter-categories")}...`}
|
||||||
@@ -161,8 +175,8 @@
|
|||||||
<hr class="my-4 border-separator" />
|
<hr class="my-4 border-separator" />
|
||||||
{#if pb.authStore.model}
|
{#if pb.authStore.model}
|
||||||
<UserSearch
|
<UserSearch
|
||||||
on:click={(e) => setAuthorFilter(e.detail)}
|
onclick={(item) => setAuthorFilter(item)}
|
||||||
on:clear={() => {
|
onclear={() => {
|
||||||
filter.author = "";
|
filter.author = "";
|
||||||
update();
|
update();
|
||||||
}}
|
}}
|
||||||
@@ -175,7 +189,7 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={filter.public}
|
checked={filter.public}
|
||||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||||
on:change={setPublicFilter}
|
onchange={setPublicFilter}
|
||||||
/>
|
/>
|
||||||
<label for="public-checkbox" class="ms-2 text-sm"
|
<label for="public-checkbox" class="ms-2 text-sm"
|
||||||
>{$_("public")}</label
|
>{$_("public")}</label
|
||||||
@@ -187,7 +201,7 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={filter.shared}
|
checked={filter.shared}
|
||||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||||
on:change={setSharedFilter}
|
onchange={setSharedFilter}
|
||||||
/>
|
/>
|
||||||
<label for="shared-checkbox" class="ms-2 text-sm"
|
<label for="shared-checkbox" class="ms-2 text-sm"
|
||||||
>{$_("shared")}</label
|
>{$_("shared")}</label
|
||||||
@@ -196,7 +210,7 @@
|
|||||||
<hr class="my-4 border-separator" />
|
<hr class="my-4 border-separator" />
|
||||||
{/if}
|
{/if}
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
on:change={(e) => setDifficultyFilter(e.detail)}
|
onchange={(value) => setDifficultyFilter(value)}
|
||||||
label={$_("difficulty")}
|
label={$_("difficulty")}
|
||||||
items={difficultyItems}
|
items={difficultyItems}
|
||||||
placeholder={`${$_("filter-difficulty")}...`}
|
placeholder={`${$_("filter-difficulty")}...`}
|
||||||
@@ -210,14 +224,14 @@
|
|||||||
placeholder="{$_('search-cities')}..."
|
placeholder="{$_('search-cities')}..."
|
||||||
clearAfterSelect={false}
|
clearAfterSelect={false}
|
||||||
bind:value={citySearchQuery}
|
bind:value={citySearchQuery}
|
||||||
on:update={(e) => searchCities(e.detail)}
|
onupdate={(q) => searchCities(q)}
|
||||||
on:click={(e) => handleSearchClick(e.detail)}
|
onclick={(item) => handleSearchClick(item)}
|
||||||
></Search>
|
></Search>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
maxValue={10000}
|
maxValue={10000}
|
||||||
bind:currentValue={filter.near.radius}
|
bind:currentValue={filter.near.radius}
|
||||||
on:set={() => update()}
|
onset={() => update()}
|
||||||
></Slider>
|
></Slider>
|
||||||
<p>
|
<p>
|
||||||
<span class="text-gray-500 text-sm">{$_("radius")}:</span>
|
<span class="text-gray-500 text-sm">{$_("radius")}:</span>
|
||||||
@@ -231,7 +245,7 @@
|
|||||||
maxValue={filter.distanceLimit}
|
maxValue={filter.distanceLimit}
|
||||||
bind:currentMin={filter.distanceMin}
|
bind:currentMin={filter.distanceMin}
|
||||||
bind:currentMax={filter.distanceMax}
|
bind:currentMax={filter.distanceMax}
|
||||||
on:set={() => update()}
|
onset={() => update()}
|
||||||
></DoubleSlider>
|
></DoubleSlider>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>{formatDistance(filter.distanceMin)}</span>
|
<span>{formatDistance(filter.distanceMin)}</span>
|
||||||
@@ -249,7 +263,7 @@
|
|||||||
maxValue={filter.elevationGainLimit}
|
maxValue={filter.elevationGainLimit}
|
||||||
bind:currentMin={filter.elevationGainMin}
|
bind:currentMin={filter.elevationGainMin}
|
||||||
bind:currentMax={filter.elevationGainMax}
|
bind:currentMax={filter.elevationGainMax}
|
||||||
on:set={() => update()}
|
onset={() => update()}
|
||||||
></DoubleSlider>
|
></DoubleSlider>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>{formatElevation(filter.elevationGainMin)}</span>
|
<span>{formatElevation(filter.elevationGainMin)}</span>
|
||||||
@@ -268,7 +282,7 @@
|
|||||||
maxValue={filter.elevationLossLimit}
|
maxValue={filter.elevationLossLimit}
|
||||||
bind:currentMin={filter.elevationLossMin}
|
bind:currentMin={filter.elevationLossMin}
|
||||||
bind:currentMax={filter.elevationLossMax}
|
bind:currentMax={filter.elevationLossMax}
|
||||||
on:set={() => update()}
|
onset={() => update()}
|
||||||
></DoubleSlider>
|
></DoubleSlider>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>{formatElevation(filter.elevationLossMin)}</span>
|
<span>{formatElevation(filter.elevationLossMin)}</span>
|
||||||
@@ -287,13 +301,13 @@
|
|||||||
name="startDate"
|
name="startDate"
|
||||||
label={$_("after")}
|
label={$_("after")}
|
||||||
bind:value={filter.startDate}
|
bind:value={filter.startDate}
|
||||||
on:change={update}
|
onchange={update}
|
||||||
></Datepicker>
|
></Datepicker>
|
||||||
<Datepicker
|
<Datepicker
|
||||||
name="endDate"
|
name="endDate"
|
||||||
label={$_("before")}
|
label={$_("before")}
|
||||||
bind:value={filter.endDate}
|
bind:value={filter.endDate}
|
||||||
on:change={update}
|
onchange={update}
|
||||||
></Datepicker>
|
></Datepicker>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -303,7 +317,7 @@
|
|||||||
name="completed"
|
name="completed"
|
||||||
items={radioGroupItems}
|
items={radioGroupItems}
|
||||||
selected={2}
|
selected={2}
|
||||||
on:change={(e) => setCompletedFilter(e.detail)}
|
onchange={(item) => setCompletedFilter(item)}
|
||||||
></RadioGroup>
|
></RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -44,10 +44,14 @@
|
|||||||
import EmptyStateDescription from "../empty_states/empty_state_description.svelte";
|
import EmptyStateDescription from "../empty_states/empty_state_description.svelte";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
export let trail: Trail;
|
interface Props {
|
||||||
export let mode: "overview" | "map" | "list" = "map";
|
trail: Trail;
|
||||||
export let markers: M.Marker[] = [];
|
mode?: "overview" | "map" | "list";
|
||||||
export let activeTab = 0;
|
markers?: M.Marker[];
|
||||||
|
activeTab?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { trail, mode = "map", markers = [], activeTab = 0 }: Props = $props();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
$_("description"),
|
$_("description"),
|
||||||
@@ -60,24 +64,19 @@
|
|||||||
const trailIsShared =
|
const trailIsShared =
|
||||||
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
|
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
|
||||||
|
|
||||||
$: thumbnail = trail.photos.length
|
let gallery: PhotoGallery;
|
||||||
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
|
||||||
: $theme === "light"
|
|
||||||
? emptyStateTrailLight
|
|
||||||
: emptyStateTrailDark;
|
|
||||||
|
|
||||||
let openGallery: (idx?: number) => void;
|
let newComment: Comment = $state({
|
||||||
|
text: "",
|
||||||
|
rating: 0,
|
||||||
|
author: "",
|
||||||
|
trail: trail.id ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
let newComment: Comment = new Comment("", 0, "", trail.id ?? "");
|
let commentsLoading: boolean = $state(activeTab == 4);
|
||||||
|
let commentCreateLoading: boolean = $state(false);
|
||||||
let commentsLoading: boolean = activeTab == 4;
|
|
||||||
let commentCreateLoading: boolean = false;
|
|
||||||
let commentDeleteLoading: boolean = false;
|
let commentDeleteLoading: boolean = false;
|
||||||
|
|
||||||
$: if (browser && activeTab == 4) {
|
|
||||||
fetchComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {});
|
onMount(async () => {});
|
||||||
|
|
||||||
function openMarkerPopup(i: number) {
|
function openMarkerPopup(i: number) {
|
||||||
@@ -144,6 +143,18 @@
|
|||||||
comments.set(newCommentList);
|
comments.set(newCommentList);
|
||||||
commentDeleteLoading = false;
|
commentDeleteLoading = false;
|
||||||
}
|
}
|
||||||
|
let thumbnail = $derived(
|
||||||
|
trail.photos.length
|
||||||
|
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
||||||
|
: $theme === "light"
|
||||||
|
? emptyStateTrailLight
|
||||||
|
: emptyStateTrailDark,
|
||||||
|
);
|
||||||
|
$effect(() => {
|
||||||
|
if (browser && activeTab == 4) {
|
||||||
|
fetchComments();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -180,8 +191,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if trailIsShared}
|
{#if trailIsShared}
|
||||||
<ShareInfo type="trail" subject={trail}
|
<ShareInfo type="trail" subject={trail}></ShareInfo>
|
||||||
></ShareInfo>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -316,8 +326,8 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{#each trail.expand?.waypoints ?? [] as waypoint, i}
|
{#each trail.expand?.waypoints ?? [] as waypoint, i}
|
||||||
<li
|
<li
|
||||||
on:mouseenter={() => openMarkerPopup(i)}
|
onmouseenter={() => openMarkerPopup(i)}
|
||||||
on:mouseleave={() => closeMarkerPopup(i)}
|
onmouseleave={() => closeMarkerPopup(i)}
|
||||||
>
|
>
|
||||||
<WaypointCard {waypoint}></WaypointCard>
|
<WaypointCard {waypoint}></WaypointCard>
|
||||||
</li>
|
</li>
|
||||||
@@ -339,14 +349,14 @@
|
|||||||
photos={trail.photos.map((p) =>
|
photos={trail.photos.map((p) =>
|
||||||
getFileURL(trail, p),
|
getFileURL(trail, p),
|
||||||
)}
|
)}
|
||||||
bind:open={openGallery}
|
bind:this={gallery}
|
||||||
></PhotoGallery>
|
></PhotoGallery>
|
||||||
{#each trail.photos ?? [] as photo, i}
|
{#each trail.photos ?? [] as photo, i}
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<img
|
<img
|
||||||
class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
|
class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
|
||||||
on:click={() => openGallery(i)}
|
onclick={() => gallery.openGallery(i)}
|
||||||
src={getFileURL(trail, photo)}
|
src={getFileURL(trail, photo)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
@@ -389,7 +399,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end mt-3">
|
<div class="flex justify-end mt-3">
|
||||||
<Button
|
<Button
|
||||||
on:click={createComment}
|
onclick={createComment}
|
||||||
loading={commentCreateLoading}
|
loading={commentCreateLoading}
|
||||||
secondary={true}
|
secondary={true}
|
||||||
disabled={commentCreateLoading ||
|
disabled={commentCreateLoading ||
|
||||||
@@ -415,9 +425,8 @@
|
|||||||
mode={comment.author == $currentUser?.id
|
mode={comment.author == $currentUser?.id
|
||||||
? "edit"
|
? "edit"
|
||||||
: "show"}
|
: "show"}
|
||||||
on:delete={(e) =>
|
ondelete={deleteComment}
|
||||||
deleteComment(e.detail)}
|
onedit={editComment}
|
||||||
on:edit={(e) => editComment(e.detail)}
|
|
||||||
></CommentCard>
|
></CommentCard>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -435,7 +444,7 @@
|
|||||||
showStyleSwitcher={false}
|
showStyleSwitcher={false}
|
||||||
showFullscreen={true}
|
showFullscreen={true}
|
||||||
mapOptions={{ attributionControl: false }}
|
mapOptions={{ attributionControl: false }}
|
||||||
on:fullscreen={toggleMapFullScreen}
|
onfullscreen={toggleMapFullScreen}
|
||||||
bind:markers
|
bind:markers
|
||||||
></MapWithElevationMaplibre>
|
></MapWithElevationMaplibre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Trail, TrailFilter } from "$lib/models/trail";
|
import type { Trail, TrailFilter } from "$lib/models/trail";
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import Pagination from "../base/pagination.svelte";
|
import Pagination from "../base/pagination.svelte";
|
||||||
import Select, { type SelectItem } from "../base/select.svelte";
|
import Select, { type SelectItem } from "../base/select.svelte";
|
||||||
@@ -10,15 +9,30 @@
|
|||||||
import TrailTable from "./trail_table.svelte";
|
import TrailTable from "./trail_table.svelte";
|
||||||
import SkeletonCard from "../base/skeleton_card.svelte";
|
import SkeletonCard from "../base/skeleton_card.svelte";
|
||||||
import SkeletonListItem from "../base/skeleton_list_item.svelte";
|
import SkeletonListItem from "../base/skeleton_list_item.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
export let filter: TrailFilter | null = null;
|
interface Props {
|
||||||
export let trails: Trail[];
|
filter?: TrailFilter | null;
|
||||||
export let pagination: { page: number; totalPages: number } = {
|
trails: Trail[];
|
||||||
|
pagination?: { page: number; totalPages: number };
|
||||||
|
loading?: boolean;
|
||||||
|
fullWidthCards?: boolean;
|
||||||
|
onupdate?: (filter: TrailFilter | null) => void;
|
||||||
|
onpagination?: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
filter = $bindable(null),
|
||||||
|
trails,
|
||||||
|
pagination = {
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
},
|
||||||
export let loading: boolean = false;
|
loading = false,
|
||||||
export let fullWidthCards: boolean = false;
|
fullWidthCards = false,
|
||||||
|
onupdate,
|
||||||
|
onpagination,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const displayOptions: SelectItem[] = [
|
const displayOptions: SelectItem[] = [
|
||||||
{ text: $_("card", { values: { n: 2 } }), value: "cards" },
|
{ text: $_("card", { values: { n: 2 } }), value: "cards" },
|
||||||
@@ -26,9 +40,7 @@
|
|||||||
{ text: $_("table"), value: "table" },
|
{ text: $_("table"), value: "table" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let selectedDisplayOption = displayOptions[0].value;
|
let selectedDisplayOption = $state(displayOptions[0].value);
|
||||||
|
|
||||||
let dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
const sortOptions: SelectItem[] = [
|
const sortOptions: SelectItem[] = [
|
||||||
{ text: $_("name"), value: "name" },
|
{ text: $_("name"), value: "name" },
|
||||||
@@ -58,7 +70,7 @@
|
|||||||
(storedSortOrder as typeof filter.sortOrder | null) ??
|
(storedSortOrder as typeof filter.sortOrder | null) ??
|
||||||
filter.sortOrder;
|
filter.sortOrder;
|
||||||
}
|
}
|
||||||
dispatch("update", filter);
|
onupdate?.(filter);
|
||||||
});
|
});
|
||||||
|
|
||||||
function setDisplayOption() {
|
function setDisplayOption() {
|
||||||
@@ -70,7 +82,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localStorage.setItem("sort", filter.sort);
|
localStorage.setItem("sort", filter.sort);
|
||||||
dispatch("update", filter);
|
onupdate?.(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSortOrder() {
|
function setSortOrder() {
|
||||||
@@ -83,7 +95,7 @@
|
|||||||
filter.sortOrder = "+";
|
filter.sortOrder = "+";
|
||||||
}
|
}
|
||||||
localStorage.setItem("sort_order", filter.sortOrder);
|
localStorage.setItem("sort_order", filter.sortOrder);
|
||||||
dispatch("update", filter);
|
onupdate?.(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSortUpdate(sort: any) {
|
function handleSortUpdate(sort: any) {
|
||||||
@@ -106,7 +118,7 @@
|
|||||||
<Pagination
|
<Pagination
|
||||||
page={pagination.page}
|
page={pagination.page}
|
||||||
totalPages={pagination.totalPages}
|
totalPages={pagination.totalPages}
|
||||||
on:pagination
|
{onpagination}
|
||||||
></Pagination>
|
></Pagination>
|
||||||
</div>
|
</div>
|
||||||
{#if filter}
|
{#if filter}
|
||||||
@@ -117,13 +129,14 @@
|
|||||||
<Select
|
<Select
|
||||||
bind:value={filter.sort}
|
bind:value={filter.sort}
|
||||||
items={sortOptions}
|
items={sortOptions}
|
||||||
on:change={setSort}
|
onchange={setSort}
|
||||||
></Select>
|
></Select>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Change sort order"
|
||||||
id="sort-order-btn"
|
id="sort-order-btn"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
class:rotated={filter.sortOrder == "-"}
|
class:rotated={filter.sortOrder == "-"}
|
||||||
on:click={() => setSortOrder()}
|
onclick={() => setSortOrder()}
|
||||||
><i class="fa fa-arrow-up"></i></button
|
><i class="fa fa-arrow-up"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +149,7 @@
|
|||||||
<Select
|
<Select
|
||||||
bind:value={selectedDisplayOption}
|
bind:value={selectedDisplayOption}
|
||||||
items={displayOptions}
|
items={displayOptions}
|
||||||
on:change={() => setDisplayOption()}
|
onchange={() => setDisplayOption()}
|
||||||
></Select>
|
></Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +183,7 @@
|
|||||||
(option) => option.value !== "elevation_loss",
|
(option) => option.value !== "elevation_loss",
|
||||||
)}
|
)}
|
||||||
{filter}
|
{filter}
|
||||||
on:sort={(sort) => handleSortUpdate(sort)}
|
onsort={handleSortUpdate}
|
||||||
></TrailTable>
|
></TrailTable>
|
||||||
{:else}
|
{:else}
|
||||||
{#each trails as trail}
|
{#each trails as trail}
|
||||||
@@ -193,7 +206,7 @@
|
|||||||
<Pagination
|
<Pagination
|
||||||
page={pagination.page}
|
page={pagination.page}
|
||||||
totalPages={pagination.totalPages}
|
totalPages={pagination.totalPages}
|
||||||
on:pagination
|
{onpagination}
|
||||||
></Pagination>
|
></Pagination>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,19 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import ShareInfo from "../share_info.svelte";
|
import ShareInfo from "../share_info.svelte";
|
||||||
|
|
||||||
export let trail: Trail;
|
|
||||||
|
|
||||||
export let showDescription: boolean = true;
|
interface Props {
|
||||||
|
trail: Trail;
|
||||||
|
showDescription?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
$: thumbnail = trail.photos.length
|
let { trail, showDescription = true }: Props = $props();
|
||||||
|
|
||||||
|
let thumbnail = $derived(trail.photos.length
|
||||||
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
||||||
: $theme === "light"
|
: $theme === "light"
|
||||||
? emptyStateTrailLight
|
? emptyStateTrailLight
|
||||||
: emptyStateTrailDark;
|
: emptyStateTrailDark);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -2,23 +2,25 @@
|
|||||||
import * as M from "maplibre-gl";
|
import * as M from "maplibre-gl";
|
||||||
|
|
||||||
import { fromFile, toGeoJson } from "$lib/util/gpx_util";
|
import { fromFile, toGeoJson } from "$lib/util/gpx_util";
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
export let trailFile: File | undefined | null;
|
interface Props {
|
||||||
export let trailData: string | undefined;
|
trailFile: File | undefined | null;
|
||||||
export let label: string = "";
|
trailData: string | undefined;
|
||||||
|
label?: string;
|
||||||
|
onchange: (data: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
trailFile = $bindable(),
|
||||||
|
trailData = $bindable(),
|
||||||
|
label = "",
|
||||||
|
onchange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let map: M.Map;
|
let map: M.Map;
|
||||||
let layer: M.LineLayerSpecification | undefined;
|
let layer: M.LineLayerSpecification | undefined;
|
||||||
let source: M.GeoJSONSource | undefined;
|
let source: M.GeoJSONSource | undefined;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
$: if (trailData !== undefined) {
|
|
||||||
showTrailOnMap();
|
|
||||||
} else {
|
|
||||||
removeTrailFromMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
await initMap();
|
await initMap();
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
trailFile = null;
|
trailFile = null;
|
||||||
trailData = undefined;
|
trailData = undefined;
|
||||||
|
|
||||||
dispatch("change", null);
|
onchange?.(null);
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("trail-input")!.click();
|
document.getElementById("trail-input")!.click();
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
|
|
||||||
trailData = parseResult.gpxData;
|
trailData = parseResult.gpxData;
|
||||||
|
|
||||||
dispatch("change", trailData);
|
onchange?.(trailData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showTrailOnMap() {
|
async function showTrailOnMap() {
|
||||||
@@ -112,10 +114,14 @@
|
|||||||
map?.removeSource(source.id);
|
map?.removeSource(source.id);
|
||||||
source = undefined;
|
source = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
trailData = undefined;
|
|
||||||
trailFile = null;
|
|
||||||
}
|
}
|
||||||
|
$effect(() => {
|
||||||
|
if (trailData !== undefined) {
|
||||||
|
showTrailOnMap();
|
||||||
|
} else {
|
||||||
|
removeTrailFromMap();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -131,11 +137,11 @@
|
|||||||
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
||||||
multiple={false}
|
multiple={false}
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
on:change={() => handleTrailSelection()}
|
onchange={() => handleTrailSelection()}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={openTrailBrowser}
|
onclick={openTrailBrowser}
|
||||||
class="h-28 aspect-square rounded-xl !bg-background border border-input-border-focus hover:!bg-secondary-hover group"
|
class="h-28 aspect-square rounded-xl !bg-background border border-input-border-focus hover:!bg-secondary-hover group"
|
||||||
id="trail-picker-map"
|
id="trail-picker-map"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import Modal from "$lib/components/base/modal.svelte";
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
import { TrailShare } from "$lib/models/trail_share";
|
import { TrailShare } from "$lib/models/trail_share";
|
||||||
import type { User } from "$lib/models/user";
|
|
||||||
import { show_toast } from "$lib/stores/toast_store";
|
|
||||||
import {
|
import {
|
||||||
shares,
|
shares,
|
||||||
trail_share_create,
|
trail_share_create,
|
||||||
@@ -11,30 +9,31 @@
|
|||||||
trail_share_index,
|
trail_share_index,
|
||||||
trail_share_update,
|
trail_share_update,
|
||||||
} from "$lib/stores/trail_share_store";
|
} from "$lib/stores/trail_share_store";
|
||||||
import { users_search } from "$lib/stores/user_store";
|
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import Button from "../base/button.svelte";
|
import Button from "../base/button.svelte";
|
||||||
import Search, { type SearchItem } from "../base/search.svelte";
|
|
||||||
import type { SelectItem } from "../base/select.svelte";
|
import type { SelectItem } from "../base/select.svelte";
|
||||||
import Select from "../base/select.svelte";
|
import Select from "../base/select.svelte";
|
||||||
import UserSearch from "../user_search.svelte";
|
import UserSearch from "../user_search.svelte";
|
||||||
|
|
||||||
let openModal: (() => void) | undefined = undefined;
|
interface Props {
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
trail: Trail;
|
||||||
export function openShareModal() {
|
onsave?: () => void;
|
||||||
fetchShares();
|
|
||||||
if (openModal) {
|
|
||||||
openModal();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export let trail: Trail;
|
let { trail, onsave }: Props = $props();
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let modal: Modal;
|
||||||
|
|
||||||
let copyButtonText = $_("copy-link");
|
export function openModal() {
|
||||||
|
openShareModalLocal();
|
||||||
|
}
|
||||||
|
async function openShareModalLocal() {
|
||||||
|
modal.openModal();
|
||||||
|
await fetchShares();
|
||||||
|
}
|
||||||
|
|
||||||
|
let copyButtonText = $state($_("copy-link"));
|
||||||
|
|
||||||
let sharesLoading: boolean = false;
|
let sharesLoading: boolean = false;
|
||||||
|
|
||||||
@@ -51,8 +50,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
dispatch("save");
|
onsave?.();
|
||||||
closeModal!();
|
modal.closeModal!();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareTrail(item: SelectItem) {
|
async function shareTrail(item: SelectItem) {
|
||||||
@@ -85,11 +84,11 @@
|
|||||||
id="share-modal"
|
id="share-modal"
|
||||||
title={$_("share-this-trail")}
|
title={$_("share-this-trail")}
|
||||||
size="max-w-sm overflow-visible"
|
size="max-w-sm overflow-visible"
|
||||||
bind:openModal
|
bind:this={modal}
|
||||||
bind:closeModal
|
|
||||||
>
|
>
|
||||||
<div slot="content">
|
{#snippet content()}
|
||||||
<UserSearch includeSelf={false} on:click={(e) => shareTrail(e.detail)}
|
<div>
|
||||||
|
<UserSearch includeSelf={false} onclick={(item) => shareTrail(item)}
|
||||||
></UserSearch>
|
></UserSearch>
|
||||||
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
|
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
|
||||||
|
|
||||||
@@ -117,16 +116,17 @@
|
|||||||
>
|
>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<Select
|
<Select
|
||||||
bind:value={share.permission}
|
value={share.permission}
|
||||||
items={permissionSelectItems}
|
items={permissionSelectItems}
|
||||||
on:change={(e) =>
|
onchange={(value) =>
|
||||||
updateSharePermission(share, e.detail)}
|
updateSharePermission(share, value)}
|
||||||
></Select>
|
></Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
aria-label="Delete share"
|
||||||
class="btn-icon text-red-500"
|
class="btn-icon text-red-500"
|
||||||
on:click={() => deleteShare(share)}
|
onclick={() => deleteShare(share)}
|
||||||
><i class="fa fa-trash"></i></button
|
><i class="fa fa-trash"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,15 +134,18 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div slot="footer" class="flex justify-between items-center gap-4">
|
{/snippet}
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="flex justify-between items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
secondary={true}
|
secondary={true}
|
||||||
disabled={copyButtonText == $_("link-copied")}
|
disabled={copyButtonText == $_("link-copied")}
|
||||||
on:click={copyURLToClipboard}
|
onclick={copyURLToClipboard}
|
||||||
>
|
>
|
||||||
<i class="fa fa-link mr-2"></i>
|
<i class="fa fa-link mr-2"></i>
|
||||||
{copyButtonText}
|
{copyButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
<button class="btn-primary" on:click={close}>{$_("close")}</button>
|
<button class="btn-primary" onclick={close}>{$_("close")}</button>
|
||||||
</div></Modal
|
</div>
|
||||||
|
{/snippet}</Modal
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,15 +8,17 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import type { SelectItem } from "../base/select.svelte";
|
import type { SelectItem } from "../base/select.svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import ShareInfo from "../share_info.svelte";
|
import ShareInfo from "../share_info.svelte";
|
||||||
|
|
||||||
export let tableHeader: SelectItem[];
|
interface Props {
|
||||||
export let trails: Trail[] | null = null;
|
tableHeader: SelectItem[];
|
||||||
export let filter: TrailFilter | null = null;
|
trails?: Trail[] | null;
|
||||||
|
filter?: TrailFilter | null;
|
||||||
|
onsort?: (value: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
let dispatch = createEventDispatcher();
|
let { tableHeader, trails = null, filter = null, onsort }: Props = $props();
|
||||||
|
|
||||||
function getColumnWidth(columnValue: string): string {
|
function getColumnWidth(columnValue: string): string {
|
||||||
switch (columnValue) {
|
switch (columnValue) {
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
class="p-4 text-left text-sm font-medium {getColumnWidth(
|
class="p-4 text-left text-sm font-medium {getColumnWidth(
|
||||||
column.value,
|
column.value,
|
||||||
)}"
|
)}"
|
||||||
on:click={() => dispatch("sort", column.value)}
|
onclick={() => onsort?.(column.value)}
|
||||||
>
|
>
|
||||||
<div class="cursor-pointer">
|
<div class="cursor-pointer">
|
||||||
{column.text}
|
{column.text}
|
||||||
@@ -69,7 +71,7 @@
|
|||||||
{#each trails as trail}
|
{#each trails as trail}
|
||||||
<tr
|
<tr
|
||||||
class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors"
|
class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors"
|
||||||
on:click={() => goto(`/trail/view/${trail.id}`)}
|
onclick={() => goto(`/trail/view/${trail.id}`)}
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="flex justify-between items-center text-sm relative"
|
class="flex justify-between items-center text-sm relative"
|
||||||
|
|||||||
@@ -3,23 +3,33 @@
|
|||||||
import { show_toast } from "$lib/stores/toast_store";
|
import { show_toast } from "$lib/stores/toast_store";
|
||||||
import { users_search } from "$lib/stores/user_store";
|
import { users_search } from "$lib/stores/user_store";
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import { createEventDispatcher, tick } from "svelte";
|
|
||||||
import Search, { type SearchItem } from "./base/search.svelte";
|
import Search, { type SearchItem } from "./base/search.svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let label: string = "";
|
interface Props {
|
||||||
export let value: string = "";
|
label?: string;
|
||||||
export let includeSelf: boolean = true;
|
value?: string;
|
||||||
export let clearAfterSelect: boolean = true;
|
includeSelf?: boolean;
|
||||||
|
clearAfterSelect?: boolean;
|
||||||
|
onclear?: () => void
|
||||||
|
onclick?: (item: SearchItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
let searchItems: SearchItem[] = [];
|
let {
|
||||||
|
label = "",
|
||||||
|
value = $bindable(""),
|
||||||
|
includeSelf = true,
|
||||||
|
clearAfterSelect = true,
|
||||||
|
onclear,
|
||||||
|
onclick
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let searchItems: SearchItem[] = $state([]);
|
||||||
|
|
||||||
async function updateUsers(q: string) {
|
async function updateUsers(q: string) {
|
||||||
if (!q.length) {
|
if (!q.length) {
|
||||||
searchItems = [];
|
searchItems = [];
|
||||||
dispatch("clear");
|
onclear?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -39,29 +49,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(e: CustomEvent<SearchItem>) {
|
function onClick(item: SearchItem) {
|
||||||
value = e.detail.value.username ?? value;
|
value = item.value.username ?? value;
|
||||||
|
|
||||||
dispatch("click", e.detail);
|
onclick?.(item);
|
||||||
searchItems = [];
|
searchItems = [];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Search
|
<Search
|
||||||
on:update={(e) => updateUsers(e.detail)}
|
onupdate={(q) => updateUsers(q)}
|
||||||
on:click={(e) => onClick(e)}
|
onclick={(item) => onClick(item)}
|
||||||
placeholder={`${$_("username")}...`}
|
placeholder={`${$_("username")}...`}
|
||||||
items={searchItems}
|
items={searchItems}
|
||||||
{clearAfterSelect}
|
{clearAfterSelect}
|
||||||
{label}
|
{label}
|
||||||
bind:value
|
bind:value
|
||||||
>
|
>
|
||||||
|
{#snippet prepend({ item })}
|
||||||
<img
|
<img
|
||||||
slot="item-header"
|
|
||||||
let:item
|
|
||||||
class="rounded-full w-8 aspect-square mr-2"
|
class="rounded-full w-8 aspect-square mr-2"
|
||||||
src={getFileURL(item.value, item.value.avatar) ||
|
src={getFileURL(item.value, item.value.avatar) ||
|
||||||
`https://api.dicebear.com/7.x/initials/svg?seed=${item.value.username}&backgroundType=gradientLinear`}
|
`https://api.dicebear.com/7.x/initials/svg?seed=${item.value.username}&backgroundType=gradientLinear`}
|
||||||
alt="avatar"
|
alt="avatar"
|
||||||
/>
|
/>
|
||||||
|
{/snippet}
|
||||||
</Search>
|
</Search>
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
import type { Waypoint } from "$lib/models/waypoint";
|
import type { Waypoint } from "$lib/models/waypoint";
|
||||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import Dropdown from "../base/dropdown.svelte";
|
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import PhotoGallery from "../photo_gallery.svelte";
|
import PhotoGallery from "../photo_gallery.svelte";
|
||||||
|
|
||||||
export let waypoint: Waypoint;
|
interface Props {
|
||||||
export let mode: "show" | "edit" = "show";
|
waypoint: Waypoint;
|
||||||
|
mode?: "show" | "edit";
|
||||||
|
onchange?: (item: DropdownItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
let openGallery: (idx?: number) => void;
|
let { waypoint, mode = "show", onchange }: Props = $props();
|
||||||
|
|
||||||
let imgSrc: string[] = [];
|
let gallery: PhotoGallery;
|
||||||
$: if (waypoint.photos?.length) {
|
|
||||||
|
let imgSrc: string[] = $state([]);
|
||||||
|
$effect(() => {
|
||||||
|
if (waypoint.photos?.length) {
|
||||||
imgSrc = waypoint.photos
|
imgSrc = waypoint.photos
|
||||||
.filter((_, i) => i < 3)
|
.filter((_, i) => i < 3)
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -30,6 +36,7 @@
|
|||||||
} else {
|
} else {
|
||||||
imgSrc = [];
|
imgSrc = [];
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const dropdownItems = [
|
const dropdownItems = [
|
||||||
{ text: $_("edit"), value: "edit" },
|
{ text: $_("edit"), value: "edit" },
|
||||||
@@ -44,13 +51,13 @@
|
|||||||
{#if mode == "show"}
|
{#if mode == "show"}
|
||||||
<PhotoGallery
|
<PhotoGallery
|
||||||
photos={waypoint.photos.map((p) => getFileURL(waypoint, p))}
|
photos={waypoint.photos.map((p) => getFileURL(waypoint, p))}
|
||||||
bind:open={openGallery}
|
bind:this={gallery}
|
||||||
></PhotoGallery>
|
></PhotoGallery>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="relative basis-16 aspect-square ml-2 mb-3 shrink-0"
|
class="relative basis-16 aspect-square ml-2 mb-3 shrink-0"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={mode == "show" ? () => openGallery() : undefined}
|
onclick={mode == "show" ? () => gallery.openGallery() : undefined}
|
||||||
>
|
>
|
||||||
{#each imgSrc as img, i}
|
{#each imgSrc as img, i}
|
||||||
<img
|
<img
|
||||||
@@ -69,7 +76,7 @@
|
|||||||
<i class="fa fa-{waypoint.icon} mr-2"></i>{waypoint.name}
|
<i class="fa fa-{waypoint.icon} mr-2"></i>{waypoint.name}
|
||||||
</h5>
|
</h5>
|
||||||
{#if mode == "edit"}
|
{#if mode == "edit"}
|
||||||
<Dropdown items={dropdownItems} on:change></Dropdown>
|
<Dropdown items={dropdownItems} {onchange}></Dropdown>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
import { type Snippet } from "svelte";
|
||||||
|
|
||||||
import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema";
|
import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema";
|
||||||
import { convertDMSToDD } from "$lib/models/gpx/utils";
|
import { convertDMSToDD } from "$lib/models/gpx/utils";
|
||||||
@@ -17,11 +17,20 @@
|
|||||||
import TextField from "../base/text_field.svelte";
|
import TextField from "../base/text_field.svelte";
|
||||||
import Textarea from "../base/textarea.svelte";
|
import Textarea from "../base/textarea.svelte";
|
||||||
import PhotoPicker from "../trail/photo_picker.svelte";
|
import PhotoPicker from "../trail/photo_picker.svelte";
|
||||||
|
import type { Waypoint } from "$lib/models/waypoint";
|
||||||
|
|
||||||
export let openModal: (() => void) | undefined = undefined;
|
interface Props {
|
||||||
export let closeModal: (() => void) | undefined = undefined;
|
children?: Snippet<[any]>;
|
||||||
|
onsave?: (waypoint: Waypoint) => void
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { children, onsave }: Props = $props();
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
|
||||||
|
export function openModal() {
|
||||||
|
modal.openModal();
|
||||||
|
}
|
||||||
|
|
||||||
const ClientWaypointCreateSchema = WaypointCreateSchema.extend({
|
const ClientWaypointCreateSchema = WaypointCreateSchema.extend({
|
||||||
_photos: z.array(z.instanceof(File)).optional(),
|
_photos: z.array(z.instanceof(File)).optional(),
|
||||||
@@ -33,9 +42,9 @@
|
|||||||
initialValues: $waypoint,
|
initialValues: $waypoint,
|
||||||
extend: validator({ schema: ClientWaypointCreateSchema }),
|
extend: validator({ schema: ClientWaypointCreateSchema }),
|
||||||
onSubmit: async (form) => {
|
onSubmit: async (form) => {
|
||||||
dispatch("save", form);
|
onsave?.(form);
|
||||||
|
|
||||||
closeModal!();
|
modal.closeModal!();
|
||||||
},
|
},
|
||||||
transform: (values: unknown) => {
|
transform: (values: unknown) => {
|
||||||
const v = values as any;
|
const v = values as any;
|
||||||
@@ -47,14 +56,25 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
$: setFields(cloneDeep($waypoint));
|
$effect(() => {
|
||||||
|
setFields(cloneDeep($waypoint));
|
||||||
|
});
|
||||||
|
|
||||||
$: filteredIcons =
|
let filteredIcons = $derived(
|
||||||
($data.icon?.length ?? 0) > 2
|
($data.icon?.length ?? 0) > 2
|
||||||
? icons
|
? icons
|
||||||
.filter((i) => i.replaceAll("-", " ").includes($data.icon?.toLowerCase() ?? ""))
|
.filter((i) =>
|
||||||
.map((i) => ({ text: i.replaceAll("-", " "), value: i, icon: i }))
|
i
|
||||||
: [];
|
.replaceAll("-", " ")
|
||||||
|
.includes($data.icon?.toLowerCase() ?? ""),
|
||||||
|
)
|
||||||
|
.map((i) => ({
|
||||||
|
text: i.replaceAll("-", " "),
|
||||||
|
value: i,
|
||||||
|
icon: i,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
function getCoordinatesFromPhoto(src: string) {
|
function getCoordinatesFromPhoto(src: string) {
|
||||||
EXIF.getData({ src: src }, function (p) {
|
EXIF.getData({ src: src }, function (p) {
|
||||||
@@ -75,25 +95,26 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const children_render = $derived(children);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
id="waypoint-modal"
|
id="waypoint-modal"
|
||||||
title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")}
|
title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")}
|
||||||
let:openModal
|
bind:this={modal}
|
||||||
bind:openModal
|
|
||||||
bind:closeModal
|
|
||||||
>
|
|
||||||
<slot {openModal} />
|
|
||||||
<form
|
|
||||||
id="waypoint-form"
|
|
||||||
slot="content"
|
|
||||||
class="modal-content space-y-4"
|
|
||||||
use:form
|
|
||||||
>
|
>
|
||||||
|
{#snippet children({ openModal })}
|
||||||
|
{@render children_render?.({ openModal })}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content()}
|
||||||
|
<form id="waypoint-form" class="modal-content space-y-4" use:form>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="basis-2/3">
|
<div class="basis-2/3">
|
||||||
<TextField name="name" label={$_("name")} error={$errors.name}
|
<TextField
|
||||||
|
name="name"
|
||||||
|
label={$_("name")}
|
||||||
|
error={$errors.name}
|
||||||
></TextField>
|
></TextField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,17 +135,23 @@
|
|||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<TextField name="lat" label={$_("latitude")} error={$errors.lat}
|
<TextField name="lat" label={$_("latitude")} error={$errors.lat}
|
||||||
></TextField>
|
></TextField>
|
||||||
<TextField name="lon" label={$_("longitude")} error={$errors.lat}
|
<TextField
|
||||||
|
name="lon"
|
||||||
|
label={$_("longitude")}
|
||||||
|
error={$errors.lat}
|
||||||
></TextField>
|
></TextField>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="waypoint-photo-input" class="text-sm font-medium pb-1">
|
<label
|
||||||
|
for="waypoint-photo-input"
|
||||||
|
class="text-sm font-medium pb-1"
|
||||||
|
>
|
||||||
{$_("photos")}
|
{$_("photos")}
|
||||||
</label>
|
</label>
|
||||||
<PhotoPicker
|
<PhotoPicker
|
||||||
id="waypoint-photo-input"
|
id="waypoint-photo-input"
|
||||||
parent={$data}
|
parent={$data}
|
||||||
on:exif={(e) => getCoordinatesFromPhoto(e.detail)}
|
onexif={(src) => getCoordinatesFromPhoto(src)}
|
||||||
bind:photos={$data.photos}
|
bind:photos={$data.photos}
|
||||||
bind:photoFiles={$data._photos}
|
bind:photoFiles={$data._photos}
|
||||||
showThumbnailControls={false}
|
showThumbnailControls={false}
|
||||||
@@ -132,12 +159,15 @@
|
|||||||
></PhotoPicker>
|
></PhotoPicker>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div slot="footer" class="flex items-center gap-4">
|
{/snippet}
|
||||||
<button class="btn-secondary" on:click={closeModal}
|
{#snippet footer()}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||||
>{$_("cancel")}</button
|
>{$_("cancel")}</button
|
||||||
>
|
>
|
||||||
<button class="btn-primary" type="submit" form="waypoint-form"
|
<button class="btn-primary" type="submit" form="waypoint-form"
|
||||||
>{$_("save")}</button
|
>{$_("save")}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { browser } from '$app/environment'
|
import { browser } from '$app/environment'
|
||||||
|
import { pb } from '$lib/pocketbase'
|
||||||
import { currentUser } from '$lib/stores/user_store'
|
import { currentUser } from '$lib/stores/user_store'
|
||||||
import { init, register } from 'svelte-i18n'
|
import { init, register } from 'svelte-i18n'
|
||||||
import { get } from 'svelte/store'
|
import { get } from 'svelte/store'
|
||||||
@@ -17,5 +18,5 @@ register('zh', () => import('./locales/zh.json'))
|
|||||||
|
|
||||||
init({
|
init({
|
||||||
fallbackLocale: defaultLocale,
|
fallbackLocale: defaultLocale,
|
||||||
initialLocale: browser ? get(currentUser)?.language ?? window.navigator.language : defaultLocale,
|
initialLocale: browser ? pb.authStore.model?.language ?? window.navigator.language : defaultLocale,
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
export function formatTimeHHMM(minutes?: number) {
|
export function formatTimeHHMM(minutes?: number) {
|
||||||
@@ -17,7 +17,7 @@ export function formatDistance(meters?: number) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
const unit = get(page).data.settings?.unit ?? "metric";
|
const unit = page.data.settings?.unit ?? "metric";
|
||||||
|
|
||||||
if (unit == "metric") {
|
if (unit == "metric") {
|
||||||
if (meters >= 1000) {
|
if (meters >= 1000) {
|
||||||
@@ -38,7 +38,7 @@ export function formatElevation(meters?: number) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
const unit = get(page).data.settings?.unit ?? "metric";
|
const unit = page.data.settings?.unit ?? "metric";
|
||||||
|
|
||||||
if (unit == "metric") {
|
if (unit == "metric") {
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
@@ -54,7 +54,7 @@ export function formatSpeed(speed?: number) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
const unit = get(page).data.settings?.unit ?? "metric";
|
const unit = page.data.settings?.unit ?? "metric";
|
||||||
|
|
||||||
if (unit == "metric") {
|
if (unit == "metric") {
|
||||||
return `${(speed * 3.6).toFixed(2)} km/h`
|
return `${(speed * 3.6).toFixed(2)} km/h`
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class FontawesomeMarker extends M.Marker {
|
|||||||
|
|
||||||
export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker: M.Marker, wpId?: string) => void): FontawesomeMarker {
|
export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker: M.Marker, wpId?: string) => void): FontawesomeMarker {
|
||||||
const marker = new FontawesomeMarker({
|
const marker = new FontawesomeMarker({
|
||||||
|
id: waypoint.id,
|
||||||
icon: `fa fa-${waypoint.icon}`,
|
icon: `fa fa-${waypoint.icon}`,
|
||||||
}, {
|
}, {
|
||||||
draggable: onDragEnd !== undefined,
|
draggable: onDragEnd !== undefined,
|
||||||
|
|||||||
44
web/src/lib/vendor/svelte-chartjs/bar.svelte
vendored
Normal file
44
web/src/lib/vendor/svelte-chartjs/bar.svelte
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Chart,
|
||||||
|
Tooltip,
|
||||||
|
BarController,
|
||||||
|
type ChartData,
|
||||||
|
type ChartOptions,
|
||||||
|
} from 'chart.js';
|
||||||
|
import type { HTMLCanvasAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
|
||||||
|
interface Props extends HTMLCanvasAttributes {
|
||||||
|
data: ChartData<'bar', number[], string>;
|
||||||
|
options: ChartOptions<'bar'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, options, ...rest }: Props = $props();
|
||||||
|
|
||||||
|
Chart.register(Tooltip, BarController);
|
||||||
|
|
||||||
|
let canvasElem: HTMLCanvasElement;
|
||||||
|
let chart: Chart;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
chart = new Chart(canvasElem, {
|
||||||
|
type: 'bar',
|
||||||
|
data,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chart.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (chart) {
|
||||||
|
chart.data = data;
|
||||||
|
chart.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvasElem} {...rest}></canvas>
|
||||||
43
web/src/lib/vendor/svelte-chartjs/pie.svelte
vendored
Normal file
43
web/src/lib/vendor/svelte-chartjs/pie.svelte
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Chart,
|
||||||
|
PieController,
|
||||||
|
Tooltip,
|
||||||
|
type ChartData,
|
||||||
|
type ChartOptions,
|
||||||
|
} from 'chart.js';
|
||||||
|
import type { HTMLCanvasAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
interface Props extends HTMLCanvasAttributes {
|
||||||
|
data: ChartData<'pie', number[], string>;
|
||||||
|
options: ChartOptions<'pie'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, options, ...rest }: Props = $props();
|
||||||
|
|
||||||
|
Chart.register(Tooltip, PieController);
|
||||||
|
|
||||||
|
let canvasElem: HTMLCanvasElement;
|
||||||
|
let chart: Chart;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
chart = new Chart(canvasElem, {
|
||||||
|
type: 'pie',
|
||||||
|
data,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chart.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (chart) {
|
||||||
|
chart.data = data;
|
||||||
|
chart.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvasElem} {...rest}></canvas>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import errorDark from "$lib/assets/svgs/empty_states/error_dark.svg";
|
import errorDark from "$lib/assets/svgs/empty_states/error_dark.svg";
|
||||||
import errorLight from "$lib/assets/svgs/empty_states/error_light.svg";
|
import errorLight from "$lib/assets/svgs/empty_states/error_light.svg";
|
||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$page.status} | wanderer</title>
|
<title>{page.status} | wanderer</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<div
|
<div
|
||||||
class="m-auto text-center max-w-md"
|
class="m-auto text-center max-w-md"
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
/>
|
/>
|
||||||
<div class="text-center mt-8">
|
<div class="text-center mt-8">
|
||||||
<h1 class="font-bold" style="line-height: 4rem;">
|
<h1 class="font-bold" style="line-height: 4rem;">
|
||||||
{$page.status}
|
{page.status}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-center mt-2">
|
<p class="text-lg text-center mt-2">
|
||||||
{$page.error?.message}
|
{page.error?.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { beforeNavigate, goto } from "$app/navigation";
|
import { beforeNavigate, goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
import Toast from "$lib/components/base/toast.svelte";
|
import Toast from "$lib/components/base/toast.svelte";
|
||||||
import Footer from "$lib/components/footer.svelte";
|
import Footer from "$lib/components/footer.svelte";
|
||||||
@@ -8,12 +8,17 @@
|
|||||||
import { currentUser } from "$lib/stores/user_store";
|
import { currentUser } from "$lib/stores/user_store";
|
||||||
import { isRouteProtected } from "$lib/util/authorization_util";
|
import { isRouteProtected } from "$lib/util/authorization_util";
|
||||||
import "@fortawesome/fontawesome-free/css/all.min.css";
|
import "@fortawesome/fontawesome-free/css/all.min.css";
|
||||||
import { onMount } from "svelte";
|
import { onMount, type Snippet } from "svelte";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
import "../css/app.css";
|
import "../css/app.css";
|
||||||
import "../css/components.css";
|
import "../css/components.css";
|
||||||
import "../css/theme.css";
|
import "../css/theme.css";
|
||||||
import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
|
import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
beforeNavigate((n) => {
|
beforeNavigate((n) => {
|
||||||
if (!$currentUser && isRouteProtected(n.to?.url?.pathname ?? "")) {
|
if (!$currentUser && isRouteProtected(n.to?.url?.pathname ?? "")) {
|
||||||
@@ -23,13 +28,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if ($page.data.origin != location.origin) {
|
if (page.data.origin != location.origin) {
|
||||||
showWarning = true;
|
showWarning = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let hideDemoHint = false;
|
let hideDemoHint = $state(false);
|
||||||
let showWarning = false;
|
let showWarning = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if env.PUBLIC_IS_DEMO === "true" && !hideDemoHint}
|
{#if env.PUBLIC_IS_DEMO === "true" && !hideDemoHint}
|
||||||
@@ -42,7 +47,10 @@
|
|||||||
>This is a demo instance. Do not store any relevant data here. You
|
>This is a demo instance. Do not store any relevant data here. You
|
||||||
can use the user 'demo' and password 'password' to login.
|
can use the user 'demo' and password 'password' to login.
|
||||||
</span>
|
</span>
|
||||||
<button class="btn-icon self-end" on:click={() => (hideDemoHint = true)}
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
class="btn-icon self-end"
|
||||||
|
onclick={() => (hideDemoHint = true)}
|
||||||
><i class="fa fa-close"></i></button
|
><i class="fa fa-close"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,10 +67,12 @@
|
|||||||
>{location.origin}</span
|
>{location.origin}</span
|
||||||
>
|
>
|
||||||
but your ORIGIN environment variable is set to
|
but your ORIGIN environment variable is set to
|
||||||
<span class="font-mono bg-gray-100">{$page.data.origin}</span>. This
|
<span class="font-mono bg-gray-100">{page.data.origin}</span>. This
|
||||||
may cause errors.
|
may cause errors.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn-icon self-end" on:click={() => (showWarning = false)}
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
class="btn-icon self-end" onclick={() => (showWarning = false)}
|
||||||
><i class="fa fa-close"></i></button
|
><i class="fa fa-close"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,6 +81,6 @@
|
|||||||
<NavBar></NavBar>
|
<NavBar></NavBar>
|
||||||
<PageLoadingBar class="text-content"></PageLoadingBar>
|
<PageLoadingBar class="text-content"></PageLoadingBar>
|
||||||
<Toast></Toast>
|
<Toast></Toast>
|
||||||
<slot />
|
{@render children?.()}
|
||||||
|
|
||||||
<Footer></Footer>
|
<Footer></Footer>
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
|
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
let searchDropdownItems: SearchItem[] = [];
|
let searchDropdownItems: SearchItem[] = $state([]);
|
||||||
|
|
||||||
async function search(q: string) {
|
async function search(q: string) {
|
||||||
const r = await fetch("/api/v1/search/multi", {
|
const r = await fetch("/api/v1/search/multi", {
|
||||||
@@ -92,15 +92,15 @@
|
|||||||
{$_("hero_section_0_text")}
|
{$_("hero_section_0_text")}
|
||||||
</h5>
|
</h5>
|
||||||
<Search
|
<Search
|
||||||
on:update={(e) => search(e.detail)}
|
onupdate={search}
|
||||||
on:click={(e) => handleSearchClick(e.detail)}
|
onclick={handleSearchClick}
|
||||||
large={true}
|
large={true}
|
||||||
placeholder="{$_('search-for-trails-places')}..."
|
placeholder="{$_('search-for-trails-places')}..."
|
||||||
items={searchDropdownItems}
|
items={searchDropdownItems}
|
||||||
></Search>
|
></Search>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:block">
|
<div class="hidden md:block">
|
||||||
<Canvas>
|
<Canvas toneMapping={0}>
|
||||||
<Scene></Scene>
|
<Scene></Scene>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export async function GET(event: RequestEvent) {
|
|||||||
min_distance: 0,
|
min_distance: 0,
|
||||||
max_distance: 20000,
|
max_distance: 20000,
|
||||||
min_elevation_gain: 0,
|
min_elevation_gain: 0,
|
||||||
max_elevation_gain: 4000
|
max_elevation_gain: 4000,
|
||||||
|
min_elevation_loss: 0,
|
||||||
|
max_elevation_loss: 4000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import Button from "$lib/components/base/button.svelte";
|
import Button from "$lib/components/base/button.svelte";
|
||||||
import TextField from "$lib/components/base/text_field.svelte";
|
import TextField from "$lib/components/base/text_field.svelte";
|
||||||
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
|
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
let loading: boolean = false;
|
let loading: boolean = $state(false);
|
||||||
|
|
||||||
const { form, errors } = createForm({
|
const { form, errors } = createForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
try {
|
try {
|
||||||
await users_confirm_reset({
|
await users_confirm_reset({
|
||||||
...form,
|
...form,
|
||||||
token: $page.params.token,
|
token: page.params.token,
|
||||||
});
|
});
|
||||||
goto("/login");
|
goto("/login");
|
||||||
show_toast({
|
show_toast({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
let loading: boolean = false;
|
let loading: boolean = $state(false);
|
||||||
|
|
||||||
const { form, errors } = createForm({
|
const { form, errors } = createForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
|
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
|
||||||
import Search, {
|
import Search, {
|
||||||
type SearchItem,
|
type SearchItem,
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte";
|
import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte";
|
||||||
import TrailList from "$lib/components/trail/trail_list.svelte";
|
import TrailList from "$lib/components/trail/trail_list.svelte";
|
||||||
import UserSearch from "$lib/components/user_search.svelte";
|
import UserSearch from "$lib/components/user_search.svelte";
|
||||||
import { List } from "$lib/models/list";
|
import { List, type ListFilter } from "$lib/models/list";
|
||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
import { lists_delete, lists_index } from "$lib/stores/list_store";
|
import { lists_delete, lists_index } from "$lib/stores/list_store";
|
||||||
import { fetchGPX } from "$lib/stores/trail_store";
|
import { fetchGPX } from "$lib/stores/trail_store";
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
|
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
const sortOptions: SelectItem[] = [
|
const sortOptions: SelectItem[] = [
|
||||||
{ text: $_("alphabetical"), value: "name" },
|
{ text: $_("alphabetical"), value: "name" },
|
||||||
@@ -40,35 +40,43 @@
|
|||||||
totalPages: data.lists.totalPages,
|
totalPages: data.lists.totalPages,
|
||||||
};
|
};
|
||||||
|
|
||||||
let openConfirmModal: () => void;
|
let lists = $state(data.lists);
|
||||||
let openShareModal: () => void;
|
|
||||||
|
|
||||||
let map: M.Map;
|
let confirmModal: ConfirmModal;
|
||||||
let mapWithElevation: MapWithElevationMaplibre;
|
let listShareModal: ListShareModal;
|
||||||
let markers: M.Marker[];
|
|
||||||
|
let filter: ListFilter = $state(page.data.filter);
|
||||||
|
|
||||||
|
let map: M.Map | undefined = $state();
|
||||||
|
let mapWithElevation: MapWithElevationMaplibre | undefined = $state();
|
||||||
|
let markers: M.Marker[] = $state([]);
|
||||||
let showMap: boolean = true;
|
let showMap: boolean = true;
|
||||||
|
|
||||||
let selectedList: List | null = $page.url.searchParams.get("list")
|
let selectedList: List | null = $state(
|
||||||
? data.lists.items[0]
|
page.url.searchParams.get("list") ? lists.items[0] : null,
|
||||||
: null;
|
);
|
||||||
let selectedTrail: Trail | null = null;
|
let selectedTrail: Trail | null = $state(null);
|
||||||
|
|
||||||
let loading: boolean = false;
|
let loading: boolean = $state(false);
|
||||||
let loadingNextPage: boolean = false;
|
let loadingNextPage: boolean = false;
|
||||||
let filterExpanded: boolean = false;
|
let filterExpanded: boolean = $state(false);
|
||||||
|
|
||||||
let loadAllListsOnNextBack = false;
|
let loadAllListsOnNextBack = false;
|
||||||
|
|
||||||
let userQuery = "";
|
let userQuery = $state("");
|
||||||
|
|
||||||
$: selectedTrailIndex = selectedTrail
|
let selectedTrailIndex = $derived(
|
||||||
|
selectedTrail
|
||||||
? (selectedList?.expand?.trails?.indexOf(selectedTrail) ?? null)
|
? (selectedList?.expand?.trails?.indexOf(selectedTrail) ?? null)
|
||||||
: null;
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
$: selectedTrailWaypoints = selectedTrail?.expand?.waypoints;
|
let selectedTrailWaypoints = $derived(
|
||||||
|
(selectedTrail as Trail | null)?.expand?.waypoints,
|
||||||
|
);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if ($page.url.searchParams.get("list") && selectedList) {
|
if (page.url.searchParams.get("list") && selectedList) {
|
||||||
setCurrentList(selectedList);
|
setCurrentList(selectedList);
|
||||||
|
|
||||||
// only the requested list has been loaded at this point
|
// only the requested list has been loaded at this point
|
||||||
@@ -77,18 +85,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleDropdownClick(e: CustomEvent<DropdownItem>) {
|
async function handleDropdownClick(item: DropdownItem) {
|
||||||
const item = e.detail;
|
|
||||||
|
|
||||||
if (!selectedList) {
|
if (!selectedList) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.value == "share") {
|
if (item.value == "share") {
|
||||||
openShareModal();
|
listShareModal.openModal();
|
||||||
} else if (item.value == "edit") {
|
} else if (item.value == "edit") {
|
||||||
goto("/lists/edit/" + selectedList.id);
|
goto("/lists/edit/" + selectedList.id);
|
||||||
} else if (item.value == "delete") {
|
} else if (item.value == "delete") {
|
||||||
openConfirmModal();
|
confirmModal.openModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +134,7 @@
|
|||||||
selectedTrail = null;
|
selectedTrail = null;
|
||||||
} else if (selectedList) {
|
} else if (selectedList) {
|
||||||
selectedList = null;
|
selectedList = null;
|
||||||
map.flyTo({
|
map?.flyTo({
|
||||||
animate: true,
|
animate: true,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
center: [0, 0],
|
center: [0, 0],
|
||||||
@@ -138,16 +144,16 @@
|
|||||||
|
|
||||||
function selectTrail(trail: Trail) {
|
function selectTrail(trail: Trail) {
|
||||||
selectedTrail = trail;
|
selectedTrail = trail;
|
||||||
mapWithElevation.unHighlightTrail(trail.id!);
|
mapWithElevation?.unHighlightTrail(trail.id!);
|
||||||
window.scrollTo({ top: 0 });
|
window.scrollTo({ top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightTrail(trail: Trail) {
|
function highlightTrail(trail: Trail) {
|
||||||
mapWithElevation.highlightTrail(trail.id!);
|
mapWithElevation?.highlightTrail(trail.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unHighlightTrail(trail: Trail) {
|
function unHighlightTrail(trail: Trail) {
|
||||||
mapWithElevation.unHighlightTrail(trail.id!);
|
mapWithElevation?.unHighlightTrail(trail.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onListScroll(e: Event) {
|
async function onListScroll(e: Event) {
|
||||||
@@ -169,7 +175,7 @@
|
|||||||
|
|
||||||
async function loadNextPage() {
|
async function loadNextPage() {
|
||||||
pagination.page += 1;
|
pagination.page += 1;
|
||||||
data.lists = await lists_index(data.filter, pagination.page);
|
lists = await lists_index(filter, pagination.page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateFilter() {
|
async function updateFilter() {
|
||||||
@@ -178,7 +184,7 @@
|
|||||||
if (selectedList || selectedTrail) {
|
if (selectedList || selectedTrail) {
|
||||||
selectedList = null;
|
selectedList = null;
|
||||||
selectedTrail = null;
|
selectedTrail = null;
|
||||||
map.flyTo({
|
map?.flyTo({
|
||||||
animate: true,
|
animate: true,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
center: [0, 0],
|
center: [0, 0],
|
||||||
@@ -186,41 +192,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
pagination.page = 0;
|
pagination.page = 0;
|
||||||
data.lists = await lists_index(data.filter, pagination.page);
|
lists = await lists_index(filter, pagination.page);
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSort(value: "name" | "created") {
|
async function setSort(value: "name" | "created") {
|
||||||
data.filter.sort = value;
|
filter.sort = value;
|
||||||
await updateFilter();
|
await updateFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSortOrder() {
|
async function setSortOrder() {
|
||||||
if (data.filter.sortOrder === "+") {
|
if (filter.sortOrder === "+") {
|
||||||
data.filter.sortOrder = "-";
|
filter.sortOrder = "-";
|
||||||
} else {
|
} else {
|
||||||
data.filter.sortOrder = "+";
|
filter.sortOrder = "+";
|
||||||
}
|
}
|
||||||
await updateFilter();
|
await updateFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setPublicFilter(e: Event) {
|
async function setPublicFilter(e: Event) {
|
||||||
data.filter.public = (e.target as HTMLInputElement).checked;
|
filter.public = (e.target as HTMLInputElement).checked;
|
||||||
updateFilter();
|
updateFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setAuthorFilter(item: SearchItem) {
|
async function setAuthorFilter(item: SearchItem) {
|
||||||
data.filter.author = item.value.id;
|
filter.author = item.value.id;
|
||||||
await updateFilter();
|
await updateFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearAuthorFilter() {
|
async function clearAuthorFilter() {
|
||||||
data.filter.author = "";
|
filter.author = "";
|
||||||
await updateFilter();
|
await updateFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSharedFilter(e: Event) {
|
async function setSharedFilter(e: Event) {
|
||||||
data.filter.shared = (e.target as HTMLInputElement).checked;
|
filter.shared = (e.target as HTMLInputElement).checked;
|
||||||
updateFilter();
|
updateFilter();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -236,19 +242,21 @@
|
|||||||
class="flex gap-x-3 items-center px-3 py-4 bg-background z-50 rounded-xl"
|
class="flex gap-x-3 items-center px-3 py-4 bg-background z-50 rounded-xl"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Back"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
class:btn-disabled={!selectedList}
|
class:btn-disabled={!selectedList}
|
||||||
disabled={!selectedList}
|
disabled={!selectedList}
|
||||||
on:click={back}><i class="fa fa-arrow-left"></i></button
|
onclick={back}><i class="fa fa-arrow-left"></i></button
|
||||||
>
|
>
|
||||||
<Search bind:value={data.filter.q} on:update={updateFilter}
|
<Search bind:value={filter.q} onupdate={updateFilter}></Search>
|
||||||
></Search>
|
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle filter"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
on:click={() => (filterExpanded = !filterExpanded)}
|
onclick={() => (filterExpanded = !filterExpanded)}
|
||||||
><i class="fa fa-sliders"></i></button
|
><i class="fa fa-sliders"></i></button
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
aria-label="New list"
|
||||||
class="btn-primary tooltip"
|
class="btn-primary tooltip"
|
||||||
data-title={$_("new-list")}
|
data-title={$_("new-list")}
|
||||||
href="/lists/edit/new"><i class="fa fa-plus"></i></a
|
href="/lists/edit/new"><i class="fa fa-plus"></i></a
|
||||||
@@ -263,15 +271,16 @@
|
|||||||
<p class="text-sm font-medium pb-1">{$_("sort")}</p>
|
<p class="text-sm font-medium pb-1">{$_("sort")}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
bind:value={data.filter.sort}
|
bind:value={filter.sort}
|
||||||
items={sortOptions}
|
items={sortOptions}
|
||||||
on:change={(e) => setSort(e.detail)}
|
onchange={setSort}
|
||||||
></Select>
|
></Select>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Set sort order"
|
||||||
id="sort-order-btn"
|
id="sort-order-btn"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
class:rotated={data.filter.sortOrder == "-"}
|
class:rotated={filter.sortOrder == "-"}
|
||||||
on:click={() => setSortOrder()}
|
onclick={() => setSortOrder()}
|
||||||
><i class="fa fa-arrow-up"></i></button
|
><i class="fa fa-arrow-up"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,8 +288,8 @@
|
|||||||
<hr class="my-4 border-separator" />
|
<hr class="my-4 border-separator" />
|
||||||
|
|
||||||
<UserSearch
|
<UserSearch
|
||||||
on:click={(e) => setAuthorFilter(e.detail)}
|
onclick={setAuthorFilter}
|
||||||
on:clear={clearAuthorFilter}
|
onclear={clearAuthorFilter}
|
||||||
bind:value={userQuery}
|
bind:value={userQuery}
|
||||||
clearAfterSelect={false}
|
clearAfterSelect={false}
|
||||||
label={$_("author")}
|
label={$_("author")}
|
||||||
@@ -289,9 +298,9 @@
|
|||||||
<input
|
<input
|
||||||
id="public-checkbox"
|
id="public-checkbox"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={data.filter.public}
|
checked={filter.public}
|
||||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||||
on:change={setPublicFilter}
|
onchange={setPublicFilter}
|
||||||
/>
|
/>
|
||||||
<label for="public-checkbox" class="ms-2 text-sm"
|
<label for="public-checkbox" class="ms-2 text-sm"
|
||||||
>{$_("public")}</label
|
>{$_("public")}</label
|
||||||
@@ -301,9 +310,9 @@
|
|||||||
<input
|
<input
|
||||||
id="shared-checkbox"
|
id="shared-checkbox"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={data.filter.shared}
|
checked={filter.shared}
|
||||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||||
on:change={setSharedFilter}
|
onchange={setSharedFilter}
|
||||||
/>
|
/>
|
||||||
<label for="shared-checkbox" class="ms-2 text-sm"
|
<label for="shared-checkbox" class="ms-2 text-sm"
|
||||||
>{$_("shared")}</label
|
>{$_("shared")}</label
|
||||||
@@ -317,7 +326,7 @@
|
|||||||
<div
|
<div
|
||||||
id="list-container"
|
id="list-container"
|
||||||
class="overflow-y-scroll overflow-x-clip max-h-full"
|
class="overflow-y-scroll overflow-x-clip max-h-full"
|
||||||
on:scroll={onListScroll}
|
onscroll={onListScroll}
|
||||||
>
|
>
|
||||||
{#if !selectedList}
|
{#if !selectedList}
|
||||||
<div class="px-4 mt-2" class:space-y-3={loading}>
|
<div class="px-4 mt-2" class:space-y-3={loading}>
|
||||||
@@ -325,13 +334,13 @@
|
|||||||
{#each { length: 3 } as _, index}
|
{#each { length: 3 } as _, index}
|
||||||
<SkeletonCard></SkeletonCard>
|
<SkeletonCard></SkeletonCard>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if data.lists.items.length == 0}
|
{:else if lists.items.length == 0}
|
||||||
<EmptyStateSearch width={356}></EmptyStateSearch>
|
<EmptyStateSearch width={356}></EmptyStateSearch>
|
||||||
{:else}
|
{:else}
|
||||||
{#each data.lists.items as item, i}
|
{#each lists.items as item, i}
|
||||||
<div
|
<div
|
||||||
class="list-list-item"
|
class="list-list-item"
|
||||||
on:click={() => setCurrentList(item)}
|
onclick={() => setCurrentList(item)}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<ListCard list={item}></ListCard>
|
<ListCard list={item}></ListCard>
|
||||||
@@ -342,10 +351,10 @@
|
|||||||
{:else if selectedList && !selectedTrail}
|
{:else if selectedList && !selectedTrail}
|
||||||
<ListPanel
|
<ListPanel
|
||||||
list={selectedList}
|
list={selectedList}
|
||||||
on:mouseenter={(e) => highlightTrail(e.detail.trail)}
|
onmouseenter={(data) => highlightTrail(data.trail)}
|
||||||
on:mouseleave={(e) => unHighlightTrail(e.detail.trail)}
|
onmouseleave={(data) => unHighlightTrail(data.trail)}
|
||||||
on:change={(e) => handleDropdownClick(e)}
|
onchange={(item) => handleDropdownClick(item)}
|
||||||
on:click={(e) => selectTrail(e.detail.trail)}
|
onclick={(data) => selectTrail(data.trail)}
|
||||||
></ListPanel>
|
></ListPanel>
|
||||||
{:else if selectedList && selectedTrail}
|
{:else if selectedList && selectedTrail}
|
||||||
<TrailInfoPanel trail={selectedTrail} mode="list" {markers}
|
<TrailInfoPanel trail={selectedTrail} mode="list" {markers}
|
||||||
@@ -362,8 +371,8 @@
|
|||||||
bind:markers
|
bind:markers
|
||||||
activeTrail={selectedTrailIndex}
|
activeTrail={selectedTrailIndex}
|
||||||
fitBounds="animate"
|
fitBounds="animate"
|
||||||
on:select={(e) => {
|
onselect={(trail) => {
|
||||||
selectedTrail = e.detail;
|
selectedTrail = trail;
|
||||||
}}
|
}}
|
||||||
showInfoPopup={true}
|
showInfoPopup={true}
|
||||||
showTerrain={true}
|
showTerrain={true}
|
||||||
@@ -375,17 +384,11 @@
|
|||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
text={$_("delete-list-confirm")}
|
text={$_("delete-list-confirm")}
|
||||||
bind:openModal={openConfirmModal}
|
bind:this={confirmModal}
|
||||||
on:confirm={deleteList}
|
onconfirm={deleteList}
|
||||||
></ConfirmModal>
|
></ConfirmModal>
|
||||||
{#if selectedList}
|
{#if selectedList}
|
||||||
<ListShareModal
|
<ListShareModal bind:this={listShareModal} list={selectedList}
|
||||||
list={selectedList}
|
|
||||||
bind:openShareModal
|
|
||||||
on:update={() => {
|
|
||||||
data.lists.items = data.lists.items;
|
|
||||||
selectedList = selectedList;
|
|
||||||
}}
|
|
||||||
></ListShareModal>
|
></ListShareModal>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { type List } from "$lib/models/list";
|
import { type List } from "$lib/models/list";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||||
import Button from "$lib/components/base/button.svelte";
|
import Button from "$lib/components/base/button.svelte";
|
||||||
@@ -38,21 +38,21 @@
|
|||||||
import { createForm } from "felte";
|
import { createForm } from "felte";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
let previewURL = data.previewUrl ?? "";
|
let previewURL = $state(data.previewUrl ?? "");
|
||||||
let searchDropdownItems: SearchItem[] = [];
|
let searchDropdownItems: SearchItem[] = $state([]);
|
||||||
|
|
||||||
let activeTrailIndex: number = -1;
|
let activeTrailIndex: number = $state(-1);
|
||||||
|
|
||||||
let map: MapWithElevationMaplibre;
|
let map: MapWithElevationMaplibre | undefined = $state();
|
||||||
|
|
||||||
let loading: boolean = false;
|
let loading: boolean = $state(false);
|
||||||
|
|
||||||
let newShares: TrailShare[] = [];
|
let newShares: TrailShare[] = [];
|
||||||
|
|
||||||
let openConfirmModal: () => void;
|
let shareConfirmModal: ConfirmModal;
|
||||||
let openPublishConfirmModal: () => void;
|
let publishConfirmModal: ConfirmModal;
|
||||||
|
|
||||||
const ClientListCreateSchema = ListCreateSchema.extend({
|
const ClientListCreateSchema = ListCreateSchema.extend({
|
||||||
_photos: z.array(z.instanceof(File)).optional(),
|
_photos: z.array(z.instanceof(File)).optional(),
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
...data.list,
|
...data.list,
|
||||||
public: data.list.id
|
public: data.list.id
|
||||||
? data.list.public
|
? data.list.public
|
||||||
: $page.data.settings?.privacy?.lists === "public",
|
: page.data.settings?.privacy?.lists === "public",
|
||||||
},
|
},
|
||||||
extend: validator({
|
extend: validator({
|
||||||
schema: ClientListCreateSchema,
|
schema: ClientListCreateSchema,
|
||||||
@@ -93,10 +93,10 @@
|
|||||||
data.list?.expand?.trails?.length !==
|
data.list?.expand?.trails?.length !==
|
||||||
$formData.expand?.trails?.length)
|
$formData.expand?.trails?.length)
|
||||||
) {
|
) {
|
||||||
openPublishConfirmModal();
|
publishConfirmModal.openModal();
|
||||||
return false;
|
return false;
|
||||||
} else if (await findNewTrailShares()) {
|
} else if (await findNewTrailShares()) {
|
||||||
openConfirmModal();
|
shareConfirmModal.openModal();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
use:form
|
use:form
|
||||||
>
|
>
|
||||||
<h2 class="text-2xl font-semibold">
|
<h2 class="text-2xl font-semibold">
|
||||||
{$page.params.id === "new" ? $_("new-list") : $_("edit-list")}
|
{page.params.id === "new" ? $_("new-list") : $_("edit-list")}
|
||||||
</h2>
|
</h2>
|
||||||
<label for="avatar" class="text-sm font-medium block">
|
<label for="avatar" class="text-sm font-medium block">
|
||||||
{$_("avatar")}
|
{$_("avatar")}
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
id="avatar"
|
id="avatar"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
on:change={handleAvatarSelection}
|
onchange={handleAvatarSelection}
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
{#if previewURL.length > 0}
|
{#if previewURL.length > 0}
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={openAvatarBrowser}>{$_("change")}...</button
|
onclick={openAvatarBrowser}>{$_("change")}...</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -318,8 +318,8 @@
|
|||||||
{$_("trail", { values: { n: 2 } })}
|
{$_("trail", { values: { n: 2 } })}
|
||||||
</h3>
|
</h3>
|
||||||
<Search
|
<Search
|
||||||
on:update={(e) => search(e.detail)}
|
onupdate={search}
|
||||||
on:click={(e) => handleSearchClick(e.detail)}
|
onclick={handleSearchClick}
|
||||||
placeholder="{$_('search-trails')}..."
|
placeholder="{$_('search-trails')}..."
|
||||||
items={searchDropdownItems}
|
items={searchDropdownItems}
|
||||||
></Search>
|
></Search>
|
||||||
@@ -329,9 +329,8 @@
|
|||||||
class="flex gap-4 p-4 rounded-xl border border-input-border cursor-pointer hover:bg-secondary-hover transition-colors items-center"
|
class="flex gap-4 p-4 rounded-xl border border-input-border cursor-pointer hover:bg-secondary-hover transition-colors items-center"
|
||||||
class:bg-secondary-hover={i == activeTrailIndex}
|
class:bg-secondary-hover={i == activeTrailIndex}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
on:mouseenter={() => map.highlightTrail(trail.id ?? "")}
|
onmouseenter={() => map?.highlightTrail(trail.id ?? "")}
|
||||||
on:mouseleave={() => map.unHighlightTrail(trail.id ?? "")}
|
onmouseleave={() => map?.unHighlightTrail(trail.id ?? "")}
|
||||||
on:click={() => map.selectTrail(trail.id ?? "")}
|
|
||||||
>
|
>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<img
|
<img
|
||||||
@@ -384,8 +383,8 @@
|
|||||||
<div class="basis-0">
|
<div class="basis-0">
|
||||||
{#if i > 0}
|
{#if i > 0}
|
||||||
<button
|
<button
|
||||||
on:click|stopPropagation={() =>
|
aria-label="Move up"
|
||||||
moveTrail(trail, i, -1)}
|
onclick={() => moveTrail(trail, i, -1)}
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
>
|
>
|
||||||
@@ -394,8 +393,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if i < $formData.expand?.trails?.length - 1}
|
{#if i < $formData.expand?.trails?.length - 1}
|
||||||
<button
|
<button
|
||||||
on:click|stopPropagation={() =>
|
aria-label="Move down"
|
||||||
moveTrail(trail, i, 1)}
|
onclick={() => moveTrail(trail, i, 1)}
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
>
|
>
|
||||||
@@ -403,9 +402,10 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
|
aria-label="Remove trail"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-icon text-red-500"
|
class="btn-icon text-red-500"
|
||||||
on:click={() => deleteTrail(trail)}
|
onclick={() => deleteTrail(trail)}
|
||||||
><i class="fa fa-trash"></i></button
|
><i class="fa fa-trash"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -426,6 +426,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<div id="trail-map" class="max-h-full">
|
<div id="trail-map" class="max-h-full">
|
||||||
<MapWithElevationMaplibre
|
<MapWithElevationMaplibre
|
||||||
|
fitBounds="animate"
|
||||||
trails={$formData.expand?.trails ?? []}
|
trails={$formData.expand?.trails ?? []}
|
||||||
bind:activeTrail={activeTrailIndex}
|
bind:activeTrail={activeTrailIndex}
|
||||||
bind:this={map}
|
bind:this={map}
|
||||||
@@ -438,8 +439,8 @@
|
|||||||
text={$_("list-share-warning-update")}
|
text={$_("list-share-warning-update")}
|
||||||
title={$_("confirm-share")}
|
title={$_("confirm-share")}
|
||||||
action="confirm"
|
action="confirm"
|
||||||
bind:openModal={openConfirmModal}
|
bind:this={shareConfirmModal}
|
||||||
on:confirm={async () => {
|
onconfirm={async () => {
|
||||||
await updateTrailShares();
|
await updateTrailShares();
|
||||||
await saveList();
|
await saveList();
|
||||||
}}
|
}}
|
||||||
@@ -450,8 +451,8 @@
|
|||||||
text={$_("list-public-warning")}
|
text={$_("list-public-warning")}
|
||||||
title={$_("confirm-publish")}
|
title={$_("confirm-publish")}
|
||||||
action="confirm"
|
action="confirm"
|
||||||
bind:openModal={openPublishConfirmModal}
|
bind:this={publishConfirmModal}
|
||||||
on:confirm={async () => {
|
onconfirm={async () => {
|
||||||
await publishTrails();
|
await publishTrails();
|
||||||
await saveList();
|
await saveList();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
import Button from "$lib/components/base/button.svelte";
|
import Button from "$lib/components/base/button.svelte";
|
||||||
import TextField from "$lib/components/base/text_field.svelte";
|
import TextField from "$lib/components/base/text_field.svelte";
|
||||||
@@ -16,19 +16,19 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
let loading: boolean = false;
|
let loading: boolean = $state(false);
|
||||||
|
|
||||||
const authProviders = $page.data.authMethods.authProviders;
|
const authProviders = page.data.authMethods.authProviders;
|
||||||
let loginLabel = "";
|
let loginLabel = $state("");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$page.data.authMethods.usernamePassword &&
|
page.data.authMethods.usernamePassword &&
|
||||||
$page.data.authMethods.emailPassword
|
page.data.authMethods.emailPassword
|
||||||
) {
|
) {
|
||||||
loginLabel = `${$_("username")}/${$_("email")}`;
|
loginLabel = `${$_("username")}/${$_("email")}`;
|
||||||
} else if ($page.data.authMethods.usernamePassword) {
|
} else if (page.data.authMethods.usernamePassword) {
|
||||||
loginLabel = `${$_("username")}`;
|
loginLabel = `${$_("username")}`;
|
||||||
} else if ($page.data.authMethods.emailPassword) {
|
} else if (page.data.authMethods.emailPassword) {
|
||||||
loginLabel = `${$_("email")}`;
|
loginLabel = `${$_("email")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(newUser);
|
await login(newUser);
|
||||||
window.location.href = $page.url.searchParams.get("r") ?? "/";
|
window.location.href = page.url.searchParams.get("r") ?? "/";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (
|
if (
|
||||||
e instanceof APIError &&
|
e instanceof APIError &&
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<LogoTextTwoLineLight></LogoTextTwoLineLight>
|
<LogoTextTwoLineLight></LogoTextTwoLineLight>
|
||||||
{/if}
|
{/if}
|
||||||
<h4 class="text-xl font-semibold">{$_("slogan")}</h4>
|
<h4 class="text-xl font-semibold">{$_("slogan")}</h4>
|
||||||
{#if $page.data.authMethods.usernamePassword || $page.data.authMethods.emailPassword}
|
{#if page.data.authMethods.usernamePassword || page.data.authMethods.emailPassword}
|
||||||
<div class="space-y-6 w-80">
|
<div class="space-y-6 w-80">
|
||||||
<TextField
|
<TextField
|
||||||
name="username"
|
name="username"
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if authProviders.length}
|
{#if authProviders.length}
|
||||||
{#if $page.data.authMethods.usernamePassword || $page.data.authMethods.emailPassword}
|
{#if page.data.authMethods.usernamePassword || page.data.authMethods.emailPassword}
|
||||||
<div class="flex gap-4 items-center w-full">
|
<div class="flex gap-4 items-center w-full">
|
||||||
<hr class="basis-full border-input-border" />
|
<hr class="basis-full border-input-border" />
|
||||||
<span class="text-gray-500 uppercase">{$_("or")}</span>
|
<span class="text-gray-500 uppercase">{$_("or")}</span>
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
<a
|
<a
|
||||||
href={provider.url}
|
href={provider.url}
|
||||||
class="btn-secondary inline-flex min-w-full justify-center"
|
class="btn-secondary inline-flex min-w-full justify-center"
|
||||||
on:click={() => setProvider(provider)}
|
onclick={() => setProvider(provider)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="w-5 aspect-square mr-4"
|
class="w-5 aspect-square mr-4"
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import { oauth_login } from "$lib/stores/user_store";
|
import { oauth_login } from "$lib/stores/user_store";
|
||||||
import type { AuthProviderInfo } from "pocketbase";
|
import type { AuthProviderInfo } from "pocketbase";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let error = $page.url.searchParams.get("error");
|
let error = $state(page.url.searchParams.get("error"));
|
||||||
let errorDescription = $page.url.searchParams.get("error_description");
|
let errorDescription = $state(page.url.searchParams.get("error_description"));
|
||||||
const errorURI = $page.url.searchParams.get("error_uri");
|
const errorURI = page.url.searchParams.get("error_uri");
|
||||||
|
|
||||||
const state = $page.url.searchParams.get("state");
|
const oauthState = page.url.searchParams.get("state");
|
||||||
const code = $page.url.searchParams.get("code");
|
const code = page.url.searchParams.get("code");
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (error || !state || !code) {
|
if (error || !oauthState || !code) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
const provider: AuthProviderInfo = JSON.parse(providerData);
|
const provider: AuthProviderInfo = JSON.parse(providerData);
|
||||||
|
|
||||||
if (provider.state !== state) {
|
if (provider.state !== oauthState) {
|
||||||
error = "mismacthed_provider";
|
error = "mismacthed_provider";
|
||||||
errorDescription =
|
errorDescription =
|
||||||
"OAuth provider does not match the one defined in local storage.";
|
"OAuth provider does not match the one defined in local storage.";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import Search, {
|
import Search, {
|
||||||
type SearchItem,
|
type SearchItem,
|
||||||
} from "$lib/components/base/search.svelte";
|
} from "$lib/components/base/search.svelte";
|
||||||
@@ -24,22 +24,22 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
|
|
||||||
let trails: Trail[] = [];
|
let trails: Trail[] = $state([]);
|
||||||
|
|
||||||
let map: M.Map;
|
let map: M.Map | undefined = $state();
|
||||||
let mapWithElevation: MapWithElevationMaplibre;
|
let mapWithElevation: MapWithElevationMaplibre | undefined = $state();
|
||||||
let searchDropdownItems: SearchItem[] = [];
|
let searchDropdownItems: SearchItem[] = $state([]);
|
||||||
|
|
||||||
let showFilter: boolean = false;
|
let showFilter: boolean = $state(false);
|
||||||
let showMap: boolean = true;
|
let showMap: boolean = $state(true);
|
||||||
|
|
||||||
const filter: TrailFilter = $page.data.filter;
|
let filter: TrailFilter = $state(page.data.filter);
|
||||||
const maxBoundingBox: TrailBoundingBox = $page.data.boundingBox;
|
const maxBoundingBox: TrailBoundingBox = page.data.boundingBox;
|
||||||
const settings: Settings = $page.data.settings;
|
const settings: Settings = page.data.settings;
|
||||||
|
|
||||||
const MIN_ZOOM = 6;
|
const MIN_ZOOM = 6;
|
||||||
|
|
||||||
let loading: boolean = true;
|
let loading: boolean = $state(true);
|
||||||
|
|
||||||
onMount(async () => {});
|
onMount(async () => {});
|
||||||
|
|
||||||
@@ -89,8 +89,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchClick(item: SearchItem) {
|
function handleSearchClick(item: SearchItem) {
|
||||||
map.setCenter([item.value._geo.lng, item.value._geo.lat]);
|
map?.setCenter([item.value._geo.lng, item.value._geo.lat]);
|
||||||
map.setZoom(14);
|
map?.setZoom(14);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchTrails(northEast: M.LngLat, southWest: M.LngLat) {
|
async function searchTrails(northEast: M.LngLat, southWest: M.LngLat) {
|
||||||
@@ -99,26 +99,32 @@
|
|||||||
northEast,
|
northEast,
|
||||||
southWest,
|
southWest,
|
||||||
filter,
|
filter,
|
||||||
map.getZoom() > MIN_ZOOM,
|
(map?.getZoom() ?? 0) > MIN_ZOOM,
|
||||||
);
|
);
|
||||||
trails = changes.trails;
|
trails = changes.trails;
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrailCardMouseEnter(trail: Trail) {
|
function handleTrailCardMouseEnter(trail: Trail) {
|
||||||
mapWithElevation.togglePopup(trail.id!, false);
|
mapWithElevation?.togglePopup(trail.id!, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrailCardMouseLeave(trail: Trail) {
|
function handleTrailCardMouseLeave(trail: Trail) {
|
||||||
mapWithElevation.togglePopup(trail.id!, true);
|
mapWithElevation?.togglePopup(trail.id!, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFilterUpdate(filter: TrailFilter) {
|
async function handleFilterUpdate(filter: TrailFilter) {
|
||||||
|
if (!map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const bounds = map.getBounds();
|
const bounds = map.getBounds();
|
||||||
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest());
|
await searchTrails(bounds.getNorthEast(), bounds.getSouthWest());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMapMove() {
|
async function handleMapMove() {
|
||||||
|
if (!map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const bounds = map.getBounds();
|
const bounds = map.getBounds();
|
||||||
|
|
||||||
const normalizedBounds = {
|
const normalizedBounds = {
|
||||||
@@ -137,60 +143,60 @@
|
|||||||
normalizedBounds.southWest,
|
normalizedBounds.southWest,
|
||||||
);
|
);
|
||||||
|
|
||||||
$page.url.searchParams.set("tl_lat", bounds.getNorth().toString());
|
page.url.searchParams.set("tl_lat", bounds.getNorth().toString());
|
||||||
$page.url.searchParams.set("tl_lon", bounds.getEast().toString());
|
page.url.searchParams.set("tl_lon", bounds.getEast().toString());
|
||||||
$page.url.searchParams.set("br_lat", bounds.getSouth().toString());
|
page.url.searchParams.set("br_lat", bounds.getSouth().toString());
|
||||||
$page.url.searchParams.set("br_lon", bounds.getWest().toString());
|
page.url.searchParams.set("br_lon", bounds.getWest().toString());
|
||||||
|
|
||||||
goto(`?${$page.url.searchParams.toString()}`);
|
goto(`?${page.url.searchParams.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMapInit() {
|
function handleMapInit() {
|
||||||
if (
|
if (
|
||||||
$page.url.searchParams.has("tl_lat") &&
|
page.url.searchParams.has("tl_lat") &&
|
||||||
$page.url.searchParams.has("tl_lon") &&
|
page.url.searchParams.has("tl_lon") &&
|
||||||
$page.url.searchParams.has("br_lat") &&
|
page.url.searchParams.has("br_lat") &&
|
||||||
$page.url.searchParams.has("br_lon")
|
page.url.searchParams.has("br_lon")
|
||||||
) {
|
) {
|
||||||
const boundingBox: M.LngLatBoundsLike = [
|
const boundingBox: M.LngLatBoundsLike = [
|
||||||
[
|
[
|
||||||
parseFloat($page.url.searchParams.get("br_lon")!),
|
parseFloat(page.url.searchParams.get("br_lon")!),
|
||||||
parseFloat($page.url.searchParams.get("tl_lat")!),
|
parseFloat(page.url.searchParams.get("tl_lat")!),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
parseFloat($page.url.searchParams.get("tl_lon")!),
|
parseFloat(page.url.searchParams.get("tl_lon")!),
|
||||||
parseFloat($page.url.searchParams.get("br_lat")!),
|
parseFloat(page.url.searchParams.get("br_lat")!),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
map.fitBounds(boundingBox, { animate: false });
|
map?.fitBounds(boundingBox, { animate: false });
|
||||||
} else if (
|
} else if (
|
||||||
$page.url.searchParams.has("lat") &&
|
page.url.searchParams.has("lat") &&
|
||||||
$page.url.searchParams.has("lon")
|
page.url.searchParams.has("lon")
|
||||||
) {
|
) {
|
||||||
const lat = $page.url.searchParams.get("lat");
|
const lat = page.url.searchParams.get("lat");
|
||||||
const lon = $page.url.searchParams.get("lon");
|
const lon = page.url.searchParams.get("lon");
|
||||||
map.setZoom(14);
|
map?.setZoom(14);
|
||||||
map.setCenter([parseFloat(lon!), parseFloat(lat!)]);
|
map?.setCenter([parseFloat(lon!), parseFloat(lat!)]);
|
||||||
} else if (settings && settings.mapFocus == "trails") {
|
} else if (settings && settings.mapFocus == "trails") {
|
||||||
const boundingBox: M.LngLatBoundsLike = [
|
const boundingBox: M.LngLatBoundsLike = [
|
||||||
[maxBoundingBox.min_lon, maxBoundingBox.max_lat],
|
[maxBoundingBox.min_lon, maxBoundingBox.max_lat],
|
||||||
[maxBoundingBox.max_lon, maxBoundingBox.min_lat],
|
[maxBoundingBox.max_lon, maxBoundingBox.min_lat],
|
||||||
];
|
];
|
||||||
map.fitBounds(boundingBox, { animate: false, padding: 32 });
|
map?.fitBounds(boundingBox, { animate: false, padding: 32 });
|
||||||
} else if (
|
} else if (
|
||||||
settings &&
|
settings &&
|
||||||
settings.mapFocus == "location" &&
|
settings.mapFocus == "location" &&
|
||||||
settings.location
|
settings.location
|
||||||
) {
|
) {
|
||||||
map.setZoom(12);
|
map?.setZoom(12);
|
||||||
map.setCenter([settings.location.lon, settings.location.lat]);
|
map?.setCenter([settings.location.lon, settings.location.lat]);
|
||||||
} else {
|
} else {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
const lat = position.coords.latitude;
|
const lat = position.coords.latitude;
|
||||||
const lon = position.coords.longitude;
|
const lon = position.coords.longitude;
|
||||||
map.setZoom(12);
|
map?.setZoom(12);
|
||||||
map.setCenter([lon, lat]);
|
map?.setCenter([lon, lat]);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error("Error getting user location:", error);
|
console.error("Error getting user location:", error);
|
||||||
@@ -212,19 +218,21 @@
|
|||||||
<div class="flex items-center gap-2 md:gap-4">
|
<div class="flex items-center gap-2 md:gap-4">
|
||||||
<Search
|
<Search
|
||||||
extraClasses="w-full"
|
extraClasses="w-full"
|
||||||
on:update={(e) => search(e.detail)}
|
onupdate={search}
|
||||||
on:click={(e) => handleSearchClick(e.detail)}
|
onclick={handleSearchClick}
|
||||||
placeholder="{$_('search-for-trails-places')}..."
|
placeholder="{$_('search-for-trails-places')}..."
|
||||||
items={searchDropdownItems}
|
items={searchDropdownItems}
|
||||||
></Search>
|
></Search>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Open filter"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
on:click={() => (showFilter = !showFilter)}
|
onclick={() => (showFilter = !showFilter)}
|
||||||
><i class="fa fa-sliders"></i></button
|
><i class="fa fa-sliders"></i></button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Toggle map"
|
||||||
class="btn-icon md:hidden"
|
class="btn-icon md:hidden"
|
||||||
on:click={() => (showMap = !showMap)}
|
onclick={() => (showMap = !showMap)}
|
||||||
><i
|
><i
|
||||||
class="fa-regular fa-{showMap
|
class="fa-regular fa-{showMap
|
||||||
? 'rectangle-list'
|
? 'rectangle-list'
|
||||||
@@ -238,8 +246,8 @@
|
|||||||
categories={$categories}
|
categories={$categories}
|
||||||
showTrailSearch={false}
|
showTrailSearch={false}
|
||||||
showCitySearch={false}
|
showCitySearch={false}
|
||||||
{filter}
|
bind:filter
|
||||||
on:update={(e) => handleFilterUpdate(e.detail)}
|
onupdate={handleFilterUpdate}
|
||||||
></TrailFilterPanel>
|
></TrailFilterPanel>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -255,14 +263,12 @@
|
|||||||
<EmptyStateSearch></EmptyStateSearch>
|
<EmptyStateSearch></EmptyStateSearch>
|
||||||
{/if}
|
{/if}
|
||||||
{#each trails as trail, i}
|
{#each trails as trail, i}
|
||||||
<a
|
<a href="map/trail/{trail.id}">
|
||||||
href="map/trail/{trail.id}"
|
|
||||||
>
|
|
||||||
<TrailCard
|
<TrailCard
|
||||||
{trail}
|
{trail}
|
||||||
on:mouseenter={() =>
|
onmouseenter={() =>
|
||||||
handleTrailCardMouseEnter(trail)}
|
handleTrailCardMouseEnter(trail)}
|
||||||
on:mouseleave={() =>
|
onmouseleave={() =>
|
||||||
handleTrailCardMouseLeave(trail)}
|
handleTrailCardMouseLeave(trail)}
|
||||||
></TrailCard>
|
></TrailCard>
|
||||||
</a>
|
</a>
|
||||||
@@ -275,8 +281,8 @@
|
|||||||
class:hidden={!showMap && browser && window.innerWidth < 768}
|
class:hidden={!showMap && browser && window.innerWidth < 768}
|
||||||
>
|
>
|
||||||
<MapWithElevationMaplibre
|
<MapWithElevationMaplibre
|
||||||
on:moveend={handleMapMove}
|
onmoveend={handleMapMove}
|
||||||
on:init={handleMapInit}
|
oninit={handleMapInit}
|
||||||
{trails}
|
{trails}
|
||||||
showElevation={false}
|
showElevation={false}
|
||||||
showInfoPopup={true}
|
showInfoPopup={true}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import "photoswipe/style.css";
|
import "photoswipe/style.css";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
let markers: M.Marker[];
|
let markers: M.Marker[] = $state([]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import "$lib/assets/fonts/IBMPlexSans-Regular-normal";
|
import "$lib/assets/fonts/IBMPlexSans-Regular-normal";
|
||||||
import "$lib/assets/fonts/IBMPlexSans-SemiBold-bold";
|
import "$lib/assets/fonts/IBMPlexSans-SemiBold-bold";
|
||||||
import "$lib/assets/fonts/fa-solid-900-normal";
|
import "$lib/assets/fonts/fa-solid-900-normal";
|
||||||
@@ -15,7 +15,10 @@
|
|||||||
formatElevation,
|
formatElevation,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
import { calculatePixelPerMeter, calculateScaleFactor } from "$lib/util/maplibre_util";
|
import {
|
||||||
|
calculatePixelPerMeter,
|
||||||
|
calculateScaleFactor,
|
||||||
|
} from "$lib/util/maplibre_util";
|
||||||
|
|
||||||
import { createRect, createText } from "$lib/util/svg_util";
|
import { createRect, createText } from "$lib/util/svg_util";
|
||||||
import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos/index";
|
import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos/index";
|
||||||
@@ -24,10 +27,10 @@
|
|||||||
import { onMount, tick } from "svelte";
|
import { onMount, tick } from "svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
let map: M.Map;
|
let map: M.Map | undefined = $state();
|
||||||
let showGrid: boolean = true;
|
let showGrid: boolean = $state(true);
|
||||||
|
|
||||||
const settings: Settings = $page.data.settings;
|
const settings: Settings = page.data.settings;
|
||||||
|
|
||||||
const paperSizes: { text: string; value: keyof typeof paperDimensions }[] =
|
const paperSizes: { text: string; value: keyof typeof paperDimensions }[] =
|
||||||
[
|
[
|
||||||
@@ -56,29 +59,31 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let selectedPaperSize = paperSizes[0].value;
|
let selectedPaperSize = $state(paperSizes[0].value);
|
||||||
|
|
||||||
const orientations: { text: string; value: "portrait" | "landscape" }[] = [
|
const orientations: { text: string; value: "portrait" | "landscape" }[] = [
|
||||||
{ text: "Portrait", value: "portrait" },
|
{ text: "Portrait", value: "portrait" },
|
||||||
{ text: "Landscape", value: "landscape" },
|
{ text: "Landscape", value: "landscape" },
|
||||||
];
|
];
|
||||||
let selectedOrientation: "portrait" | "landscape" = orientations[0].value;
|
let selectedOrientation: "portrait" | "landscape" = $state(
|
||||||
|
orientations[0].value,
|
||||||
|
);
|
||||||
|
|
||||||
const gridOptions = [
|
const gridOptions = [
|
||||||
{ text: $_("degrees"), value: "degree" },
|
{ text: $_("degrees"), value: "degree" },
|
||||||
{ text: $_("no-grid"), value: "off" },
|
{ text: $_("no-grid"), value: "off" },
|
||||||
];
|
];
|
||||||
let selectedGrid = gridOptions[0].value;
|
let selectedGrid = $state(gridOptions[0].value);
|
||||||
|
|
||||||
let scale: number = 1;
|
let scale: number = $state(1);
|
||||||
|
|
||||||
let printLoading: boolean = false;
|
let printLoading: boolean = $state(false);
|
||||||
|
|
||||||
let includeDescription: boolean = false;
|
let includeDescription: boolean = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let qrcode = new QrCodeWithLogo({
|
let qrcode = new QrCodeWithLogo({
|
||||||
content: $page.url.href.replace("/print", ""),
|
content: page.url.href.replace("/print", ""),
|
||||||
image: document.getElementById("qrcode") as HTMLImageElement,
|
image: document.getElementById("qrcode") as HTMLImageElement,
|
||||||
logo: {
|
logo: {
|
||||||
src: "/favicon.png",
|
src: "/favicon.png",
|
||||||
@@ -87,6 +92,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function print() {
|
async function print() {
|
||||||
|
if (!map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
printLoading = true;
|
printLoading = true;
|
||||||
const doc = new jsPDF(selectedOrientation, "mm", [
|
const doc = new jsPDF(selectedOrientation, "mm", [
|
||||||
paperDimensions[selectedPaperSize].pdf[
|
paperDimensions[selectedPaperSize].pdf[
|
||||||
@@ -110,7 +118,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var img = document.createElement("img");
|
var img = document.createElement("img");
|
||||||
var dimensions = map.getCanvas().getBoundingClientRect();
|
let dimensions = map.getCanvas().getBoundingClientRect();
|
||||||
img.width = dimensions.width;
|
img.width = dimensions.width;
|
||||||
img.height = dimensions.height;
|
img.height = dimensions.height;
|
||||||
let ratio = img.height / img.width;
|
let ratio = img.height / img.width;
|
||||||
@@ -289,7 +297,7 @@
|
|||||||
const elevationChartCanvas = document.getElementById(
|
const elevationChartCanvas = document.getElementById(
|
||||||
"elevation-profile-chart",
|
"elevation-profile-chart",
|
||||||
) as HTMLCanvasElement;
|
) as HTMLCanvasElement;
|
||||||
var dimensions = elevationChartCanvas.getBoundingClientRect();
|
dimensions = elevationChartCanvas.getBoundingClientRect();
|
||||||
img.width = dimensions.width;
|
img.width = dimensions.width;
|
||||||
img.height = dimensions.height;
|
img.height = dimensions.height;
|
||||||
ratio = img.height / img.width;
|
ratio = img.height / img.width;
|
||||||
@@ -469,25 +477,25 @@
|
|||||||
bind:value={selectedPaperSize}
|
bind:value={selectedPaperSize}
|
||||||
items={paperSizes}
|
items={paperSizes}
|
||||||
label={$_("paper-size")}
|
label={$_("paper-size")}
|
||||||
on:change={updateMapSize}
|
onchange={updateMapSize}
|
||||||
></Select>
|
></Select>
|
||||||
<Select
|
<Select
|
||||||
bind:value={selectedOrientation}
|
bind:value={selectedOrientation}
|
||||||
items={orientations}
|
items={orientations}
|
||||||
label={$_("orientation")}
|
label={$_("orientation")}
|
||||||
on:change={updateMapSize}
|
onchange={updateMapSize}
|
||||||
></Select>
|
></Select>
|
||||||
<Select
|
<Select
|
||||||
bind:value={selectedGrid}
|
bind:value={selectedGrid}
|
||||||
items={gridOptions}
|
items={gridOptions}
|
||||||
label={$_("grid")}
|
label={$_("grid")}
|
||||||
on:change={toggleGrid}
|
onchange={toggleGrid}
|
||||||
></Select>
|
></Select>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
id="description-checkbox"
|
id="description-checkbox"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:value={includeDescription}
|
bind:checked={includeDescription}
|
||||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<label for="description-checkbox" class="ms-2 text-sm"
|
<label for="description-checkbox" class="ms-2 text-sm"
|
||||||
@@ -499,7 +507,7 @@
|
|||||||
loading={printLoading}
|
loading={printLoading}
|
||||||
extraClasses="mt-2"
|
extraClasses="mt-2"
|
||||||
primary={true}
|
primary={true}
|
||||||
on:click={print}>{$_("print")}!</Button
|
onclick={print}>{$_("print")}!</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -518,7 +526,7 @@
|
|||||||
trails={[$trail]}
|
trails={[$trail]}
|
||||||
waypoints={$trail.expand?.waypoints}
|
waypoints={$trail.expand?.waypoints}
|
||||||
activeTrail={0}
|
activeTrail={0}
|
||||||
on:zoom={(e) => updateScale(e.detail)}
|
onzoom={updateScale}
|
||||||
bind:map
|
bind:map
|
||||||
{showGrid}
|
{showGrid}
|
||||||
elevationProfileContainer="elevation-profile"
|
elevationProfileContainer="elevation-profile"
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from "$app/navigation";
|
import { invalidateAll } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import Button from "$lib/components/base/button.svelte";
|
import Button from "$lib/components/base/button.svelte";
|
||||||
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
|
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
|
||||||
import type { SelectItem } from "$lib/components/base/select.svelte";
|
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||||
import ProfileShareModal from "$lib/components/profile/profile_share_modal.svelte";
|
import ProfileShareModal from "$lib/components/profile/profile_share_modal.svelte";
|
||||||
|
import TrailShareModal from "$lib/components/trail/trail_share_modal.svelte";
|
||||||
import { follows_create, follows_delete } from "$lib/stores/follow_store";
|
import { follows_create, follows_delete } from "$lib/stores/follow_store";
|
||||||
import { currentUser } from "$lib/stores/user_store";
|
import { currentUser } from "$lib/stores/user_store";
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let data;
|
let { data, children } = $props();
|
||||||
|
|
||||||
let openShareModal: () => void;
|
let profileShareModal: ProfileShareModal;
|
||||||
|
|
||||||
let followLoading: boolean = false;
|
let followLoading: boolean = $state(false);
|
||||||
|
|
||||||
const profileLinks: DropdownItem[] = [
|
const profileLinks: DropdownItem[] = [
|
||||||
{
|
{
|
||||||
text: $_("profile"),
|
text: $_("profile"),
|
||||||
value: `/profile/${$page.params.id}`,
|
value: `/profile/${page.params.id}`,
|
||||||
icon: "user",
|
icon: "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: $_("trail", { values: { n: 2 } }),
|
text: $_("trail", { values: { n: 2 } }),
|
||||||
value: `/profile/${$page.params.id}/trails`,
|
value: `/profile/${page.params.id}/trails`,
|
||||||
icon: "route",
|
icon: "route",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: $_("statistics"),
|
text: $_("statistics"),
|
||||||
value: `/profile/${$page.params.id}/stats`,
|
value: `/profile/${page.params.id}/stats`,
|
||||||
icon: "chart-pie",
|
icon: "chart-pie",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
$: activeIndex = profileLinks.findIndex(
|
let activeIndex = $derived(
|
||||||
(l) => l.value === $page.url.pathname,
|
profileLinks.findIndex((l) => l.value === page.url.pathname),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function follow() {
|
async function follow() {
|
||||||
@@ -90,14 +91,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="flex gap-x-6 text-sm px-6">
|
<div class="flex gap-x-6 text-sm px-6">
|
||||||
<a
|
<a
|
||||||
class:font-bold={$page.url.pathname.endsWith("followers")}
|
class:font-bold={page.url.pathname.endsWith("followers")}
|
||||||
href="/profile/{data.user.id}/users/followers"
|
href="/profile/{data.user.id}/users/followers"
|
||||||
>
|
>
|
||||||
<p class="font-semibold">{data.followers}</p>
|
<p class="font-semibold">{data.followers}</p>
|
||||||
<p>{$_("followers")}</p>
|
<p>{$_("followers")}</p>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class:font-bold={$page.url.pathname.endsWith("following")}
|
class:font-bold={page.url.pathname.endsWith("following")}
|
||||||
href="/profile/{data.user.id}/users/following"
|
href="/profile/{data.user.id}/users/following"
|
||||||
>
|
>
|
||||||
<p class="font-semibold">{data.following}</p>
|
<p class="font-semibold">{data.following}</p>
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
primary={!data.follow}
|
primary={!data.follow}
|
||||||
secondary={!!data.follow}
|
secondary={!!data.follow}
|
||||||
icon={data.follow ? "check" : ""}
|
icon={data.follow ? "check" : ""}
|
||||||
on:click={() => follow()}
|
onclick={() => follow()}
|
||||||
>
|
>
|
||||||
{data.follow ? $_("following") : $_("follow")}</Button
|
{data.follow ? $_("following") : $_("follow")}</Button
|
||||||
>
|
>
|
||||||
@@ -132,7 +133,7 @@
|
|||||||
<div class="px-6 mb-4 flex flex-col gap-2">
|
<div class="px-6 mb-4 flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
class="btn-secondary basis-full"
|
class="btn-secondary basis-full"
|
||||||
on:click={() => openShareModal()}
|
onclick={() => profileShareModal.openModal()}
|
||||||
>{$_("share-profile")}</button
|
>{$_("share-profile")}</button
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -140,11 +141,11 @@
|
|||||||
href="/settings/profile">{$_("settings")}</a
|
href="/settings/profile">{$_("settings")}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<ProfileShareModal bind:openModal={openShareModal}
|
<ProfileShareModal bind:this={profileShareModal}
|
||||||
></ProfileShareModal>
|
></ProfileShareModal>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="md:mr-6">
|
<div class="md:mr-6">
|
||||||
<slot></slot>
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ActivityCard from "$lib/components/profile/activity_card.svelte";
|
import { goto } from "$app/navigation";
|
||||||
import { activities_index } from "$lib/stores/activity_store.js";
|
|
||||||
import { getFileURL } from "$lib/util/file_util.js";
|
|
||||||
import { _ } from "svelte-i18n";
|
|
||||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||||
|
import ActivityCard from "$lib/components/profile/activity_card.svelte";
|
||||||
|
import { activities_index } from "$lib/stores/activity_store.js";
|
||||||
import { theme } from "$lib/stores/theme_store.js";
|
import { theme } from "$lib/stores/theme_store.js";
|
||||||
|
import { getFileURL } from "$lib/util/file_util.js";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
|
let activities = $state(data.activities);
|
||||||
|
|
||||||
let loading: boolean = false;
|
let loading: boolean = false;
|
||||||
|
|
||||||
$: pagination = {
|
let pagination = $derived({
|
||||||
page: data.activities.page,
|
page: data.activities.page,
|
||||||
totalPages: data.activities.totalPages,
|
totalPages: data.activities.totalPages,
|
||||||
};
|
});
|
||||||
|
|
||||||
async function onListScroll(e: Event) {
|
async function onListScroll(e: Event) {
|
||||||
if (
|
if (
|
||||||
@@ -31,11 +34,11 @@
|
|||||||
|
|
||||||
async function loadNextPage() {
|
async function loadNextPage() {
|
||||||
pagination.page += 1;
|
pagination.page += 1;
|
||||||
data.activities = await activities_index(data.user.id, pagination.page);
|
activities = await activities_index(data.user.id, pagination.page);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:scroll={onListScroll} />
|
<svelte:window onscroll={onListScroll} />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_("profile")} | wanderer</title>
|
<title>{$_("profile")} | wanderer</title>
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
{$_("about")}
|
{$_("about")}
|
||||||
{data.user.username}
|
{data.user.username}
|
||||||
{#if data.isOwnProfile && data.settings.bio?.length}
|
{#if data.isOwnProfile && data.settings.bio?.length}
|
||||||
<a class="ml-4" href="/settings/profile"
|
<a aria-label="Edit bio" class="ml-4" href="/settings/profile"
|
||||||
><i class="fa fa-pen text-base"></i></a
|
><i class="fa fa-pen text-base"></i></a
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -109,26 +112,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h4 class="text-xl font-semibold">Timeline</h4>
|
<h4 class="text-xl font-semibold">Timeline</h4>
|
||||||
{#if !data.activities.items.length && data.isOwnProfile}
|
{#if !activities.items.length && data.isOwnProfile}
|
||||||
<a class="btn-primary inline-block" href="/trails/edit/new"
|
<a class="btn-primary inline-block" href="/trails/edit/new"
|
||||||
>+ {$_("new-trail")}</a
|
>+ {$_("new-trail")}</a
|
||||||
>
|
>
|
||||||
{:else if !data.activities.items.length}
|
{:else if !activities.items.length}
|
||||||
<p class="w-full text-center text-gray-500 text-sm">
|
<p class="w-full text-center text-gray-500 text-sm">
|
||||||
{$_("empty-activities", {
|
{$_("empty-activities", {
|
||||||
values: { username: data.user.username },
|
values: { username: data.user.username },
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#each data.activities.items as activity}
|
{#each activities.items as activity}
|
||||||
<a
|
<div
|
||||||
class="block"
|
role="presentation"
|
||||||
href={activity.type == "trail"
|
class="cursor-pointer"
|
||||||
|
onclick={() => {
|
||||||
|
goto(
|
||||||
|
activity.type == "trail"
|
||||||
? `/trail/view/${activity.id}`
|
? `/trail/view/${activity.id}`
|
||||||
: `/trail/view/${activity.trail_id}?t=3`}
|
: `/trail/view/${activity.trail_id}?t=3`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ActivityCard {activity} user={data.user}></ActivityCard>
|
<ActivityCard {activity} user={data.user}></ActivityCard>
|
||||||
</a>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import Calendar from "$lib/components/base/calendar.svelte";
|
import Calendar from "$lib/components/base/calendar.svelte";
|
||||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||||
import MultiSelect from "$lib/components/base/multi_select.svelte";
|
import MultiSelect from "$lib/components/base/multi_select.svelte";
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
formatSpeed,
|
formatSpeed,
|
||||||
formatTimeHHMM,
|
formatTimeHHMM,
|
||||||
} from "$lib/util/format_util";
|
} from "$lib/util/format_util";
|
||||||
|
import Bar from "$lib/vendor/svelte-chartjs/bar.svelte";
|
||||||
|
import Pie from "$lib/vendor/svelte-chartjs/pie.svelte";
|
||||||
import {
|
import {
|
||||||
ArcElement,
|
ArcElement,
|
||||||
BarElement,
|
BarElement,
|
||||||
@@ -28,10 +30,9 @@
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "chart.js";
|
} from "chart.js";
|
||||||
import { Bar, Pie } from "svelte-chartjs";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
Title,
|
Title,
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
BarElement,
|
BarElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
const filter = data.filter;
|
const filter = $state(data.filter);
|
||||||
|
|
||||||
const categorySelectItems: SelectItem[] = $categories.map((c) => ({
|
const categorySelectItems: SelectItem[] = $categories.map((c) => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let barChartSelectedValue = barChartSelectItems[0].value;
|
let barChartSelectedValue = $state(barChartSelectItems[0].value);
|
||||||
|
|
||||||
const categoryColors = [
|
const categoryColors = [
|
||||||
"#fb8500",
|
"#fb8500",
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
elevation_loss: 3.28084,
|
elevation_loss: 3.28084,
|
||||||
};
|
};
|
||||||
|
|
||||||
$: logCategories = $summitLogs.reduce(
|
let logCategories = $derived($summitLogs.reduce(
|
||||||
(acc, log) => {
|
(acc, log) => {
|
||||||
const cat =
|
const cat =
|
||||||
log.expand?.trails_via_summit_logs?.at(0)?.expand?.category
|
log.expand?.trails_via_summit_logs?.at(0)?.expand?.category
|
||||||
@@ -95,18 +96,18 @@
|
|||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, number>,
|
{} as Record<string, number>,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: categoryLabels = Object.keys(logCategories).sort();
|
let categoryLabels = $derived(Object.keys(logCategories).sort());
|
||||||
$: categoryValues = Object.values(logCategories);
|
let categoryValues = $derived(Object.values(logCategories));
|
||||||
$: categoryColorMap = Object.fromEntries(
|
let categoryColorMap = $derived(Object.fromEntries(
|
||||||
categoryLabels.map((label, index) => [
|
categoryLabels.map((label, index) => [
|
||||||
label,
|
label,
|
||||||
categoryColors[index % categoryColors.length],
|
categoryColors[index % categoryColors.length],
|
||||||
]),
|
]),
|
||||||
);
|
));
|
||||||
|
|
||||||
$: categoryChartData = {
|
let categoryChartData = $derived({
|
||||||
labels: categoryLabels,
|
labels: categoryLabels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
@@ -114,10 +115,10 @@
|
|||||||
backgroundColor: categoryColors,
|
backgroundColor: categoryColors,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
function barChartUnit() {
|
function barChartUnit() {
|
||||||
const unit = $page.data.settings?.unit ?? "metric";
|
const unit = page.data.settings?.unit ?? "metric";
|
||||||
|
|
||||||
switch (barChartSelectedValue) {
|
switch (barChartSelectedValue) {
|
||||||
case "duration":
|
case "duration":
|
||||||
@@ -130,7 +131,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: barChartDataByDate = $summitLogs.reduce(
|
let barChartDataByDate = $derived($summitLogs.reduce(
|
||||||
(acc, log) => {
|
(acc, log) => {
|
||||||
const date = new Date(log.date).toLocaleDateString(undefined, {
|
const date = new Date(log.date).toLocaleDateString(undefined, {
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
@@ -145,7 +146,7 @@
|
|||||||
} else if (barChartSelectedValue === "duration") {
|
} else if (barChartSelectedValue === "duration") {
|
||||||
acc[date] = acc[date] / 60 / 60;
|
acc[date] = acc[date] / 60 / 60;
|
||||||
}
|
}
|
||||||
if ($page.data.settings?.unit !== "metric") {
|
if (page.data.settings?.unit !== "metric") {
|
||||||
acc[date] =
|
acc[date] =
|
||||||
acc[date] *
|
acc[date] *
|
||||||
(conversionFactors as any)[barChartSelectedValue];
|
(conversionFactors as any)[barChartSelectedValue];
|
||||||
@@ -153,12 +154,12 @@
|
|||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, number>,
|
{} as Record<string, number>,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: barChartLabels = Object.keys(barChartDataByDate); // Dates as labels
|
let barChartLabels = $derived(Object.keys(barChartDataByDate)); // Dates as labels
|
||||||
$: barChartValues = Object.values(barChartDataByDate);
|
let barChartValues = $derived(Object.values(barChartDataByDate));
|
||||||
|
|
||||||
$: barChartData = {
|
let barChartData = $derived({
|
||||||
labels: barChartLabels,
|
labels: barChartLabels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
@@ -170,36 +171,36 @@
|
|||||||
borderRadius: 15,
|
borderRadius: 15,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
$: totalDistance = $summitLogs.reduce(
|
let totalDistance = $derived($summitLogs.reduce(
|
||||||
(sum, log) => sum + (log.distance ?? 0),
|
(sum, log) => sum + (log.distance ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: totalDuration = $summitLogs.reduce(
|
let totalDuration = $derived($summitLogs.reduce(
|
||||||
(sum, log) => sum + (log.duration ?? 0),
|
(sum, log) => sum + (log.duration ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: totalElevationGain = $summitLogs.reduce(
|
let totalElevationGain = $derived($summitLogs.reduce(
|
||||||
(sum, log) => sum + (log.elevation_gain ?? 0),
|
(sum, log) => sum + (log.elevation_gain ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: totalElevationLoss = $summitLogs.reduce(
|
let totalElevationLoss = $derived($summitLogs.reduce(
|
||||||
(sum, log) => sum + (log.elevation_loss ?? 0),
|
(sum, log) => sum + (log.elevation_loss ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
));
|
||||||
|
|
||||||
$: averageSpeed =
|
let averageSpeed =
|
||||||
totalDuration > 0
|
$derived(totalDuration > 0
|
||||||
? $summitLogs.reduce(
|
? $summitLogs.reduce(
|
||||||
(sum, log) =>
|
(sum, log) =>
|
||||||
sum + (log.distance && log.duration ? log.distance : 0),
|
sum + (log.distance && log.duration ? log.distance : 0),
|
||||||
0,
|
0,
|
||||||
) / totalDuration
|
) / totalDuration
|
||||||
: undefined;
|
: undefined);
|
||||||
|
|
||||||
function updateFilterCategory(categories: SelectItem[]) {
|
function updateFilterCategory(categories: SelectItem[]) {
|
||||||
filter.category = categories.map((c) => c.value);
|
filter.category = categories.map((c) => c.value);
|
||||||
@@ -216,7 +217,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummitLogs() {
|
async function loadSummitLogs() {
|
||||||
const logs = await summit_logs_index($page.params.id, filter);
|
const logs = await summit_logs_index(page.params.id, filter);
|
||||||
summitLogs.set(logs.items);
|
summitLogs.set(logs.items);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -230,18 +231,18 @@
|
|||||||
class="flex flex-wrap lg:flex-nowrap col-start-1 lg:col-start-2 gap-x-4 justify-end"
|
class="flex flex-wrap lg:flex-nowrap col-start-1 lg:col-start-2 gap-x-4 justify-end"
|
||||||
>
|
>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
on:change={(e) => updateFilterCategory(e.detail)}
|
onchange={updateFilterCategory}
|
||||||
label={$_("categories")}
|
label={$_("categories")}
|
||||||
items={categorySelectItems}
|
items={categorySelectItems}
|
||||||
placeholder={`${$_("filter-categories")}...`}
|
placeholder={`${$_("filter-categories")}...`}
|
||||||
></MultiSelect>
|
></MultiSelect>
|
||||||
<Datepicker
|
<Datepicker
|
||||||
on:change={loadSummitLogs}
|
onchange={loadSummitLogs}
|
||||||
bind:value={filter.startDate}
|
bind:value={filter.startDate}
|
||||||
label={$_("after")}
|
label={$_("after")}
|
||||||
></Datepicker>
|
></Datepicker>
|
||||||
<Datepicker
|
<Datepicker
|
||||||
on:change={loadSummitLogs}
|
onchange={loadSummitLogs}
|
||||||
bind:value={filter.endDate}
|
bind:value={filter.endDate}
|
||||||
label={$_("before")}
|
label={$_("before")}
|
||||||
></Datepicker>
|
></Datepicker>
|
||||||
@@ -249,7 +250,7 @@
|
|||||||
<div class="space-y-4 grow-0 lg:mr-4">
|
<div class="space-y-4 grow-0 lg:mr-4">
|
||||||
<div class="border border-input-border rounded-xl p-6">
|
<div class="border border-input-border rounded-xl p-6">
|
||||||
<Calendar
|
<Calendar
|
||||||
on:click={(e) => handleDateClick(e.detail)}
|
onclick={handleDateClick}
|
||||||
logs={$summitLogs}
|
logs={$summitLogs}
|
||||||
colorMap={categoryColorMap}
|
colorMap={categoryColorMap}
|
||||||
></Calendar>
|
></Calendar>
|
||||||
|
|||||||
@@ -1,29 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import TrailList from "$lib/components/trail/trail_list.svelte";
|
import TrailList from "$lib/components/trail/trail_list.svelte";
|
||||||
|
import type { Trail, TrailFilter } from "$lib/models/trail.js";
|
||||||
import { trails_search_filter } from "$lib/stores/trail_store";
|
import { trails_search_filter } from "$lib/stores/trail_store";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
|
|
||||||
$: pagination = {
|
let pagination = $state({
|
||||||
page: data.trails.page,
|
page: data.trails.page,
|
||||||
totalPages: data.trails.totalPages,
|
totalPages: data.trails.totalPages,
|
||||||
};
|
});
|
||||||
|
|
||||||
|
let trails = $state(data.trails);
|
||||||
|
|
||||||
|
let filter: TrailFilter = $state(data.filter);
|
||||||
|
|
||||||
async function handleFilterUpdate() {
|
async function handleFilterUpdate() {
|
||||||
loading = true;
|
loading = true;
|
||||||
data.trails = await trails_search_filter(data.filter, pagination.page);
|
trails = await trails_search_filter(filter, pagination.page);
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function paginate(page: number) {
|
async function paginate(page: number) {
|
||||||
pagination.page = page;
|
pagination.page = page;
|
||||||
data.trails = await trails_search_filter(data.filter, page);
|
trails = await trails_search_filter(filter, page);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_("profile")} | wanderer</title>
|
<title>{$_("profile")} | wanderer</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
@@ -31,8 +37,8 @@
|
|||||||
{pagination}
|
{pagination}
|
||||||
{loading}
|
{loading}
|
||||||
fullWidthCards={true}
|
fullWidthCards={true}
|
||||||
trails={data.trails.items}
|
trails={trails.items}
|
||||||
filter={data.filter}
|
{filter}
|
||||||
on:update={() => handleFilterUpdate()}
|
onupdate={handleFilterUpdate}
|
||||||
on:pagination={(e) => paginate(e.detail)}
|
onpagination={paginate}
|
||||||
></TrailList>
|
></TrailList>
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import { follows_index } from "$lib/stores/follow_store.js";
|
import { follows_index } from "$lib/stores/follow_store.js";
|
||||||
import { getFileURL } from "$lib/util/file_util.js";
|
import { getFileURL } from "$lib/util/file_util.js";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
|
let follows = $state(data.follows);
|
||||||
|
|
||||||
let loading: boolean = false;
|
let loading: boolean = false;
|
||||||
|
|
||||||
$: key = ($page.params.type == "following" ? "followee" : "follower") as "follower" | "followee";
|
let key = $derived(
|
||||||
|
(page.params.type == "following" ? "followee" : "follower") as
|
||||||
|
| "follower"
|
||||||
|
| "followee",
|
||||||
|
);
|
||||||
|
|
||||||
$: pagination = {
|
let pagination = $derived({
|
||||||
page: data.follows.page,
|
page: data.follows.page,
|
||||||
totalPages: data.follows.totalPages,
|
totalPages: data.follows.totalPages,
|
||||||
};
|
});
|
||||||
|
|
||||||
async function onListScroll(e: Event) {
|
async function onListScroll(e: Event) {
|
||||||
if (
|
if (
|
||||||
@@ -29,18 +35,18 @@
|
|||||||
|
|
||||||
async function loadNextPage() {
|
async function loadNextPage() {
|
||||||
pagination.page += 1;
|
pagination.page += 1;
|
||||||
data.follows = await follows_index(
|
follows = await follows_index(
|
||||||
{ followee: $page.params.id },
|
{ followee: page.params.id },
|
||||||
pagination.page,
|
pagination.page,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:scroll={onListScroll} />
|
<svelte:window onscroll={onListScroll} />
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-semibold mb-4">{$_($page.params.type)}</h1>
|
<h1 class="text-3xl font-semibold mb-4">{$_(page.params.type)}</h1>
|
||||||
<ul class="space-y-4">
|
<ul class="space-y-4">
|
||||||
{#each data.follows.items as follow}
|
{#each follows.items as follow}
|
||||||
{#if !follow.expand?.[key].private}
|
{#if !follow.expand?.[key].private}
|
||||||
<a href="/profile/{follow.expand?.[key].id}">
|
<a href="/profile/{follow.expand?.[key].id}">
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, invalidateAll } from "$app/navigation";
|
import { goto, invalidateAll } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import Button from "$lib/components/base/button.svelte";
|
import Button from "$lib/components/base/button.svelte";
|
||||||
import TextField from "$lib/components/base/text_field.svelte";
|
import TextField from "$lib/components/base/text_field.svelte";
|
||||||
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
|
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { INVALID, z } from "zod";
|
import { INVALID, z } from "zod";
|
||||||
|
|
||||||
let loading: boolean = false;
|
let loading: boolean = $state(false);
|
||||||
const { form, errors } = createForm<User>({
|
const { form, errors } = createForm<User>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
id: "",
|
id: "",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
)
|
)
|
||||||
? (window.navigator.language as Language)
|
? (window.navigator.language as Language)
|
||||||
: Language.en;
|
: Language.en;
|
||||||
await settings_update({ id: $page.data.settings.id, language });
|
await settings_update({ id: page.data.settings.id, language });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import type { SelectItem } from "$lib/components/base/select.svelte";
|
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
const settingsLinks: SelectItem[] = [
|
const settingsLinks: SelectItem[] = [
|
||||||
{ text: $_("profile"), value: "/settings/profile" },
|
{ text: $_("profile"), value: "/settings/profile" },
|
||||||
@@ -22,11 +28,12 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
$: activeIndex = settingsLinks.findIndex(
|
let activeIndex = $derived(
|
||||||
(l) => l.value === $page.url.pathname,
|
settingsLinks.findIndex((l) => l.value === page.url.pathname),
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleItemClick(item: SelectItem) {
|
function handleItemClick(e: Event, item: SelectItem) {
|
||||||
|
e.stopPropagation();
|
||||||
if (item.value != settingsLinks.at(-1)?.value) {
|
if (item.value != settingsLinks.at(-1)?.value) {
|
||||||
goto(item.value);
|
goto(item.value);
|
||||||
} else {
|
} else {
|
||||||
@@ -44,13 +51,13 @@
|
|||||||
class="menu-item flex items-center px-4 py-3 my-1 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors rounded-md"
|
class="menu-item flex items-center px-4 py-3 my-1 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors rounded-md"
|
||||||
class:bg-menu-item-background-hover={i == activeIndex}
|
class:bg-menu-item-background-hover={i == activeIndex}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
onmousedown={(e) => handleItemClick(e, item)}
|
||||||
>
|
>
|
||||||
{item.text}
|
{item.text}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</menu>
|
</menu>
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<slot></slot>
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
||||||
import EmailModal from "$lib/components/settings/email_modal.svelte";
|
import EmailModal from "$lib/components/settings/email_modal.svelte";
|
||||||
import PasswordModal from "$lib/components/settings/password_modal.svelte";
|
import PasswordModal from "$lib/components/settings/password_modal.svelte";
|
||||||
@@ -14,16 +14,16 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
const settings = $page.data.settings;
|
const settings = page.data.settings;
|
||||||
|
|
||||||
let selectedLanguage = "en";
|
let selectedLanguage = "en";
|
||||||
let selectedMapFocus = "trails";
|
let selectedMapFocus = "trails";
|
||||||
|
|
||||||
let citySearchQuery: string = "";
|
let citySearchQuery: string = "";
|
||||||
|
|
||||||
let openConfirmModal: () => void;
|
let confirmModal: ConfirmModal;
|
||||||
let openEmailModal: () => void;
|
let emailModal: EmailModal;
|
||||||
let openPasswordModal: () => void;
|
let passwordModal: PasswordModal;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
citySearchQuery = settings?.location?.name ?? "";
|
citySearchQuery = settings?.location?.name ?? "";
|
||||||
@@ -84,33 +84,37 @@
|
|||||||
<hr class="mt-4 mb-6 border-input-border" />
|
<hr class="mt-4 mb-6 border-input-border" />
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h4 class="text-xl font-medium">{$_("login-details")}</h4>
|
<h4 class="text-xl font-medium">{$_("login-details")}</h4>
|
||||||
<button class="btn-secondary block" on:click={openEmailModal}
|
<button
|
||||||
>{$_("change-email")}</button
|
class="btn-secondary block"
|
||||||
|
onclick={() => emailModal.openModal()}>{$_("change-email")}</button
|
||||||
>
|
>
|
||||||
<button class="btn-secondary" on:click={openPasswordModal}
|
<button class="btn-secondary" onclick={() => passwordModal.openModal()}
|
||||||
>{$_("change-password")}</button
|
>{$_("change-password")}</button
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h4 class="text-xl text-red-400 font-medium">
|
<h4 class="text-xl text-red-400 font-medium">
|
||||||
{$_("danger-zone")}
|
{$_("danger-zone")}
|
||||||
</h4>
|
</h4>
|
||||||
<button id="delete-account" class="btn-danger" on:click={openConfirmModal}
|
<button
|
||||||
|
id="delete-account"
|
||||||
|
class="btn-danger"
|
||||||
|
onclick={() => confirmModal.openModal()}
|
||||||
>{$_("delete-account")}</button
|
>{$_("delete-account")}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EmailModal
|
<EmailModal
|
||||||
email={$currentUser.email}
|
email={$currentUser.email}
|
||||||
on:save={(e) => updateEmail(e.detail)}
|
onsave={updateEmail}
|
||||||
bind:openModal={openEmailModal}
|
bind:this={emailModal}
|
||||||
></EmailModal>
|
></EmailModal>
|
||||||
<PasswordModal
|
<PasswordModal
|
||||||
on:save={(e) => updatePassword(e.detail)}
|
onsave={updatePassword}
|
||||||
bind:openModal={openPasswordModal}
|
bind:this={passwordModal}
|
||||||
></PasswordModal>
|
></PasswordModal>
|
||||||
{/if}
|
{/if}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
text={$_("account-delete-confirm")}
|
text={$_("account-delete-confirm")}
|
||||||
bind:openModal={openConfirmModal}
|
bind:this={confirmModal}
|
||||||
on:confirm={deleteAccount}
|
onconfirm={deleteAccount}
|
||||||
></ConfirmModal>
|
></ConfirmModal>
|
||||||
|
|||||||
@@ -13,22 +13,22 @@
|
|||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { linear } from "svelte/easing";
|
import { linear } from "svelte/easing";
|
||||||
import { tweened } from "svelte/motion";
|
import { Tween } from "svelte/motion";
|
||||||
import emptyStateUploadDark from "$lib/assets/svgs/empty_states/empty_state_upload_dark.svg";
|
import emptyStateUploadDark from "$lib/assets/svgs/empty_states/empty_state_upload_dark.svg";
|
||||||
import emptyStateUploadLight from "$lib/assets/svgs/empty_states/empty_state_upload_light.svg";
|
import emptyStateUploadLight from "$lib/assets/svgs/empty_states/empty_state_upload_light.svg";
|
||||||
import { theme } from "$lib/stores/theme_store";
|
import { theme } from "$lib/stores/theme_store";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import JSConfetti from "js-confetti";
|
import JSConfetti from "js-confetti";
|
||||||
|
|
||||||
const uploadProgress = tweened(0, {
|
const uploadProgress = new Tween(0, {
|
||||||
duration: 300,
|
duration: 300,
|
||||||
easing: linear,
|
easing: linear,
|
||||||
});
|
});
|
||||||
|
|
||||||
let openExportModal: () => void;
|
let exportModal: TrailExportModal;
|
||||||
|
|
||||||
let offerUpload: boolean = false;
|
let offerUpload: boolean = $state(false);
|
||||||
let uploading: boolean = false;
|
let uploading: boolean = $state(false);
|
||||||
|
|
||||||
let jsConfetti: JSConfetti;
|
let jsConfetti: JSConfetti;
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
uploading = false;
|
uploading = false;
|
||||||
jsConfetti.addConfetti({
|
jsConfetti.addConfetti({
|
||||||
confettiRadius: 4,
|
confettiRadius: 4,
|
||||||
emojis: ['🍃', '🍁'],
|
emojis: ["🍃", "🍁"],
|
||||||
});
|
});
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
@@ -191,13 +191,18 @@
|
|||||||
: 'border border-content border-dashed'} rounded-xl flex items-center justify-center text-gray-500 bg-background cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
: 'border border-content border-dashed'} rounded-xl flex items-center justify-center text-gray-500 bg-background cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||||
class:bg-menu-item-background-hover={uploading}
|
class:bg-menu-item-background-hover={uploading}
|
||||||
class:border-2={offerUpload && !uploading}
|
class:border-2={offerUpload && !uploading}
|
||||||
style="--progress: {$uploadProgress}%"
|
style="--progress: {uploadProgress.current}%"
|
||||||
on:click={openFileBrowser}
|
onclick={openFileBrowser}
|
||||||
on:dragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
on:dragleave={handleDragLeave}
|
ondragleave={handleDragLeave}
|
||||||
on:drop={handleDrop}
|
ondrop={handleDrop}
|
||||||
>
|
>
|
||||||
<canvas id="confetti-canvas" class="absolute" style="width: 100%; height: 200%"> </canvas>
|
<canvas
|
||||||
|
id="confetti-canvas"
|
||||||
|
class="absolute"
|
||||||
|
style="width: 100%; height: 200%"
|
||||||
|
>
|
||||||
|
</canvas>
|
||||||
<div class="">
|
<div class="">
|
||||||
<img
|
<img
|
||||||
class="rounded-full aspect-square mx-auto"
|
class="rounded-full aspect-square mx-auto"
|
||||||
@@ -216,18 +221,18 @@
|
|||||||
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
on:change={() => handleFileSelection()}
|
onchange={() => handleFileSelection()}
|
||||||
/>
|
/>
|
||||||
<h3 class="text-2xl font-semibold">{$_("export")}</h3>
|
<h3 class="text-2xl font-semibold">{$_("export")}</h3>
|
||||||
<hr class="mt-4 mb-6 border-input-border" />
|
<hr class="mt-4 mb-6 border-input-border" />
|
||||||
<Button secondary={true} on:click={() => openExportModal()}
|
<Button secondary={true} onclick={() => exportModal.openModal()}
|
||||||
>{$_("export-all-trails")}</Button
|
>{$_("export-all-trails")}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TrailExportModal
|
<TrailExportModal
|
||||||
bind:openModal={openExportModal}
|
bind:this={exportModal}
|
||||||
on:export={(e) => exportTrails(e.detail)}
|
onexport={exportTrails}
|
||||||
></TrailExportModal>
|
></TrailExportModal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import RadioGroup, {
|
import RadioGroup, {
|
||||||
type RadioItem,
|
type RadioItem,
|
||||||
} from "$lib/components/base/radio_group.svelte";
|
} from "$lib/components/base/radio_group.svelte";
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
import { settings_update } from "$lib/stores/settings_store";
|
import { settings_update } from "$lib/stores/settings_store";
|
||||||
import { _, locale } from "svelte-i18n";
|
import { _, locale } from "svelte-i18n";
|
||||||
|
|
||||||
const settings = $page.data.settings;
|
const settings = page.data.settings;
|
||||||
|
|
||||||
const languages: SelectItem[] = [
|
const languages: SelectItem[] = [
|
||||||
{ text: $_("chinese"), value: "zh" },
|
{ text: $_("chinese"), value: "zh" },
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
{ text: $_("imperial"), value: "imperial" },
|
{ text: $_("imperial"), value: "imperial" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let selectedLanguage = settings?.language ?? "en";
|
let selectedLanguage = $state(settings?.language ?? "en");
|
||||||
|
|
||||||
async function handleLanguageSelection(
|
async function handleLanguageSelection(
|
||||||
value: Language,
|
value: Language,
|
||||||
@@ -59,12 +59,12 @@
|
|||||||
<Select
|
<Select
|
||||||
items={languages}
|
items={languages}
|
||||||
bind:value={selectedLanguage}
|
bind:value={selectedLanguage}
|
||||||
on:change={(e) => handleLanguageSelection(e.detail)}
|
onchange={handleLanguageSelection}
|
||||||
></Select>
|
></Select>
|
||||||
<h4 class="text-xl font-medium mt-6 mb-2">{$_("units")}</h4>
|
<h4 class="text-xl font-medium mt-6 mb-2">{$_("units")}</h4>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
name="unit"
|
name="unit"
|
||||||
items={units}
|
items={units}
|
||||||
selected={settings?.unit == "metric" ? 0 : 1}
|
selected={settings?.unit == "metric" ? 0 : 1}
|
||||||
on:change={(e) => handleUnitSelection(e.detail)}
|
onchange={handleUnitSelection}
|
||||||
></RadioGroup>
|
></RadioGroup>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import Search, {
|
import Search, {
|
||||||
type SearchItem,
|
type SearchItem,
|
||||||
} from "$lib/components/base/search.svelte";
|
} from "$lib/components/base/search.svelte";
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
$: settings = $page.data.settings;
|
let settings = $derived(page.data.settings);
|
||||||
|
|
||||||
const mapFocus: SelectItem[] = [
|
const mapFocus: SelectItem[] = [
|
||||||
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
||||||
@@ -22,15 +22,15 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
let selectedLanguage = "en";
|
let selectedLanguage = "en";
|
||||||
let selectedMapFocus = "trails";
|
let selectedMapFocus = $state("trails");
|
||||||
|
|
||||||
let searchDropdownItems: SearchItem[] = [];
|
let searchDropdownItems: SearchItem[] = $state([]);
|
||||||
let citySearchQuery: string = "";
|
let citySearchQuery: string = $state("");
|
||||||
|
|
||||||
let customTilesetName: string = "";
|
let customTilesetName: string = $state("");
|
||||||
let customTilesetURL: string = "";
|
let customTilesetURL: string = $state("");
|
||||||
let terrainURL: string = "";
|
let terrainURL: string = $state("");
|
||||||
let hillshadingURL: string = "";
|
let hillshadingURL: string = $state("");
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
citySearchQuery = settings?.location?.name ?? "";
|
citySearchQuery = settings?.location?.name ?? "";
|
||||||
@@ -106,9 +106,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$: terrainSaveEnabled =
|
let terrainSaveEnabled = $derived(
|
||||||
terrainURL !== settings?.terrain?.terrain ||
|
terrainURL !== settings?.terrain?.terrain ||
|
||||||
hillshadingURL !== settings?.terrain?.hillshading;
|
hillshadingURL !== settings?.terrain?.hillshading,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
<Select
|
<Select
|
||||||
items={mapFocus}
|
items={mapFocus}
|
||||||
bind:value={selectedMapFocus}
|
bind:value={selectedMapFocus}
|
||||||
on:change={(e) => handleMapFocusSelection(e.detail)}
|
onchange={handleMapFocusSelection}
|
||||||
></Select>
|
></Select>
|
||||||
{#if selectedMapFocus == "location"}
|
{#if selectedMapFocus == "location"}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
@@ -132,8 +133,8 @@
|
|||||||
placeholder="{$_('search-cities')}..."
|
placeholder="{$_('search-cities')}..."
|
||||||
clearAfterSelect={false}
|
clearAfterSelect={false}
|
||||||
bind:value={citySearchQuery}
|
bind:value={citySearchQuery}
|
||||||
on:update={(e) => searchCities(e.detail)}
|
onupdate={searchCities}
|
||||||
on:click={(e) => handleSearchClick(e.detail)}
|
onclick={handleSearchClick}
|
||||||
></Search>
|
></Search>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -150,8 +151,9 @@
|
|||||||
<p class="text-sm text-gray-500">{tileset.url}</p>
|
<p class="text-sm text-gray-500">{tileset.url}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Delete tileset"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
on:click={() => handleTilesetDelete(i)}
|
onclick={() => handleTilesetDelete(i)}
|
||||||
><i class="fa fa-trash text-red-500"></i></button
|
><i class="fa fa-trash text-red-500"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +172,10 @@
|
|||||||
placeholder="https://.../style.json"
|
placeholder="https://.../style.json"
|
||||||
></TextField>
|
></TextField>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon mt-6" on:click={handleTilesetAdd}
|
<button
|
||||||
|
aria-label="Add tileset"
|
||||||
|
class="btn-icon mt-6"
|
||||||
|
onclick={handleTilesetAdd}
|
||||||
><i class="fa fa-plus"></i></button
|
><i class="fa fa-plus"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,10 +200,11 @@
|
|||||||
></TextField>
|
></TextField>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Save terrain settings"
|
||||||
disabled={!terrainSaveEnabled}
|
disabled={!terrainSaveEnabled}
|
||||||
class="btn-icon mt-6"
|
class="btn-icon mt-6"
|
||||||
class:hover:!bg-background={!terrainSaveEnabled}
|
class:hover:!bg-background={!terrainSaveEnabled}
|
||||||
on:click={handleTerrainAdd}
|
onclick={handleTerrainAdd}
|
||||||
class:text-gray-500={!terrainSaveEnabled}
|
class:text-gray-500={!terrainSaveEnabled}
|
||||||
><i class="fa fa-save"></i></button
|
><i class="fa fa-save"></i></button
|
||||||
>
|
>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user