feat: add plugin system (#1034)
* feat: add plugin system * fix db docker build * fix hammerhead readme, add strava subscription news to docs * fixes and sdk improvements * fix: reduce Meilisearch load, debounce federation sync (#1012) * optimize meili trail index * several fixes --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032) Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-version: 5.56.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Release v0.19.2 (#1035) * chore: release v0.19.2 * add changelog --------- Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com> Co-authored-by: Christian Beutel <> * speed up plugin sync and several small fixes * concepts for security improvements and process stability * improve concept * security concept implemented * remove insecure TLS * worker concept implemented * fixes and cleanup * fixes * docu * mermaid, namings * WASM plugin host improvements, plugin logging * fix db migration * Improve plugin config and category mapping UI * fixes * further fixes * remove manual test sync * fix db migration and strava mapping * type added, UI improvements * fix plugin card toggle clickable area * optimize synch status card layout * plugin type 'trails' instead of 'integration' * session auth validation in UI * fix komoot date and waypoints * improve category mapping * fix send to hammerhead: trail name * plugin setup error handling improved * fix review findings * re-mapping added * rename remote_category --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
This commit is contained in:
@@ -6,11 +6,14 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
items?: SelectItem[];
|
||||
value?: any;
|
||||
label?: string;
|
||||
error?: string | string[] | null;
|
||||
disabled?: boolean;
|
||||
onchange?: (value: any) => void
|
||||
}
|
||||
@@ -20,6 +23,7 @@
|
||||
items = [],
|
||||
value = $bindable(items.at(0)?.value ?? ""),
|
||||
label = "",
|
||||
error = "",
|
||||
disabled = false,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
@@ -38,6 +42,8 @@
|
||||
<select
|
||||
{name}
|
||||
class="block bg-input-background h-10 px-4 border-r-8 border-transparent outline-1 outline-input-border rounded-md focus:outline-input-border-focus transition-colors"
|
||||
class:outline-red-400={(error?.length ?? 0) > 0}
|
||||
class:bg-input-background-error={(error?.length ?? 0) > 0}
|
||||
class:text-gray-500={disabled}
|
||||
{disabled}
|
||||
bind:value
|
||||
@@ -47,4 +53,10 @@
|
||||
<option value={item.value}>{item.text}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if error}
|
||||
<span class="select-error text-xs text-red-400">
|
||||
{error instanceof Array ? $_(error[0]) : error}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
257
web/src/lib/components/base/single_select.svelte
Normal file
257
web/src/lib/components/base/single_select.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script module lang="ts">
|
||||
export type SingleSelectItem = {
|
||||
text: string;
|
||||
value: any;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
items?: SingleSelectItem[];
|
||||
value?: any;
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
onchange?: (value: any) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
name = "",
|
||||
items = [],
|
||||
value = $bindable(""),
|
||||
label = "",
|
||||
ariaLabel = "",
|
||||
placeholder = "",
|
||||
disabled = false,
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let trigger: HTMLButtonElement | undefined = $state();
|
||||
let menu: HTMLUListElement | undefined = $state();
|
||||
let menuStyle = $state("");
|
||||
let activeIndex = $state(-1);
|
||||
|
||||
let selectedItem = $derived(items.find((item) => item.value === value));
|
||||
let selectedIndex = $derived(items.findIndex((item) => item.value === value));
|
||||
|
||||
async function positionMenu() {
|
||||
await tick();
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const viewportPadding = 12;
|
||||
const maxHeight = 256;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding;
|
||||
const spaceAbove = rect.top - viewportPadding;
|
||||
const openAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const availableHeight = Math.max(
|
||||
80,
|
||||
Math.min(maxHeight, openAbove ? spaceAbove - gap : spaceBelow - gap),
|
||||
);
|
||||
const top = openAbove ? rect.top - gap - availableHeight : rect.bottom + gap;
|
||||
|
||||
menuStyle = [
|
||||
`left: ${rect.left}px`,
|
||||
`top: ${top}px`,
|
||||
`width: ${rect.width}px`,
|
||||
`max-height: ${availableHeight}px`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
async function scrollActiveItemIntoView() {
|
||||
await tick();
|
||||
menu?.querySelector<HTMLElement>(`[data-single-select-index="${activeIndex}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function selectItem(item: SingleSelectItem) {
|
||||
if (disabled) return;
|
||||
value = item.value;
|
||||
open = false;
|
||||
onchange?.(item.value);
|
||||
}
|
||||
|
||||
async function openMenu(index = selectedIndex >= 0 ? selectedIndex : 0) {
|
||||
if (disabled) return;
|
||||
activeIndex = items.length ? Math.max(0, Math.min(index, items.length - 1)) : -1;
|
||||
open = true;
|
||||
await positionMenu();
|
||||
await scrollActiveItemIntoView();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (disabled) return;
|
||||
if (open) {
|
||||
closeMenu();
|
||||
} else {
|
||||
await openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveIndex(index: number) {
|
||||
if (!items.length) return;
|
||||
activeIndex = (index + items.length) % items.length;
|
||||
await scrollActiveItemIntoView();
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
await openMenu(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
} else {
|
||||
await setActiveIndex(activeIndex + 1);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
await openMenu(selectedIndex >= 0 ? selectedIndex : items.length - 1);
|
||||
} else {
|
||||
await setActiveIndex(activeIndex - 1);
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
if (!open) return;
|
||||
event.preventDefault();
|
||||
await setActiveIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
if (!open) return;
|
||||
event.preventDefault();
|
||||
await setActiveIndex(items.length - 1);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
await openMenu();
|
||||
} else if (activeIndex >= 0 && items[activeIndex]) {
|
||||
selectItem(items[activeIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
if (!open) return;
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
function handleDocumentMouseDown(event: MouseEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || trigger?.contains(target) || menu?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleDocumentKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocumentScroll(event: Event) {
|
||||
if (!open) return;
|
||||
const target = event.target as Node | null;
|
||||
if (target && menu?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!open) return;
|
||||
positionMenu();
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleDocumentMouseDown);
|
||||
document.addEventListener("keydown", handleDocumentKeyDown);
|
||||
document.addEventListener("scroll", handleDocumentScroll, true);
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentMouseDown);
|
||||
document.removeEventListener("keydown", handleDocumentKeyDown);
|
||||
document.removeEventListener("scroll", handleDocumentScroll, true);
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
{#if label.length}
|
||||
<label for={name} class="text-sm font-medium pb-1">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={trigger}
|
||||
id={name}
|
||||
type="button"
|
||||
aria-label={ariaLabel || label || name}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
class="relative flex h-10 w-full items-center rounded-md border border-input-border bg-input-background px-3 text-left transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
|
||||
class:opacity-75={disabled}
|
||||
class:cursor-default={disabled}
|
||||
disabled={disabled}
|
||||
onclick={toggle}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
{#if selectedItem}
|
||||
<span class="truncate">{selectedItem.text}</span>
|
||||
{:else}
|
||||
<span class="truncate text-gray-400">{placeholder}</span>
|
||||
{/if}
|
||||
<i
|
||||
class="fa fa-caret-down absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 transition-transform"
|
||||
class:rotate-180={open}
|
||||
></i>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<ul
|
||||
bind:this={menu}
|
||||
role="listbox"
|
||||
class="fixed z-[1001] overflow-y-auto rounded-md border border-input-border bg-menu-background shadow-lg"
|
||||
style={menuStyle}
|
||||
>
|
||||
{#each items as item, i}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={item.value === value}
|
||||
tabindex="-1"
|
||||
class="flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus"
|
||||
class:bg-menu-item-background-focus={activeIndex === i}
|
||||
data-single-select-index={i}
|
||||
onmousedown={(event) => {
|
||||
event.preventDefault();
|
||||
selectItem(item);
|
||||
}}
|
||||
>
|
||||
<span class="truncate">{item.text}</span>
|
||||
{#if item.value === value}
|
||||
<i class="fa fa-check ml-3"></i>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
error?: string | string[] | null;
|
||||
icon?: string;
|
||||
extraClasses?: string;
|
||||
type?: "text" | "password" | "search";
|
||||
type?: "text" | "password" | "search" | "url";
|
||||
autocomplete?: "on" | "off";
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
text: string;
|
||||
action?: string;
|
||||
deny?: string;
|
||||
alternative?: string;
|
||||
id?: string;
|
||||
onconfirm?: () => void
|
||||
oncancel?: () => void
|
||||
onalternative?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -17,9 +19,11 @@
|
||||
text,
|
||||
action = "delete",
|
||||
deny ="cancel",
|
||||
alternative,
|
||||
id = "confirm-modal",
|
||||
onconfirm,
|
||||
oncancel
|
||||
oncancel,
|
||||
onalternative
|
||||
}: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
@@ -29,13 +33,18 @@
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
oncancel?.();
|
||||
modal.closeModal!();
|
||||
oncancel?.();
|
||||
}
|
||||
|
||||
function alternativeAction() {
|
||||
modal.closeModal!();
|
||||
onalternative?.();
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
onconfirm?.()
|
||||
modal.closeModal!();
|
||||
onconfirm?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,6 +57,11 @@
|
||||
<button class="btn-secondary" onclick={cancel}
|
||||
>{$_(deny)}</button
|
||||
>
|
||||
{#if alternative}
|
||||
<button class="btn-secondary" type="button" onclick={alternativeAction}
|
||||
>{$_(alternative)}</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
id="confirm"
|
||||
class={action === "delete" ? "btn-danger" : "btn-primary"}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { HammerheadSchema } from "$lib/models/api/integration_schema";
|
||||
import type {
|
||||
Integration,
|
||||
HammerheadIntegration,
|
||||
} from "$lib/models/integration";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
integration?: Integration;
|
||||
onsave?: (hammerheadIntegration: HammerheadIntegration) => void;
|
||||
}
|
||||
|
||||
let { integration, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
errors.set({});
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const getInitialFormValues = () => ({
|
||||
email: integration?.hammerhead?.email ?? "",
|
||||
password: integration?.hammerhead?.password ?? "",
|
||||
completed: integration?.hammerhead?.completed ?? true,
|
||||
planned: integration?.hammerhead?.planned ?? true,
|
||||
active: integration?.hammerhead?.active ?? false,
|
||||
after: integration?.hammerhead?.after,
|
||||
merge: integration?.hammerhead?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
data: formData,
|
||||
} = createForm({
|
||||
initialValues: getInitialFormValues(),
|
||||
extend: validator({
|
||||
schema: HammerheadSchema,
|
||||
}),
|
||||
onSubmit: async (form) => {
|
||||
form.active = integration?.hammerhead?.active ?? form.active;
|
||||
onsave?.(form);
|
||||
modal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
function clearAfterDate() {
|
||||
($formData as any).after = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="hammerhead-settings-modal"
|
||||
size="md:max-w-lg"
|
||||
title={"Hammerhead " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<form id="hammerhead-settings-form" class="space-y-2" use:form>
|
||||
<TextField
|
||||
label={$_("email")}
|
||||
placeholder="user@example.com"
|
||||
name="email"
|
||||
error={$errors.email}
|
||||
></TextField>
|
||||
<TextField
|
||||
label={$_("password")}
|
||||
placeholder={integration?.hammerhead ? `(${$_("unchanged")})` : ""}
|
||||
name="password"
|
||||
type="password"
|
||||
error={$errors.password}
|
||||
></TextField>
|
||||
<div class="flex flex-wrap gap-x-4">
|
||||
<Toggle name="planned" label={$_("planned-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
<Toggle
|
||||
name="completed"
|
||||
label={$_("completed-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-gray-500 max-w-lg pt-4 pb-1 border-t border-input-border"
|
||||
>
|
||||
{$_("hammerhead-integration-after-date-hint")}
|
||||
</p>
|
||||
<div class="flex items-end relative gap-x-2">
|
||||
<Datepicker
|
||||
error={$errors.after}
|
||||
label={$_("ignore-trails-before-date")}
|
||||
bind:value={$formData.after}
|
||||
></Datepicker>
|
||||
<button
|
||||
class="btn-icon mb-[10px]"
|
||||
type="button"
|
||||
onclick={clearAfterDate}
|
||||
aria-label="Clear 'after' date"
|
||||
><i class="fa fa-close"></i></button
|
||||
>
|
||||
</div>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="hammerhead-settings-form"
|
||||
type="submit"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
ontoggle: (value: boolean) => void;
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
img: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onclick,
|
||||
ontoggle,
|
||||
active = $bindable(),
|
||||
disabled,
|
||||
img,
|
||||
title,
|
||||
description,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
<div class="border border-input-border rounded-lg p-4 space-y-4 flex flex-col justify-between">
|
||||
<img class="h-20" src={img} alt="integration logo"/>
|
||||
<div>
|
||||
<h5 class="text-xl font-semibold">{title}</h5>
|
||||
<p class="text-sm text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="btn-secondary" {onclick}
|
||||
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
|
||||
>
|
||||
<Toggle bind:value={active} onchange={ontoggle} {disabled}></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,130 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { KomootSchema } from "$lib/models/api/integration_schema";
|
||||
import type {
|
||||
Integration,
|
||||
KomootIntegration,
|
||||
} from "$lib/models/integration";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
integration?: Integration;
|
||||
onsave?: (komootIntegration: KomootIntegration) => void;
|
||||
}
|
||||
|
||||
let { integration, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
const privacySelectItems: SelectItem[] = [
|
||||
{
|
||||
text: $_("keep-original"),
|
||||
value: "original",
|
||||
},
|
||||
{ text: $_("apply-user-settings"), value: "settings" },
|
||||
];
|
||||
|
||||
export function openModal() {
|
||||
errors.set({});
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const getInitialFormValues = () => ({
|
||||
email: integration?.komoot?.email ?? "",
|
||||
password: integration?.komoot?.password ?? "",
|
||||
completed: integration?.komoot?.completed ?? true,
|
||||
planned: integration?.komoot?.planned ?? true,
|
||||
active: integration?.komoot?.active ?? false,
|
||||
privacy: integration?.komoot?.privacy ?? "original",
|
||||
merge: integration?.komoot?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
data: d,
|
||||
} = createForm({
|
||||
initialValues: getInitialFormValues(),
|
||||
extend: validator({
|
||||
schema: KomootSchema,
|
||||
}),
|
||||
onSubmit: async (form) => {
|
||||
form.active = integration?.komoot?.active ?? form.active;
|
||||
onsave?.(form);
|
||||
modal.closeModal();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="komoot-settings-modal"
|
||||
size="md:max-w-lg"
|
||||
title={"komoot " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<form id="komoot-settings-form" class="space-y-2" use:form>
|
||||
<TextField
|
||||
label={$_("email")}
|
||||
placeholder="user@example.com"
|
||||
name="email"
|
||||
error={$errors.email}
|
||||
></TextField>
|
||||
<TextField
|
||||
label={$_("password")}
|
||||
placeholder={integration?.komoot ? `(${$_("unchanged")})` : ""}
|
||||
name="password"
|
||||
type="password"
|
||||
error={$errors.password}
|
||||
></TextField>
|
||||
<div class="flex flex-wrap gap-x-4">
|
||||
<Toggle
|
||||
name="planned"
|
||||
label={$_("planned-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
<Toggle
|
||||
name="completed"
|
||||
label={$_("completed-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label={$_("privacy")}
|
||||
items={privacySelectItems}
|
||||
name="privacy"
|
||||
></Select>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{#if $d.privacy == "original"}
|
||||
{$_("integration-privacy-hint-original")}
|
||||
{:else}
|
||||
{$_("integration-privacy-hint-user")}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="komoot-settings-form"
|
||||
type="submit"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
@@ -1,154 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||
import Select from "$lib/components/base/select.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { StravaSchema } from "$lib/models/api/integration_schema";
|
||||
import type {
|
||||
Integration,
|
||||
StravaIntegration,
|
||||
} from "$lib/models/integration";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
integration?: Integration;
|
||||
onsave?: (stravaIntegration: StravaIntegration) => void;
|
||||
}
|
||||
|
||||
let { integration, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
const privacySelectItems: SelectItem[] = [
|
||||
{
|
||||
text: $_("keep-original"),
|
||||
value: "original",
|
||||
},
|
||||
{ text: $_("apply-user-settings"), value: "settings" },
|
||||
];
|
||||
|
||||
export function openModal() {
|
||||
errors.set({});
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const getInitialFormValues = () => ({
|
||||
clientId: integration?.strava?.clientId ?? "",
|
||||
clientSecret: integration?.strava?.clientSecret ?? "",
|
||||
routes: integration?.strava?.routes ?? true,
|
||||
activities: integration?.strava?.activities ?? true,
|
||||
active: integration?.strava?.active ?? false,
|
||||
after: integration?.strava?.after,
|
||||
privacy: integration?.strava?.privacy ?? "original",
|
||||
merge: integration?.strava?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
data: formData,
|
||||
} = createForm({
|
||||
initialValues: getInitialFormValues(),
|
||||
extend: validator({
|
||||
schema: StravaSchema,
|
||||
}),
|
||||
onSubmit: async (form) => {
|
||||
form.active = integration?.strava?.active ?? form.active;
|
||||
onsave?.(form);
|
||||
modal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
function clearAfterDate() {
|
||||
($formData as any).after = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="strava-settings-modal"
|
||||
size="md:min-w-lg"
|
||||
title={"Strava " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<form id="strava-settings-form" class="space-y-2" use:form>
|
||||
<TextField
|
||||
label="Client-ID"
|
||||
placeholder="000000"
|
||||
name="clientId"
|
||||
error={$errors.clientId}
|
||||
></TextField>
|
||||
<TextField
|
||||
label="Client Secret"
|
||||
placeholder={integration?.strava
|
||||
? `(${$_("unchanged")})`
|
||||
: "de8b3789bd7116d..."}
|
||||
name="clientSecret"
|
||||
type="password"
|
||||
error={$errors.clientSecret}
|
||||
></TextField>
|
||||
<div class="flex gap-x-4">
|
||||
<Toggle name="routes" label={$_("route", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
<Toggle
|
||||
name="activities"
|
||||
label={$_("activity", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label={$_("privacy")}
|
||||
items={privacySelectItems}
|
||||
name="privacy"
|
||||
></Select>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{#if $formData.privacy == "original"}
|
||||
{$_("integration-privacy-hint-original")}
|
||||
{:else}
|
||||
{$_("integration-privacy-hint-user")}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="flex items-end relative gap-x-2 pt-2 border-t border-input-border">
|
||||
<Datepicker
|
||||
error={$errors.after}
|
||||
label={$_("ignore-trails-before-date")}
|
||||
bind:value={$formData.after}
|
||||
></Datepicker>
|
||||
<button
|
||||
class="btn-icon mb-[10px]"
|
||||
type="button"
|
||||
onclick={clearAfterDate}
|
||||
aria-label="Clear 'after' date"
|
||||
><i class="fa fa-close"></i></button
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-gray-500 max-w-lg"
|
||||
>
|
||||
{$_("strava-integration-after-date-hint")}
|
||||
</p>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="strava-settings-form"
|
||||
type="submit"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
111
web/src/lib/components/settings/plugins/plugin_card.svelte
Normal file
111
web/src/lib/components/settings/plugins/plugin_card.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
ontoggle: (value: boolean) => void;
|
||||
active: boolean;
|
||||
toggleDisabled?: boolean;
|
||||
settingsDisabled?: boolean;
|
||||
img?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
lastSyncAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onclick,
|
||||
ontoggle,
|
||||
active = $bindable(),
|
||||
toggleDisabled = false,
|
||||
settingsDisabled = false,
|
||||
img,
|
||||
title,
|
||||
description = "",
|
||||
lastSyncAt = "",
|
||||
error = "",
|
||||
}: Props = $props();
|
||||
|
||||
function formatLastSyncAt(value: string) {
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-xl border border-input-border p-4 transition-colors hover:bg-secondary-hover md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-6">
|
||||
{#if img}
|
||||
<img
|
||||
class="h-16 w-24 shrink-0 object-contain object-left"
|
||||
src={img}
|
||||
alt="plugin logo"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-16 w-24 shrink-0 items-center justify-center rounded border border-input-border bg-input-background text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{title.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<h5 class="truncate text-lg font-semibold">{title}</h5>
|
||||
{#if description}
|
||||
<p class="line-clamp-2 text-sm text-gray-500">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col gap-2 md:items-start">
|
||||
<div class="flex items-center justify-between gap-4 md:justify-end">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
class:btn-disabled={settingsDisabled}
|
||||
{onclick}
|
||||
disabled={settingsDisabled}
|
||||
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
|
||||
>
|
||||
<div class="plugin-card-toggle">
|
||||
<Toggle bind:value={active} onchange={ontoggle} disabled={toggleDisabled}></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-5 max-w-56 text-xs text-gray-500">
|
||||
{#if lastSyncAt}
|
||||
<span
|
||||
class:text-red-400={error}
|
||||
class="inline-flex min-w-0 items-center gap-2"
|
||||
title={error
|
||||
? $_("plugin-setup-error")
|
||||
: `${$_("last-sync")}: ${formatLastSyncAt(lastSyncAt)}`}
|
||||
>
|
||||
{#if error}
|
||||
<i
|
||||
class="fa fa-triangle-exclamation shrink-0 text-[0.8rem]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
{:else}
|
||||
<i
|
||||
class="fa fa-clock shrink-0 text-[0.8rem]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
{/if}
|
||||
<span class="truncate">{formatLastSyncAt(lastSyncAt)}</span>
|
||||
</span>
|
||||
{:else if error}
|
||||
<span class="inline-flex items-center gap-2 text-red-400" title={$_("plugin-setup-error")}>
|
||||
<i
|
||||
class="fa fa-triangle-exclamation shrink-0 text-[0.8rem]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="truncate">{$_("plugin-setup-error")}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,753 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import Select, { type SelectItem } from "$lib/components/base/select.svelte";
|
||||
import SingleSelect from "$lib/components/base/single_select.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import PluginMergeSettings from "$lib/components/settings/plugins/plugin_merge_settings.svelte";
|
||||
import type { Category } from "$lib/models/category";
|
||||
import type { PluginInstance } from "$lib/models/plugin_instance";
|
||||
import type { ConfigField, PluginProvider } from "$lib/models/plugin_provider";
|
||||
import { plugin_auth_validate, plugin_oauth_start } from "$lib/stores/plugin_instance_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { translatePluginAPIError } from "$lib/util/plugin_error_i18n";
|
||||
import {
|
||||
configFieldDescription,
|
||||
configFieldLabel,
|
||||
configFieldOptionLabel,
|
||||
pluginTitle as localizedPluginTitle,
|
||||
providerCategoryLabel,
|
||||
} from "$lib/util/plugin_i18n";
|
||||
import { tick } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
interface CategoryMappingRow {
|
||||
providerCategory: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
type PluginInstanceForm = Partial<PluginInstance> & {
|
||||
mappingChanged?: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
plugin: PluginProvider;
|
||||
categories?: Category[];
|
||||
instance?: PluginInstance;
|
||||
onbeforecategorymappingsave?: (instance: PluginInstanceForm) => Promise<boolean> | boolean;
|
||||
onsave?: (instance: Partial<PluginInstance>) => Promise<PluginInstance | void> | PluginInstance | void;
|
||||
}
|
||||
|
||||
let { plugin, categories = [], instance, onbeforecategorymappingsave, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
let auth: Record<string, string> = $state(initialAuth());
|
||||
let planned = $state(true);
|
||||
let completed = $state(true);
|
||||
let mergeEnabled = $state(false);
|
||||
let privacy = $state("original");
|
||||
let categoryMappingRows: CategoryMappingRow[] = $state(initialCategoryMappingRows());
|
||||
let categoryMappingList: HTMLDivElement | undefined = $state();
|
||||
let authFields = $derived(plugin.auth.fields ?? []);
|
||||
let secretFields = $derived(new Set(plugin.auth.secretFields ?? plugin.auth.fields ?? []));
|
||||
let isOAuthPlugin = $derived(plugin.auth.type === "oauth2");
|
||||
let isSessionPlugin = $derived(plugin.auth.type === "session");
|
||||
let isConnected = $derived(instance?.status === "configured");
|
||||
let isSaving = $state(false);
|
||||
let needsOAuthConnect = $derived(isOAuthPlugin && (!isConnected || authChanged()));
|
||||
let plugin_id = $derived(plugin.id);
|
||||
let configSchema = $derived(plugin.configSchema ?? []);
|
||||
let visibleConfigSchema = $derived(configSchema.filter((field) => !field.hidden));
|
||||
let extraConfig: Record<string, any> = $state(initialExtraConfig());
|
||||
let configErrors: Record<string, string> = $state({});
|
||||
let supportsPlanned = $derived(
|
||||
plugin.capabilities?.includes("list_routes.v1") ?? false,
|
||||
);
|
||||
let supportsCompleted = $derived(
|
||||
plugin.capabilities?.includes("list_activities.v1") ?? false,
|
||||
);
|
||||
let hasTourKindChoice = $derived(supportsPlanned && supportsCompleted);
|
||||
let supportsSourcePrivacy = $derived(
|
||||
plugin.capabilities?.includes("source_privacy") ?? false,
|
||||
);
|
||||
let mergeAvailable = $derived((hostConfig().merge as any)?.available !== false);
|
||||
let supportsCategoryMapping = $derived(supportsPlanned || supportsCompleted);
|
||||
let categorySelectItems: SelectItem[] = $derived(
|
||||
categories
|
||||
.map((category) => ({
|
||||
text: $_(category.name),
|
||||
value: category.id,
|
||||
}))
|
||||
.sort((a, b) => a.text.localeCompare(b.text, $locale ?? undefined)),
|
||||
);
|
||||
let providerCategorySelectItems: SelectItem[] = $derived(providerCategoryItems());
|
||||
let canAddCategoryMappingRow = $derived(
|
||||
categoryMappingRows.every((row) => row.providerCategory && row.category) &&
|
||||
providerCategorySelectItems.some(
|
||||
(item) => !categoryMappingRows.some((row) => row.providerCategory === item.value),
|
||||
),
|
||||
);
|
||||
|
||||
const configLabels: Record<string, string> = {
|
||||
after: "ignore-trails-before-date",
|
||||
};
|
||||
const configHints: Record<string, string> = {
|
||||
after: "plugin-after-date-hint",
|
||||
};
|
||||
const privacySelectItems: SelectItem[] = [
|
||||
{ text: $_("keep-original"), value: "original" },
|
||||
{ text: $_("apply-user-settings"), value: "settings" },
|
||||
];
|
||||
const authLabels: Record<string, string> = {
|
||||
email: $_("email"),
|
||||
password: $_("password"),
|
||||
clientId: "Client ID",
|
||||
clientSecret: "Client Secret",
|
||||
};
|
||||
|
||||
function initialAuth() {
|
||||
return Object.fromEntries(
|
||||
(plugin.auth.fields ?? []).map((field) => [
|
||||
field,
|
||||
instance?.auth?.[field] == null ? "" : String(instance.auth[field]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function initialExtraConfig(): Record<string, any> {
|
||||
const config = pluginConfig();
|
||||
return Object.fromEntries(
|
||||
configSchema.map((field) => {
|
||||
const saved = config[field.key];
|
||||
if (saved !== undefined) return [field.key, saved];
|
||||
if (field.type === "boolean") {
|
||||
return [field.key, booleanDefault(field.default, false)];
|
||||
}
|
||||
if (field.default !== undefined && field.default !== null) {
|
||||
return [field.key, String(field.default)];
|
||||
}
|
||||
if (field.type === "select" && field.options?.length) {
|
||||
return [field.key, field.options[0].value];
|
||||
}
|
||||
if (field.type === "select") {
|
||||
return [field.key, ""];
|
||||
}
|
||||
if (field.type === "text" || field.type === "url") {
|
||||
return [field.key, ""];
|
||||
}
|
||||
return [field.key, undefined];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function initialCategoryMappingRows(): CategoryMappingRow[] {
|
||||
return Object.entries(categoryMapping())
|
||||
.filter(([, category]) => category !== "")
|
||||
.map(([providerCategory, category]) => ({
|
||||
providerCategory,
|
||||
category: categoryTargetValue(category),
|
||||
}));
|
||||
}
|
||||
|
||||
function manifestCategoryMapping(): Record<string, string> {
|
||||
const raw = plugin.hostConfig?.categoryMapping;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
return stringMapping(raw as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function categoryMapping(): Record<string, string> {
|
||||
const raw = hostConfig().categoryMapping;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
return stringMapping(raw as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function categoryMappingsEqual(
|
||||
left: Record<string, string>,
|
||||
right: Record<string, string>,
|
||||
) {
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
return leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]);
|
||||
}
|
||||
|
||||
function stringMapping(raw: Record<string, unknown>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw)
|
||||
.filter(([, value]) => typeof value === "string")
|
||||
.map(([key, value]) => [key, value as string]),
|
||||
);
|
||||
}
|
||||
|
||||
function categoryTargetValue(value: string): string {
|
||||
const match = categories.find((category) => category.id === value || category.name === value);
|
||||
return match?.id ?? value;
|
||||
}
|
||||
|
||||
function providerCategoryItems(): SelectItem[] {
|
||||
const values = new Set<string>([
|
||||
...Object.keys(manifestCategoryMapping()),
|
||||
...Object.keys(categoryMapping()),
|
||||
...categoryMappingRows.map((row) => row.providerCategory).filter(Boolean),
|
||||
]);
|
||||
return [...values]
|
||||
.map((value) => ({
|
||||
text: providerCategoryLabel(plugin, value, $locale),
|
||||
value,
|
||||
}))
|
||||
.sort((a, b) => a.text.localeCompare(b.text, $locale ?? undefined));
|
||||
}
|
||||
|
||||
function providerCategoryItemsForRow(index: number): SelectItem[] {
|
||||
const currentValue = categoryMappingRows[index]?.providerCategory;
|
||||
const assignedValues = new Set(
|
||||
categoryMappingRows
|
||||
.filter((_, i) => i !== index)
|
||||
.map((row) => row.providerCategory)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return providerCategorySelectItems.filter(
|
||||
(item) => item.value === currentValue || !assignedValues.has(item.value),
|
||||
);
|
||||
}
|
||||
|
||||
function booleanDefault(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") return value.trim().toLowerCase() === "true";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function selectItems(field: ConfigField): SelectItem[] {
|
||||
return (field.options ?? []).map((o) => ({
|
||||
text: configFieldOptionLabel(o, $locale, $_(o.value)),
|
||||
value: o.value,
|
||||
}));
|
||||
}
|
||||
|
||||
function fieldLabel(field: ConfigField): string {
|
||||
return configFieldLabel(field, $locale, $_(configLabels[field.key] ?? field.key));
|
||||
}
|
||||
|
||||
function fieldHint(field: ConfigField): string | undefined {
|
||||
const description = configFieldDescription(field, $locale);
|
||||
if (description) return description;
|
||||
const hint = configHints[field.key];
|
||||
return hint ? $_(hint) : undefined;
|
||||
}
|
||||
|
||||
function fieldError(field: ConfigField): string {
|
||||
return configErrors[field.key] ?? "";
|
||||
}
|
||||
|
||||
async function addCategoryMappingRow() {
|
||||
const newIndex = categoryMappingRows.length;
|
||||
categoryMappingRows = [
|
||||
...categoryMappingRows,
|
||||
{
|
||||
providerCategory: "",
|
||||
category: "",
|
||||
},
|
||||
];
|
||||
await tick();
|
||||
categoryMappingList?.querySelector<HTMLElement>(`[data-category-mapping-row="${newIndex}"]`)
|
||||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function removeCategoryMappingRow(index: number) {
|
||||
categoryMappingRows = categoryMappingRows.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function validateConfig(): boolean {
|
||||
const errors: Record<string, string> = {};
|
||||
const hiddenMissing: ConfigField[] = [];
|
||||
for (const field of configSchema) {
|
||||
if (!field.required) continue;
|
||||
const value = extraConfig[field.key];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
if (field.hidden) {
|
||||
hiddenMissing.push(field);
|
||||
continue;
|
||||
}
|
||||
errors[field.key] = $_("required");
|
||||
}
|
||||
}
|
||||
configErrors = errors;
|
||||
if (hiddenMissing.length > 0) {
|
||||
show_toast({
|
||||
text: `${hiddenMissing.map((field) => fieldLabel(field)).join(", ")}: ${$_("required")}`,
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
return Object.keys(errors).length === 0 && hiddenMissing.length === 0;
|
||||
}
|
||||
|
||||
function authLabel(field: string): string {
|
||||
return authLabels[field] ?? field;
|
||||
}
|
||||
|
||||
function authFieldType(field: string): "password" | "text" {
|
||||
return secretFields.has(field) ? "password" : "text";
|
||||
}
|
||||
|
||||
function configSection(key: string): Record<string, any> {
|
||||
const section = instance?.config?.[key];
|
||||
if (section && typeof section === "object" && !Array.isArray(section)) {
|
||||
return section as Record<string, any>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function pluginConfig(): Record<string, any> {
|
||||
return configSection("plugin");
|
||||
}
|
||||
|
||||
function hostConfig(): Record<string, any> {
|
||||
const manifestConfig = plugin.hostConfig ?? {};
|
||||
const instanceConfig = configSection("host");
|
||||
const manifestMerge =
|
||||
manifestConfig.merge && typeof manifestConfig.merge === "object"
|
||||
? (manifestConfig.merge as Record<string, unknown>)
|
||||
: {};
|
||||
const instanceMerge =
|
||||
instanceConfig.merge && typeof instanceConfig.merge === "object"
|
||||
? (instanceConfig.merge as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...manifestConfig,
|
||||
...instanceConfig,
|
||||
merge: {
|
||||
...manifestMerge,
|
||||
...instanceMerge,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authChanged() {
|
||||
for (const field of authFields) {
|
||||
const value = auth[field] ?? "";
|
||||
if (secretFields.has(field)) {
|
||||
if (value !== "") {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (value !== ((instance?.auth?.[field] as string | undefined) ?? "")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function openModal() {
|
||||
auth = initialAuth();
|
||||
const config = hostConfig();
|
||||
planned = supportsPlanned && (!hasTourKindChoice || ((config.planned as boolean | undefined) ?? true));
|
||||
completed =
|
||||
supportsCompleted && (!hasTourKindChoice || ((config.completed as boolean | undefined) ?? true));
|
||||
mergeEnabled = mergeAvailable && Boolean((config.merge as any)?.enabled);
|
||||
privacy = (config.privacy as string | undefined) ?? "original";
|
||||
extraConfig = initialExtraConfig();
|
||||
categoryMappingRows = initialCategoryMappingRows();
|
||||
configErrors = {};
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
function pluginInstanceFromForm(): PluginInstanceForm {
|
||||
const submittedAuth: Record<string, string> = {};
|
||||
for (const field of authFields) {
|
||||
submittedAuth[field] = auth[field] ?? "";
|
||||
}
|
||||
|
||||
const pluginRuntimeConfig: Record<string, unknown> = { ...pluginConfig() };
|
||||
const pluginHostConfig: Record<string, unknown> = { ...hostConfig() };
|
||||
if (supportsPlanned) {
|
||||
pluginHostConfig.planned = hasTourKindChoice ? planned : true;
|
||||
} else {
|
||||
delete pluginHostConfig.planned;
|
||||
}
|
||||
if (supportsCompleted) {
|
||||
pluginHostConfig.completed = hasTourKindChoice ? completed : true;
|
||||
} else {
|
||||
delete pluginHostConfig.completed;
|
||||
}
|
||||
if (supportsSourcePrivacy) {
|
||||
pluginHostConfig.privacy = privacy;
|
||||
}
|
||||
const currentMergeConfig =
|
||||
pluginHostConfig.merge && typeof pluginHostConfig.merge === "object"
|
||||
? (pluginHostConfig.merge as Record<string, unknown>)
|
||||
: {};
|
||||
pluginHostConfig.merge = {
|
||||
...currentMergeConfig,
|
||||
enabled: mergeAvailable && mergeEnabled,
|
||||
};
|
||||
const categoryMappingConfig: Record<string, string> = {};
|
||||
const assignedProviderCategories = new Set<string>();
|
||||
for (const row of categoryMappingRows) {
|
||||
const providerCategory = row.providerCategory.trim();
|
||||
if (!providerCategory || !row.category) {
|
||||
continue;
|
||||
}
|
||||
assignedProviderCategories.add(providerCategory);
|
||||
categoryMappingConfig[providerCategory] = row.category;
|
||||
}
|
||||
for (const providerCategory of [
|
||||
...Object.keys(manifestCategoryMapping()),
|
||||
...Object.keys(categoryMapping()),
|
||||
]) {
|
||||
if (!assignedProviderCategories.has(providerCategory)) {
|
||||
categoryMappingConfig[providerCategory] = "";
|
||||
}
|
||||
}
|
||||
const mappingChanged = !categoryMappingsEqual(categoryMapping(), categoryMappingConfig);
|
||||
if (mappingChanged) {
|
||||
pluginHostConfig.categoryMappingUpdatedAt = new Date().toISOString();
|
||||
}
|
||||
pluginHostConfig.categoryMapping = categoryMappingConfig;
|
||||
for (const field of configSchema) {
|
||||
const val = extraConfig[field.key];
|
||||
if (val !== undefined && val !== "") {
|
||||
pluginRuntimeConfig[field.key] = val;
|
||||
} else {
|
||||
delete pluginRuntimeConfig[field.key];
|
||||
}
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
plugin: pluginRuntimeConfig,
|
||||
host: pluginHostConfig,
|
||||
};
|
||||
|
||||
const status = isOAuthPlugin
|
||||
? !instance?.id
|
||||
? "needs_auth"
|
||||
: authChanged()
|
||||
? isConnected
|
||||
? "needs_reauth"
|
||||
: "needs_auth"
|
||||
: instance.status
|
||||
: instance?.enabled
|
||||
? "configured"
|
||||
: "disabled";
|
||||
|
||||
return {
|
||||
...instance,
|
||||
plugin_id,
|
||||
enabled: isOAuthPlugin && status !== "configured" ? false : (instance?.enabled ?? false),
|
||||
auth: submittedAuth,
|
||||
config,
|
||||
status,
|
||||
mappingChanged,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateAuthIfNeeded() {
|
||||
if (!isSessionPlugin || !authChanged()) {
|
||||
return;
|
||||
}
|
||||
await plugin_auth_validate({
|
||||
pluginId: plugin.id,
|
||||
instanceId: instance?.id,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validateConfig()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
isSaving = true;
|
||||
await validateAuthIfNeeded();
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: translatePluginAPIError(e, $_("error-setting-up-plugin", { values: { provider: pluginTitle() } })),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
isSaving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidate = pluginInstanceFromForm();
|
||||
if (candidate.mappingChanged) {
|
||||
const shouldSave = await onbeforecategorymappingsave?.(candidate);
|
||||
if (shouldSave === false) {
|
||||
return;
|
||||
}
|
||||
delete candidate.mappingChanged;
|
||||
}
|
||||
await onsave?.(candidate as Partial<PluginInstance>);
|
||||
modal.closeModal();
|
||||
} catch {
|
||||
// onsave owns persistence error reporting so the modal does not
|
||||
// duplicate toasts.
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pluginTitle() {
|
||||
return localizedPluginTitle(plugin, $locale);
|
||||
}
|
||||
|
||||
async function startOAuth() {
|
||||
if (!validateConfig()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const candidate = pluginInstanceFromForm();
|
||||
delete candidate.mappingChanged;
|
||||
const saved = await onsave?.(candidate);
|
||||
const instanceId = saved?.id ?? instance?.id;
|
||||
if (!instanceId) {
|
||||
return;
|
||||
}
|
||||
const redirectUri = `${window.location.origin}/settings/plugins/oauth/callback`;
|
||||
const result = await plugin_oauth_start({
|
||||
pluginId: plugin.id,
|
||||
instanceId,
|
||||
redirectUri,
|
||||
});
|
||||
sessionStorage.setItem(`wanderer_plugin_oauth_${result.state}`, result.instanceId);
|
||||
window.location.href = result.url;
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: e instanceof Error ? e.message : $_("error-starting-oauth"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="{plugin_id}-plugin-settings-modal"
|
||||
size="md:min-w-xl lg:min-w-2xl"
|
||||
title={pluginTitle() + " " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<form
|
||||
id="{plugin_id}-plugin-settings-form"
|
||||
class="space-y-3"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{#each authFields as field}
|
||||
<TextField
|
||||
label={authLabel(field)}
|
||||
placeholder={secretFields.has(field)
|
||||
? instance?.id
|
||||
? `(${$_("unchanged")})`
|
||||
: ""
|
||||
: field == "email"
|
||||
? "user@example.com"
|
||||
: ""}
|
||||
bind:value={auth[field]}
|
||||
name={field}
|
||||
type={authFieldType(field)}
|
||||
></TextField>
|
||||
{/each}
|
||||
|
||||
{#if isOAuthPlugin}
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{#if isConnected}
|
||||
{$_("plugin-oauth-connected-hint")}
|
||||
{:else}
|
||||
{$_("plugin-oauth-needs-connect-hint")}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if hasTourKindChoice}
|
||||
<div class="flex flex-wrap gap-x-4">
|
||||
<Toggle
|
||||
bind:value={planned}
|
||||
label={$_("planned-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
<Toggle
|
||||
bind:value={completed}
|
||||
label={$_("completed-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if supportsSourcePrivacy}
|
||||
<Select
|
||||
label={$_("privacy")}
|
||||
items={privacySelectItems}
|
||||
bind:value={privacy}
|
||||
></Select>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{#if privacy == "original"}
|
||||
{$_("plugin-privacy-hint-original")}
|
||||
{:else}
|
||||
{$_("plugin-privacy-hint-user")}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#each visibleConfigSchema as field}
|
||||
{#if field.type === "select"}
|
||||
<Select
|
||||
label={fieldLabel(field)}
|
||||
items={selectItems(field)}
|
||||
bind:value={extraConfig[field.key] as string}
|
||||
error={fieldError(field)}
|
||||
></Select>
|
||||
{:else if field.type === "boolean"}
|
||||
<Toggle
|
||||
bind:value={extraConfig[field.key]}
|
||||
label={fieldLabel(field)}
|
||||
error={fieldError(field)}
|
||||
></Toggle>
|
||||
{@const hint = fieldHint(field)}
|
||||
{#if hint}
|
||||
<p class="text-xs text-gray-500 max-w-lg">{hint}</p>
|
||||
{/if}
|
||||
{:else if field.type === "date"}
|
||||
{@const hint = fieldHint(field)}
|
||||
{#if hint}
|
||||
<p
|
||||
class="text-xs text-gray-500 max-w-lg pt-4 pb-1 border-t border-input-border"
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-end relative gap-x-2">
|
||||
<Datepicker
|
||||
label={fieldLabel(field)}
|
||||
bind:value={extraConfig[field.key]}
|
||||
error={fieldError(field)}
|
||||
></Datepicker>
|
||||
<button
|
||||
class="btn-icon mb-[10px]"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
extraConfig[field.key] = undefined;
|
||||
}}
|
||||
aria-label={$_("clear")}
|
||||
><i class="fa fa-close"></i></button>
|
||||
</div>
|
||||
{:else if field.type === "text" || field.type === "url"}
|
||||
<TextField
|
||||
label={fieldLabel(field)}
|
||||
bind:value={extraConfig[field.key]}
|
||||
name={field.key}
|
||||
type={field.type === "url" ? "url" : "text"}
|
||||
error={fieldError(field)}
|
||||
></TextField>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if supportsCategoryMapping && categorySelectItems.length > 0}
|
||||
<div class="space-y-2 pt-4 border-t border-input-border">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">{$_("category-mapping")}</h4>
|
||||
<p class="text-xs text-secondary mt-1">
|
||||
{$_("category-mapping-help")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary text-sm"
|
||||
class:btn-disabled={!canAddCategoryMappingRow}
|
||||
type="button"
|
||||
disabled={!canAddCategoryMappingRow}
|
||||
onclick={addCategoryMappingRow}
|
||||
>{$_("add-entry")}</button>
|
||||
</div>
|
||||
{#if categoryMappingRows.length > 0}
|
||||
<div class="hidden md:grid grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] gap-3 text-sm font-medium">
|
||||
<span>{$_("provider-category")}</span>
|
||||
<span>{$_("category")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div
|
||||
bind:this={categoryMappingList}
|
||||
class="space-y-3 pr-1 scroll-smooth"
|
||||
class:max-h-[300px]={categoryMappingRows.length > 6}
|
||||
class:overflow-y-auto={categoryMappingRows.length > 6}
|
||||
class:overflow-y-visible={categoryMappingRows.length <= 6}
|
||||
>
|
||||
{#each categoryMappingRows as row, i}
|
||||
{@const providerItems = providerCategoryItemsForRow(i)}
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] items-end gap-3"
|
||||
data-category-mapping-row={i}
|
||||
>
|
||||
<SingleSelect
|
||||
ariaLabel={$_("provider-category")}
|
||||
placeholder={$_("select-provider-category")}
|
||||
items={providerItems}
|
||||
bind:value={row.providerCategory}
|
||||
disabled={row.providerCategory !== "" && providerItems.length <= 1}
|
||||
></SingleSelect>
|
||||
<SingleSelect
|
||||
ariaLabel={$_("category")}
|
||||
placeholder={$_("select-category")}
|
||||
items={categorySelectItems}
|
||||
bind:value={row.category}
|
||||
disabled={row.category !== "" && categorySelectItems.length <= 1}
|
||||
></SingleSelect>
|
||||
<button
|
||||
class="btn-icon h-10"
|
||||
type="button"
|
||||
onclick={() => removeCategoryMappingRow(i)}
|
||||
aria-label={$_("remove")}
|
||||
><i class="fa fa-close"></i></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mergeAvailable}
|
||||
<PluginMergeSettings prefix="merge" bind:value={mergeEnabled} />
|
||||
{/if}
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()} disabled={isSaving}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
{#if isOAuthPlugin}
|
||||
{#if needsOAuthConnect}
|
||||
<button class="btn-primary" type="button" onclick={startOAuth} disabled={isSaving}
|
||||
>{isConnected ? $_("save-and-reconnect") : $_("save-and-connect")}</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="{plugin_id}-plugin-settings-form"
|
||||
type="submit"
|
||||
name="save"
|
||||
disabled={isSaving}>{$_("save")}</button
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="{plugin_id}-plugin-settings-form"
|
||||
type="submit"
|
||||
name="save"
|
||||
disabled={isSaving}
|
||||
>{isSessionPlugin && authChanged() ? $_("save-and-validate") : $_("save")}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -4,17 +4,19 @@
|
||||
|
||||
interface Props {
|
||||
prefix: string;
|
||||
value?: boolean;
|
||||
}
|
||||
|
||||
let { prefix }: Props = $props();
|
||||
let { prefix, value = $bindable(false) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="pt-4 border-t border-input-border space-y-2">
|
||||
<Toggle
|
||||
name={`${prefix}.enabled`}
|
||||
label={$_("integration-auto-merge-label")}
|
||||
bind:value
|
||||
label={$_("plugin-auto-merge-label")}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{$_("integration-auto-merge-hint")}
|
||||
{$_("plugin-auto-merge-hint")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -3,11 +3,6 @@
|
||||
import { page } from "$app/state";
|
||||
import type { List } from "$lib/models/list";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import {
|
||||
integrations,
|
||||
integrations_index,
|
||||
uploadGpx,
|
||||
} from "$lib/stores/integration_store";
|
||||
import {
|
||||
lists_add_trail,
|
||||
lists_index,
|
||||
@@ -25,9 +20,8 @@
|
||||
import { trail2gpx } from "$lib/util/gpx_util";
|
||||
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
||||
import JSZip from "jszip";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import { type Snippet } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { get } from "svelte/store";
|
||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||
import ConfirmModal from "../confirm_modal.svelte";
|
||||
import ListSearchModal from "../list/list_search_modal.svelte";
|
||||
@@ -43,6 +37,7 @@
|
||||
import type { MergeSelection, MergeSettings } from "./trail_merge_modal.svelte";
|
||||
import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte";
|
||||
import { trail_merge } from "$lib/stores/trail_merge_api";
|
||||
import { hasSendCapablePlugin } from "$lib/stores/plugin_store";
|
||||
|
||||
export interface MergeResult {
|
||||
targetTrail: Trail;
|
||||
@@ -65,58 +60,11 @@
|
||||
let confirmModal: ConfirmModal;
|
||||
let listSelectModal: ListSearchModal;
|
||||
let trailExportModal: TrailExportModal;
|
||||
let trailSendModal: TrailSendModal;
|
||||
let trailShareModal: TrailShareModal;
|
||||
let trailMergeModal: TrailMergeModal;
|
||||
let trailSendModal: TrailSendModal;
|
||||
|
||||
const hammerheadIntegration = $derived(
|
||||
$integrations.find((integration) =>
|
||||
Boolean(integration.hammerhead?.active)
|
||||
)
|
||||
);
|
||||
|
||||
let lists: List[] = $state([]);
|
||||
let integrationsLoading = false;
|
||||
let integrationsLoadedForUser: string | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = currentUser.subscribe(async (user) => {
|
||||
if (!user) {
|
||||
integrationsLoadedForUser = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = get(integrations);
|
||||
if (
|
||||
existing.length &&
|
||||
existing[0]?.user === user.id
|
||||
) {
|
||||
integrationsLoadedForUser = user.id;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
integrationsLoading ||
|
||||
integrationsLoadedForUser === user.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
integrationsLoading = true;
|
||||
try {
|
||||
await integrations_index();
|
||||
integrationsLoadedForUser = user.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to load integrations", error);
|
||||
} finally {
|
||||
integrationsLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
let loading: boolean = $state(false);
|
||||
|
||||
@@ -214,6 +162,16 @@
|
||||
return !isMultiselectMode();
|
||||
}
|
||||
|
||||
function allowSend(): boolean {
|
||||
return (
|
||||
hasTrail() &&
|
||||
!isMultiselectMode() &&
|
||||
hasGpx() &&
|
||||
Boolean($currentUser) &&
|
||||
$hasSendCapablePlugin
|
||||
);
|
||||
}
|
||||
|
||||
function allowPublish(): boolean {
|
||||
if (mode !== "multi-select") return false;
|
||||
|
||||
@@ -240,7 +198,7 @@
|
||||
});
|
||||
const allowListManagement = isFromCurrentUser();
|
||||
const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser();
|
||||
const allowSingleOutput = canExport() || (!isMultiselectMode() && hammerheadIntegration && canExport());
|
||||
const allowSingleOutput = canExport();
|
||||
|
||||
if (isMultiselectMode()) {
|
||||
return [
|
||||
@@ -389,7 +347,7 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode() && hammerheadIntegration && canExport()
|
||||
...(allowSend()
|
||||
? [
|
||||
{
|
||||
text: $_("send-to"),
|
||||
@@ -528,6 +486,8 @@
|
||||
}
|
||||
} else if (ddVal == "share") {
|
||||
trailShareModal.openModal();
|
||||
} else if (ddVal == "send-to") {
|
||||
trailSendModal.openModal();
|
||||
} else if (ddVal == "download") {
|
||||
trailExportModal.openModal();
|
||||
} else if (ddVal == "edit") {
|
||||
@@ -548,8 +508,6 @@
|
||||
if (trail()) {
|
||||
await trailMergeModal.openSimilarTrailsModal(trail()!);
|
||||
}
|
||||
} else if (item.value == "send-to") {
|
||||
trailSendModal.openModal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,49 +563,6 @@
|
||||
await trail_merge(trailSource.id, trailTarget.id, settings);
|
||||
onProgress?.(1);
|
||||
}
|
||||
async function uploadToHammerhead() {
|
||||
if (!hammerheadIntegration || !hasTrail()) {
|
||||
console.error("No Hammerhead integration found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const uTrail of trails!) {
|
||||
try {
|
||||
if (uTrail.gpx) {
|
||||
const gpxData = await trail2gpx(uTrail, $currentUser);
|
||||
const formData = new FormData();
|
||||
const gpxFile = new File(
|
||||
[gpxData],
|
||||
`${uTrail.name || "trail"}.gpx`,
|
||||
{ type: "application/gpx+xml" },
|
||||
);
|
||||
formData.append("file", gpxFile);
|
||||
|
||||
await uploadGpx("hammerhead", gpxFile);
|
||||
|
||||
show_toast({
|
||||
type: "success",
|
||||
icon: "check",
|
||||
text: $_("uploaded-trail-to-hammerhead"),
|
||||
});
|
||||
} else {
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("trail-has-no-gpx"),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("error-uploading-trail-to-hammerhead"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTrailsVisibility() {
|
||||
const newVisibility = !majorityOfSelectedTrailsArePublic();
|
||||
|
||||
@@ -926,17 +841,14 @@
|
||||
onsave={handleShareUpdate}
|
||||
bind:this={trailShareModal}
|
||||
></TrailShareModal>
|
||||
<TrailSendModal
|
||||
trail={trail()}
|
||||
share={page.url.searchParams.get("share") ?? undefined}
|
||||
bind:this={trailSendModal}
|
||||
></TrailSendModal>
|
||||
<TrailMergeModal
|
||||
bind:this={trailMergeModal}
|
||||
onmerge={(settings, selection) => mergeTrails(settings, selection)}
|
||||
></TrailMergeModal>
|
||||
|
||||
<MergeDialog/>
|
||||
<TrailSendModal
|
||||
bind:this={trailSendModal}
|
||||
onsend={async (settings) => {
|
||||
if (settings.integrationName === "hammerhead") {
|
||||
await uploadToHammerhead();
|
||||
}
|
||||
}}
|
||||
></TrailSendModal>
|
||||
|
||||
@@ -1,42 +1,145 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import hammerheadLogoWhite from "$lib/assets/svgs/logos/hammerhead_white.svg";
|
||||
import hammerheadLogoDark from "$lib/assets/svgs/logos/hammerhead_dark.svg";
|
||||
import type { PluginInstance } from "$lib/models/plugin_instance";
|
||||
import type { PluginProvider } from "$lib/models/plugin_provider";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { plugin_instances_index } from "$lib/stores/plugin_instance_store";
|
||||
import { plugins_index } from "$lib/stores/plugin_store";
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { pluginTitle as localizedPluginTitle } from "$lib/util/plugin_i18n";
|
||||
import { onMount } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
onsend?: (settings: typeof sendSettings) => void;
|
||||
trail?: Trail;
|
||||
share?: string;
|
||||
}
|
||||
|
||||
let { title = $_("send-to"), onsend: onexport }: Props = $props();
|
||||
let { trail, share }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
let loading = $state(false);
|
||||
let sending = $state("");
|
||||
let eligible: PluginProvider[] = $state([]);
|
||||
let currentTheme: "dark" | "light" = $state("light");
|
||||
|
||||
export function openModal() {
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const sendSettings: {
|
||||
integrationName: string;
|
||||
} = $state({
|
||||
integrationName: "hammerhead",
|
||||
onMount(() => {
|
||||
currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
return theme.subscribe((value) => {
|
||||
currentTheme = value;
|
||||
});
|
||||
});
|
||||
|
||||
function sendTrail() {
|
||||
onexport?.(sendSettings);
|
||||
modal.closeModal();
|
||||
export async function openModal() {
|
||||
modal.openModal();
|
||||
await loadEligible();
|
||||
}
|
||||
|
||||
async function loadEligible() {
|
||||
loading = true;
|
||||
eligible = [];
|
||||
try {
|
||||
const [plugins, instances] = await Promise.all([
|
||||
plugins_index(),
|
||||
plugin_instances_index(),
|
||||
]);
|
||||
const enabledProviders = new Set(
|
||||
instances
|
||||
.filter((a: PluginInstance) => a.enabled)
|
||||
.map((a) => a.plugin_id),
|
||||
);
|
||||
eligible = plugins.filter(
|
||||
(p) =>
|
||||
p.status === "available" &&
|
||||
(p.capabilities ?? []).includes("prepare_trail_send.v1") &&
|
||||
enabledProviders.has(p.id),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pluginLogo(plugin: PluginProvider): string | undefined {
|
||||
if (currentTheme === "dark" && plugin.iconDark) {
|
||||
return plugin.iconDark;
|
||||
}
|
||||
return plugin.icon || undefined;
|
||||
}
|
||||
|
||||
function pluginTitle(plugin: PluginProvider): string {
|
||||
return localizedPluginTitle(plugin, $locale);
|
||||
}
|
||||
|
||||
async function send(plugin: PluginProvider) {
|
||||
if (!trail?.id) {
|
||||
return;
|
||||
}
|
||||
sending = plugin.id;
|
||||
try {
|
||||
const response = await fetch("/api/v1/plugin-system/trail-send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pluginId: plugin.id, trailId: trail.id, share }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
show_toast({
|
||||
type: "success",
|
||||
icon: "check",
|
||||
text: $_("trail-sent"),
|
||||
});
|
||||
modal.closeModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("error-sending-trail"),
|
||||
});
|
||||
} finally {
|
||||
sending = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal id="send-modal" {title} size="md:min-w-sm" bind:this={modal}>
|
||||
<Modal id="send-modal" title={$_("send-to")} size="md:min-w-sm" bind:this={modal}>
|
||||
{#snippet content()}
|
||||
<div>
|
||||
<button class="btn-secondary" onclick={sendTrail}>
|
||||
<img class="h-20" src={$theme == "light" ? hammerheadLogoDark : hammerheadLogoWhite} alt="integration logo"/>
|
||||
</button>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="flex justify-center p-4">
|
||||
<div class="spinner light:spinner-dark"></div>
|
||||
</div>
|
||||
{:else if eligible.length === 0}
|
||||
<p class="text-sm text-gray-500">{$_("no-send-plugins")}</p>
|
||||
<a class="btn-secondary mt-4 inline-block" href="/settings/plugins"
|
||||
>{$_("plugins")}</a
|
||||
>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
{#each eligible as plugin (plugin.id)}
|
||||
<button
|
||||
class="btn-secondary"
|
||||
disabled={sending !== ""}
|
||||
onclick={() => send(plugin)}
|
||||
>
|
||||
{#if sending === plugin.id}
|
||||
<div class="spinner light:spinner-dark h-20"></div>
|
||||
{:else if pluginLogo(plugin)}
|
||||
<img
|
||||
class="h-20"
|
||||
src={pluginLogo(plugin)}
|
||||
alt={pluginTitle(plugin)}
|
||||
/>
|
||||
{:else}
|
||||
<span>{pluginTitle(plugin)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -44,5 +147,5 @@
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user