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:
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>
|
||||
Reference in New Issue
Block a user