Migrates to svelte 5
This commit is contained in:
@@ -1,15 +1,38 @@
|
||||
<script lang="ts">
|
||||
export let type: "button" | "submit" | "reset" | null | undefined =
|
||||
undefined;
|
||||
export let icon: string = "";
|
||||
export let extraClasses: string = "";
|
||||
export let primary: boolean = false;
|
||||
export let secondary: boolean = false;
|
||||
export let large: boolean = false;
|
||||
export let loading: boolean = false;
|
||||
export let disabled: boolean = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let id: string | undefined = undefined
|
||||
import type { Snippet } from "svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
import { createBubbler } from "svelte/legacy";
|
||||
|
||||
const bubble = createBubbler();
|
||||
interface Props {
|
||||
type?: "button" | "submit" | "reset" | null | undefined;
|
||||
icon?: string;
|
||||
extraClasses?: string;
|
||||
primary?: boolean;
|
||||
secondary?: boolean;
|
||||
large?: boolean;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip?: string | undefined;
|
||||
id?: string | undefined;
|
||||
children?: Snippet;
|
||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
let {
|
||||
type = undefined,
|
||||
icon = "",
|
||||
extraClasses = "",
|
||||
primary = false,
|
||||
secondary = false,
|
||||
large = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
tooltip = undefined,
|
||||
id = undefined,
|
||||
children,
|
||||
onclick = undefined,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -21,7 +44,7 @@
|
||||
disabled={disabled || loading}
|
||||
class:tooltip={(tooltip?.length ?? 0) > 0}
|
||||
data-title={tooltip}
|
||||
on:click
|
||||
{onclick}
|
||||
{type}
|
||||
{id}
|
||||
>
|
||||
@@ -29,7 +52,7 @@
|
||||
{#if icon}
|
||||
<i class="fa fa-{icon} mr-2"></i>
|
||||
{/if}
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="absolute aspect-square spinner"></div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { isSameDay, isToday } from "../../util/date_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
export let logs: SummitLog[] = [];
|
||||
export let colorMap: Record<string, string> = {};
|
||||
interface Props {
|
||||
logs?: SummitLog[];
|
||||
colorMap?: Record<string, string>;
|
||||
onforward?: (data: { start: Date; end: Date }) => void;
|
||||
onbackward?: (data: { start: Date; end: Date }) => void;
|
||||
onclick?: (date: Date) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
forward: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
backward: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
click: Date;
|
||||
}>();
|
||||
let {
|
||||
logs = [],
|
||||
colorMap = {},
|
||||
onforward,
|
||||
onbackward,
|
||||
onclick,
|
||||
}: Props = $props();
|
||||
|
||||
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
const months = [
|
||||
@@ -34,14 +34,13 @@
|
||||
"Dezember",
|
||||
];
|
||||
const today = new Date();
|
||||
let currentMonth = today.getMonth();
|
||||
let currentYear = today.getFullYear();
|
||||
let currentMonth = $state(today.getMonth());
|
||||
let currentYear = $state(today.getFullYear());
|
||||
let currentMonthArray: ({
|
||||
date: Date | undefined;
|
||||
today: boolean;
|
||||
log?: SummitLog;
|
||||
} | null)[];
|
||||
$: currentMonthArray = generateMonthArray(currentYear, currentMonth, logs);
|
||||
} | null)[] = $derived(generateMonthArray(currentYear, currentMonth, logs));
|
||||
|
||||
function calculateFirstDayOfMonthDayOfWeek(year: number, month: number) {
|
||||
const date = new Date(year, month, 1);
|
||||
@@ -94,7 +93,7 @@
|
||||
} else {
|
||||
currentMonth++;
|
||||
}
|
||||
dispatch("forward", {
|
||||
onforward?.({
|
||||
start: new Date(currentYear, currentMonth, 1),
|
||||
end: new Date(currentYear, currentMonth + 1, 0),
|
||||
});
|
||||
@@ -107,7 +106,7 @@
|
||||
} else {
|
||||
currentMonth--;
|
||||
}
|
||||
dispatch("backward", {
|
||||
onbackward?.({
|
||||
start: new Date(currentYear, currentMonth, 1),
|
||||
end: new Date(currentYear, currentMonth + 1, 0),
|
||||
});
|
||||
@@ -124,7 +123,7 @@
|
||||
if (!date) {
|
||||
return;
|
||||
}
|
||||
dispatch("click", date);
|
||||
onclick?.(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -133,10 +132,12 @@
|
||||
<span class="text-lg">{months[currentMonth]}</span>
|
||||
<span>{currentYear}</span>
|
||||
</div>
|
||||
<button class="btn-icon mr-2" on:click={monthMinus}
|
||||
><i class="fa fa-caret-left"></i></button
|
||||
<button
|
||||
aria-label="Previous month"
|
||||
class="btn-icon mr-2"
|
||||
onclick={monthMinus}><i class="fa fa-caret-left"></i></button
|
||||
>
|
||||
<button class="btn-icon" on:click={monthPlus}
|
||||
<button aria-label="Next month" class="btn-icon" onclick={monthPlus}
|
||||
><i class="fa fa-caret-right"></i></button
|
||||
>
|
||||
</div>
|
||||
@@ -157,7 +158,7 @@
|
||||
{#each { length: 42 } as _, i}
|
||||
<button
|
||||
class="calendar-day flex items-center justify-center rounded-xl"
|
||||
on:click={() => handleDateClick(currentMonthArray[i]?.date)}
|
||||
onclick={() => handleDateClick(currentMonthArray[i]?.date)}
|
||||
class:today={currentMonthArray[i]?.today}
|
||||
style="background-color: {colorMap[
|
||||
colorKey(currentMonthArray, i)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script context="module" lang="ts">
|
||||
<script module lang="ts">
|
||||
export type ComboboxItem = {
|
||||
text: string;
|
||||
value: any;
|
||||
@@ -7,22 +7,41 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
import type { ChangeEventHandler } from "svelte/elements";
|
||||
import TextField from "./text_field.svelte";
|
||||
import { tick } from "svelte";
|
||||
|
||||
export let name: string = "";
|
||||
export let icon: string = "";
|
||||
export let label: string = "";
|
||||
export let value: string = "";
|
||||
export let items: ComboboxItem[] = [];
|
||||
export let placeholder: string = "";
|
||||
export let extraClasses: string = "";
|
||||
interface Props {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
items?: ComboboxItem[];
|
||||
placeholder?: string;
|
||||
extraClasses?: string;
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
onupdate?: (q: string) => void;
|
||||
onclick?: (item: ComboboxItem) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
name = "",
|
||||
icon = "",
|
||||
label = "",
|
||||
value = $bindable(""),
|
||||
items = [],
|
||||
placeholder = "",
|
||||
extraClasses = "",
|
||||
onchange,
|
||||
onupdate,
|
||||
onclick,
|
||||
}: Props = $props();
|
||||
|
||||
let searching: boolean = false;
|
||||
let searching: boolean = $state(false);
|
||||
|
||||
$: dropDownOpen = value.length > 0 && items.length > 0 && searching;
|
||||
let dropDownOpen = $derived(
|
||||
value.length > 0 && items.length > 0 && searching,
|
||||
);
|
||||
|
||||
async function onSearchType() {
|
||||
await tick();
|
||||
@@ -45,12 +64,13 @@
|
||||
}
|
||||
|
||||
function update(q: string) {
|
||||
dispatch("update", q);
|
||||
onupdate?.(q);
|
||||
}
|
||||
|
||||
function handleItemClick(item: ComboboxItem) {
|
||||
function handleItemClick(e: Event, item: ComboboxItem) {
|
||||
e.stopPropagation();
|
||||
value = item.value;
|
||||
dispatch("click", item);
|
||||
onclick?.(item);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,10 +83,10 @@
|
||||
{label}
|
||||
{placeholder}
|
||||
bind:value
|
||||
on:change
|
||||
on:input={onSearchType}
|
||||
on:focusin={() => (searching = true)}
|
||||
on:focusout={() => (searching = false)}
|
||||
{onchange}
|
||||
oninput={onSearchType}
|
||||
onfocusin={() => (searching = true)}
|
||||
onfocusout={() => (searching = false)}
|
||||
></TextField>
|
||||
|
||||
{#if dropDownOpen}
|
||||
@@ -76,13 +96,13 @@
|
||||
style="z-index: 1001"
|
||||
>
|
||||
{#each items as item}
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
tabindex="0"
|
||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
||||
on:keydown|stopPropagation={() => handleItemClick(item)}
|
||||
onmousedown={(e) => handleItemClick(e, item)}
|
||||
onkeydown={(e) => handleItemClick(e, item)}
|
||||
>
|
||||
<i class="fa fa-{item.icon} mr-6"></i>
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import type { ChangeEventHandler } from "svelte/elements";
|
||||
|
||||
export let name: string = "";
|
||||
export let value: string | Date = "";
|
||||
export let label: string = "";
|
||||
export let error: string | string[] | null = "";
|
||||
interface Props {
|
||||
name?: string;
|
||||
value?: string | Date;
|
||||
label?: string;
|
||||
error?: string | string[] | null;
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
let {
|
||||
name = "",
|
||||
value = $bindable(),
|
||||
label = "",
|
||||
error = "",
|
||||
onchange
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -21,7 +33,7 @@
|
||||
class:bg-input-background-error={(error?.length ?? 0) > 0}
|
||||
type="date"
|
||||
bind:value
|
||||
on:change
|
||||
{onchange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script lang="ts">
|
||||
import * as noUiSlider from "nouislider";
|
||||
import "nouislider/dist/nouislider.css";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let minValue = 0;
|
||||
export let maxValue = 100;
|
||||
interface Props {
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
currentMin?: any;
|
||||
currentMax?: any;
|
||||
onset?: (data: [number, number]) => void;
|
||||
}
|
||||
|
||||
export let currentMin = minValue;
|
||||
export let currentMax = maxValue;
|
||||
let {
|
||||
minValue = 0,
|
||||
maxValue = 100,
|
||||
currentMin = $bindable(minValue),
|
||||
currentMax = $bindable(maxValue),
|
||||
onset,
|
||||
}: Props = $props();
|
||||
|
||||
let sliderContainer: any;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let sliderContainer: any = $state();
|
||||
|
||||
onMount(() => {
|
||||
const updateValues = (values: string[]) => {
|
||||
@@ -31,7 +39,7 @@
|
||||
sliderContainer.noUiSlider.on("update", updateValues);
|
||||
|
||||
sliderContainer.noUiSlider.on("set", () => {
|
||||
dispatch("set", [currentMin, currentMax]);
|
||||
onset?.([currentMin, currentMax]);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
<script lang="ts">
|
||||
|
||||
import { browser } from "$app/environment";
|
||||
import { beforeNavigate } from "$app/navigation";
|
||||
import type { Snippet } from 'svelte';
|
||||
import { backIn, backOut } from "svelte/easing";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
export let open: boolean = false;
|
||||
|
||||
$: if (open && browser) {
|
||||
document.body.style.position = "fixed";
|
||||
document.body.style.top = `-${window.scrollY}px`;
|
||||
} else if (browser) {
|
||||
const scrollY = document.body.style.top;
|
||||
document.body.style.position = "";
|
||||
document.body.style.top = "";
|
||||
window.scrollTo(0, parseInt(scrollY || "0") * -1);
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), children }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (open && browser) {
|
||||
document.body.style.position = "fixed";
|
||||
document.body.style.top = `-${window.scrollY}px`;
|
||||
} else if (browser) {
|
||||
const scrollY = document.body.style.top;
|
||||
document.body.style.position = "";
|
||||
document.body.style.top = "";
|
||||
window.scrollTo(0, parseInt(scrollY || "0") * -1);
|
||||
}
|
||||
});
|
||||
|
||||
beforeNavigate(() => {
|
||||
open = false;
|
||||
});
|
||||
@@ -24,8 +33,8 @@
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500/50 cursor-pointer"
|
||||
on:click={() => (open = false)}
|
||||
on:keydown={(e) => {
|
||||
onclick={() => (open = false)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key == "Escape") open = false;
|
||||
}}
|
||||
role="button"
|
||||
@@ -36,6 +45,6 @@
|
||||
in:slide={{ axis: "x", easing: backOut }}
|
||||
out:slide={{ axis: "x", easing: backIn }}
|
||||
>
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script context="module" lang="ts">
|
||||
<script module lang="ts">
|
||||
export type DropdownItem = {
|
||||
text: string;
|
||||
value: any;
|
||||
@@ -7,15 +7,19 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { type Snippet } from "svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
export let items: DropdownItem[] = [];
|
||||
export let size: string = "regular";
|
||||
interface Props {
|
||||
items?: DropdownItem[];
|
||||
size?: string;
|
||||
children?: Snippet<[any]>;
|
||||
onchange?: (item: DropdownItem) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { items = [], size = "regular", children, onchange }: Props = $props();
|
||||
|
||||
let isOpen = false;
|
||||
let isOpen = $state(false);
|
||||
|
||||
export function toggleMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
@@ -27,13 +31,18 @@
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleItemClick(item: { text: string; value: any }) {
|
||||
dispatch("change", item);
|
||||
function handleItemClick(e: Event, item: { text: string; value: any }) {
|
||||
e.stopPropagation();
|
||||
onchange?.(item);
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).parentElement?.classList.contains("dropdown-toggle")) {
|
||||
if (
|
||||
(e.target as HTMLElement).parentElement?.classList.contains(
|
||||
"dropdown-toggle",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,19 +50,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:mouseup={handleWindowClick} />
|
||||
<svelte:window onmouseup={handleWindowClick} />
|
||||
|
||||
<div class="dropdown relative">
|
||||
<div class="dropdown-toggle">
|
||||
<slot {toggleMenu}>
|
||||
{#if children}{@render children({ toggleMenu })}{:else}
|
||||
<button
|
||||
aria-label="Toggle menu"
|
||||
class="btn-icon flex items-center justify-center"
|
||||
on:click={toggleMenu}
|
||||
onclick={toggleMenu}
|
||||
type="button"
|
||||
>
|
||||
<i class="fa fa-ellipsis-vertical text-{size}"></i>
|
||||
</button>
|
||||
</slot>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
@@ -68,7 +78,7 @@
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
role="presentation"
|
||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
||||
onmousedown={(e) => handleItemClick(e, item)}
|
||||
>
|
||||
{#if item.icon}
|
||||
<i class="fa fa-{item.icon} mr-3"></i>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
<script lang="ts">
|
||||
export let label: string = "";
|
||||
export let name: string = "";
|
||||
export let accept: string = "*";
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
|
||||
export let files: FileList | null = null;
|
||||
const bubble = createBubbler();
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
name?: string;
|
||||
accept?: string;
|
||||
files?: FileList | null;
|
||||
}
|
||||
|
||||
let {
|
||||
label = "",
|
||||
name = "",
|
||||
accept = "*",
|
||||
files = $bindable(null)
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -18,6 +30,6 @@
|
||||
bind:files
|
||||
class="cursor-pointer bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full file:rounded-lg file:px-4 file:py-2 file:border file:border-input-border-focus file:font-medium file:mr-4"
|
||||
type="file"
|
||||
on:change
|
||||
onchange={bubble('change')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
<script lang="ts">
|
||||
export let id: string;
|
||||
export let title: string;
|
||||
export let size: string = "max-w-2xl";
|
||||
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
size?: string;
|
||||
children?: Snippet<[any]>;
|
||||
content?: Snippet;
|
||||
footer?: Snippet<[any]>;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
size = "max-w-2xl",
|
||||
children,
|
||||
content,
|
||||
footer,
|
||||
}: Props = $props();
|
||||
|
||||
export function openModal() {
|
||||
document.body.style.top = `-${window.scrollY}px`;
|
||||
document.body.style.position = "fixed";
|
||||
@@ -21,7 +37,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<slot {openModal} />
|
||||
{@render children?.({ openModal })}
|
||||
<dialog
|
||||
{id}
|
||||
tabindex="-1"
|
||||
@@ -40,7 +56,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full btn-icon"
|
||||
on:click={closeModal}
|
||||
onclick={closeModal}
|
||||
>
|
||||
<i class="fa fa-close"></i>
|
||||
<span class="sr-only">Close modal</span>
|
||||
@@ -48,11 +64,11 @@
|
||||
</div>
|
||||
<!-- Modal body -->
|
||||
<div class="p-4 md:p-5 space-y-4">
|
||||
<slot name="content" />
|
||||
{@render content?.()}
|
||||
</div>
|
||||
<!-- Modal footer -->
|
||||
<div class="p-4 md:p-5 border-t border-separator rounded-b">
|
||||
<slot name="footer" {closeModal} />
|
||||
{@render footer?.({ closeModal })}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -1,45 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import type { SelectItem } from "./select.svelte";
|
||||
|
||||
export let items: SelectItem[] = [];
|
||||
export let value: SelectItem[] = [];
|
||||
export let label: string = "";
|
||||
export let name: string = "";
|
||||
export let placeholder: string = "";
|
||||
|
||||
let showDropdown = false;
|
||||
let dropdownRef;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function toggleItem(item: SelectItem) {
|
||||
if (value.includes(item)) {
|
||||
value = value.filter((i) => i !== item);
|
||||
} else {
|
||||
value = [...value, item];
|
||||
}
|
||||
|
||||
dispatch("change", value);
|
||||
interface Props {
|
||||
items?: SelectItem[];
|
||||
value?: SelectItem[];
|
||||
label?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
onchange?: (value: SelectItem[]) => void
|
||||
}
|
||||
|
||||
function removeItem(item: SelectItem) {
|
||||
let {
|
||||
items = [],
|
||||
value = $bindable([]),
|
||||
label = "",
|
||||
name = "",
|
||||
placeholder = "",
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
let showDropdown = $state(false);
|
||||
let dropdownRef = $state();
|
||||
|
||||
function toggleItem(item: SelectItem) {
|
||||
const itemIndex = value.findIndex((i) => i.value == item.value);
|
||||
|
||||
if (itemIndex >= 0) {
|
||||
value.splice(itemIndex, 1);
|
||||
} else {
|
||||
value.push(item);
|
||||
}
|
||||
|
||||
onchange?.(value);
|
||||
}
|
||||
|
||||
function removeItem(e: Event, item: SelectItem) {
|
||||
e.stopPropagation();
|
||||
value = value.filter((i) => i !== item);
|
||||
dispatch("change", value);
|
||||
onchange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="relative max-w-full">
|
||||
{#if label.length}
|
||||
<label for={name} class="text-sm font-medium pb-1 block">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
<button
|
||||
<div
|
||||
role="presentation"
|
||||
class="min-w-44 w-full flex flex-wrap items-center gap-2 border border-input-border bg-input-background min-h-[50px] p-3 rounded-md transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
|
||||
on:click={() => (showDropdown = !showDropdown)}
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
>
|
||||
{#if value.length === 0}
|
||||
<span class="text-gray-400">{placeholder}</span>
|
||||
@@ -50,14 +63,15 @@
|
||||
>
|
||||
<span class="text-sm">{$_(item.text)}</span>
|
||||
<button
|
||||
on:click|stopPropagation={() => removeItem(item)}
|
||||
aria-label="Close"
|
||||
onclick={(e) => removeItem(e, item)}
|
||||
class="text-white hover:bg-primary-hover rounded-full w-4 h-4 flex items-center justify-center"
|
||||
>
|
||||
<i class="fa fa-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
{#if showDropdown}
|
||||
@@ -67,11 +81,11 @@
|
||||
>
|
||||
{#each items as item}
|
||||
<button
|
||||
on:click={() => toggleItem(item)}
|
||||
onclick={() => toggleItem(item)}
|
||||
class="px-3 py-2 hover:bg-menu-item-background-hover cursor-pointer flex justify-between items-center w-full"
|
||||
>
|
||||
<span>{$_(item.text)}</span>
|
||||
{#if value.includes(item)}
|
||||
{#if value.findIndex((i) => i.value == item.value) >= 0}
|
||||
<div class="ml-auto">
|
||||
<i class="fa fa-check"></i>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { range } from "$lib/util/array_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let page: number;
|
||||
export let totalPages: number;
|
||||
interface Props {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onpagination?: (page: number) => void;
|
||||
}
|
||||
|
||||
let { page, totalPages, onpagination }: Props = $props();
|
||||
|
||||
const maxPagesToRender = 6;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update(clickedPage: number) {
|
||||
if (page !== clickedPage) {
|
||||
dispatch("pagination", clickedPage);
|
||||
onpagination?.(clickedPage);
|
||||
}
|
||||
}
|
||||
|
||||
$: numberGenerator = () => {
|
||||
let numberGenerator = $derived(() => {
|
||||
if (totalPages < 4) {
|
||||
return [];
|
||||
}
|
||||
@@ -31,21 +34,22 @@
|
||||
return range(page + Math.max(1, maxPagesToRender - 5), page - 2);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex gap-x-4 justify-center">
|
||||
{#if totalPages > 1}
|
||||
<button
|
||||
aria-label="Next page"
|
||||
class:text-gray-500={page == 1}
|
||||
disabled={page == 1}
|
||||
on:click={() => update(page - 1)}
|
||||
onclick={() => update(page - 1)}
|
||||
><i class="fa fa-caret-left"></i></button
|
||||
>
|
||||
<button
|
||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||
class:border={page == 1}
|
||||
on:click={() => update(1)}>1</button
|
||||
onclick={() => update(1)}>1</button
|
||||
>
|
||||
{#if totalPages > maxPagesToRender && page >= maxPagesToRender - 2 && page > 2}
|
||||
<span>...</span>
|
||||
@@ -53,14 +57,14 @@
|
||||
<button
|
||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||
class:border={page == 2}
|
||||
on:click={() => update(2)}>2</button
|
||||
onclick={() => update(2)}>2</button
|
||||
>
|
||||
{/if}
|
||||
{#each numberGenerator() as i}
|
||||
<button
|
||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||
class:border={page == i + 1}
|
||||
on:click={() => update(i + 1)}>{i + 1}</button
|
||||
onclick={() => update(i + 1)}>{i + 1}</button
|
||||
>
|
||||
{/each}
|
||||
{#if totalPages > maxPagesToRender && page < totalPages - 3}
|
||||
@@ -69,18 +73,19 @@
|
||||
<button
|
||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||
class:border={page == totalPages - 1}
|
||||
on:click={() => update(totalPages - 1)}>{totalPages - 1}</button
|
||||
onclick={() => update(totalPages - 1)}>{totalPages - 1}</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="w-8 aspect-square rounded-lg transition-colors border-input-border hover:bg-menu-item-background-hover"
|
||||
class:border={page == totalPages}
|
||||
on:click={() => update(totalPages)}>{totalPages}</button
|
||||
onclick={() => update(totalPages)}>{totalPages}</button
|
||||
>
|
||||
<button
|
||||
aria-label="Previous page"
|
||||
class:text-gray-500={page == totalPages}
|
||||
disabled={page == totalPages}
|
||||
on:click={() => update(page + 1)}
|
||||
onclick={() => update(page + 1)}
|
||||
><i class="fa fa-caret-right"></i></button
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script context="module" lang="ts">
|
||||
<script module lang="ts">
|
||||
export type RadioItem = {
|
||||
text: string;
|
||||
value: string;
|
||||
@@ -8,17 +8,19 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
interface Props {
|
||||
items: RadioItem[];
|
||||
name: string;
|
||||
selected?: number;
|
||||
onchange?: (item: RadioItem) => void
|
||||
}
|
||||
|
||||
export let items: RadioItem[];
|
||||
export let name: string;
|
||||
export let selected: number = 0;
|
||||
let { items, name, selected = $bindable(0), onchange }: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleRadioChange(radioIndex: number) {
|
||||
selected = radioIndex;
|
||||
dispatch("change", items[selected]);
|
||||
onchange?.(items[selected]);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,7 +33,7 @@
|
||||
checked={i == selected}
|
||||
value={item.value}
|
||||
class="w-4 h-4 accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
on:change={() => handleRadioChange(i)}
|
||||
onchange={() => handleRadioChange(i)}
|
||||
/>
|
||||
<div class="ms-2 text-sm">
|
||||
<label for="{name}-radio-{i}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script context="module" lang="ts">
|
||||
<!-- @migration-task Error while migrating Svelte code: This migration would change the name of a slot making the component unusable -->
|
||||
<script module lang="ts">
|
||||
export type SearchItem = {
|
||||
text: string;
|
||||
description?: string;
|
||||
@@ -8,26 +9,43 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import TextField from "./text_field.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
export let maxSearchLength: number = 5;
|
||||
export let value: string = "";
|
||||
export let items: SearchItem[] = [];
|
||||
export let placeholder: string = "Search...";
|
||||
export let large: boolean = false;
|
||||
export let extraClasses: string = "";
|
||||
export let label: string = "";
|
||||
export let clearAfterSelect: boolean = true;
|
||||
interface Props {
|
||||
maxSearchLength?: number;
|
||||
value?: string;
|
||||
items?: SearchItem[];
|
||||
placeholder?: string;
|
||||
large?: boolean;
|
||||
extraClasses?: string;
|
||||
label?: string;
|
||||
clearAfterSelect?: boolean;
|
||||
prepend?: Snippet<[any]>;
|
||||
onupdate?: (q: string) => void;
|
||||
onclick?: (item: SearchItem) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
maxSearchLength = 5,
|
||||
value = $bindable(""),
|
||||
items = $bindable([]),
|
||||
placeholder = "Search...",
|
||||
large = false,
|
||||
extraClasses = "",
|
||||
label = "",
|
||||
clearAfterSelect = true,
|
||||
prepend,
|
||||
onupdate,
|
||||
onclick
|
||||
}: Props = $props();
|
||||
|
||||
let lastSearch: string = "";
|
||||
let searching: boolean = false;
|
||||
let searching: boolean = $state(false);
|
||||
let typingTimer!: any;
|
||||
|
||||
$: dropDownOpen = value.length > 0 && items.length > 0 && searching;
|
||||
let dropDownOpen = $derived(value.length > 0 && items.length > 0 && searching);
|
||||
|
||||
function onSearchType() {
|
||||
clearTimeout(typingTimer);
|
||||
@@ -42,12 +60,13 @@
|
||||
|
||||
function update(q: string) {
|
||||
lastSearch = q;
|
||||
dispatch("update", q);
|
||||
onupdate?.(q);
|
||||
}
|
||||
|
||||
function handleItemClick(item: SearchItem) {
|
||||
function handleItemClick(e: Event, item: SearchItem) {
|
||||
e.stopPropagation();
|
||||
searching = false;
|
||||
dispatch("click", item);
|
||||
onclick?.(item);
|
||||
if (clearAfterSelect) {
|
||||
clear();
|
||||
}
|
||||
@@ -70,11 +89,12 @@
|
||||
</span>
|
||||
{#if value.length > 0}
|
||||
<button
|
||||
aria-label="Clear"
|
||||
type="button"
|
||||
class="btn-icon absolute {large
|
||||
? 'bottom-[31px]'
|
||||
: 'bottom-[25px]'} translate-y-1/2 right-0 mr-2"
|
||||
on:click={clear}
|
||||
onclick={clear}
|
||||
in:fade={{ duration: 150 }}
|
||||
out:fade={{ duration: 150 }}
|
||||
>
|
||||
@@ -91,9 +111,9 @@
|
||||
: "px-10"}
|
||||
{placeholder}
|
||||
bind:value
|
||||
on:input={onSearchType}
|
||||
on:focusin={() => (searching = true)}
|
||||
on:focusout={() => (searching = false)}
|
||||
oninput={onSearchType}
|
||||
onfocusin={() => (searching = true)}
|
||||
onfocusout={() => (searching = false)}
|
||||
></TextField>
|
||||
|
||||
{#if dropDownOpen}
|
||||
@@ -103,17 +123,17 @@
|
||||
style="z-index: 1001"
|
||||
>
|
||||
{#each items as item}
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
tabindex="0"
|
||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
||||
on:keydown|stopPropagation={() => handleItemClick(item)}
|
||||
onmousedown={(e) => handleItemClick(e, item)}
|
||||
onkeydown={(e) => handleItemClick(e, item)}
|
||||
>
|
||||
<slot name="item-header" {item}>
|
||||
{#if prepend}{@render prepend({ item, })}{:else}
|
||||
<i class="fa fa-{item.icon} mr-6"></i>
|
||||
</slot>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<p>{item.text}</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script context="module" lang="ts">
|
||||
<script module lang="ts">
|
||||
export type SelectItem = {
|
||||
text: string;
|
||||
value: any;
|
||||
@@ -6,18 +6,26 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
interface Props {
|
||||
name?: string;
|
||||
items?: SelectItem[];
|
||||
value?: any;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
onchange?: (value: any) => void
|
||||
}
|
||||
|
||||
export let name: string = "";
|
||||
export let items: SelectItem[] = [];
|
||||
export let value: any = items.at(0)?.value ?? "";
|
||||
export let label: string = "";
|
||||
export let disabled: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
name = "",
|
||||
items = [],
|
||||
value = $bindable(items.at(0)?.value ?? ""),
|
||||
label = "",
|
||||
disabled = false,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
function onChange(target: any) {
|
||||
dispatch("change", target?.value);
|
||||
onchange?.(target?.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,7 +41,7 @@
|
||||
class:text-gray-500={disabled}
|
||||
{disabled}
|
||||
bind:value
|
||||
on:change={(e) => onChange(e.target)}
|
||||
onchange={(e) => onChange(e.target)}
|
||||
>
|
||||
{#each items as item}
|
||||
<option value={item.value}>{item.text}</option>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import noUiSlider from "nouislider";
|
||||
import "nouislider/dist/nouislider.css";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let minValue = 0;
|
||||
export let maxValue = 100;
|
||||
interface Props {
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
currentValue?: any;
|
||||
onset?: (value: number) => void;
|
||||
}
|
||||
|
||||
export let currentValue = maxValue / 2;
|
||||
let {
|
||||
minValue = 0,
|
||||
maxValue = 100,
|
||||
currentValue = $bindable(maxValue / 2),
|
||||
onset,
|
||||
}: Props = $props();
|
||||
|
||||
let sliderContainer: any;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let sliderContainer: any = $state();
|
||||
|
||||
onMount(() => {
|
||||
const updateValues = (values: string[]) => {
|
||||
@@ -29,7 +36,7 @@
|
||||
sliderContainer.noUiSlider.on("update", updateValues);
|
||||
|
||||
sliderContainer.noUiSlider.on("set", () => {
|
||||
dispatch("set", currentValue);
|
||||
onset?.(currentValue);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { tweened } from "svelte/motion";
|
||||
import { Tween } from "svelte/motion";
|
||||
|
||||
export let tabs: string[];
|
||||
export let activeTab: number;
|
||||
export let extraClasses: string = "";
|
||||
interface Props {
|
||||
tabs: string[];
|
||||
activeTab: number;
|
||||
extraClasses?: string;
|
||||
}
|
||||
|
||||
const indicatorPosition = tweened(0, {
|
||||
let { tabs, activeTab = $bindable(), extraClasses = "" }: Props = $props();
|
||||
|
||||
const indicatorPosition = new Tween(0, {
|
||||
duration: 300,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
const indicatorWidth = tweened(0, {
|
||||
const indicatorWidth = new Tween(0, {
|
||||
duration: 300,
|
||||
easing: cubicOut,
|
||||
});
|
||||
@@ -37,13 +41,13 @@
|
||||
<div id="tabs" class="flex gap-2 overflow-x-auto relative {extraClasses}">
|
||||
<div
|
||||
class="absolute h-full bg-menu-item-background-hover rounded-t-lg top-0 z-0"
|
||||
style="width: {$indicatorWidth}px; left: {$indicatorPosition}px;"
|
||||
style="width: {indicatorWidth.current}px; left: {indicatorPosition.current}px;"
|
||||
></div>
|
||||
{#each tabs as tab, i}
|
||||
<button
|
||||
class="tab z-10"
|
||||
class:tab-active={activeTab == i}
|
||||
on:click={() => switchTabs(i)}>{tab}</button
|
||||
onclick={() => switchTabs(i)}>{tab}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import type { ChangeEventHandler, FocusEventHandler, FormEventHandler } from "svelte/elements";
|
||||
|
||||
export let name: string = "";
|
||||
export let value: string | number = "";
|
||||
export let placeholder: string = "";
|
||||
export let disabled: boolean = false;
|
||||
export let label: string = "";
|
||||
export let error: string | string[] | null = "";
|
||||
export let icon: string = "";
|
||||
export let extraClasses: string = "";
|
||||
export let type: "text" | "password" | "search" = "text";
|
||||
export let autocomplete: "on" | "off" = "on";
|
||||
interface Props {
|
||||
name?: string;
|
||||
value?: string | number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
error?: string | string[] | null;
|
||||
icon?: string;
|
||||
extraClasses?: string;
|
||||
type?: "text" | "password" | "search";
|
||||
autocomplete?: "on" | "off";
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
onfocusin?: FocusEventHandler<HTMLInputElement>;
|
||||
onfocusout?: FocusEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
let {
|
||||
name = "",
|
||||
value = $bindable(""),
|
||||
placeholder = "",
|
||||
disabled = false,
|
||||
label = "",
|
||||
error = "",
|
||||
icon = "",
|
||||
extraClasses = "",
|
||||
type = "text",
|
||||
autocomplete = "on",
|
||||
onchange,
|
||||
oninput,
|
||||
onfocusin,
|
||||
onfocusout
|
||||
}: Props = $props();
|
||||
function typeAction(node: HTMLInputElement) {
|
||||
node.type = type;
|
||||
}
|
||||
@@ -38,10 +62,10 @@
|
||||
{autocomplete}
|
||||
use:typeAction
|
||||
bind:value
|
||||
on:change
|
||||
on:input
|
||||
on:focusin
|
||||
on:focusout
|
||||
{onchange}
|
||||
{oninput}
|
||||
{onfocusin}
|
||||
{onfocusout}
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let name: string = "";
|
||||
export let value: string | number = "";
|
||||
export let placeholder: string = "";
|
||||
export let rows: number = 3;
|
||||
export let label: string = "";
|
||||
export let error: string | string[] | null = "";
|
||||
export let extraClasses: string = "";
|
||||
interface Props {
|
||||
name?: string;
|
||||
value?: string | number;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
label?: string;
|
||||
error?: string | string[] | null;
|
||||
extraClasses?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
name = "",
|
||||
value = $bindable(""),
|
||||
placeholder = "",
|
||||
rows = 3,
|
||||
label = "",
|
||||
error = "",
|
||||
extraClasses = ""
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -24,8 +39,8 @@
|
||||
class:border-red-400={(error?.length ?? 0) > 0}
|
||||
class:bg-input-background-error={(error?.length ?? 0) > 0}
|
||||
bind:value
|
||||
on:change
|
||||
/>
|
||||
onchange={bubble('change')}
|
||||
></textarea>
|
||||
{#if error}
|
||||
<span class="textfield-error text-xs text-red-400">
|
||||
{error instanceof Array ? $_(error[0]) : error}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white ml-6"
|
||||
on:click={closeToast}
|
||||
onclick={closeToast}
|
||||
>
|
||||
<i class="fa fa-close"></i>
|
||||
<span class="sr-only">Close modal</span>
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let name: string = "";
|
||||
export let value: boolean = false;
|
||||
export let label: string = "";
|
||||
export let error: string = "";
|
||||
interface Props {
|
||||
name?: string;
|
||||
value?: boolean;
|
||||
label?: string;
|
||||
error?: string;
|
||||
onchange?: (value: boolean) => void
|
||||
}
|
||||
|
||||
let {
|
||||
name = "",
|
||||
value = $bindable(false),
|
||||
label = "",
|
||||
error = "",
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleToggleChange() {
|
||||
dispatch("change", value);
|
||||
onchange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,7 +29,7 @@
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
value="1"
|
||||
on:change={handleToggleChange}
|
||||
onchange={handleToggleChange}
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import type { Category } from "$lib/models/category";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
export let category: Category;
|
||||
interface Props {
|
||||
category: Category;
|
||||
}
|
||||
|
||||
let { category }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
<script lang="ts">
|
||||
import type { Comment } from "$lib/models/comment";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { formatTimeSince } from "$lib/util/format_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let comment: Comment;
|
||||
export let mode: "show" | "edit" = "show";
|
||||
interface Props {
|
||||
comment: Comment;
|
||||
mode?: "show" | "edit";
|
||||
ondelete?: (comment: Comment) => void
|
||||
onedit?: (data: {comment: Comment, text: string}) => void
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { comment, mode = "show", ondelete, onedit }: Props = $props();
|
||||
|
||||
let editing: boolean = false;
|
||||
let editing: boolean = $state(false);
|
||||
|
||||
let editedComment = comment.text;
|
||||
let editedComment = $state(comment.text);
|
||||
|
||||
$: avatarSrc = comment.expand?.author.avatar
|
||||
? getFileURL(comment.expand.author, comment.expand.author.avatar)
|
||||
: `https://api.dicebear.com/7.x/initials/svg?seed=${comment.expand?.author.username ?? ""}&backgroundType=gradientLinear`;
|
||||
$effect(() => {
|
||||
editedComment = comment.text;
|
||||
});
|
||||
|
||||
$: timeSince = formatTimeSince(new Date(comment.created ?? ""));
|
||||
let avatarSrc = $derived(
|
||||
comment.expand?.author.avatar
|
||||
? getFileURL(comment.expand.author, comment.expand.author.avatar)
|
||||
: `https://api.dicebear.com/7.x/initials/svg?seed=${comment.expand?.author.username ?? ""}&backgroundType=gradientLinear`,
|
||||
);
|
||||
|
||||
let timeSince = $derived(formatTimeSince(new Date(comment.created ?? "")));
|
||||
|
||||
function deleteComment() {
|
||||
dispatch("delete", comment);
|
||||
ondelete?.(comment)
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (editing) {
|
||||
dispatch("edit", { comment: comment, text: editedComment });
|
||||
onedit?.({ comment: comment, text: editedComment });
|
||||
}
|
||||
editing = !editing;
|
||||
}
|
||||
@@ -74,22 +83,27 @@
|
||||
<span class="text-xs text-gray-500 ml-2"
|
||||
>{$_(`n-${timeSince.unit}-ago`, {
|
||||
values: { n: timeSince.value },
|
||||
})} {comment.updated != comment.created ? `(${$_("edited")})` : ""}</span
|
||||
})}
|
||||
{comment.updated != comment.created
|
||||
? `(${$_("edited")})`
|
||||
: ""}</span
|
||||
>
|
||||
</p>
|
||||
{#if mode == "edit"}
|
||||
<button
|
||||
aria-label="Edit comment"
|
||||
type="button"
|
||||
class="btn-icon ml-2"
|
||||
style="font-size: 0.75rem;"
|
||||
on:click={toggleEdit}
|
||||
onclick={toggleEdit}
|
||||
><i class="fa fa-{editing ? 'check' : 'pen'}"></i></button
|
||||
>
|
||||
<button
|
||||
aria-label="Delete comment"
|
||||
type="button"
|
||||
class="btn-icon text-xs"
|
||||
style="font-size: 0.75rem;"
|
||||
on:click={deleteComment}><i class="fa fa-trash"></i></button
|
||||
onclick={deleteComment}><i class="fa fa-trash"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,51 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
|
||||
export let title: string = $_('confirm-deletion');
|
||||
export let text: string;
|
||||
export let action: string = "delete";
|
||||
export let id: string = "confirm-modal";
|
||||
interface Props {
|
||||
title?: string;
|
||||
text: string;
|
||||
action?: string;
|
||||
id?: string;
|
||||
onconfirm?: () => void
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
title = $_("confirm-deletion"),
|
||||
text,
|
||||
action = "delete",
|
||||
id = "confirm-modal",
|
||||
onconfirm
|
||||
}: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
dispatch("confirm");
|
||||
closeModal!();
|
||||
onconfirm?.()
|
||||
modal.closeModal!();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {id} {title} bind:openModal bind:closeModal>
|
||||
<p slot="content">{text}</p>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button id="confirm" class={action === "delete" ? "btn-danger" : "btn-primary"} type="button" on:click={confirm} name="delete"
|
||||
>{$_(action)}</button
|
||||
>
|
||||
</div></Modal
|
||||
<Modal {id} {title} bind:this={modal}>
|
||||
{#snippet content()}
|
||||
<p>{text}</p>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
id="confirm"
|
||||
class={action === "delete" ? "btn-danger" : "btn-primary"}
|
||||
type="button"
|
||||
onclick={confirm}
|
||||
name="delete">{$_(action)}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
|
||||
import { _ } from "svelte-i18n";
|
||||
export let width: number = 192;
|
||||
interface Props {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
let { width = 192 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
|
||||
import { _ } from "svelte-i18n";
|
||||
export let width: number = 192;
|
||||
interface Props {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
let { width = 192 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
|
||||
import { _ } from "svelte-i18n";
|
||||
export let width: number = 192;
|
||||
interface Props {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
let { width = 192 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
|
||||
import { _ } from "svelte-i18n";
|
||||
export let width: number = 256;
|
||||
interface Props {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
let { width = 256 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
|
||||
import { _ } from "svelte-i18n";
|
||||
export let width: number = 192;
|
||||
interface Props {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
let { width = 192 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-center items-center gap-y-4">
|
||||
|
||||
@@ -12,30 +12,34 @@
|
||||
import { _ } from "svelte-i18n";
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
|
||||
export let list: List;
|
||||
export let active: boolean = false;
|
||||
interface Props {
|
||||
list: List;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
$: cumulativeDistance = list.expand?.trails?.reduce(
|
||||
let { list, active = false }: Props = $props();
|
||||
|
||||
let cumulativeDistance = $derived(list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.distance!,
|
||||
0,
|
||||
);
|
||||
));
|
||||
|
||||
$: cumulativeElevationGain = list.expand?.trails?.reduce(
|
||||
let cumulativeElevationGain = $derived(list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.elevation_gain!,
|
||||
0,
|
||||
);
|
||||
));
|
||||
|
||||
$: cumulativeElevationLoss = list.expand?.trails?.reduce(
|
||||
let cumulativeElevationLoss = $derived(list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.elevation_loss!,
|
||||
0,
|
||||
);
|
||||
));
|
||||
|
||||
$: cumulativeDuration = list.expand?.trails?.reduce(
|
||||
let cumulativeDuration = $derived(list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.duration!,
|
||||
0,
|
||||
);
|
||||
));
|
||||
|
||||
$: listIsShared = (list.expand?.list_share_via_list?.length ?? 0) > 0;
|
||||
let listIsShared = $derived((list.expand?.list_share_via_list?.length ?? 0) > 0);
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
||||
@@ -11,44 +11,50 @@
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Dropdown from "../base/dropdown.svelte";
|
||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
import TrailListItem from "../trail/trail_list_item.svelte";
|
||||
export let list: List;
|
||||
interface Props {
|
||||
list: List;
|
||||
onclick?: (data: { trail: Trail; index: number }) => void;
|
||||
onmouseenter?: (data: { trail: Trail; index: number }) => void;
|
||||
onmouseleave?: (data: { trail: Trail; index: number }) => void;
|
||||
onchange?: (item: DropdownItem) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { list, onclick, onmouseenter, onmouseleave, onchange }: Props =
|
||||
$props();
|
||||
|
||||
$: cumulativeDistance = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.distance!,
|
||||
0,
|
||||
let cumulativeDistance = $derived(
|
||||
list.expand?.trails?.reduce((s, b) => s + b.distance!, 0),
|
||||
);
|
||||
|
||||
$: cumulativeElevationGain = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.elevation_gain!,
|
||||
0,
|
||||
let cumulativeElevationGain = $derived(
|
||||
list.expand?.trails?.reduce((s, b) => s + b.elevation_gain!, 0),
|
||||
);
|
||||
|
||||
$: cumulativeElevationLoss = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.elevation_loss!,
|
||||
0,
|
||||
let cumulativeElevationLoss = $derived(
|
||||
list.expand?.trails?.reduce((s, b) => s + b.elevation_loss!, 0),
|
||||
);
|
||||
|
||||
$: cumulativeDuration = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.duration!,
|
||||
0,
|
||||
let cumulativeDuration = $derived(
|
||||
list.expand?.trails?.reduce((s, b) => s + b.duration!, 0),
|
||||
);
|
||||
|
||||
$: allowEdit =
|
||||
let allowEdit = $derived(
|
||||
list.author == $currentUser?.id ||
|
||||
list.expand?.list_share_via_list?.some((s) => s.permission == "edit");
|
||||
list.expand?.list_share_via_list?.some(
|
||||
(s) => s.permission == "edit",
|
||||
),
|
||||
);
|
||||
|
||||
$: allowShare =
|
||||
let allowShare = $derived(
|
||||
list.author == $currentUser?.id &&
|
||||
!list.expand?.trails?.some((t) => t.author !== $currentUser?.id);
|
||||
!list.expand?.trails?.some((t) => t.author !== $currentUser?.id),
|
||||
);
|
||||
|
||||
$: dropdownItems = [
|
||||
let dropdownItems = $derived([
|
||||
...(allowShare
|
||||
? [{ text: $_("share"), value: "share", icon: "share" }]
|
||||
: []),
|
||||
@@ -58,22 +64,24 @@
|
||||
...(list.author == $currentUser?.id
|
||||
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
|
||||
: []),
|
||||
];
|
||||
]);
|
||||
|
||||
$: listIsShared = (list.expand?.list_share_via_list?.length ?? 0) > 0;
|
||||
let listIsShared = $derived(
|
||||
(list.expand?.list_share_via_list?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
let fullDescription: boolean = false;
|
||||
let fullDescription: boolean = $state(false);
|
||||
|
||||
function handleTrailSelect(trail: Trail, index: number) {
|
||||
dispatch("click", { trail, index });
|
||||
onclick?.({ trail, index });
|
||||
}
|
||||
|
||||
function handleTrailMouseEnter(trail: Trail, index: number) {
|
||||
dispatch("mouseenter", { trail, index });
|
||||
onmouseenter?.({ trail, index });
|
||||
}
|
||||
|
||||
function handleTrailMouseLeave(trail: Trail, index: number) {
|
||||
dispatch("mouseleave", { trail, index });
|
||||
onmouseleave?.({ trail, index });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -100,17 +108,17 @@
|
||||
{/if}
|
||||
{#if dropdownItems.length}
|
||||
<div class="absolute bottom-8 right-8">
|
||||
<Dropdown
|
||||
items={dropdownItems}
|
||||
on:change
|
||||
let:toggleMenu={openDropdown}
|
||||
><button
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
||||
on:click={openDropdown}
|
||||
>
|
||||
<i class="fa fa-ellipsis-vertical"></i>
|
||||
</button></Dropdown
|
||||
>
|
||||
<Dropdown items={dropdownItems} {onchange}
|
||||
>{#snippet children({ toggleMenu: openDropdown })}
|
||||
<button
|
||||
aria-label="Open dropdown"
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
||||
onclick={openDropdown}
|
||||
>
|
||||
<i class="fa fa-ellipsis-vertical"></i>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
@@ -182,7 +190,7 @@
|
||||
? list.description?.substring(0, 100)
|
||||
: list.description}
|
||||
{#if (list.description?.length ?? 0) > 100 && !fullDescription}
|
||||
<button on:click={() => (fullDescription = true)}>
|
||||
<button onclick={() => (fullDescription = true)}>
|
||||
... <span class="underline">{$_("read-more")}</span></button
|
||||
>
|
||||
{/if}
|
||||
@@ -195,9 +203,9 @@
|
||||
{#each list.expand?.trails ?? [] as trail, i}
|
||||
<div
|
||||
role="presentation"
|
||||
on:click={() => handleTrailSelect(trail, i)}
|
||||
on:mouseenter={() => handleTrailMouseEnter(trail, i)}
|
||||
on:mouseleave={() => handleTrailMouseLeave(trail, i)}
|
||||
onclick={() => handleTrailSelect(trail, i)}
|
||||
onmouseenter={() => handleTrailMouseEnter(trail, i)}
|
||||
onmouseleave={() => handleTrailMouseLeave(trail, i)}
|
||||
>
|
||||
<TrailListItem {trail} showDescription={false}></TrailListItem>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { type Snippet } from "svelte";
|
||||
|
||||
import type { List } from "$lib/models/list";
|
||||
import { trail } from "$lib/stores/trail_store";
|
||||
@@ -10,58 +10,68 @@
|
||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||
|
||||
export let lists: List[];
|
||||
interface Props {
|
||||
lists: List[];
|
||||
children?: Snippet<[any]>;
|
||||
onchange?: (list: List) => void
|
||||
}
|
||||
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
let { lists, children, onchange }: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
function handleSelect(list: List) {
|
||||
dispatch("change", list);
|
||||
closeModal!();
|
||||
onchange?.(list);
|
||||
modal.closeModal!();
|
||||
}
|
||||
|
||||
function listContainsCurrentTrail(list: List) {
|
||||
return list.trails?.includes($trail.id!);
|
||||
}
|
||||
|
||||
const children_render = $derived(children);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="list-modal"
|
||||
title={$_("select-list")}
|
||||
size="max-w-sm"
|
||||
let:openModal
|
||||
bind:openModal
|
||||
bind:closeModal
|
||||
bind:this={modal}
|
||||
>
|
||||
<slot {openModal} />
|
||||
{#snippet children({ openModal })}
|
||||
{@render children_render?.({ openModal })}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<ul>
|
||||
{#each lists as list}
|
||||
<li
|
||||
class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer"
|
||||
onclick={() => handleSelect(list)}
|
||||
role="presentation"
|
||||
>
|
||||
<img
|
||||
class="w-12 aspect-square rounded-full"
|
||||
src={list.avatar
|
||||
? getFileURL(list, list.avatar)
|
||||
: $theme === "light"
|
||||
? emptyStateTrailLight
|
||||
: emptyStateTrailDark}
|
||||
alt="avatar"
|
||||
/>
|
||||
|
||||
<ul slot="content">
|
||||
{#each lists as list}
|
||||
<li
|
||||
class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer"
|
||||
on:click={() => handleSelect(list)}
|
||||
role="presentation"
|
||||
>
|
||||
<img
|
||||
class="w-12 aspect-square rounded-full"
|
||||
src={list.avatar
|
||||
? getFileURL(list, list.avatar)
|
||||
: $theme === "light"
|
||||
? emptyStateTrailLight
|
||||
: emptyStateTrailDark}
|
||||
alt="avatar"
|
||||
/>
|
||||
<h5 class="text-md font-semibold">{list.name}</h5>
|
||||
|
||||
<h5 class="text-md font-semibold">{list.name}</h5>
|
||||
|
||||
<i
|
||||
class="fa fa-{listContainsCurrentTrail(list)
|
||||
? 'minus'
|
||||
: 'plus'} rounded-full border border-input-border p-2"
|
||||
></i>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<i
|
||||
class="fa fa-{listContainsCurrentTrail(list)
|
||||
? 'minus'
|
||||
: 'plus'} rounded-full border border-input-border p-2"
|
||||
></i>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -16,28 +16,36 @@
|
||||
trail_share_index,
|
||||
} from "$lib/stores/trail_share_store";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Button from "../base/button.svelte";
|
||||
import type { SelectItem } from "../base/select.svelte";
|
||||
import Select from "../base/select.svelte";
|
||||
import UserSearch from "../user_search.svelte";
|
||||
|
||||
let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
export function openShareModal() {
|
||||
shares.set([]);
|
||||
fetchShares();
|
||||
if (openModal) {
|
||||
openModal();
|
||||
}
|
||||
interface Props {
|
||||
list: List;
|
||||
onsave?: () => void,
|
||||
onupdate?: (list: List) => void
|
||||
}
|
||||
|
||||
export let list: List;
|
||||
let {
|
||||
list,
|
||||
onsave,
|
||||
onupdate
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let modal: Modal;
|
||||
|
||||
let copyButtonText = $_("copy-link");
|
||||
export function openModal() {
|
||||
openShareModal();
|
||||
}
|
||||
|
||||
async function openShareModal() {
|
||||
await fetchShares();
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
let copyButtonText = $state($_("copy-link"));
|
||||
|
||||
let sharesLoading: boolean = false;
|
||||
|
||||
@@ -56,8 +64,8 @@
|
||||
}
|
||||
|
||||
function close() {
|
||||
dispatch("save");
|
||||
closeModal!();
|
||||
onsave?.()
|
||||
modal.closeModal!();
|
||||
}
|
||||
|
||||
async function shareTrails(userId: string) {
|
||||
@@ -109,10 +117,10 @@
|
||||
const fetchedShares = await list_share_index(list.id!);
|
||||
list.expand = {
|
||||
trails: list.expand?.trails ?? [],
|
||||
list_share_via_list: fetchedShares,
|
||||
list_share_via_list: fetchedShares.items,
|
||||
};
|
||||
sharesLoading = false;
|
||||
dispatch("update", list)
|
||||
sharesLoading = false;
|
||||
onupdate?.(list);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -120,66 +128,73 @@
|
||||
id="share-modal"
|
||||
title={$_("share-this-list")}
|
||||
size="max-w-sm overflow-visible"
|
||||
bind:openModal
|
||||
bind:closeModal
|
||||
bind:this={modal}
|
||||
>
|
||||
<div slot="content">
|
||||
<p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500">
|
||||
{$_("list-share-warning")}
|
||||
</p>
|
||||
<UserSearch includeSelf={false} on:click={(e) => shareList(e.detail)}></UserSearch>
|
||||
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
|
||||
|
||||
{#if $shares.length == 0}
|
||||
<p class="text-gray-500 text-center mt-2 text-sm">
|
||||
{$_("list-not-shared")}
|
||||
{#snippet content()}
|
||||
<div>
|
||||
<p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500">
|
||||
{$_("list-share-warning")}
|
||||
</p>
|
||||
{:else}
|
||||
{#each $shares as share}
|
||||
{#if share.expand}
|
||||
<div class="flex items-center gap-x-2 p-2">
|
||||
<img
|
||||
class="rounded-full w-8 aspect-square mr-2"
|
||||
src={getFileURL(
|
||||
share.expand.user,
|
||||
share.expand.user.avatar,
|
||||
) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<p>{share.expand.user.username}</p>
|
||||
<span
|
||||
class="basis-full text-sm text-center text-gray-500"
|
||||
>{$_("can")}</span
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<Select
|
||||
bind:value={share.permission}
|
||||
items={permissionSelectItems}
|
||||
on:change={(e) =>
|
||||
updateSharePermission(share, e.detail)}
|
||||
></Select>
|
||||
</div>
|
||||
<UserSearch
|
||||
includeSelf={false}
|
||||
onclick={(item) => shareList(item)}
|
||||
></UserSearch>
|
||||
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
|
||||
|
||||
<button
|
||||
class="btn-icon text-red-500"
|
||||
on:click={() => deleteShare(share)}
|
||||
><i class="fa fa-trash"></i></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div slot="footer" class="flex justify-between items-center gap-4">
|
||||
<Button
|
||||
secondary={true}
|
||||
disabled={copyButtonText == $_("link-copied")}
|
||||
on:click={copyURLToClipboard}
|
||||
>
|
||||
<i class="fa fa-link mr-2"></i>
|
||||
{copyButtonText}
|
||||
</Button>
|
||||
<button class="btn-primary" on:click={close}>{$_("close")}</button>
|
||||
</div></Modal
|
||||
{#if $shares.length == 0}
|
||||
<p class="text-gray-500 text-center mt-2 text-sm">
|
||||
{$_("list-not-shared")}
|
||||
</p>
|
||||
{:else}
|
||||
{#each $shares as share}
|
||||
{#if share.expand}
|
||||
<div class="flex items-center gap-x-2 p-2">
|
||||
<img
|
||||
class="rounded-full w-8 aspect-square mr-2"
|
||||
src={getFileURL(
|
||||
share.expand.user,
|
||||
share.expand.user.avatar,
|
||||
) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<p>{share.expand.user.username}</p>
|
||||
<span
|
||||
class="basis-full text-sm text-center text-gray-500"
|
||||
>{$_("can")}</span
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<Select
|
||||
bind:value={share.permission}
|
||||
items={permissionSelectItems}
|
||||
onchange={(value) =>
|
||||
updateSharePermission(share, value)}
|
||||
></Select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Delete share"
|
||||
class="btn-icon text-red-500"
|
||||
onclick={() => deleteShare(share)}
|
||||
><i class="fa fa-trash"></i></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<Button
|
||||
secondary={true}
|
||||
disabled={copyButtonText == $_("link-copied")}
|
||||
onclick={copyURLToClipboard}
|
||||
>
|
||||
<i class="fa fa-link mr-2"></i>
|
||||
{copyButtonText}
|
||||
</Button>
|
||||
<button class="btn-primary" onclick={close}>{$_("close")}</button>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
export let height: number = 64;
|
||||
interface Props {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { height = 64 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<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">
|
||||
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
import { T, forwardEventHandlers } from "@threlte/core";
|
||||
import { useGltf } from "@threlte/extras";
|
||||
import { T } from "@threlte/core";
|
||||
import { useDraco, useGltf } from "@threlte/extras";
|
||||
import { backInOut } from "svelte/easing";
|
||||
import { tweened } from "svelte/motion";
|
||||
import { Tween } from "svelte/motion";
|
||||
import { Group, Mesh } from "three";
|
||||
|
||||
import { interactivity } from "@threlte/extras";
|
||||
import type { Snippet } from "svelte";
|
||||
interactivity();
|
||||
|
||||
export const ref = new Group();
|
||||
export let rotation: number = 0;
|
||||
interface Props {
|
||||
rotation?: number;
|
||||
fallback?: Snippet;
|
||||
errorSlot?: Snippet<[any]>;
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
let dog: Mesh;
|
||||
let { rotation = 0, fallback, errorSlot, ...rest }: Props = $props();
|
||||
|
||||
const gltf = useGltf("/models/mountain.glb", { useDraco: true });
|
||||
let dog: Mesh | undefined = $state();
|
||||
|
||||
const component = forwardEventHandlers();
|
||||
const dracoLoader = useDraco()
|
||||
const gltf = useGltf("/models/mountain.glb", { dracoLoader });
|
||||
|
||||
const bark = new Audio("/audio/sophie.mp3");
|
||||
const bark = $state(new Audio("/audio/sophie.mp3"));
|
||||
bark.volume = 0.5
|
||||
|
||||
const sunZPosition = tweened($theme == "light" ? 11.7 : -20, {
|
||||
const sunZPosition = new Tween($theme == "light" ? 11.7 : -20, {
|
||||
duration: 1000,
|
||||
easing: backInOut,
|
||||
});
|
||||
$: sunZPosition.set($theme == "light" ? 11.7 : -20);
|
||||
$effect(() => {
|
||||
sunZPosition.set($theme == "light" ? 11.7 : -20);
|
||||
});
|
||||
|
||||
const moonZPosition = tweened($theme == "dark" ? 14 : -20, {
|
||||
const moonZPosition = new Tween($theme == "dark" ? 14 : -20, {
|
||||
duration: 1000,
|
||||
easing: backInOut,
|
||||
});
|
||||
$: moonZPosition.set($theme == "dark" ? 14 : -20);
|
||||
$effect(() => {
|
||||
moonZPosition.set($theme == "dark" ? 14 : -20);
|
||||
});
|
||||
|
||||
const campfireLightIntensity = tweened($theme == "dark" ? 2 : 0, {
|
||||
const campfireLightIntensity = new Tween($theme == "dark" ? 2 : 0, {
|
||||
duration: 500,
|
||||
});
|
||||
$: campfireLightIntensity.set($theme == "dark" ? 2 : 0);
|
||||
$effect(() => {
|
||||
campfireLightIntensity.set($theme == "dark" ? 2 : 0);
|
||||
});
|
||||
|
||||
const cabinLightIntensity = tweened($theme == "dark" ? 4 : 0, {
|
||||
const cabinLightIntensity = new Tween($theme == "dark" ? 4 : 0, {
|
||||
duration: 400,
|
||||
});
|
||||
$: cabinLightIntensity.set($theme == "dark" ? 4 : 0);
|
||||
$effect(() => {
|
||||
cabinLightIntensity.set($theme == "dark" ? 4 : 0);
|
||||
});
|
||||
|
||||
const sunLightIntensity = tweened($theme == "light" ? 5 : 0, {
|
||||
const sunLightIntensity = new Tween($theme == "light" ? 5 : 0, {
|
||||
duration: 500,
|
||||
});
|
||||
$: sunLightIntensity.set($theme == "light" ? 5 : 0);
|
||||
$effect(() => {
|
||||
sunLightIntensity.set($theme == "light" ? 5 : 0);
|
||||
});
|
||||
|
||||
const moonLightIntensity = tweened($theme == "dark" ? 0.8 : 0, {
|
||||
const moonLightIntensity = new Tween($theme == "dark" ? 0.8 : 0, {
|
||||
duration: 500,
|
||||
});
|
||||
$: moonLightIntensity.set($theme == "dark" ? 0.8 : 0);
|
||||
$effect(() => {
|
||||
moonLightIntensity.set($theme == "dark" ? 0.8 : 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<T is={ref} dispose={false} {...$$restProps} bind:this={$component}>
|
||||
<T is={ref} dispose={false} {...rest}>
|
||||
{#await gltf}
|
||||
<slot name="fallback" />
|
||||
{@render fallback?.()}
|
||||
{:then gltf}
|
||||
<T.Group
|
||||
position={[3.98, 4.61, 3.15]}
|
||||
@@ -135,7 +150,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
<T.PointLight
|
||||
position={[14.37, 3.8, 4.11]}
|
||||
color="#C98A76"
|
||||
intensity={$campfireLightIntensity}
|
||||
intensity={campfireLightIntensity.current}
|
||||
></T.PointLight>
|
||||
<T.Mesh
|
||||
geometry={gltf.nodes.campfire.geometry}
|
||||
@@ -153,7 +168,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
<T.Mesh
|
||||
geometry={gltf.nodes.Moon.geometry}
|
||||
material={gltf.materials.DD9944}
|
||||
position={[-23.68, $moonZPosition, -6.37]}
|
||||
position={[-23.68, moonZPosition.current, -6.37]}
|
||||
rotation={[0, -0.81, 0]}
|
||||
scale={0.53}
|
||||
><T.PointLight position={[0.5, 5, 0.5]} color="#a2cbf5" intensity={4}
|
||||
@@ -161,12 +176,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
<T.DirectionalLight
|
||||
position={[20, 20, 40]}
|
||||
color="#a2cbf5"
|
||||
intensity={$moonLightIntensity}
|
||||
intensity={moonLightIntensity.current}
|
||||
castShadow
|
||||
></T.DirectionalLight></T.Mesh
|
||||
>
|
||||
<T.Group
|
||||
position={[-5.61, $sunZPosition, -26.34]}
|
||||
position={[-5.61, sunZPosition.current, -26.34]}
|
||||
rotation={[0, 0.75, 0]}
|
||||
scale={0.11}
|
||||
>
|
||||
@@ -189,7 +204,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
<T.DirectionalLight
|
||||
position={[20, 40, 20]}
|
||||
color="#FCF9D9"
|
||||
intensity={$sunLightIntensity}
|
||||
intensity={sunLightIntensity.current}
|
||||
castShadow
|
||||
/></T.Mesh
|
||||
>
|
||||
@@ -198,7 +213,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
position={[-4.58, 4.85, 13.62]}
|
||||
angle={Math.PI / 5}
|
||||
color="#ffffff"
|
||||
intensity={$campfireLightIntensity * 1.5}
|
||||
intensity={campfireLightIntensity.current * 1.5}
|
||||
target={dog}
|
||||
></T.SpotLight>
|
||||
<T.Mesh
|
||||
@@ -215,7 +230,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
rotation={[-2.94, 0.25, Math.PI]}
|
||||
scale={1.5}
|
||||
bind:ref={dog}
|
||||
on:click={() => bark.play()}
|
||||
onclick={() => bark.play()}
|
||||
/>
|
||||
<T.Mesh
|
||||
geometry={gltf.nodes.ground.geometry}
|
||||
@@ -769,7 +784,7 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
<T.PointLight
|
||||
position={[-8.6, 9.3, -1]}
|
||||
color="#FCF9D9"
|
||||
intensity={$cabinLightIntensity}
|
||||
intensity={cabinLightIntensity.current}
|
||||
></T.PointLight>
|
||||
<T.Mesh
|
||||
geometry={gltf.nodes.Cabin.geometry}
|
||||
@@ -804,12 +819,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
<T.PointLight
|
||||
position={[-1, 0.8, 0]}
|
||||
color="#ffffff"
|
||||
intensity={$campfireLightIntensity}
|
||||
intensity={campfireLightIntensity.current}
|
||||
></T.PointLight>
|
||||
<T.PointLight
|
||||
position={[1, 0.8, 0]}
|
||||
color="#ffffff"
|
||||
intensity={$campfireLightIntensity}
|
||||
intensity={campfireLightIntensity.current}
|
||||
></T.PointLight></T.Mesh
|
||||
>
|
||||
</T.Group>
|
||||
@@ -828,12 +843,12 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
<T.PointLight
|
||||
position={[-1, 0.8, 0]}
|
||||
color="#ffffff"
|
||||
intensity={$campfireLightIntensity}
|
||||
intensity={campfireLightIntensity.current}
|
||||
></T.PointLight>
|
||||
<T.PointLight
|
||||
position={[1, 0.8, 0]}
|
||||
color="#ffffff"
|
||||
intensity={$campfireLightIntensity}
|
||||
intensity={campfireLightIntensity.current}
|
||||
></T.PointLight></T.Mesh
|
||||
>
|
||||
</T.Group>
|
||||
@@ -849,18 +864,16 @@ Command: npx @threlte/gltf@2.0.1 static/models/mountain.glb
|
||||
<T.PointLight
|
||||
position={[-1, 0.8, 0]}
|
||||
color="#ffffff"
|
||||
intensity={$campfireLightIntensity}
|
||||
intensity={campfireLightIntensity.current}
|
||||
></T.PointLight>
|
||||
<T.PointLight
|
||||
position={[1, 0.8, 0]}
|
||||
color="#ffffff"
|
||||
intensity={$campfireLightIntensity}
|
||||
intensity={campfireLightIntensity.current}
|
||||
></T.PointLight></T.Mesh
|
||||
>
|
||||
</T.Group>
|
||||
{:catch error}
|
||||
<slot name="error" {error} />
|
||||
{@render errorSlot?.({ error, })}
|
||||
{/await}
|
||||
|
||||
<slot {ref} />
|
||||
</T>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import LogoText from "$lib/components/logo/logo_text.svelte";
|
||||
import { pb } from "$lib/pocketbase";
|
||||
import { theme, toggleTheme } from "$lib/stores/theme_store";
|
||||
@@ -8,12 +7,13 @@
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { backInOut, cubicOut } from "svelte/easing";
|
||||
import { tweened } from "svelte/motion";
|
||||
import Drawer from "./base/drawer.svelte";
|
||||
import Dropdown from "./base/dropdown.svelte";
|
||||
import LogoTextLight from "./logo/logo_text_light.svelte";
|
||||
import NotificationDropdown from "./notification/notification_dropdown.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { page } from "$app/state";
|
||||
import { Tween } from "svelte/motion";
|
||||
|
||||
let navBarItems = [
|
||||
{ text: "Home", value: "/" },
|
||||
@@ -27,22 +27,22 @@
|
||||
{ text: $_("logout"), value: "logout", icon: "right-from-bracket" },
|
||||
];
|
||||
|
||||
const indicatorPosition = tweened(0, {
|
||||
const indicatorPosition = new Tween(0, {
|
||||
duration: 300,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
const indicatorWidth = tweened(0, {
|
||||
const indicatorWidth = new Tween(0, {
|
||||
duration: 300,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
const indicatorScale = tweened(0, {
|
||||
const indicatorScale = new Tween(0, {
|
||||
duration: 600,
|
||||
easing: backInOut,
|
||||
});
|
||||
|
||||
let drawerOpen: boolean = false;
|
||||
let drawerOpen: boolean = $state(false);
|
||||
|
||||
afterNavigate((e) => {
|
||||
const routeId = e.to?.route.id;
|
||||
@@ -91,19 +91,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: user = browser ? $currentUser : pb.authStore.model;
|
||||
let user = $derived(browser ? $currentUser : pb.authStore.model);
|
||||
</script>
|
||||
|
||||
<Drawer bind:open={drawerOpen}>
|
||||
<div class="flex gap-4 items-center m-4">
|
||||
<div class="basis-full"></div>
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
class="btn-icon fa-regular fa-{$theme === 'light' ? 'sun' : 'moon'}"
|
||||
on:click={() => toggleTheme()}
|
||||
onclick={() => toggleTheme()}
|
||||
></button>
|
||||
<button
|
||||
aria-label="Toggle drawer"
|
||||
class="btn-icon block fa fa-close float-right"
|
||||
on:click={() => (drawerOpen = false)}
|
||||
onclick={() => (drawerOpen = false)}
|
||||
></button>
|
||||
</div>
|
||||
<div class="flex flex-col px-12 gap-8">
|
||||
@@ -140,7 +142,8 @@
|
||||
</p>
|
||||
</a>
|
||||
<button
|
||||
on:click={() => {
|
||||
aria-label="Logout"
|
||||
onclick={() => {
|
||||
logout();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
@@ -168,7 +171,7 @@
|
||||
<menu id="nav-bar-links" class="hidden lg:flex gap-8 relative py-1 px-2">
|
||||
<div
|
||||
class="absolute h-full w-16 bg-menu-item-background-hover rounded-xl top-0 z-0"
|
||||
style="width: {$indicatorWidth}px; left: {$indicatorPosition}px; scale: {$indicatorScale}"
|
||||
style="width: {indicatorWidth.current}px; left: {indicatorPosition.current}px; scale: {indicatorScale.current}"
|
||||
></div>
|
||||
{#each navBarItems as item}
|
||||
<a class="font-semibold z-10" href={item.value}>{item.text}</a>
|
||||
@@ -182,50 +185,55 @@
|
||||
{#if user}
|
||||
<div class="hidden lg:flex gap-6 items-center">
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
class="btn-icon fa-regular fa-{$theme === 'light'
|
||||
? 'sun'
|
||||
: 'moon'}"
|
||||
on:click={() => toggleTheme()}
|
||||
onclick={() => toggleTheme()}
|
||||
></button>
|
||||
<a class="btn-primary btn-large" href="/trail/edit/new"
|
||||
><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a
|
||||
>
|
||||
{#if $page.data.notifications}
|
||||
{#if page.data.notifications}
|
||||
<NotificationDropdown></NotificationDropdown>
|
||||
{/if}
|
||||
<Dropdown
|
||||
items={dropdownItems}
|
||||
on:change={(e) => handleDropdownClick(e.detail)}
|
||||
let:toggleMenu={openDropdown}
|
||||
onchange={(item) => handleDropdownClick(item)}
|
||||
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 aspect-square"
|
||||
on:click={openDropdown}
|
||||
>
|
||||
<img
|
||||
class="rounded-full w-full h-full"
|
||||
src={getFileURL(user, user.avatar) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
{#snippet children({ toggleMenu: openDropdown })}
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 aspect-square"
|
||||
onclick={openDropdown}
|
||||
>
|
||||
<img
|
||||
class="rounded-full w-full h-full"
|
||||
src={getFileURL(user, user.avatar) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
class="btn-icon fa-regular fa-{$theme === 'light'
|
||||
? 'sun'
|
||||
: 'moon'}"
|
||||
on:click={() => toggleTheme()}
|
||||
onclick={() => toggleTheme()}
|
||||
></button>
|
||||
<a class="btn-primary btn-large" href="/login">{$_("login")}</a>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
aria-label="Toggle drawer"
|
||||
class="btn-icon fa fa-bars lg:hidden"
|
||||
on:click={() => (drawerOpen = !drawerOpen)}
|
||||
onclick={() => (drawerOpen = !drawerOpen)}
|
||||
></button>
|
||||
</nav>
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
} from "$lib/models/notification";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { formatTimeSince } from "$lib/util/format_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let notification: Notification;
|
||||
interface Props {
|
||||
notification: Notification;
|
||||
onclick?: (data: {notification: Notification, link: string | null}) => void
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { notification, onclick }: Props = $props();
|
||||
|
||||
const avatarSrc = notification.expand?.author.avatar
|
||||
? getFileURL(
|
||||
@@ -21,9 +23,6 @@
|
||||
|
||||
const timeSince = formatTimeSince(new Date(notification.created ?? ""));
|
||||
|
||||
$: title = getTitle(notification);
|
||||
$: description = getDescription(notification);
|
||||
$: link = getLink(notification);
|
||||
|
||||
function getTitle(n: Notification) {
|
||||
switch (n.type) {
|
||||
@@ -92,14 +91,17 @@
|
||||
}
|
||||
|
||||
function handleItemClick() {
|
||||
dispatch("click", { notification, link });
|
||||
onclick?.({ notification, link });
|
||||
}
|
||||
let title = $derived(getTitle(notification));
|
||||
let description = $derived(getDescription(notification));
|
||||
let link = $derived(getLink(notification));
|
||||
</script>
|
||||
|
||||
<li
|
||||
class="flex items-center gap-x-3 px-3 py-2 hover:bg-menu-item-background-hover relative cursor-pointer"
|
||||
role="presentation"
|
||||
on:click={handleItemClick}
|
||||
onclick={handleItemClick}
|
||||
>
|
||||
<img class="rounded-full w-8 aspect-square" src={avatarSrc} alt="avatar" />
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { page } from "$app/state";
|
||||
import type { Notification } from "$lib/models/notification";
|
||||
import {
|
||||
notifications_index,
|
||||
@@ -16,24 +16,23 @@
|
||||
import emptyStateNotificationLight from "$lib/assets/svgs/empty_states/empty_state_notification_light.svg";
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
|
||||
let notifications: Notification[] = [];
|
||||
let notifications: Notification[] = $state([]);
|
||||
|
||||
const pagination = {
|
||||
page: $page.data.notifications.page,
|
||||
totalPages: $page.data.notifications.totalPages,
|
||||
page: page.data.notifications.page,
|
||||
totalPages: page.data.notifications.totalPages,
|
||||
};
|
||||
|
||||
let loadingNextPage: boolean = false;
|
||||
let isOpen = false;
|
||||
let loadingNextPage: boolean = $state(false);
|
||||
let isOpen = $state(false);
|
||||
|
||||
$: unreadCount = notifications.reduce(
|
||||
(value, n) => (value += n.seen ? 0 : 1),
|
||||
0,
|
||||
let unreadCount = $derived(
|
||||
notifications.reduce((value, n) => (value += n.seen ? 0 : 1), 0),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (!notifications.length && $page.data.notifications?.items?.length) {
|
||||
notifications = $page.data.notifications.items;
|
||||
if (!notifications.length && page.data.notifications?.items?.length) {
|
||||
notifications = page.data.notifications.items;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,18 +87,21 @@
|
||||
loadingNextPage = false;
|
||||
}
|
||||
|
||||
async function handleNotificationClick(e: CustomEvent) {
|
||||
await notifications_mark_as_seen(e.detail.notification);
|
||||
e.detail.notification.seen = true;
|
||||
async function handleNotificationClick(data: {
|
||||
notification: Notification;
|
||||
link: string | null;
|
||||
}) {
|
||||
await notifications_mark_as_seen(data.notification);
|
||||
data.notification.seen = true;
|
||||
notifications = notifications;
|
||||
|
||||
if (e.detail.link) {
|
||||
goto(e.detail.link);
|
||||
if (data.link) {
|
||||
goto(data.link);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:mouseup={handleWindowClick} />
|
||||
<svelte:window onmouseup={handleWindowClick} />
|
||||
|
||||
<div class="dropdown relative">
|
||||
{#if unreadCount > 0}
|
||||
@@ -110,7 +112,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="dropdown-toggle">
|
||||
<button on:click={toggleMenu} class="btn-icon">
|
||||
<button
|
||||
aria-label="Toggle notification dropdown"
|
||||
onclick={toggleMenu}
|
||||
class="btn-icon"
|
||||
>
|
||||
<i class="fa fa-bell"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -119,7 +125,7 @@
|
||||
<ul
|
||||
class="menu absolute bg-menu-background border border-input-border rounded-l-xl rounded-b-xl shadow-md right-0 overflow-scroll mt-4 max-h-96 w-72"
|
||||
class:none={isOpen}
|
||||
on:scroll={onListScroll}
|
||||
onscroll={onListScroll}
|
||||
style="z-index: 1001"
|
||||
in:fly={{ y: -10, duration: 150 }}
|
||||
out:fly={{ y: -10, duration: 150 }}
|
||||
@@ -131,13 +137,14 @@
|
||||
{:else if notifications.length}
|
||||
{#each notifications as notification}
|
||||
<NotificationCard
|
||||
on:click={handleNotificationClick}
|
||||
onclick={(data) => handleNotificationClick(data)}
|
||||
{notification}
|
||||
></NotificationCard>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-center p-8">
|
||||
<img class="mx-auto"
|
||||
<img
|
||||
class="mx-auto"
|
||||
src={$theme === "light"
|
||||
? emptyStateNotificationLight
|
||||
: emptyStateNotificationDark}
|
||||
|
||||
@@ -6,12 +6,30 @@
|
||||
The progress bar will show during navigation if loading takes more than `displayThresholdMs`
|
||||
and will hide when navigation is complete.
|
||||
-->
|
||||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate } from "$app/navigation";
|
||||
import { beforeNavigate } from "$app/navigation";
|
||||
|
||||
// This component is a modified version of the component from the following repo:
|
||||
// https://github.com/saibotsivad/svelte-progress-bar
|
||||
|
||||
let {
|
||||
id = undefined,
|
||||
viewTransitionName = "sveltekit-progress-bar",
|
||||
busy = $bindable(false),
|
||||
color = "currentColor",
|
||||
class: textColorClass = "",
|
||||
zIndex = 1,
|
||||
minimum: defaultMinimum = 0.08,
|
||||
maximum = 0.994,
|
||||
settleTime: defaultSettleTime = 700,
|
||||
intervalTime = 700,
|
||||
stepSizes = [0, 0.005, 0.01, 0.02],
|
||||
displayThresholdMs = 150,
|
||||
noNavigationProgress = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Towards the end of the progress bar animation, we want to shorten the increment
|
||||
// step size, to give it the appearance of slowing down. This indicates to the user
|
||||
// that progress is still happening, but not as fast as they might like.
|
||||
@@ -24,83 +42,111 @@
|
||||
};
|
||||
|
||||
// Internal private state.
|
||||
let running: boolean = false;
|
||||
let updater: ReturnType<typeof setInterval> | null = null;
|
||||
let completed = false;
|
||||
let width: number = 0;
|
||||
let running = $state(false);
|
||||
let updater = $state<ReturnType<typeof setInterval> | null>(null);
|
||||
let completed = $state(false);
|
||||
let width = $state(0);
|
||||
|
||||
/** If set, an ID for the progress bar on the HTML page.
|
||||
* This ID must be unique on the page to avoid conflicts.
|
||||
*
|
||||
* Might be used with another element to signal to assistive technologies that
|
||||
* progress is ongoing.
|
||||
*
|
||||
* @example
|
||||
* <ProgressBar id="my-progress-bar" bind:busy />
|
||||
* <div aria-busy={busy} aria-describedby="my-progress-bar">
|
||||
* A div that is currently loading...
|
||||
* </div>
|
||||
*/
|
||||
export let id: string | undefined = undefined;
|
||||
/** Will be set to true when the progress bar is running. */
|
||||
export let busy: boolean = false;
|
||||
$: running = busy;
|
||||
/**
|
||||
* The CSS color to use to style the progress bar.
|
||||
*
|
||||
* If you're using Tailwind or Windi CSS, leave this to the default
|
||||
* and set the `class` attribute to a `text-` class instead.
|
||||
*/
|
||||
export let color: string = "currentColor";
|
||||
/**
|
||||
* A Tailwind/Windi `text-` class to use to color the Progress Bar.
|
||||
*
|
||||
* This prop will be ignored if the `color` prop is set to something other than `currentColor`.
|
||||
*
|
||||
* **WARNING**: Do not set this prop with something other than a `text-` class,
|
||||
* as it could interfere with the styling of the Progress Bar.
|
||||
*
|
||||
* @example text-green-500
|
||||
*/
|
||||
let textColorClass: `text-${string}` | "" = "";
|
||||
export { textColorClass as class };
|
||||
// Update the busy prop when the internal `running` variable changes.
|
||||
$effect.pre(() => {
|
||||
busy = running;
|
||||
});
|
||||
|
||||
/**
|
||||
* The `z-index` CSS property value to use for the progress bar.
|
||||
* Be aware that the glowing effect on the bar will use this `zIndex` + 1.
|
||||
*/
|
||||
export let zIndex: number = 1;
|
||||
interface Props {
|
||||
/** If set, an ID for the progress bar on the HTML page.
|
||||
* This ID must be unique on the page to avoid conflicts.
|
||||
*
|
||||
* Might be used with another element to signal to assistive technologies that
|
||||
* progress is ongoing.
|
||||
*
|
||||
* @example
|
||||
* <ProgressBar id="my-progress-bar" bind:busy />
|
||||
* <div aria-busy={busy} aria-describedby="my-progress-bar">
|
||||
* A div that is currently loading...
|
||||
* </div>
|
||||
*/
|
||||
id?: string | undefined;
|
||||
/**
|
||||
* The name of the CSS view transition to use for the progress bar.
|
||||
* Sets the `view-transition-name` CSS property on the progress bar and leader elements.
|
||||
*
|
||||
* @default "sveltekit-progress-bar"
|
||||
* @see https://developer.chrome.com/docs/web-platform/view-transitions/
|
||||
* @see https://svelte.dev/blog/view-transitions
|
||||
*/
|
||||
viewTransitionName?: string;
|
||||
/** Will be set to true when the progress bar is running. */
|
||||
busy?: boolean;
|
||||
/**
|
||||
* The CSS color to use to style the progress bar.
|
||||
*
|
||||
* If you're using Tailwind or Windi CSS, leave this to the default
|
||||
* and set the `class` attribute to a `text-` class instead.
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* A Tailwind/Windi `text-` class to use to color the Progress Bar.
|
||||
*
|
||||
* This prop will be ignored if the `color` prop is set to something other than `currentColor`.
|
||||
*
|
||||
* **WARNING**: Do not set this prop with something other than a `text-` class,
|
||||
* as it could interfere with the styling of the Progress Bar.
|
||||
*
|
||||
* @example text-green-500
|
||||
*/
|
||||
class?: `text-${string}` | "";
|
||||
|
||||
// These are defaults that you shouldn't need to change, but are exposed here in case you do.
|
||||
/**
|
||||
* The starting percent width use when the bar starts.
|
||||
* Starting at 0 doesn't usually look very good.
|
||||
* @default 0.08
|
||||
*/
|
||||
let defaultMinimum = 0.08;
|
||||
export { defaultMinimum as minimum };
|
||||
/**
|
||||
* The maximum percent width value to use when the bar is at the end but not marked as complete.
|
||||
* Letting the bar stay at 100% width for a while doesn't usually look very good either.
|
||||
* @default 0.994
|
||||
*/
|
||||
export let maximum = 0.994;
|
||||
/**
|
||||
* Milliseconds to wait after the complete method is called to hide the progress bar.
|
||||
* Letting it sit at 100% width for a very short time makes it feel more fluid.
|
||||
* @default 700
|
||||
*/
|
||||
let defaultSettleTime = 700;
|
||||
export { defaultSettleTime as settleTime };
|
||||
/**
|
||||
* Milliseconds to wait between incrementing bar width when using
|
||||
* the `start` (auto-increment) method.
|
||||
* @default 700
|
||||
*/
|
||||
export let intervalTime = 700;
|
||||
export let stepSizes = [0, 0.005, 0.01, 0.02];
|
||||
/**
|
||||
* The `z-index` CSS property value to use for the progress bar.
|
||||
* Be aware that the glowing effect on the bar will use this `zIndex` + 1.
|
||||
*/
|
||||
zIndex?: number;
|
||||
|
||||
// These are defaults that you shouldn't need to change, but are exposed here in case you do.
|
||||
/**
|
||||
* The starting percent width use when the bar starts.
|
||||
* Starting at 0 doesn't usually look very good.
|
||||
* @default 0.08
|
||||
*/
|
||||
minimum?: number;
|
||||
|
||||
/**
|
||||
* The maximum percent width value to use when the bar is at the end but not marked as complete.
|
||||
* Letting the bar stay at 100% width for a while doesn't usually look very good either.
|
||||
* @default 0.994
|
||||
*/
|
||||
maximum?: number;
|
||||
/**
|
||||
* Milliseconds to wait after the complete method is called to hide the progress bar.
|
||||
* Letting it sit at 100% width for a very short time makes it feel more fluid.
|
||||
* @default 700
|
||||
*/
|
||||
settleTime?: number;
|
||||
|
||||
/**
|
||||
* Milliseconds to wait between incrementing bar width when using
|
||||
* the `start` (auto-increment) method.
|
||||
* @default 700
|
||||
*/
|
||||
intervalTime?: number;
|
||||
|
||||
/** Reset the progress bar back to the beginning, leaving it in a running state. */
|
||||
stepSizes?: any;
|
||||
|
||||
/** When navigating, this is the threshold duration in milliseconds
|
||||
* that the progress bar will wait before showing.
|
||||
*
|
||||
* This means that if the navigation takes less than this amount of time,
|
||||
* the progress bar will not be shown. This is to prevent the progress bar
|
||||
* from flashing in and out on the screen.
|
||||
*
|
||||
* @default 150 ms
|
||||
*/
|
||||
displayThresholdMs?: number;
|
||||
/** Set to `true` to disable the showing of the progress bar on navigation. */
|
||||
noNavigationProgress?: boolean;
|
||||
}
|
||||
|
||||
/** Reset the progress bar back to the beginning, leaving it in a running state. */
|
||||
export const reset = (minimum = defaultMinimum) => {
|
||||
width = minimum;
|
||||
running = true;
|
||||
@@ -186,32 +232,21 @@
|
||||
};
|
||||
};
|
||||
|
||||
let barStyle: string;
|
||||
$: barStyle =
|
||||
const barStyle = $derived(
|
||||
(color ? `background-color: ${color};` : "") +
|
||||
(width && width * 100 ? `width: ${width * 100}%;` : "") +
|
||||
`z-index: ${zIndex};`;
|
||||
(width && width * 100 ? `width: ${width * 100}%;` : "") +
|
||||
`z-index: ${zIndex};` +
|
||||
`view-transition-name: ${viewTransitionName}-bar;`,
|
||||
);
|
||||
// the box shadow of the leader bar uses `color` to set its shadow color
|
||||
let leaderColorStyle: string;
|
||||
$: leaderColorStyle =
|
||||
const leaderColorStyle = $derived(
|
||||
(color ? `background-color: ${color}; color: ${color};` : "") +
|
||||
`z-index: ${zIndex + 1};`;
|
||||
|
||||
/** When navigating, this is the threshold duration in milliseconds
|
||||
* that the progress bar will wait before showing.
|
||||
*
|
||||
* This means that if the navigation takes less than this amount of time,
|
||||
* the progress bar will not be shown. This is to prevent the progress bar
|
||||
* from flashing in and out on the screen.
|
||||
*
|
||||
* @default 150 ms
|
||||
*/
|
||||
export let displayThresholdMs = 150;
|
||||
|
||||
/** Set to `true` to disable the showing of the progress bar on navigation. */
|
||||
export let noNavigationProgress = false;
|
||||
`z-index: ${zIndex + 1};` +
|
||||
`view-transition-name: ${viewTransitionName}-leader;`,
|
||||
);
|
||||
|
||||
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
beforeNavigate((nav) => {
|
||||
if (progressBarStartTimeout) {
|
||||
clearTimeout(progressBarStartTimeout);
|
||||
@@ -229,15 +264,15 @@
|
||||
displayThresholdMs,
|
||||
);
|
||||
} else start();
|
||||
}
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
if (progressBarStartTimeout) {
|
||||
clearTimeout(progressBarStartTimeout);
|
||||
progressBarStartTimeout = null;
|
||||
nav.complete.catch().finally(() => {
|
||||
if (progressBarStartTimeout) {
|
||||
clearTimeout(progressBarStartTimeout);
|
||||
progressBarStartTimeout = null;
|
||||
}
|
||||
complete();
|
||||
});
|
||||
}
|
||||
complete();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -254,7 +289,7 @@
|
||||
style={barStyle}
|
||||
>
|
||||
{#if running}
|
||||
<div class="svelte-progress-bar-leader" style={leaderColorStyle} />
|
||||
<div class="svelte-progress-bar-leader" style={leaderColorStyle}></div>
|
||||
{/if}
|
||||
</output>
|
||||
{/if}
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let src: string;
|
||||
export let isThumbnail: boolean = false;
|
||||
export let showThumbnailControls: boolean = true;
|
||||
export let showExifControls: boolean = false;
|
||||
interface Props {
|
||||
src: string;
|
||||
isThumbnail?: boolean;
|
||||
showThumbnailControls?: boolean;
|
||||
showExifControls?: boolean;
|
||||
onthumbnail?: (src: string) => void;
|
||||
onexif?: (src: string) => void;
|
||||
ondelete?: (src: string) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
src,
|
||||
isThumbnail = false,
|
||||
showThumbnailControls = true,
|
||||
showExifControls = false,
|
||||
onthumbnail,
|
||||
onexif,
|
||||
ondelete,
|
||||
}: Props = $props();
|
||||
|
||||
function handleThumbnailClick() {
|
||||
dispatch("thumbnail", src);
|
||||
onthumbnail?.(src);
|
||||
}
|
||||
|
||||
function handleExifClick() {
|
||||
dispatch("exif", src);
|
||||
onexif?.(src);
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
dispatch("delete", src);
|
||||
ondelete?.(src);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -36,26 +48,30 @@
|
||||
>
|
||||
{#if showThumbnailControls}
|
||||
<button
|
||||
aria-label="Make thumbnail"
|
||||
type="button"
|
||||
class="tooltip"
|
||||
data-title={$_("make-thumbnail")}
|
||||
on:click={handleThumbnailClick}
|
||||
onclick={handleThumbnailClick}
|
||||
><i class="fa fa-file-image text-content"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if showExifControls}
|
||||
<button
|
||||
aria-label="Get EXIF coordinates"
|
||||
type="button"
|
||||
class="tooltip"
|
||||
data-title={$_("get-position-from-exif")}
|
||||
on:click={handleExifClick}
|
||||
><i class="fa fa-magnifying-glass-location text-content"></i></button
|
||||
onclick={handleExifClick}
|
||||
><i class="fa fa-magnifying-glass-location text-content"
|
||||
></i></button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
aria-label="Delete photo"
|
||||
class="tooltip"
|
||||
data-title={$_("delete")}
|
||||
on:click={handleDeleteClick}
|
||||
onclick={handleDeleteClick}
|
||||
type="button"><i class="fa fa-trash text-red-500"></i></button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let photos: string[];
|
||||
export function open(idx: number = 0) {
|
||||
lightbox.loadAndOpen(idx, lightboxDataSource)
|
||||
interface Props {
|
||||
photos: string[];
|
||||
open?: (idx: number) => void;
|
||||
}
|
||||
|
||||
let { photos }: Props = $props();
|
||||
|
||||
export function openGallery(idx: number = 0) {
|
||||
lightbox.loadAndOpen(idx, lightboxDataSource);
|
||||
}
|
||||
let lightbox: PhotoSwipeLightbox;
|
||||
let lightboxDataSource: DataSource;
|
||||
|
||||
@@ -8,10 +8,14 @@
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
export let activity: Activity;
|
||||
export let user: UserAnonymous;
|
||||
interface Props {
|
||||
activity: Activity;
|
||||
user: UserAnonymous;
|
||||
}
|
||||
|
||||
let fullDescription: boolean = false;
|
||||
let { activity, user }: Props = $props();
|
||||
|
||||
let fullDescription: boolean = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="activity-card p-6 space-y-6 rounded-xl border border-input-border">
|
||||
@@ -92,7 +96,7 @@
|
||||
: activity.description}
|
||||
{#if (activity.description?.length ?? 0) > 100 && !fullDescription}
|
||||
<button
|
||||
on:click={(e) => {
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
fullDescription = true;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { page } from "$app/state";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos";
|
||||
import { onMount } from "svelte";
|
||||
@@ -7,14 +7,17 @@
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
let qrcode: any | null = null;
|
||||
|
||||
onMount(() => {
|
||||
qrcode = new QrCodeWithLogo({
|
||||
content: $page.url.href,
|
||||
content: page.url.href,
|
||||
image: document.getElementById("qrcode") as HTMLImageElement,
|
||||
logo: {
|
||||
src: "/favicon.png",
|
||||
@@ -23,7 +26,7 @@
|
||||
});
|
||||
|
||||
function copyURLToClipboard() {
|
||||
navigator.clipboard.writeText($page.url.href);
|
||||
navigator.clipboard.writeText(page.url.href);
|
||||
show_toast({
|
||||
text: $_("link-copied"),
|
||||
icon: "circle-info",
|
||||
@@ -43,33 +46,36 @@
|
||||
id="password-modal"
|
||||
size="max-w-xl"
|
||||
title={$_("share-profile")}
|
||||
bind:openModal
|
||||
bind:closeModal
|
||||
bind:this={modal}
|
||||
>
|
||||
<div class="flex" slot="content">
|
||||
<div class="space-y-2">
|
||||
<img class="w-64" id="qrcode" alt="QR Code" />
|
||||
<button
|
||||
class="btn-secondary w-full"
|
||||
on:click={() => downloadQRCode()}
|
||||
>
|
||||
<i class="fa fa-download mr-2"></i>
|
||||
<span>{$_("download")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<p>
|
||||
Send this link or the QR code on the left to someone to share
|
||||
your profile.
|
||||
</p>
|
||||
<div class="flex gap-x-2 items-center">
|
||||
<div class="basis-full">
|
||||
<TextField value={$page.url.href} disabled></TextField>
|
||||
</div>
|
||||
<button class="btn-icon" on:click={copyURLToClipboard}>
|
||||
<i class="fa-regular fa-copy"></i>
|
||||
{#snippet content()}
|
||||
<div class="flex">
|
||||
<div class="space-y-2">
|
||||
<img class="w-64" id="qrcode" alt="QR Code" />
|
||||
<button
|
||||
class="btn-secondary w-full"
|
||||
onclick={() => downloadQRCode()}
|
||||
>
|
||||
<i class="fa fa-download mr-2"></i>
|
||||
<span>{$_("download")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<p>
|
||||
Send this link or the QR code on the left to someone to
|
||||
share your profile.
|
||||
</p>
|
||||
<div class="flex gap-x-2 items-center">
|
||||
<div class="basis-full">
|
||||
<TextField value={page.url.href} disabled></TextField>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Copy profile URL"
|
||||
class="btn-icon" onclick={copyURLToClipboard}>
|
||||
<i class="fa-regular fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<script lang="ts">
|
||||
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
import { T, useTask, useThrelte } from "@threlte/core";
|
||||
import { T, useTask } from "@threlte/core";
|
||||
import { onMount } from "svelte";
|
||||
import { tweened } from "svelte/motion";
|
||||
import { Tween } from "svelte/motion";
|
||||
import * as THREE from "three";
|
||||
import Mountain from "./mountain.svelte";
|
||||
|
||||
let rotation = 0;
|
||||
let rotation = $state(0);
|
||||
useTask((delta) => {
|
||||
rotation += delta / 2;
|
||||
});
|
||||
|
||||
const { toneMapping } = useThrelte();
|
||||
toneMapping.set(THREE.NoToneMapping);
|
||||
|
||||
const ambientIntensitiy = tweened($theme == "light" ? 3 : 0, {
|
||||
const ambientIntensity = new Tween($theme == "light" ? 3 : 0, {
|
||||
duration: 500,
|
||||
});
|
||||
|
||||
$: ambientColor = $theme == "light" ? "#ffffff" : "#4c7fe6";
|
||||
$: ambientIntensitiy.set($theme == "light" ? 3 : 0.1);
|
||||
let ambientColor = $derived($theme == "light" ? "#ffffff" : "#4c7fe6");
|
||||
$effect(() => {
|
||||
ambientIntensity.set($theme == "light" ? 3 : 0.1);
|
||||
});
|
||||
|
||||
let starsGeometry!: THREE.BufferGeometry;
|
||||
let starsMaterial!: THREE.PointsMaterial;
|
||||
let starsGeometry: THREE.BufferGeometry | undefined = $state();
|
||||
let starsMaterial: THREE.PointsMaterial | undefined = $state();
|
||||
onMount(() => {
|
||||
const stars = new Array(0);
|
||||
for (var i = 0; i < 500; i++) {
|
||||
@@ -43,8 +43,8 @@
|
||||
<T.PerspectiveCamera
|
||||
makeDefault
|
||||
position={[35.5, 32, 35.5]}
|
||||
on:create={({ ref }) => {
|
||||
ref.lookAt(0, 0, 0);
|
||||
oncreate={(c) => {
|
||||
c.lookAt(0, 0, 0);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -58,5 +58,5 @@
|
||||
<T.HemisphereLight
|
||||
skyColor={ambientColor}
|
||||
groundColor="#242734"
|
||||
intensity={$ambientIntensitiy}
|
||||
intensity={ambientIntensity.current}
|
||||
/>
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { z } from "zod";
|
||||
import TextField from "../base/text_field.svelte";
|
||||
|
||||
let _openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
interface Props {
|
||||
email?: string;
|
||||
onsave?: (email: string) => void;
|
||||
}
|
||||
|
||||
let { email = "", onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
setFields("email", email);
|
||||
setErrors("email", []);
|
||||
_openModal!();
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
export let email = "";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const { form, errors, setFields, setErrors } = createForm<{
|
||||
email: string;
|
||||
}>({
|
||||
@@ -32,8 +34,8 @@
|
||||
}),
|
||||
}),
|
||||
onSubmit: async (form) => {
|
||||
dispatch("save", form.email);
|
||||
closeModal!();
|
||||
onsave?.(form.email);
|
||||
modal.closeModal!();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -42,18 +44,24 @@
|
||||
id="email-modal"
|
||||
size="max-w-xl"
|
||||
title={$_("change-email")}
|
||||
bind:openModal={_openModal}
|
||||
bind:closeModal
|
||||
bind:this={modal}
|
||||
>
|
||||
<form id="email-form" slot="content" use:form>
|
||||
<TextField name="email" error={$errors.email}></TextField>
|
||||
</form>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button class="btn-primary" type="submit" form="email-form" name="save"
|
||||
>{$_("save")}</button
|
||||
>
|
||||
</div></Modal
|
||||
{#snippet content()}
|
||||
<form id="email-form" use:form>
|
||||
<TextField name="email" error={$errors.email}></TextField>
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="submit"
|
||||
form="email-form"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { z } from "zod";
|
||||
|
||||
let _openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
export function openModal() {
|
||||
setFields("password", "")
|
||||
setFields("oldPassword", "")
|
||||
setErrors("password", [])
|
||||
setErrors("oldPassword", [])
|
||||
_openModal!();
|
||||
interface Props {
|
||||
onsave?: (data: {
|
||||
oldPassword: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
export function openModal() {
|
||||
setFields("password", "");
|
||||
setFields("oldPassword", "");
|
||||
setErrors("password", []);
|
||||
setErrors("oldPassword", []);
|
||||
modal.openModal!();
|
||||
}
|
||||
|
||||
const { form, errors, setFields, setErrors } = createForm<{
|
||||
oldPassword: string;
|
||||
@@ -53,12 +59,12 @@
|
||||
}),
|
||||
}),
|
||||
onSubmit: async (form) => {
|
||||
dispatch("save", {
|
||||
onsave?.({
|
||||
oldPassword: form.oldPassword,
|
||||
password: form.password,
|
||||
passwordConfirm: form.password,
|
||||
});
|
||||
closeModal!();
|
||||
modal.closeModal();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -67,38 +73,41 @@
|
||||
id="password-modal"
|
||||
size="max-w-sm"
|
||||
title={$_("change-password")}
|
||||
bind:openModal={_openModal}
|
||||
bind:closeModal
|
||||
bind:this={modal}
|
||||
>
|
||||
<form id="password-form" slot="content" use:form>
|
||||
<TextField
|
||||
name="oldPassword"
|
||||
label={$_("current-password")}
|
||||
type="password"
|
||||
error={$errors.oldPassword}
|
||||
></TextField>
|
||||
<TextField
|
||||
name="password"
|
||||
label={$_("new-password")}
|
||||
type="password"
|
||||
error={$errors.password}
|
||||
></TextField>
|
||||
<TextField
|
||||
name="passwordConfirm"
|
||||
label={$_("password-confirm")}
|
||||
type="password"
|
||||
error={$errors.passwordConfirm}
|
||||
></TextField>
|
||||
</form>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="submit"
|
||||
form="password-form"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div></Modal
|
||||
{#snippet content()}
|
||||
<form id="password-form" use:form>
|
||||
<TextField
|
||||
name="oldPassword"
|
||||
label={$_("current-password")}
|
||||
type="password"
|
||||
error={$errors.oldPassword}
|
||||
></TextField>
|
||||
<TextField
|
||||
name="password"
|
||||
label={$_("new-password")}
|
||||
type="password"
|
||||
error={$errors.password}
|
||||
></TextField>
|
||||
<TextField
|
||||
name="passwordConfirm"
|
||||
label={$_("password-confirm")}
|
||||
type="password"
|
||||
error={$errors.passwordConfirm}
|
||||
></TextField>
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="submit"
|
||||
form="password-form"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
|
||||
@@ -8,17 +8,21 @@
|
||||
import { _ } from "svelte-i18n";
|
||||
import { fly, slide } from "svelte/transition";
|
||||
|
||||
export let subject: Trail | List;
|
||||
export let large: boolean = false;
|
||||
export let type: "trail" | "list"
|
||||
interface Props {
|
||||
subject: Trail | List;
|
||||
large?: boolean;
|
||||
type: "trail" | "list";
|
||||
}
|
||||
|
||||
let { subject, large = false, type }: Props = $props();
|
||||
|
||||
const shareData = type == "trail" ? (subject as Trail).expand?.trail_share_via_trail : (subject as List).expand?.list_share_via_list
|
||||
|
||||
let showInfo: boolean = false;
|
||||
let loading: boolean = false;
|
||||
let infoLoaded: boolean = false;
|
||||
let showInfo: boolean = $state(false);
|
||||
let loading: boolean = $state(false);
|
||||
let infoLoaded: boolean = $state(false);
|
||||
let subjectIsOwned: boolean = subject.author == pb.authStore.model?.id;
|
||||
let author: UserAnonymous;
|
||||
let author: UserAnonymous | undefined = $state();
|
||||
|
||||
async function fetchInfo() {
|
||||
if (!infoLoaded) {
|
||||
@@ -47,8 +51,8 @@
|
||||
class:text-xl={large}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:mouseenter={fetchInfo}
|
||||
on:mouseleave={() => (showInfo = false)}
|
||||
onmouseenter={fetchInfo}
|
||||
onmouseleave={() => (showInfo = false)}
|
||||
></i>
|
||||
{#if showInfo}
|
||||
<ul
|
||||
|
||||
@@ -1,43 +1,55 @@
|
||||
<script lang="ts">
|
||||
|
||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||
import {
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
|
||||
let thumbnail: string =
|
||||
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark;
|
||||
let thumbnail: string = $state(
|
||||
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark,
|
||||
);
|
||||
|
||||
$: Promise.all(
|
||||
(log._photos ?? []).map(async (f) => {
|
||||
return await readAsDataURLAsync(f);
|
||||
}),
|
||||
).then((v) => {
|
||||
if (log.photos.length) {
|
||||
thumbnail = getFileURL(log, log.photos[0]);
|
||||
} else if (v.length) {
|
||||
thumbnail = v[0];
|
||||
} else {
|
||||
thumbnail =
|
||||
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark;
|
||||
}
|
||||
});
|
||||
interface Props {
|
||||
log: SummitLog;
|
||||
mode?: "show" | "edit";
|
||||
onchange?: (item: DropdownItem) => void;
|
||||
}
|
||||
|
||||
export let log: SummitLog;
|
||||
export let mode: "show" | "edit" = "show";
|
||||
let { log, mode = "show", onchange }: Props = $props();
|
||||
|
||||
const dropdownItems: DropdownItem[] = [
|
||||
{ text: $_("edit"), value: "edit" },
|
||||
{ text: $_("delete"), value: "delete" },
|
||||
];
|
||||
$effect(() => fetchPhotos(log._photos ?? []));
|
||||
|
||||
function fetchPhotos(photos: File[]) {
|
||||
Promise.all(
|
||||
photos.map(async (f) => {
|
||||
return await readAsDataURLAsync(f);
|
||||
}),
|
||||
).then((v) => {
|
||||
if (log.photos.length) {
|
||||
thumbnail = getFileURL(log, log.photos[0]);
|
||||
} else if (v.length) {
|
||||
thumbnail = v[0];
|
||||
} else {
|
||||
thumbnail =
|
||||
$theme === "light"
|
||||
? emptyStateTrailLight
|
||||
: emptyStateTrailDark;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 my-2 border border-input-border rounded-xl">
|
||||
@@ -65,7 +77,7 @@
|
||||
</h5>
|
||||
|
||||
{#if mode == "edit"}
|
||||
<Dropdown items={dropdownItems} on:change></Dropdown>
|
||||
<Dropdown items={dropdownItems} {onchange}></Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
{#if log.distance || log.elevation_gain || log.elevation_loss || log.duration}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { type Snippet } from "svelte";
|
||||
|
||||
import {
|
||||
SummitLogCreateSchema,
|
||||
} from "$lib/models/api/summit_log_schema";
|
||||
import { SummitLogCreateSchema } from "$lib/models/api/summit_log_schema";
|
||||
import GPX from "$lib/models/gpx/gpx";
|
||||
import { summitLog } from "$lib/stores/summit_log_store";
|
||||
import { createForm } from "felte";
|
||||
import { cloneDeep } from "$lib/util/deep_util";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { z } from "zod";
|
||||
import Datepicker from "../base/datepicker.svelte";
|
||||
@@ -15,11 +14,19 @@
|
||||
import Textarea from "../base/textarea.svelte";
|
||||
import PhotoPicker from "../trail/photo_picker.svelte";
|
||||
import TrailPicker from "../trail/trail_picker.svelte";
|
||||
import { cloneDeep } from "$lib/util/deep_util";
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
interface Props {
|
||||
children?: Snippet<[any]>;
|
||||
onsave?: (summitLog: SummitLog) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { children, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const ClientSummitLogCreateSchema = SummitLogCreateSchema.extend({
|
||||
_photos: z.array(z.instanceof(File)).optional(),
|
||||
@@ -36,7 +43,7 @@
|
||||
>({
|
||||
initialValues: $summitLog,
|
||||
extend: validator({ schema: ClientSummitLogCreateSchema }),
|
||||
onSubmit: async (form) => {
|
||||
onSubmit: async (form) => {
|
||||
if (!form.expand?.gpx_data) {
|
||||
form.gpx = "";
|
||||
}
|
||||
@@ -56,15 +63,22 @@
|
||||
form._photos = [new File([blob], "route")];
|
||||
}
|
||||
|
||||
dispatch("save", form);
|
||||
closeModal!();
|
||||
onsave?.(form);
|
||||
modal.closeModal!();
|
||||
},
|
||||
});
|
||||
$: setFields(cloneDeep($summitLog));
|
||||
|
||||
$: if ($summitLog._gpx) {
|
||||
$data._gpx = $summitLog._gpx;
|
||||
}
|
||||
let trailData = $derived($data.expand?.gpx_data);
|
||||
|
||||
$effect(() => {
|
||||
setFields(cloneDeep($summitLog));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($summitLog._gpx) {
|
||||
$data._gpx = $summitLog._gpx;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleTrailSelection(trailData: string | null) {
|
||||
if (!trailData) {
|
||||
@@ -85,63 +99,69 @@
|
||||
$data.elevation_gain = totals.elevationGain;
|
||||
$data.elevation_loss = totals.elevationLoss;
|
||||
$data.distance = totals.distance;
|
||||
$data.expand!.gpx_data = trailData;
|
||||
}
|
||||
|
||||
const children_render = $derived(children);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="summit-log-modal"
|
||||
title={$data.id ? $_("edit-entry") : $_("add-entry")}
|
||||
let:openModal
|
||||
bind:openModal
|
||||
bind:closeModal
|
||||
bind:this={modal}
|
||||
>
|
||||
<slot {openModal} />
|
||||
<form
|
||||
id="summit-log-form"
|
||||
slot="content"
|
||||
class="modal-content space-y-4"
|
||||
use:form
|
||||
>
|
||||
<div class="flex">
|
||||
<Datepicker name="date" label={$_("date")} error={$errors.date}
|
||||
></Datepicker>
|
||||
</div>
|
||||
<div>
|
||||
<label for="summitlog-photo-input" class="text-sm font-medium pb-1">
|
||||
{$_("photos")}
|
||||
</label>
|
||||
<PhotoPicker
|
||||
id="summitlog-photo-input"
|
||||
parent={$data}
|
||||
bind:photos={$data.photos}
|
||||
bind:photoFiles={$data._photos}
|
||||
showThumbnailControls={false}
|
||||
></PhotoPicker>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
{#if $data.expand}
|
||||
<TrailPicker
|
||||
bind:trailFile={$data._gpx}
|
||||
bind:trailData={$data.expand.gpx_data}
|
||||
label={$_("trail", { values: { n: 1 } })}
|
||||
on:change={(e) => handleTrailSelection(e.detail)}
|
||||
></TrailPicker>
|
||||
{/if}
|
||||
<div class="basis-full">
|
||||
<Textarea
|
||||
name="text"
|
||||
extraClasses="h-28"
|
||||
label={$_("text")}
|
||||
error={$errors.text}
|
||||
></Textarea>
|
||||
{#snippet children({ openModal })}
|
||||
{@render children_render?.({ openModal })}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<form id="summit-log-form" class="modal-content space-y-4" use:form>
|
||||
<div class="flex">
|
||||
<Datepicker name="date" label={$_("date")} error={$errors.date}
|
||||
></Datepicker>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="summitlog-photo-input"
|
||||
class="text-sm font-medium pb-1"
|
||||
>
|
||||
{$_("photos")}
|
||||
</label>
|
||||
<PhotoPicker
|
||||
id="summitlog-photo-input"
|
||||
parent={$data}
|
||||
bind:photos={$data.photos}
|
||||
bind:photoFiles={$data._photos}
|
||||
showThumbnailControls={false}
|
||||
></PhotoPicker>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
{#if $data.expand}
|
||||
<TrailPicker
|
||||
bind:trailFile={$data._gpx}
|
||||
{trailData}
|
||||
label={$_("trail", { values: { n: 1 } })}
|
||||
onchange={(trail) => handleTrailSelection(trail)}
|
||||
></TrailPicker>
|
||||
{/if}
|
||||
<div class="basis-full">
|
||||
<Textarea
|
||||
name="text"
|
||||
extraClasses="h-28"
|
||||
label={$_("text")}
|
||||
error={$errors.text}
|
||||
></Textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button class="btn-primary" type="submit" form="summit-log-form"
|
||||
>{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button class="btn-primary" type="submit" form="summit-log-form"
|
||||
>{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -10,24 +10,32 @@
|
||||
import { _ } from "svelte-i18n";
|
||||
import MapWithElevationMaplibre from "../trail/map_with_elevation_maplibre.svelte";
|
||||
|
||||
export let summitLogs: SummitLog[] = [];
|
||||
export let showCategory: boolean = false;
|
||||
export let showTrail: boolean = false;
|
||||
export let showAuthor: boolean = false;
|
||||
export let showRoute: boolean = false;
|
||||
export let showPhotos: boolean = false;
|
||||
interface Props {
|
||||
summitLogs?: SummitLog[];
|
||||
showCategory?: boolean;
|
||||
showTrail?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showRoute?: boolean;
|
||||
showPhotos?: boolean;
|
||||
}
|
||||
|
||||
let openMapModal: () => void;
|
||||
let closeMapModal: () => void;
|
||||
let {
|
||||
summitLogs = [],
|
||||
showCategory = false,
|
||||
showTrail = false,
|
||||
showAuthor = false,
|
||||
showRoute = false,
|
||||
showPhotos = false,
|
||||
}: Props = $props();
|
||||
|
||||
let openTextModal: () => void;
|
||||
let closeTextModal: () => void;
|
||||
let mapModal: Modal;
|
||||
let textModal: Modal;
|
||||
|
||||
let map: M.Map;
|
||||
let map: M.Map | undefined = $state();
|
||||
|
||||
let trail: Trail | null = null;
|
||||
let trail: Trail | null = $state(null);
|
||||
|
||||
let currentText: string = "";
|
||||
let currentText: string = $state("");
|
||||
|
||||
onMount(async () => {});
|
||||
|
||||
@@ -40,21 +48,21 @@
|
||||
trail.id = log.id;
|
||||
trail.expand!.gpx_data = log.expand.gpx_data;
|
||||
|
||||
openMapModal();
|
||||
mapModal.openModal();
|
||||
await tick();
|
||||
return;
|
||||
}
|
||||
|
||||
async function openText(log: SummitLog) {
|
||||
currentText = log.text ?? "";
|
||||
openTextModal();
|
||||
textModal.openModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<table class="w-full">
|
||||
<thead class="text-left text-gray-500">
|
||||
<tr class="text-sm">
|
||||
{#if showPhotos && summitLogs.some(l => l.photos.length)}
|
||||
{#if showPhotos && summitLogs.some((l) => l.photos.length)}
|
||||
<th class="w-24"></th>
|
||||
{/if}
|
||||
<th>{$_("date")}</th>
|
||||
@@ -75,7 +83,7 @@
|
||||
{#if summitLogs.some((l) => l.text?.length)}
|
||||
<th>{$_("description")}</th>
|
||||
{/if}
|
||||
{#if showAuthor && summitLogs.some(l => l.expand?.author)}
|
||||
{#if showAuthor && summitLogs.some((l) => l.expand?.author)}
|
||||
<th>
|
||||
{$_("author", { values: { n: 1 } })}
|
||||
</th>
|
||||
@@ -91,13 +99,14 @@
|
||||
{#each summitLogs as log, i}
|
||||
<SummitLogTableRow
|
||||
{log}
|
||||
on:open={(e) => openMap(e.detail)}
|
||||
on:text={(e) => openText(e.detail)}
|
||||
onopen={(log) => openMap(log)}
|
||||
ontext={(log) => openText(log)}
|
||||
{showCategory}
|
||||
{showTrail}
|
||||
{showAuthor}
|
||||
showPhotos={showPhotos && summitLogs.some(l => l.photos.length)}
|
||||
showDescription={summitLogs.some(l => l.text?.length)}
|
||||
showPhotos={showPhotos &&
|
||||
summitLogs.some((l) => l.photos.length)}
|
||||
showDescription={summitLogs.some((l) => l.text?.length)}
|
||||
showRoute={showRoute && summitLogs.some((l) => l.gpx)}
|
||||
></SummitLogTableRow>
|
||||
{/each}
|
||||
@@ -110,24 +119,29 @@
|
||||
id="summit-log-table-map-modal"
|
||||
size="max-w-4xl"
|
||||
title=""
|
||||
bind:openModal={openMapModal}
|
||||
bind:closeModal={closeMapModal}
|
||||
bind:this={mapModal}
|
||||
>
|
||||
<div slot="content" id="summit-log-table-map" class="h-[32rem]">
|
||||
{#if trail}
|
||||
<MapWithElevationMaplibre trails={[trail]} bind:map showTerrain
|
||||
></MapWithElevationMaplibre>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet content()}
|
||||
<div id="summit-log-table-map" class="h-[32rem]">
|
||||
{#if trail}
|
||||
<MapWithElevationMaplibre
|
||||
trails={[trail]}
|
||||
bind:map
|
||||
showTerrain
|
||||
></MapWithElevationMaplibre>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
<Modal
|
||||
id="summit-log-table-text-modal"
|
||||
size="max-w-xl"
|
||||
title={$_("description")}
|
||||
bind:openModal={openTextModal}
|
||||
bind:closeModal={closeTextModal}
|
||||
bind:this={textModal}
|
||||
>
|
||||
<p slot="content" class="whitespace-pre-wrap">{currentText}</p>
|
||||
{#snippet content()}
|
||||
<p class="whitespace-pre-wrap">{currentText}</p>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -7,38 +7,53 @@
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import PhotoGallery from "../photo_gallery.svelte";
|
||||
|
||||
export let log: SummitLog;
|
||||
export let showCategory: boolean = false;
|
||||
export let showTrail: boolean = false;
|
||||
export let showRoute: boolean = false;
|
||||
export let showAuthor: boolean = false;
|
||||
export let showDescription: boolean = false;
|
||||
export let showPhotos: boolean = false;
|
||||
|
||||
let openGallery: (idx?: number) => void;
|
||||
|
||||
let imgSrc: string[] = [];
|
||||
$: if (log.photos?.length) {
|
||||
imgSrc = log.photos
|
||||
.filter((_, i) => i < 3)
|
||||
.reverse()
|
||||
.map((p) => getFileURL(log, p));
|
||||
} else {
|
||||
imgSrc = [];
|
||||
interface Props {
|
||||
log: SummitLog;
|
||||
showCategory?: boolean;
|
||||
showTrail?: boolean;
|
||||
showRoute?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showDescription?: boolean;
|
||||
showPhotos?: boolean;
|
||||
ontext?: (summitLog: SummitLog) => void;
|
||||
onopen?: (summitLog: SummitLog) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
log,
|
||||
showCategory = false,
|
||||
showTrail = false,
|
||||
showRoute = false,
|
||||
showAuthor = false,
|
||||
showDescription = false,
|
||||
showPhotos = false,
|
||||
onopen,
|
||||
ontext
|
||||
}: Props = $props();
|
||||
|
||||
let gallery: PhotoGallery;
|
||||
|
||||
let imgSrc: string[] = $state([]);
|
||||
$effect(() => {
|
||||
if (log.photos?.length) {
|
||||
imgSrc = log.photos
|
||||
.filter((_, i) => i < 3)
|
||||
.reverse()
|
||||
.map((p) => getFileURL(log, p));
|
||||
} else {
|
||||
imgSrc = [];
|
||||
}
|
||||
});
|
||||
|
||||
function openText() {
|
||||
dispatch("text", log);
|
||||
ontext?.(log);
|
||||
}
|
||||
|
||||
function openRoute() {
|
||||
dispatch("open", log);
|
||||
onopen?.(log);
|
||||
}
|
||||
|
||||
function colCount() {
|
||||
@@ -58,12 +73,12 @@
|
||||
{#if imgSrc.length}
|
||||
<PhotoGallery
|
||||
photos={log.photos.map((p) => getFileURL(log, p))}
|
||||
bind:open={openGallery}
|
||||
bind:this={gallery}
|
||||
></PhotoGallery>
|
||||
<button
|
||||
class="relative w-16 aspect-square ml-2 mb-3 shrink-0"
|
||||
type="button"
|
||||
on:click={() => openGallery()}
|
||||
onclick={() => gallery.openGallery()}
|
||||
>
|
||||
{#each imgSrc as img, i}
|
||||
<img
|
||||
@@ -110,6 +125,7 @@
|
||||
{#if showTrail}
|
||||
<td>
|
||||
<a
|
||||
aria-label="Go to trail"
|
||||
class="btn-icon aspect-square"
|
||||
href="/trail/view/{log.expand?.trails_via_summit_logs?.at(0)
|
||||
?.id ?? ''}"
|
||||
@@ -120,7 +136,7 @@
|
||||
{#if showDescription}
|
||||
<td>
|
||||
{#if log.text}
|
||||
<button on:click={openText}
|
||||
<button onclick={openText}
|
||||
><p
|
||||
class="rounded-full bg-menu-item-background-hover hover:bg-menu-item-background-focus text-ellipsis max-w-28 whitespace-nowrap overflow-hidden px-3 py-1"
|
||||
>
|
||||
@@ -164,7 +180,11 @@
|
||||
{/if}
|
||||
{#if showRoute && log.expand?.gpx_data}
|
||||
<td>
|
||||
<button on:click={openRoute} class="btn-icon">
|
||||
<button
|
||||
aria-label="Open route"
|
||||
onclick={openRoute}
|
||||
class="btn-icon"
|
||||
>
|
||||
<i class="fa fa-map-location-dot px-[3px] text-xl"></i></button
|
||||
>
|
||||
</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { page } from "$app/state";
|
||||
import directionCaret from "$lib/assets/svgs/caret-right-solid.svg";
|
||||
import type { Settings } from "$lib/models/settings";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
@@ -19,34 +19,72 @@
|
||||
import type { GeoJSON } from "geojson";
|
||||
import * as M from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { createEventDispatcher, onDestroy, onMount } from "svelte";
|
||||
import { onDestroy, onMount, untrack } from "svelte";
|
||||
|
||||
export let trails: Trail[] = [];
|
||||
export let waypoints: Waypoint[] = [];
|
||||
export let markers: M.Marker[] = [];
|
||||
export let map: M.Map | null = null;
|
||||
export let drawing: boolean = false;
|
||||
export let showElevation: boolean = true;
|
||||
export let showInfoPopup: boolean = false;
|
||||
export let showGrid: boolean = false;
|
||||
export let showStyleSwitcher: boolean = true;
|
||||
export let showFullscreen: boolean = false;
|
||||
export let showTerrain: boolean = false;
|
||||
export let fitBounds: "animate" | "instant" | "off" = "instant";
|
||||
export let onMarkerDragEnd:
|
||||
| ((marker: M.Marker, wpId?: string) => void)
|
||||
| undefined = undefined;
|
||||
interface Props {
|
||||
trails?: Trail[];
|
||||
waypoints?: Waypoint[];
|
||||
markers?: M.Marker[];
|
||||
map?: M.Map | null;
|
||||
drawing?: boolean;
|
||||
showElevation?: boolean;
|
||||
showInfoPopup?: boolean;
|
||||
showGrid?: boolean;
|
||||
showStyleSwitcher?: boolean;
|
||||
showFullscreen?: boolean;
|
||||
showTerrain?: boolean;
|
||||
fitBounds?: "animate" | "instant" | "off";
|
||||
onmarkerdragend?:
|
||||
| ((marker: M.Marker, wpId?: string) => void)
|
||||
| undefined;
|
||||
elevationProfileContainer?: string | HTMLDivElement | undefined;
|
||||
mapOptions?: Partial<M.MapOptions> | undefined;
|
||||
activeTrail?: number | null;
|
||||
minZoom?: number;
|
||||
onsegmentdragend?: (data: {
|
||||
segment: number;
|
||||
event: M.MapMouseEvent;
|
||||
}) => void;
|
||||
onselect?: (trail: Trail) => void;
|
||||
onunselect?: (trail: Trail) => void;
|
||||
onfullscreen?: () => void;
|
||||
onmoveend?: (map: M.Map) => void;
|
||||
onzoom?: (map: M.Map) => void;
|
||||
onclick?: (event: M.MapMouseEvent & Object) => void;
|
||||
oninit?: (map: M.Map) => void;
|
||||
}
|
||||
|
||||
export let elevationProfileContainer: string | HTMLDivElement | undefined =
|
||||
undefined;
|
||||
export let mapOptions: Partial<M.MapOptions> | undefined = undefined;
|
||||
|
||||
export let activeTrail: number | null = null;
|
||||
export let minZoom: number = 0;
|
||||
let {
|
||||
trails = [],
|
||||
waypoints = [],
|
||||
markers = $bindable([]),
|
||||
map = $bindable(),
|
||||
drawing = false,
|
||||
showElevation = true,
|
||||
showInfoPopup = false,
|
||||
showGrid = false,
|
||||
showStyleSwitcher = true,
|
||||
showFullscreen = false,
|
||||
showTerrain = false,
|
||||
fitBounds = "instant",
|
||||
elevationProfileContainer = undefined,
|
||||
mapOptions = undefined,
|
||||
activeTrail = $bindable(0),
|
||||
minZoom = 0,
|
||||
onmarkerdragend,
|
||||
onsegmentdragend,
|
||||
onselect,
|
||||
onunselect,
|
||||
onfullscreen,
|
||||
onmoveend,
|
||||
onzoom,
|
||||
onclick,
|
||||
oninit,
|
||||
}: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let epc: ElevationProfileControl | null = null;
|
||||
let graticule: MaplibreGraticule | null = null;
|
||||
let epc: ElevationProfileControl;
|
||||
let graticule: MaplibreGraticule;
|
||||
|
||||
let layers: Record<
|
||||
string,
|
||||
@@ -71,8 +109,6 @@
|
||||
|
||||
let hoveringTrail: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const trailColors = [
|
||||
"#3549BB",
|
||||
"#592E9E",
|
||||
@@ -86,78 +122,76 @@
|
||||
"#DE7DC5",
|
||||
];
|
||||
|
||||
$: data = getData(trails);
|
||||
|
||||
$: if (data && map) {
|
||||
initMap(map.loaded());
|
||||
}
|
||||
|
||||
$: if (activeTrail !== null && trails[activeTrail] !== undefined) {
|
||||
if (
|
||||
!drawing &&
|
||||
fitBounds !== "off" &&
|
||||
data.some((d) => d.bbox !== undefined)
|
||||
) {
|
||||
focusTrail(trails[activeTrail]);
|
||||
let data = $derived(getData(trails));
|
||||
$effect(() => {
|
||||
if (data && map) {
|
||||
untrack(() => initMap(map?.loaded() ?? false));
|
||||
}
|
||||
} else if (activeTrail === null && trails.length) {
|
||||
unFocusTrail();
|
||||
}
|
||||
|
||||
$: if ($theme == "dark") {
|
||||
epc?.toggleTheme({
|
||||
profileBackgroundColor: "#191b24",
|
||||
elevationGridColor: "#ddd2",
|
||||
labelColor: "#ddd8",
|
||||
crosshairColor: "#fff5",
|
||||
tooltipBackgroundColor: "#242734",
|
||||
tooltipTextColor: "#fff",
|
||||
});
|
||||
} else {
|
||||
epc?.toggleTheme({
|
||||
profileBackgroundColor: "#242734",
|
||||
elevationGridColor: "#0002",
|
||||
labelColor: "#0009",
|
||||
crosshairColor: "#0005",
|
||||
tooltipBackgroundColor: "#fff",
|
||||
tooltipTextColor: "#000",
|
||||
});
|
||||
}
|
||||
|
||||
$: if (drawing && map) {
|
||||
startDrawing();
|
||||
} else if (!drawing && map) {
|
||||
stopDrawing();
|
||||
}
|
||||
|
||||
$: if (showGrid) {
|
||||
if (!graticule) {
|
||||
graticule = new MaplibreGraticule({
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
showLabels: true,
|
||||
labelType: "hdms",
|
||||
labelSize: 10,
|
||||
labelColor: "#858585",
|
||||
longitudePosition: "top",
|
||||
latitudePosition: "right",
|
||||
paint: {
|
||||
"line-opacity": 0.8,
|
||||
"line-color": "rgba(0,0,0,0.2)",
|
||||
},
|
||||
});
|
||||
$effect(() => {
|
||||
adjustTrailFocus(activeTrail);
|
||||
});
|
||||
$effect(() => {
|
||||
if ($theme == "dark") {
|
||||
epc?.toggleTheme({
|
||||
profileBackgroundColor: "#191b24",
|
||||
elevationGridColor: "#ddd2",
|
||||
labelColor: "#ddd8",
|
||||
crosshairColor: "#fff5",
|
||||
tooltipBackgroundColor: "#242734",
|
||||
tooltipTextColor: "#fff",
|
||||
});
|
||||
} else {
|
||||
epc?.toggleTheme({
|
||||
profileBackgroundColor: "#242734",
|
||||
elevationGridColor: "#0002",
|
||||
labelColor: "#0009",
|
||||
crosshairColor: "#0005",
|
||||
tooltipBackgroundColor: "#fff",
|
||||
tooltipTextColor: "#000",
|
||||
});
|
||||
}
|
||||
map?.addControl(graticule);
|
||||
} else {
|
||||
if (graticule) {
|
||||
map?.removeControl(graticule);
|
||||
});
|
||||
$effect(() => {
|
||||
if (drawing && map) {
|
||||
startDrawing();
|
||||
} else if (!drawing && map) {
|
||||
stopDrawing();
|
||||
}
|
||||
}
|
||||
|
||||
$: if (waypoints) {
|
||||
showWaypoints();
|
||||
refreshElevationProfile();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (showGrid) {
|
||||
if (!graticule) {
|
||||
graticule = new MaplibreGraticule({
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
showLabels: true,
|
||||
labelType: "hdms",
|
||||
labelSize: 10,
|
||||
labelColor: "#858585",
|
||||
longitudePosition: "top",
|
||||
latitudePosition: "right",
|
||||
paint: {
|
||||
"line-opacity": 0.8,
|
||||
"line-color": "rgba(0,0,0,0.2)",
|
||||
},
|
||||
});
|
||||
}
|
||||
map?.addControl(graticule);
|
||||
} else {
|
||||
if (graticule) {
|
||||
map?.removeControl(graticule);
|
||||
}
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (waypoints) {
|
||||
untrack(() => {
|
||||
showWaypoints();
|
||||
refreshElevationProfile();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getData(trails: Trail[]) {
|
||||
if (!trails.length) {
|
||||
@@ -168,7 +202,7 @@
|
||||
trails.forEach((t) => {
|
||||
if (t.expand?.gpx_data) {
|
||||
r.push(toGeoJson(t.expand.gpx_data) as GeoJSON);
|
||||
} else if (t.lat && t.lon) {
|
||||
} else if (t.lat !== null && t.lon !== null) {
|
||||
r.push({
|
||||
id: "",
|
||||
type: "Feature",
|
||||
@@ -212,7 +246,7 @@
|
||||
fitBounds !== "off" &&
|
||||
data.some((d) => d.bbox !== undefined)
|
||||
) {
|
||||
if (activeTrail !== null && mapLoaded) {
|
||||
if (activeTrail !== null && trails[activeTrail] && mapLoaded) {
|
||||
focusTrail(trails[activeTrail]);
|
||||
} else {
|
||||
flyToBounds();
|
||||
@@ -246,7 +280,7 @@
|
||||
return new M.LngLatBounds([minX, minY, maxX, maxY]);
|
||||
}
|
||||
|
||||
function flyToBounds() {
|
||||
function flyToBounds() {
|
||||
const bounds =
|
||||
activeTrail !== null && data[activeTrail]
|
||||
? (data[activeTrail].bbox as M.LngLatBoundsLike)
|
||||
@@ -325,7 +359,7 @@
|
||||
|
||||
createEmptyLayer(id);
|
||||
|
||||
if (!layers[id].source) {
|
||||
if (!layers[id].source || !map.getSource(id)) {
|
||||
try {
|
||||
map.addSource(id, {
|
||||
type: "geojson",
|
||||
@@ -339,7 +373,7 @@
|
||||
layers[id].source.setData(geojson);
|
||||
}
|
||||
|
||||
if (!layers[id].layer) {
|
||||
if (!layers[id].layer || !map.getLayer(id)) {
|
||||
map.addLayer({
|
||||
id: id,
|
||||
type: "line",
|
||||
@@ -407,12 +441,12 @@
|
||||
function handleDragEnd(e: M.MapMouseEvent) {
|
||||
map?.off("mousemove", moveElevationMarkerToCursorPosition);
|
||||
epc?.hideCrosshair();
|
||||
dispatch("segmentDragEnd", { segment: draggingSegment, event: e });
|
||||
onsegmentdragend?.({ segment: draggingSegment!, event: e });
|
||||
draggingSegment = null;
|
||||
}
|
||||
|
||||
function addCaretLayer(id?: string) {
|
||||
if (!map || !id) {
|
||||
if (!map || !id || !map.getSource(id)) {
|
||||
return;
|
||||
}
|
||||
if (map.getLayer("direction-carets")) {
|
||||
@@ -459,11 +493,16 @@
|
||||
id: string,
|
||||
showElevationMarker: boolean = false,
|
||||
) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (showElevationMarker) {
|
||||
elevationMarker.setOpacity("1");
|
||||
}
|
||||
map?.setPaintProperty(id, "line-width", 7);
|
||||
hoveringTrail = true;
|
||||
if (map?.getLayer(id)) {
|
||||
hoveringTrail = true;
|
||||
}
|
||||
// map?.setPaintProperty(id, "line-color", "#2766e3");
|
||||
}
|
||||
|
||||
@@ -474,17 +513,33 @@
|
||||
elevationMarker.setOpacity("0");
|
||||
epc?.hideCrosshair();
|
||||
hoveringTrail = false;
|
||||
map?.setPaintProperty(id, "line-width", 5);
|
||||
if (map?.getLayer(id)) {
|
||||
map?.setPaintProperty(id, "line-width", 5);
|
||||
}
|
||||
// map?.setPaintProperty(id, "line-color", "#648ad5");
|
||||
}
|
||||
|
||||
function adjustTrailFocus(activeTrail: number | null) {
|
||||
if (activeTrail !== null && trails[activeTrail] !== undefined) {
|
||||
if (
|
||||
!drawing &&
|
||||
fitBounds !== "off" &&
|
||||
data.some((d) => d.bbox !== undefined)
|
||||
) {
|
||||
untrack(() => focusTrail(trails[activeTrail]));
|
||||
}
|
||||
} else if (activeTrail === null && trails.length) {
|
||||
untrack(() => unFocusTrail());
|
||||
}
|
||||
}
|
||||
|
||||
function focusTrail(trail: Trail) {
|
||||
activeTrail = trails.findIndex((t) => t.id == trail.id);
|
||||
if (activeTrail < 0) {
|
||||
activeTrail = null;
|
||||
return;
|
||||
}
|
||||
dispatch("select", trail);
|
||||
onselect?.(trail);
|
||||
|
||||
try {
|
||||
refreshElevationProfile();
|
||||
@@ -501,7 +556,7 @@
|
||||
|
||||
function unFocusTrail(trail?: Trail) {
|
||||
if (trail) {
|
||||
dispatch("unselect", trail);
|
||||
onunselect?.(trail);
|
||||
unHighlightTrail(trail.id!);
|
||||
}
|
||||
|
||||
@@ -617,9 +672,14 @@
|
||||
|
||||
hideWaypoints();
|
||||
for (const waypoint of waypoints) {
|
||||
const marker = createMarkerFromWaypoint(waypoint, onMarkerDragEnd);
|
||||
marker.addTo(map);
|
||||
markers.push(marker);
|
||||
if (!markers.find((m) => m._element.id == waypoint.id)) {
|
||||
const marker = createMarkerFromWaypoint(
|
||||
waypoint,
|
||||
onmarkerdragend,
|
||||
);
|
||||
marker.addTo(map);
|
||||
markers.push(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,6 +690,7 @@
|
||||
for (const m of markers) {
|
||||
m.remove();
|
||||
}
|
||||
markers = [];
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -646,7 +707,7 @@
|
||||
|
||||
const mapStyles: { text: string; value: string; thumbnail?: string }[] =
|
||||
[
|
||||
...(($page.data.settings as Settings)?.tilesets ?? []).map(
|
||||
...((page.data.settings as Settings)?.tilesets ?? []).map(
|
||||
(t) => ({
|
||||
text: t.name,
|
||||
value: t.url,
|
||||
@@ -683,6 +744,10 @@
|
||||
preferredMapStyleIndex = 0;
|
||||
}
|
||||
|
||||
if (!mapContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalMapOptions: M.MapOptions = {
|
||||
...{
|
||||
container: mapContainer,
|
||||
@@ -730,7 +795,7 @@
|
||||
map.addControl(
|
||||
new M.ScaleControl({
|
||||
maxWidth: 120,
|
||||
unit: $page.data.settings?.unit ?? "metric",
|
||||
unit: page.data.settings?.unit ?? "metric",
|
||||
}),
|
||||
"top-left",
|
||||
);
|
||||
@@ -741,7 +806,7 @@
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
fitBoundsOptions: {
|
||||
animate: fitBounds == "animate"
|
||||
animate: fitBounds == "animate",
|
||||
},
|
||||
trackUserLocation: true,
|
||||
}),
|
||||
@@ -757,7 +822,7 @@
|
||||
profileBackgroundColor:
|
||||
$theme == "light" ? "#242734" : "#191b24",
|
||||
backgroundColor: "bg-menu-background/90",
|
||||
unit: $page.data.settings?.unit ?? "metric",
|
||||
unit: page.data.settings?.unit ?? "metric",
|
||||
profileLineWidth: 3,
|
||||
displayDistanceGrid: true,
|
||||
tooltipDisplayDPlus: false,
|
||||
@@ -785,7 +850,7 @@
|
||||
if (showFullscreen) {
|
||||
map.addControl(
|
||||
new FullscreenControl(() => {
|
||||
dispatch("fullscreen");
|
||||
onfullscreen?.();
|
||||
}),
|
||||
"bottom-right",
|
||||
);
|
||||
@@ -803,21 +868,21 @@
|
||||
if (showTerrain) {
|
||||
try {
|
||||
if (
|
||||
$page.data.settings?.terrain?.terrain &&
|
||||
page.data.settings?.terrain?.terrain &&
|
||||
!map?.getSource("terrain")
|
||||
) {
|
||||
map!.addSource("terrain", {
|
||||
type: "raster-dem",
|
||||
url: $page.data.settings?.terrain?.terrain,
|
||||
url: page.data.settings?.terrain?.terrain,
|
||||
});
|
||||
}
|
||||
if (
|
||||
$page.data.settings?.terrain?.hillshading &&
|
||||
page.data.settings?.terrain?.hillshading &&
|
||||
!map?.getSource("hillshading")
|
||||
) {
|
||||
map!.addSource("hillshading", {
|
||||
type: "raster-dem",
|
||||
url: $page.data.settings?.terrain?.hillshading,
|
||||
url: page.data.settings?.terrain?.hillshading,
|
||||
});
|
||||
map!.addLayer({
|
||||
id: "hillshading",
|
||||
@@ -830,7 +895,7 @@
|
||||
});
|
||||
|
||||
map.on("moveend", (e) => {
|
||||
dispatch("moveend", e.target);
|
||||
onmoveend?.(e.target);
|
||||
});
|
||||
|
||||
map.on("zoom", (e) => {
|
||||
@@ -843,19 +908,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
dispatch("zoom", e.target);
|
||||
onzoom?.(e.target);
|
||||
});
|
||||
|
||||
map.on("click", (e) => {
|
||||
if (hoveringTrail && drawing) {
|
||||
return;
|
||||
}
|
||||
dispatch("click", e);
|
||||
onclick?.(e);
|
||||
});
|
||||
|
||||
map.on("load", () => {
|
||||
initMap(true);
|
||||
dispatch("init", map);
|
||||
oninit?.(map!);
|
||||
});
|
||||
|
||||
showWaypoints();
|
||||
|
||||
@@ -1,30 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||
import { onMount } from "svelte";
|
||||
import PhotoCard from "../photo_card.svelte";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import PhotoCard from "../photo_card.svelte";
|
||||
|
||||
export let id: string;
|
||||
export let photos: string[];
|
||||
export let photoFiles: File[] | undefined;
|
||||
export let parent: { [key: string]: any };
|
||||
export let thumbnail: number = 0;
|
||||
export let showThumbnailControls: boolean = true;
|
||||
export let showExifControls: boolean = false;
|
||||
export let maxSizeBytes = 20971520;
|
||||
interface Props {
|
||||
id: string;
|
||||
photos: string[];
|
||||
photoFiles: File[] | undefined;
|
||||
parent: { [key: string]: any };
|
||||
thumbnail?: number;
|
||||
showThumbnailControls?: boolean;
|
||||
showExifControls?: boolean;
|
||||
maxSizeBytes?: number;
|
||||
onexif?: (src: string) => void;
|
||||
}
|
||||
|
||||
let photoPreviews: string[] = [];
|
||||
let {
|
||||
id,
|
||||
photos = $bindable(),
|
||||
photoFiles = $bindable(),
|
||||
parent,
|
||||
thumbnail = $bindable(0),
|
||||
showThumbnailControls = true,
|
||||
showExifControls = false,
|
||||
maxSizeBytes = 20971520,
|
||||
onexif,
|
||||
}: Props = $props();
|
||||
|
||||
$: Promise.all(
|
||||
(photoFiles ?? []).map(async (f) => {
|
||||
return await readAsDataURLAsync(f);
|
||||
}),
|
||||
).then((v) => {
|
||||
photoPreviews = v;
|
||||
});
|
||||
let photoPreviews: string[] = $state([]);
|
||||
|
||||
let offerUpload: boolean = false;
|
||||
$effect(() => fetchPhotos(photoFiles ?? []));
|
||||
|
||||
function fetchPhotos(photos: File[]) {
|
||||
Promise.all(
|
||||
photos.map(async (f) => {
|
||||
return await readAsDataURLAsync(f);
|
||||
}),
|
||||
).then((v) => {
|
||||
photoPreviews = v;
|
||||
});
|
||||
}
|
||||
|
||||
let offerUpload: boolean = $state(false);
|
||||
|
||||
function handlePhotoDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
@@ -120,14 +138,15 @@
|
||||
? 'outline-dashed outline-input-border'
|
||||
: ''}"
|
||||
role="dialog"
|
||||
on:dragover={handlePhotoDragOver}
|
||||
on:dragleave={handlePhotoDragLeave}
|
||||
on:drop={handlePhotoDrop}
|
||||
ondragover={handlePhotoDragOver}
|
||||
ondragleave={handlePhotoDragLeave}
|
||||
ondrop={handlePhotoDrop}
|
||||
>
|
||||
<button
|
||||
aria-label="Open photo browser"
|
||||
class="btn-secondary h-32 w-32 shrink-0 grow-0 basis-auto"
|
||||
type="button"
|
||||
on:click={openPhotoBrowser}><i class="fa fa-plus"></i></button
|
||||
onclick={openPhotoBrowser}><i class="fa fa-plus"></i></button
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -135,17 +154,17 @@
|
||||
accept="image/*"
|
||||
multiple={true}
|
||||
style="display: none;"
|
||||
on:change={() => handlePhotoSelection()}
|
||||
onchange={() => handlePhotoSelection()}
|
||||
/>
|
||||
<div class="flex overflow-x-auto gap-x-3 w-full">
|
||||
{#each (photos ?? []).concat(photoPreviews) as photo, i}
|
||||
<div class="shrink-0 grow-0 basis-auto">
|
||||
<PhotoCard
|
||||
src={i >= photos.length ? photo : getFileURL(parent, photo)}
|
||||
on:delete={() => handlePhotoDelete(i)}
|
||||
ondelete={() => handlePhotoDelete(i)}
|
||||
isThumbnail={thumbnail === i}
|
||||
on:thumbnail={() => makePhotoThumbnail(i)}
|
||||
on:exif
|
||||
onthumbnail={() => makePhotoThumbnail(i)}
|
||||
{onexif}
|
||||
{showThumbnailControls}
|
||||
{showExifControls}
|
||||
></PhotoCard>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler } from "svelte/legacy";
|
||||
|
||||
const bubble = createBubbler();
|
||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
@@ -12,25 +15,41 @@
|
||||
} from "$lib/util/format_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
export let trail: Trail;
|
||||
export let fullWidth: boolean = false;
|
||||
interface Props {
|
||||
trail: Trail;
|
||||
fullWidth?: boolean;
|
||||
onmouseenter?: MouseEventHandler<HTMLDivElement>;
|
||||
onmouseleave?: MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
$: thumbnail = trail.photos.length
|
||||
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
||||
: $theme === "light"
|
||||
? emptyStateTrailLight
|
||||
: emptyStateTrailDark;
|
||||
let {
|
||||
trail,
|
||||
fullWidth = false,
|
||||
onmouseenter,
|
||||
onmouseleave,
|
||||
}: Props = $props();
|
||||
|
||||
$: trailIsShared = (trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
|
||||
let thumbnail = $derived(
|
||||
trail.photos.length
|
||||
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
||||
: $theme === "light"
|
||||
? emptyStateTrailLight
|
||||
: emptyStateTrailDark,
|
||||
);
|
||||
|
||||
let trailIsShared = $derived(
|
||||
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="trail-card relative rounded-2xl border border-input-border min-w-72 {fullWidth
|
||||
? ''
|
||||
: 'lg:w-72'} cursor-pointer"
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
{onmouseenter}
|
||||
{onmouseleave}
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -22,19 +22,25 @@
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import { pb } from "$lib/pocketbase";
|
||||
|
||||
export let trail: Trail;
|
||||
export let mode: "overview" | "map" | "list";
|
||||
interface Props {
|
||||
trail: Trail;
|
||||
mode: "overview" | "map" | "list";
|
||||
}
|
||||
|
||||
let openConfirmModal: () => void;
|
||||
let openListSelectModal: () => void;
|
||||
let openExportModal: () => void;
|
||||
let openShareModal: () => void;
|
||||
let { trail, mode }: Props = $props();
|
||||
|
||||
let lists: List[] = [];
|
||||
let confirmModal: ConfirmModal;
|
||||
let listSelectModal: ListSelectModal;
|
||||
let trailExportModal: TrailExportModal;
|
||||
let trailShareModal: TrailShareModal;
|
||||
|
||||
let lists: List[] = $state([]);
|
||||
|
||||
const allowEdit =
|
||||
trail.author == $currentUser?.id ||
|
||||
trail.expand?.trail_share_via_trail?.some((s) => s.permission == "edit");
|
||||
trail.expand?.trail_share_via_trail?.some(
|
||||
(s) => s.permission == "edit",
|
||||
);
|
||||
|
||||
const dropdownItems: DropdownItem[] = [
|
||||
mode == "overview"
|
||||
@@ -85,7 +91,7 @@
|
||||
-1,
|
||||
)
|
||||
).items;
|
||||
openListSelectModal();
|
||||
listSelectModal.openModal();
|
||||
} else if (item.value == "direction") {
|
||||
window
|
||||
.open(
|
||||
@@ -96,13 +102,13 @@
|
||||
} else if (item.value == "print") {
|
||||
goto(`/map/trail/${trail.id}/print`);
|
||||
} else if (item.value == "share") {
|
||||
openShareModal();
|
||||
trailShareModal.openModal();
|
||||
} else if (item.value == "download") {
|
||||
openExportModal();
|
||||
trailExportModal.openModal();
|
||||
} else if (item.value == "edit") {
|
||||
goto(`/trail/edit/${trail.id}`);
|
||||
} else if (item.value == "delete") {
|
||||
openConfirmModal();
|
||||
confirmModal.openModal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +170,7 @@
|
||||
}
|
||||
|
||||
async function deleteTrail() {
|
||||
trails_delete(trail).then(() => history.back());
|
||||
trails_delete(trail).then(() => goto('/trails'));
|
||||
}
|
||||
|
||||
async function handleListSelection(list: List) {
|
||||
@@ -196,30 +202,30 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dropdown
|
||||
items={dropdownItems}
|
||||
on:change={(e) => handleDropdownClick(e.detail)}
|
||||
let:toggleMenu={openDropdown}
|
||||
><button
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
||||
on:click={openDropdown}
|
||||
>
|
||||
<i class="fa fa-ellipsis-vertical"></i>
|
||||
</button></Dropdown
|
||||
>
|
||||
<Dropdown items={dropdownItems} onchange={(item) => handleDropdownClick(item)}
|
||||
>{#snippet children({ toggleMenu: openDropdown })}
|
||||
<button
|
||||
aria-label="Open dropdown"
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
||||
onclick={openDropdown}
|
||||
>
|
||||
<i class="fa fa-ellipsis-vertical"></i>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
|
||||
<ConfirmModal
|
||||
text={$_("delete-trail-confirm")}
|
||||
bind:openModal={openConfirmModal}
|
||||
on:confirm={deleteTrail}
|
||||
bind:this={confirmModal}
|
||||
onconfirm={deleteTrail}
|
||||
></ConfirmModal>
|
||||
<ListSelectModal
|
||||
{lists}
|
||||
bind:openModal={openListSelectModal}
|
||||
on:change={(e) => handleListSelection(e.detail)}
|
||||
bind:this={listSelectModal}
|
||||
onchange={(list) => handleListSelection(list)}
|
||||
></ListSelectModal>
|
||||
<TrailExportModal
|
||||
bind:openModal={openExportModal}
|
||||
on:export={(e) => exportTrail(e.detail)}
|
||||
bind:this={trailExportModal}
|
||||
onexport={(settings) => exportTrail(settings)}
|
||||
></TrailExportModal>
|
||||
<TrailShareModal {trail} bind:openShareModal></TrailShareModal>
|
||||
<TrailShareModal {trail} bind:this={trailShareModal}></TrailShareModal>
|
||||
|
||||
@@ -1,73 +1,87 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Select from "../base/select.svelte";
|
||||
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
export let title: string = $_("export");
|
||||
interface Props {
|
||||
title?: string;
|
||||
onexport?: (settings: typeof exportSettings) => void;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { title = $_("export"), onexport }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const fileFormats = [
|
||||
{ text: "GPX", value: "gpx" },
|
||||
{ text: "GeoJSON", value: "json" },
|
||||
];
|
||||
|
||||
const exportSettings = {
|
||||
const exportSettings: {
|
||||
fileFormat: "gpx" | "json";
|
||||
photos: boolean;
|
||||
summitLog: boolean;
|
||||
} = $state({
|
||||
fileFormat: "gpx",
|
||||
photos: false,
|
||||
summitLog: false,
|
||||
};
|
||||
});
|
||||
|
||||
function exportTrail() {
|
||||
dispatch("export", exportSettings);
|
||||
closeModal!();
|
||||
onexport?.(exportSettings);
|
||||
modal.closeModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal id="export-modal" {title} size="max-w-sm" bind:openModal bind:closeModal>
|
||||
<div slot="content">
|
||||
<div class="mb-3">
|
||||
<Select
|
||||
bind:value={exportSettings.fileFormat}
|
||||
items={fileFormats}
|
||||
label={$_("file-format")}
|
||||
></Select>
|
||||
<Modal id="export-modal" {title} size="max-w-sm" bind:this={modal}>
|
||||
{#snippet content()}
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<Select
|
||||
bind:value={exportSettings.fileFormat}
|
||||
items={fileFormats}
|
||||
label={$_("file-format")}
|
||||
></Select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-photos-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={exportSettings.photos}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-photos-checkbox" class="ms-2 text-sm"
|
||||
>{$_("photos")}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-summit-log-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={exportSettings.summitLog}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
|
||||
>{$_("summit-book")}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-photos-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={exportSettings.photos}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-photos-checkbox" class="ms-2 text-sm"
|
||||
>{$_("photos")}</label
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
onclick={exportTrail}
|
||||
name="save">{$_("export")}</button
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-summit-log-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={exportSettings.summitLog}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
|
||||
>{$_("summit-book")}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
on:click={exportTrail}
|
||||
name="save">{$_("export")}</button
|
||||
>
|
||||
</div></Modal
|
||||
{/snippet}</Modal
|
||||
>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { TrailFilter } from "$lib/models/trail";
|
||||
import { country_codes } from "$lib/util/country_code_util";
|
||||
import { formatDistance, formatElevation } from "$lib/util/format_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { slide } from "svelte/transition";
|
||||
import Datepicker from "../base/datepicker.svelte";
|
||||
@@ -17,18 +16,30 @@
|
||||
import UserSearch from "../user_search.svelte";
|
||||
import { pb } from "$lib/pocketbase";
|
||||
|
||||
export let categories: Category[];
|
||||
export let filterExpanded: boolean = true;
|
||||
export let filter: TrailFilter;
|
||||
export let showTrailSearch: boolean = true;
|
||||
export let showCitySearch: boolean = true;
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
filterExpanded?: boolean;
|
||||
filter: TrailFilter;
|
||||
showTrailSearch?: boolean;
|
||||
showCitySearch?: boolean;
|
||||
onupdate?: (filter: TrailFilter) => void;
|
||||
}
|
||||
|
||||
$: categorySelectItems = categories.map((c) => ({
|
||||
value: c.id,
|
||||
text: c.name,
|
||||
}));
|
||||
let {
|
||||
categories,
|
||||
filterExpanded = $bindable(true),
|
||||
filter = $bindable(),
|
||||
showTrailSearch = true,
|
||||
showCitySearch = true,
|
||||
onupdate,
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let categorySelectItems = $derived(
|
||||
categories.map((c) => ({
|
||||
value: c.id,
|
||||
text: c.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const radioGroupItems: RadioItem[] = [
|
||||
{ text: $_("completed"), value: "completed" },
|
||||
@@ -42,12 +53,12 @@
|
||||
{ text: $_("difficult"), value: "difficult" },
|
||||
];
|
||||
|
||||
let searchDropdownItems: SearchItem[] = [];
|
||||
let searchDropdownItems: SearchItem[] = $state([]);
|
||||
|
||||
let citySearchQuery: string = "";
|
||||
let citySearchQuery: string = $state("");
|
||||
|
||||
async function update() {
|
||||
dispatch("update", filter);
|
||||
onupdate?.(filter);
|
||||
}
|
||||
|
||||
function setCategoryFilter(categories: SelectItem[]) {
|
||||
@@ -134,13 +145,14 @@
|
||||
<div class="basis-full">
|
||||
<Search
|
||||
bind:value={filter.q}
|
||||
on:update={update}
|
||||
onupdate={update}
|
||||
placeholder="{$_('search-trails')}..."
|
||||
></Search>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Toggle filter"
|
||||
class="btn-icon md:hidden"
|
||||
on:click={() => (filterExpanded = !filterExpanded)}
|
||||
onclick={() => (filterExpanded = !filterExpanded)}
|
||||
><i class="fa fa-sliders"></i></button
|
||||
>
|
||||
</div>
|
||||
@@ -152,8 +164,10 @@
|
||||
<hr class="my-4 border-separator" />
|
||||
{/if}
|
||||
<MultiSelect
|
||||
on:change={(e) => setCategoryFilter(e.detail)}
|
||||
value={categorySelectItems.filter(i => filter.category.includes(i.value))}
|
||||
onchange={(value) => setCategoryFilter(value)}
|
||||
value={categorySelectItems.filter((i) =>
|
||||
filter.category.includes(i.value),
|
||||
)}
|
||||
label={$_("categories")}
|
||||
items={categorySelectItems}
|
||||
placeholder={`${$_("filter-categories")}...`}
|
||||
@@ -161,8 +175,8 @@
|
||||
<hr class="my-4 border-separator" />
|
||||
{#if pb.authStore.model}
|
||||
<UserSearch
|
||||
on:click={(e) => setAuthorFilter(e.detail)}
|
||||
on:clear={() => {
|
||||
onclick={(item) => setAuthorFilter(item)}
|
||||
onclear={() => {
|
||||
filter.author = "";
|
||||
update();
|
||||
}}
|
||||
@@ -175,7 +189,7 @@
|
||||
type="checkbox"
|
||||
checked={filter.public}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
on:change={setPublicFilter}
|
||||
onchange={setPublicFilter}
|
||||
/>
|
||||
<label for="public-checkbox" class="ms-2 text-sm"
|
||||
>{$_("public")}</label
|
||||
@@ -187,7 +201,7 @@
|
||||
type="checkbox"
|
||||
checked={filter.shared}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
on:change={setSharedFilter}
|
||||
onchange={setSharedFilter}
|
||||
/>
|
||||
<label for="shared-checkbox" class="ms-2 text-sm"
|
||||
>{$_("shared")}</label
|
||||
@@ -196,7 +210,7 @@
|
||||
<hr class="my-4 border-separator" />
|
||||
{/if}
|
||||
<MultiSelect
|
||||
on:change={(e) => setDifficultyFilter(e.detail)}
|
||||
onchange={(value) => setDifficultyFilter(value)}
|
||||
label={$_("difficulty")}
|
||||
items={difficultyItems}
|
||||
placeholder={`${$_("filter-difficulty")}...`}
|
||||
@@ -210,14 +224,14 @@
|
||||
placeholder="{$_('search-cities')}..."
|
||||
clearAfterSelect={false}
|
||||
bind:value={citySearchQuery}
|
||||
on:update={(e) => searchCities(e.detail)}
|
||||
on:click={(e) => handleSearchClick(e.detail)}
|
||||
onupdate={(q) => searchCities(q)}
|
||||
onclick={(item) => handleSearchClick(item)}
|
||||
></Search>
|
||||
</div>
|
||||
<Slider
|
||||
maxValue={10000}
|
||||
bind:currentValue={filter.near.radius}
|
||||
on:set={() => update()}
|
||||
onset={() => update()}
|
||||
></Slider>
|
||||
<p>
|
||||
<span class="text-gray-500 text-sm">{$_("radius")}:</span>
|
||||
@@ -231,7 +245,7 @@
|
||||
maxValue={filter.distanceLimit}
|
||||
bind:currentMin={filter.distanceMin}
|
||||
bind:currentMax={filter.distanceMax}
|
||||
on:set={() => update()}
|
||||
onset={() => update()}
|
||||
></DoubleSlider>
|
||||
<div class="flex justify-between">
|
||||
<span>{formatDistance(filter.distanceMin)}</span>
|
||||
@@ -249,7 +263,7 @@
|
||||
maxValue={filter.elevationGainLimit}
|
||||
bind:currentMin={filter.elevationGainMin}
|
||||
bind:currentMax={filter.elevationGainMax}
|
||||
on:set={() => update()}
|
||||
onset={() => update()}
|
||||
></DoubleSlider>
|
||||
<div class="flex justify-between">
|
||||
<span>{formatElevation(filter.elevationGainMin)}</span>
|
||||
@@ -268,7 +282,7 @@
|
||||
maxValue={filter.elevationLossLimit}
|
||||
bind:currentMin={filter.elevationLossMin}
|
||||
bind:currentMax={filter.elevationLossMax}
|
||||
on:set={() => update()}
|
||||
onset={() => update()}
|
||||
></DoubleSlider>
|
||||
<div class="flex justify-between">
|
||||
<span>{formatElevation(filter.elevationLossMin)}</span>
|
||||
@@ -287,13 +301,13 @@
|
||||
name="startDate"
|
||||
label={$_("after")}
|
||||
bind:value={filter.startDate}
|
||||
on:change={update}
|
||||
onchange={update}
|
||||
></Datepicker>
|
||||
<Datepicker
|
||||
name="endDate"
|
||||
label={$_("before")}
|
||||
bind:value={filter.endDate}
|
||||
on:change={update}
|
||||
onchange={update}
|
||||
></Datepicker>
|
||||
</div>
|
||||
|
||||
@@ -303,7 +317,7 @@
|
||||
name="completed"
|
||||
items={radioGroupItems}
|
||||
selected={2}
|
||||
on:change={(e) => setCompletedFilter(e.detail)}
|
||||
onchange={(item) => setCompletedFilter(item)}
|
||||
></RadioGroup>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -44,10 +44,14 @@
|
||||
import EmptyStateDescription from "../empty_states/empty_state_description.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export let trail: Trail;
|
||||
export let mode: "overview" | "map" | "list" = "map";
|
||||
export let markers: M.Marker[] = [];
|
||||
export let activeTab = 0;
|
||||
interface Props {
|
||||
trail: Trail;
|
||||
mode?: "overview" | "map" | "list";
|
||||
markers?: M.Marker[];
|
||||
activeTab?: number;
|
||||
}
|
||||
|
||||
let { trail, mode = "map", markers = [], activeTab = 0 }: Props = $props();
|
||||
|
||||
const tabs = [
|
||||
$_("description"),
|
||||
@@ -60,24 +64,19 @@
|
||||
const trailIsShared =
|
||||
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
|
||||
|
||||
$: thumbnail = trail.photos.length
|
||||
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
||||
: $theme === "light"
|
||||
? emptyStateTrailLight
|
||||
: emptyStateTrailDark;
|
||||
let gallery: PhotoGallery;
|
||||
|
||||
let openGallery: (idx?: number) => void;
|
||||
let newComment: Comment = $state({
|
||||
text: "",
|
||||
rating: 0,
|
||||
author: "",
|
||||
trail: trail.id ?? "",
|
||||
});
|
||||
|
||||
let newComment: Comment = new Comment("", 0, "", trail.id ?? "");
|
||||
|
||||
let commentsLoading: boolean = activeTab == 4;
|
||||
let commentCreateLoading: boolean = false;
|
||||
let commentsLoading: boolean = $state(activeTab == 4);
|
||||
let commentCreateLoading: boolean = $state(false);
|
||||
let commentDeleteLoading: boolean = false;
|
||||
|
||||
$: if (browser && activeTab == 4) {
|
||||
fetchComments();
|
||||
}
|
||||
|
||||
onMount(async () => {});
|
||||
|
||||
function openMarkerPopup(i: number) {
|
||||
@@ -144,6 +143,18 @@
|
||||
comments.set(newCommentList);
|
||||
commentDeleteLoading = false;
|
||||
}
|
||||
let thumbnail = $derived(
|
||||
trail.photos.length
|
||||
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
||||
: $theme === "light"
|
||||
? emptyStateTrailLight
|
||||
: emptyStateTrailDark,
|
||||
);
|
||||
$effect(() => {
|
||||
if (browser && activeTab == 4) {
|
||||
fetchComments();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -180,8 +191,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if trailIsShared}
|
||||
<ShareInfo type="trail" subject={trail}
|
||||
></ShareInfo>
|
||||
<ShareInfo type="trail" subject={trail}></ShareInfo>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -316,8 +326,8 @@
|
||||
<ul>
|
||||
{#each trail.expand?.waypoints ?? [] as waypoint, i}
|
||||
<li
|
||||
on:mouseenter={() => openMarkerPopup(i)}
|
||||
on:mouseleave={() => closeMarkerPopup(i)}
|
||||
onmouseenter={() => openMarkerPopup(i)}
|
||||
onmouseleave={() => closeMarkerPopup(i)}
|
||||
>
|
||||
<WaypointCard {waypoint}></WaypointCard>
|
||||
</li>
|
||||
@@ -339,14 +349,14 @@
|
||||
photos={trail.photos.map((p) =>
|
||||
getFileURL(trail, p),
|
||||
)}
|
||||
bind:open={openGallery}
|
||||
bind:this={gallery}
|
||||
></PhotoGallery>
|
||||
{#each trail.photos ?? [] as photo, i}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<img
|
||||
class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
|
||||
on:click={() => openGallery(i)}
|
||||
onclick={() => gallery.openGallery(i)}
|
||||
src={getFileURL(trail, photo)}
|
||||
alt=""
|
||||
/>
|
||||
@@ -389,7 +399,7 @@
|
||||
</div>
|
||||
<div class="flex justify-end mt-3">
|
||||
<Button
|
||||
on:click={createComment}
|
||||
onclick={createComment}
|
||||
loading={commentCreateLoading}
|
||||
secondary={true}
|
||||
disabled={commentCreateLoading ||
|
||||
@@ -415,9 +425,8 @@
|
||||
mode={comment.author == $currentUser?.id
|
||||
? "edit"
|
||||
: "show"}
|
||||
on:delete={(e) =>
|
||||
deleteComment(e.detail)}
|
||||
on:edit={(e) => editComment(e.detail)}
|
||||
ondelete={deleteComment}
|
||||
onedit={editComment}
|
||||
></CommentCard>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -435,7 +444,7 @@
|
||||
showStyleSwitcher={false}
|
||||
showFullscreen={true}
|
||||
mapOptions={{ attributionControl: false }}
|
||||
on:fullscreen={toggleMapFullScreen}
|
||||
onfullscreen={toggleMapFullScreen}
|
||||
bind:markers
|
||||
></MapWithElevationMaplibre>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Trail, TrailFilter } from "$lib/models/trail";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Pagination from "../base/pagination.svelte";
|
||||
import Select, { type SelectItem } from "../base/select.svelte";
|
||||
@@ -10,15 +9,30 @@
|
||||
import TrailTable from "./trail_table.svelte";
|
||||
import SkeletonCard from "../base/skeleton_card.svelte";
|
||||
import SkeletonListItem from "../base/skeleton_list_item.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let filter: TrailFilter | null = null;
|
||||
export let trails: Trail[];
|
||||
export let pagination: { page: number; totalPages: number } = {
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
export let loading: boolean = false;
|
||||
export let fullWidthCards: boolean = false;
|
||||
interface Props {
|
||||
filter?: TrailFilter | null;
|
||||
trails: Trail[];
|
||||
pagination?: { page: number; totalPages: number };
|
||||
loading?: boolean;
|
||||
fullWidthCards?: boolean;
|
||||
onupdate?: (filter: TrailFilter | null) => void;
|
||||
onpagination?: (page: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filter = $bindable(null),
|
||||
trails,
|
||||
pagination = {
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
loading = false,
|
||||
fullWidthCards = false,
|
||||
onupdate,
|
||||
onpagination,
|
||||
}: Props = $props();
|
||||
|
||||
const displayOptions: SelectItem[] = [
|
||||
{ text: $_("card", { values: { n: 2 } }), value: "cards" },
|
||||
@@ -26,9 +40,7 @@
|
||||
{ text: $_("table"), value: "table" },
|
||||
];
|
||||
|
||||
let selectedDisplayOption = displayOptions[0].value;
|
||||
|
||||
let dispatch = createEventDispatcher();
|
||||
let selectedDisplayOption = $state(displayOptions[0].value);
|
||||
|
||||
const sortOptions: SelectItem[] = [
|
||||
{ text: $_("name"), value: "name" },
|
||||
@@ -58,7 +70,7 @@
|
||||
(storedSortOrder as typeof filter.sortOrder | null) ??
|
||||
filter.sortOrder;
|
||||
}
|
||||
dispatch("update", filter);
|
||||
onupdate?.(filter);
|
||||
});
|
||||
|
||||
function setDisplayOption() {
|
||||
@@ -70,7 +82,7 @@
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("sort", filter.sort);
|
||||
dispatch("update", filter);
|
||||
onupdate?.(filter);
|
||||
}
|
||||
|
||||
function setSortOrder() {
|
||||
@@ -83,7 +95,7 @@
|
||||
filter.sortOrder = "+";
|
||||
}
|
||||
localStorage.setItem("sort_order", filter.sortOrder);
|
||||
dispatch("update", filter);
|
||||
onupdate?.(filter);
|
||||
}
|
||||
|
||||
function handleSortUpdate(sort: any) {
|
||||
@@ -106,7 +118,7 @@
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
on:pagination
|
||||
{onpagination}
|
||||
></Pagination>
|
||||
</div>
|
||||
{#if filter}
|
||||
@@ -117,13 +129,14 @@
|
||||
<Select
|
||||
bind:value={filter.sort}
|
||||
items={sortOptions}
|
||||
on:change={setSort}
|
||||
onchange={setSort}
|
||||
></Select>
|
||||
<button
|
||||
aria-label="Change sort order"
|
||||
id="sort-order-btn"
|
||||
class="btn-icon"
|
||||
class:rotated={filter.sortOrder == "-"}
|
||||
on:click={() => setSortOrder()}
|
||||
onclick={() => setSortOrder()}
|
||||
><i class="fa fa-arrow-up"></i></button
|
||||
>
|
||||
</div>
|
||||
@@ -136,7 +149,7 @@
|
||||
<Select
|
||||
bind:value={selectedDisplayOption}
|
||||
items={displayOptions}
|
||||
on:change={() => setDisplayOption()}
|
||||
onchange={() => setDisplayOption()}
|
||||
></Select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +183,7 @@
|
||||
(option) => option.value !== "elevation_loss",
|
||||
)}
|
||||
{filter}
|
||||
on:sort={(sort) => handleSortUpdate(sort)}
|
||||
onsort={handleSortUpdate}
|
||||
></TrailTable>
|
||||
{:else}
|
||||
{#each trails as trail}
|
||||
@@ -193,7 +206,7 @@
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
on:pagination
|
||||
{onpagination}
|
||||
></Pagination>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,15 +13,19 @@
|
||||
import { _ } from "svelte-i18n";
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
|
||||
export let trail: Trail;
|
||||
|
||||
export let showDescription: boolean = true;
|
||||
interface Props {
|
||||
trail: Trail;
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
$: thumbnail = trail.photos.length
|
||||
let { trail, showDescription = true }: Props = $props();
|
||||
|
||||
let thumbnail = $derived(trail.photos.length
|
||||
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
|
||||
: $theme === "light"
|
||||
? emptyStateTrailLight
|
||||
: emptyStateTrailDark;
|
||||
: emptyStateTrailDark);
|
||||
</script>
|
||||
|
||||
<li
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
import * as M from "maplibre-gl";
|
||||
|
||||
import { fromFile, toGeoJson } from "$lib/util/gpx_util";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
export let trailFile: File | undefined | null;
|
||||
export let trailData: string | undefined;
|
||||
export let label: string = "";
|
||||
import { onMount } from "svelte";
|
||||
interface Props {
|
||||
trailFile: File | undefined | null;
|
||||
trailData: string | undefined;
|
||||
label?: string;
|
||||
onchange: (data: string | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
trailFile = $bindable(),
|
||||
trailData = $bindable(),
|
||||
label = "",
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
let map: M.Map;
|
||||
let layer: M.LineLayerSpecification | undefined;
|
||||
let source: M.GeoJSONSource | undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: if (trailData !== undefined) {
|
||||
showTrailOnMap();
|
||||
} else {
|
||||
removeTrailFromMap();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!map) {
|
||||
await initMap();
|
||||
@@ -40,7 +42,7 @@
|
||||
trailFile = null;
|
||||
trailData = undefined;
|
||||
|
||||
dispatch("change", null);
|
||||
onchange?.(null);
|
||||
} else {
|
||||
document.getElementById("trail-input")!.click();
|
||||
}
|
||||
@@ -64,7 +66,7 @@
|
||||
|
||||
trailData = parseResult.gpxData;
|
||||
|
||||
dispatch("change", trailData);
|
||||
onchange?.(trailData);
|
||||
}
|
||||
|
||||
async function showTrailOnMap() {
|
||||
@@ -112,10 +114,14 @@
|
||||
map?.removeSource(source.id);
|
||||
source = undefined;
|
||||
}
|
||||
|
||||
trailData = undefined;
|
||||
trailFile = null;
|
||||
}
|
||||
$effect(() => {
|
||||
if (trailData !== undefined) {
|
||||
showTrailOnMap();
|
||||
} else {
|
||||
removeTrailFromMap();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -131,11 +137,11 @@
|
||||
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
||||
multiple={false}
|
||||
style="display: none;"
|
||||
on:change={() => handleTrailSelection()}
|
||||
onchange={() => handleTrailSelection()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={openTrailBrowser}
|
||||
onclick={openTrailBrowser}
|
||||
class="h-28 aspect-square rounded-xl !bg-background border border-input-border-focus hover:!bg-secondary-hover group"
|
||||
id="trail-picker-map"
|
||||
>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { TrailShare } from "$lib/models/trail_share";
|
||||
import type { User } from "$lib/models/user";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import {
|
||||
shares,
|
||||
trail_share_create,
|
||||
@@ -11,30 +9,31 @@
|
||||
trail_share_index,
|
||||
trail_share_update,
|
||||
} from "$lib/stores/trail_share_store";
|
||||
import { users_search } from "$lib/stores/user_store";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Button from "../base/button.svelte";
|
||||
import Search, { type SearchItem } from "../base/search.svelte";
|
||||
import type { SelectItem } from "../base/select.svelte";
|
||||
import Select from "../base/select.svelte";
|
||||
import UserSearch from "../user_search.svelte";
|
||||
|
||||
let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
export function openShareModal() {
|
||||
fetchShares();
|
||||
if (openModal) {
|
||||
openModal();
|
||||
}
|
||||
interface Props {
|
||||
trail: Trail;
|
||||
onsave?: () => void;
|
||||
}
|
||||
|
||||
export let trail: Trail;
|
||||
let { trail, onsave }: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let modal: Modal;
|
||||
|
||||
let copyButtonText = $_("copy-link");
|
||||
export function openModal() {
|
||||
openShareModalLocal();
|
||||
}
|
||||
async function openShareModalLocal() {
|
||||
modal.openModal();
|
||||
await fetchShares();
|
||||
}
|
||||
|
||||
let copyButtonText = $state($_("copy-link"));
|
||||
|
||||
let sharesLoading: boolean = false;
|
||||
|
||||
@@ -51,8 +50,8 @@
|
||||
}
|
||||
|
||||
function close() {
|
||||
dispatch("save");
|
||||
closeModal!();
|
||||
onsave?.();
|
||||
modal.closeModal!();
|
||||
}
|
||||
|
||||
async function shareTrail(item: SelectItem) {
|
||||
@@ -85,64 +84,68 @@
|
||||
id="share-modal"
|
||||
title={$_("share-this-trail")}
|
||||
size="max-w-sm overflow-visible"
|
||||
bind:openModal
|
||||
bind:closeModal
|
||||
bind:this={modal}
|
||||
>
|
||||
<div slot="content">
|
||||
<UserSearch includeSelf={false} on:click={(e) => shareTrail(e.detail)}
|
||||
></UserSearch>
|
||||
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
|
||||
{#snippet content()}
|
||||
<div>
|
||||
<UserSearch includeSelf={false} onclick={(item) => shareTrail(item)}
|
||||
></UserSearch>
|
||||
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
|
||||
|
||||
{#if $shares.length == 0}
|
||||
<p class="text-gray-500 text-center mt-2 text-sm">
|
||||
{$_("trail-not-shared")}
|
||||
</p>
|
||||
{:else}
|
||||
{#each $shares as share}
|
||||
{#if share.expand}
|
||||
<div class="flex items-center gap-x-2 p-2">
|
||||
<img
|
||||
class="rounded-full w-8 aspect-square mr-2"
|
||||
src={getFileURL(
|
||||
share.expand.user,
|
||||
share.expand.user.avatar,
|
||||
) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<p>{share.expand.user.username}</p>
|
||||
<span
|
||||
class="basis-full text-sm text-center text-gray-500"
|
||||
>{$_("can")}</span
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<Select
|
||||
bind:value={share.permission}
|
||||
items={permissionSelectItems}
|
||||
on:change={(e) =>
|
||||
updateSharePermission(share, e.detail)}
|
||||
></Select>
|
||||
{#if $shares.length == 0}
|
||||
<p class="text-gray-500 text-center mt-2 text-sm">
|
||||
{$_("trail-not-shared")}
|
||||
</p>
|
||||
{:else}
|
||||
{#each $shares as share}
|
||||
{#if share.expand}
|
||||
<div class="flex items-center gap-x-2 p-2">
|
||||
<img
|
||||
class="rounded-full w-8 aspect-square mr-2"
|
||||
src={getFileURL(
|
||||
share.expand.user,
|
||||
share.expand.user.avatar,
|
||||
) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<p>{share.expand.user.username}</p>
|
||||
<span
|
||||
class="basis-full text-sm text-center text-gray-500"
|
||||
>{$_("can")}</span
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<Select
|
||||
value={share.permission}
|
||||
items={permissionSelectItems}
|
||||
onchange={(value) =>
|
||||
updateSharePermission(share, value)}
|
||||
></Select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Delete share"
|
||||
class="btn-icon text-red-500"
|
||||
onclick={() => deleteShare(share)}
|
||||
><i class="fa fa-trash"></i></button
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-icon text-red-500"
|
||||
on:click={() => deleteShare(share)}
|
||||
><i class="fa fa-trash"></i></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div slot="footer" class="flex justify-between items-center gap-4">
|
||||
<Button
|
||||
secondary={true}
|
||||
disabled={copyButtonText == $_("link-copied")}
|
||||
on:click={copyURLToClipboard}
|
||||
>
|
||||
<i class="fa fa-link mr-2"></i>
|
||||
{copyButtonText}
|
||||
</Button>
|
||||
<button class="btn-primary" on:click={close}>{$_("close")}</button>
|
||||
</div></Modal
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<Button
|
||||
secondary={true}
|
||||
disabled={copyButtonText == $_("link-copied")}
|
||||
onclick={copyURLToClipboard}
|
||||
>
|
||||
<i class="fa fa-link mr-2"></i>
|
||||
{copyButtonText}
|
||||
</Button>
|
||||
<button class="btn-primary" onclick={close}>{$_("close")}</button>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
|
||||
@@ -8,15 +8,17 @@
|
||||
import { _ } from "svelte-i18n";
|
||||
import type { SelectItem } from "../base/select.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
|
||||
export let tableHeader: SelectItem[];
|
||||
export let trails: Trail[] | null = null;
|
||||
export let filter: TrailFilter | null = null;
|
||||
interface Props {
|
||||
tableHeader: SelectItem[];
|
||||
trails?: Trail[] | null;
|
||||
filter?: TrailFilter | null;
|
||||
onsort?: (value: any) => void
|
||||
}
|
||||
|
||||
let dispatch = createEventDispatcher();
|
||||
let { tableHeader, trails = null, filter = null, onsort }: Props = $props();
|
||||
|
||||
function getColumnWidth(columnValue: string): string {
|
||||
switch (columnValue) {
|
||||
@@ -48,7 +50,7 @@
|
||||
class="p-4 text-left text-sm font-medium {getColumnWidth(
|
||||
column.value,
|
||||
)}"
|
||||
on:click={() => dispatch("sort", column.value)}
|
||||
onclick={() => onsort?.(column.value)}
|
||||
>
|
||||
<div class="cursor-pointer">
|
||||
{column.text}
|
||||
@@ -69,7 +71,7 @@
|
||||
{#each trails as trail}
|
||||
<tr
|
||||
class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors"
|
||||
on:click={() => goto(`/trail/view/${trail.id}`)}
|
||||
onclick={() => goto(`/trail/view/${trail.id}`)}
|
||||
>
|
||||
<td
|
||||
class="flex justify-between items-center text-sm relative"
|
||||
|
||||
@@ -3,23 +3,33 @@
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import { users_search } from "$lib/stores/user_store";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
import Search, { type SearchItem } from "./base/search.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let label: string = "";
|
||||
export let value: string = "";
|
||||
export let includeSelf: boolean = true;
|
||||
export let clearAfterSelect: boolean = true;
|
||||
interface Props {
|
||||
label?: string;
|
||||
value?: string;
|
||||
includeSelf?: boolean;
|
||||
clearAfterSelect?: boolean;
|
||||
onclear?: () => void
|
||||
onclick?: (item: SearchItem) => void
|
||||
}
|
||||
|
||||
let searchItems: SearchItem[] = [];
|
||||
let {
|
||||
label = "",
|
||||
value = $bindable(""),
|
||||
includeSelf = true,
|
||||
clearAfterSelect = true,
|
||||
onclear,
|
||||
onclick
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let searchItems: SearchItem[] = $state([]);
|
||||
|
||||
async function updateUsers(q: string) {
|
||||
if (!q.length) {
|
||||
searchItems = [];
|
||||
dispatch("clear");
|
||||
onclear?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -39,29 +49,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e: CustomEvent<SearchItem>) {
|
||||
value = e.detail.value.username ?? value;
|
||||
function onClick(item: SearchItem) {
|
||||
value = item.value.username ?? value;
|
||||
|
||||
dispatch("click", e.detail);
|
||||
onclick?.(item);
|
||||
searchItems = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<Search
|
||||
on:update={(e) => updateUsers(e.detail)}
|
||||
on:click={(e) => onClick(e)}
|
||||
onupdate={(q) => updateUsers(q)}
|
||||
onclick={(item) => onClick(item)}
|
||||
placeholder={`${$_("username")}...`}
|
||||
items={searchItems}
|
||||
{clearAfterSelect}
|
||||
{label}
|
||||
bind:value
|
||||
>
|
||||
<img
|
||||
slot="item-header"
|
||||
let:item
|
||||
class="rounded-full w-8 aspect-square mr-2"
|
||||
src={getFileURL(item.value, item.value.avatar) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${item.value.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
{#snippet prepend({ item })}
|
||||
<img
|
||||
|
||||
|
||||
class="rounded-full w-8 aspect-square mr-2"
|
||||
src={getFileURL(item.value, item.value.avatar) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${item.value.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
{/snippet}
|
||||
</Search>
|
||||
|
||||
@@ -2,35 +2,42 @@
|
||||
import type { Waypoint } from "$lib/models/waypoint";
|
||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Dropdown from "../base/dropdown.svelte";
|
||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import PhotoGallery from "../photo_gallery.svelte";
|
||||
|
||||
export let waypoint: Waypoint;
|
||||
export let mode: "show" | "edit" = "show";
|
||||
|
||||
let openGallery: (idx?: number) => void;
|
||||
|
||||
let imgSrc: string[] = [];
|
||||
$: if (waypoint.photos?.length) {
|
||||
imgSrc = waypoint.photos
|
||||
.filter((_, i) => i < 3)
|
||||
.reverse()
|
||||
.map((p) => getFileURL(waypoint, p));
|
||||
} else if (waypoint._photos?.length && browser) {
|
||||
Promise.all(
|
||||
waypoint._photos
|
||||
.filter((_, i) => i < 3)
|
||||
.map(async (f) => {
|
||||
return await readAsDataURLAsync(f);
|
||||
}),
|
||||
).then((v) => {
|
||||
imgSrc = v;
|
||||
});
|
||||
} else {
|
||||
imgSrc = [];
|
||||
interface Props {
|
||||
waypoint: Waypoint;
|
||||
mode?: "show" | "edit";
|
||||
onchange?: (item: DropdownItem) => void;
|
||||
}
|
||||
|
||||
let { waypoint, mode = "show", onchange }: Props = $props();
|
||||
|
||||
let gallery: PhotoGallery;
|
||||
|
||||
let imgSrc: string[] = $state([]);
|
||||
$effect(() => {
|
||||
if (waypoint.photos?.length) {
|
||||
imgSrc = waypoint.photos
|
||||
.filter((_, i) => i < 3)
|
||||
.reverse()
|
||||
.map((p) => getFileURL(waypoint, p));
|
||||
} else if (waypoint._photos?.length && browser) {
|
||||
Promise.all(
|
||||
waypoint._photos
|
||||
.filter((_, i) => i < 3)
|
||||
.map(async (f) => {
|
||||
return await readAsDataURLAsync(f);
|
||||
}),
|
||||
).then((v) => {
|
||||
imgSrc = v;
|
||||
});
|
||||
} else {
|
||||
imgSrc = [];
|
||||
}
|
||||
});
|
||||
|
||||
const dropdownItems = [
|
||||
{ text: $_("edit"), value: "edit" },
|
||||
{ text: $_("delete"), value: "delete" },
|
||||
@@ -44,13 +51,13 @@
|
||||
{#if mode == "show"}
|
||||
<PhotoGallery
|
||||
photos={waypoint.photos.map((p) => getFileURL(waypoint, p))}
|
||||
bind:open={openGallery}
|
||||
bind:this={gallery}
|
||||
></PhotoGallery>
|
||||
{/if}
|
||||
<button
|
||||
class="relative basis-16 aspect-square ml-2 mb-3 shrink-0"
|
||||
type="button"
|
||||
on:click={mode == "show" ? () => openGallery() : undefined}
|
||||
onclick={mode == "show" ? () => gallery.openGallery() : undefined}
|
||||
>
|
||||
{#each imgSrc as img, i}
|
||||
<img
|
||||
@@ -69,7 +76,7 @@
|
||||
<i class="fa fa-{waypoint.icon} mr-2"></i>{waypoint.name}
|
||||
</h5>
|
||||
{#if mode == "edit"}
|
||||
<Dropdown items={dropdownItems} on:change></Dropdown>
|
||||
<Dropdown items={dropdownItems} {onchange}></Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { type Snippet } from "svelte";
|
||||
|
||||
import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema";
|
||||
import { convertDMSToDD } from "$lib/models/gpx/utils";
|
||||
@@ -17,11 +17,20 @@
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import Textarea from "../base/textarea.svelte";
|
||||
import PhotoPicker from "../trail/photo_picker.svelte";
|
||||
import type { Waypoint } from "$lib/models/waypoint";
|
||||
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
interface Props {
|
||||
children?: Snippet<[any]>;
|
||||
onsave?: (waypoint: Waypoint) => void
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { children, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const ClientWaypointCreateSchema = WaypointCreateSchema.extend({
|
||||
_photos: z.array(z.instanceof(File)).optional(),
|
||||
@@ -33,9 +42,9 @@
|
||||
initialValues: $waypoint,
|
||||
extend: validator({ schema: ClientWaypointCreateSchema }),
|
||||
onSubmit: async (form) => {
|
||||
dispatch("save", form);
|
||||
onsave?.(form);
|
||||
|
||||
closeModal!();
|
||||
modal.closeModal!();
|
||||
},
|
||||
transform: (values: unknown) => {
|
||||
const v = values as any;
|
||||
@@ -47,14 +56,25 @@
|
||||
},
|
||||
});
|
||||
|
||||
$: setFields(cloneDeep($waypoint));
|
||||
$effect(() => {
|
||||
setFields(cloneDeep($waypoint));
|
||||
});
|
||||
|
||||
$: filteredIcons =
|
||||
let filteredIcons = $derived(
|
||||
($data.icon?.length ?? 0) > 2
|
||||
? icons
|
||||
.filter((i) => i.replaceAll("-", " ").includes($data.icon?.toLowerCase() ?? ""))
|
||||
.map((i) => ({ text: i.replaceAll("-", " "), value: i, icon: i }))
|
||||
: [];
|
||||
.filter((i) =>
|
||||
i
|
||||
.replaceAll("-", " ")
|
||||
.includes($data.icon?.toLowerCase() ?? ""),
|
||||
)
|
||||
.map((i) => ({
|
||||
text: i.replaceAll("-", " "),
|
||||
value: i,
|
||||
icon: i,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
|
||||
function getCoordinatesFromPhoto(src: string) {
|
||||
EXIF.getData({ src: src }, function (p) {
|
||||
@@ -75,69 +95,79 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const children_render = $derived(children);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="waypoint-modal"
|
||||
title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")}
|
||||
let:openModal
|
||||
bind:openModal
|
||||
bind:closeModal
|
||||
bind:this={modal}
|
||||
>
|
||||
<slot {openModal} />
|
||||
<form
|
||||
id="waypoint-form"
|
||||
slot="content"
|
||||
class="modal-content space-y-4"
|
||||
use:form
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="basis-2/3">
|
||||
<TextField name="name" label={$_("name")} error={$errors.name}
|
||||
></TextField>
|
||||
{#snippet children({ openModal })}
|
||||
{@render children_render?.({ openModal })}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<form id="waypoint-form" class="modal-content space-y-4" use:form>
|
||||
<div class="flex gap-4">
|
||||
<div class="basis-2/3">
|
||||
<TextField
|
||||
name="name"
|
||||
label={$_("name")}
|
||||
error={$errors.name}
|
||||
></TextField>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
name="icon"
|
||||
icon={$data.icon}
|
||||
bind:value={$data.icon}
|
||||
items={filteredIcons}
|
||||
label={$_("icon")}
|
||||
></Combobox>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
name="icon"
|
||||
icon={$data.icon}
|
||||
bind:value={$data.icon}
|
||||
items={filteredIcons}
|
||||
label={$_("icon")}
|
||||
></Combobox>
|
||||
<Textarea
|
||||
name="description"
|
||||
label={$_("description")}
|
||||
error={$errors.description}
|
||||
></Textarea>
|
||||
<div class="flex gap-4">
|
||||
<TextField name="lat" label={$_("latitude")} error={$errors.lat}
|
||||
></TextField>
|
||||
<TextField
|
||||
name="lon"
|
||||
label={$_("longitude")}
|
||||
error={$errors.lat}
|
||||
></TextField>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="waypoint-photo-input"
|
||||
class="text-sm font-medium pb-1"
|
||||
>
|
||||
{$_("photos")}
|
||||
</label>
|
||||
<PhotoPicker
|
||||
id="waypoint-photo-input"
|
||||
parent={$data}
|
||||
onexif={(src) => getCoordinatesFromPhoto(src)}
|
||||
bind:photos={$data.photos}
|
||||
bind:photoFiles={$data._photos}
|
||||
showThumbnailControls={false}
|
||||
showExifControls={true}
|
||||
></PhotoPicker>
|
||||
</div>
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button class="btn-primary" type="submit" form="waypoint-form"
|
||||
>{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
name="description"
|
||||
label={$_("description")}
|
||||
error={$errors.description}
|
||||
></Textarea>
|
||||
<div class="flex gap-4">
|
||||
<TextField name="lat" label={$_("latitude")} error={$errors.lat}
|
||||
></TextField>
|
||||
<TextField name="lon" label={$_("longitude")} error={$errors.lat}
|
||||
></TextField>
|
||||
</div>
|
||||
<div>
|
||||
<label for="waypoint-photo-input" class="text-sm font-medium pb-1">
|
||||
{$_("photos")}
|
||||
</label>
|
||||
<PhotoPicker
|
||||
id="waypoint-photo-input"
|
||||
parent={$data}
|
||||
on:exif={(e) => getCoordinatesFromPhoto(e.detail)}
|
||||
bind:photos={$data.photos}
|
||||
bind:photoFiles={$data._photos}
|
||||
showThumbnailControls={false}
|
||||
showExifControls={true}
|
||||
></PhotoPicker>
|
||||
</div>
|
||||
</form>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button class="btn-primary" type="submit" form="waypoint-form"
|
||||
>{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user