Federation (#327)
* initial commit federation * more federation * more federation * more federation * completes follow, accept, undo * add trail create activity * process trail create activity * more trail create activity * adds missing endpoints * adds update and delete activity * adds comment activities * adds activities back * adds summit logs activities * deletes follow counts table * fixes migrations * fixes migrations * fixes migrations * fixes migrations * adds remote profiles * ctd * ctd * we are getting closer... * adds public summit logs * fixes federated trails in lists * adds remote lists * adds list activites * fixes lists * adds notifications * adds iri redirect * fixes comments and summitlogs * fixes follows and profiles * adds asynchronous send * fixes list search * adds encryption key * bug fixes * fixes activity signing * removes custon activity object types * adds html editor * fixes html editor * fixes display issues on mastodon * adds federated sharing * finishes announcements * fixes small summit log issues * adds trail likes * finalizes likes * fixes images for komoot * add disable federation option * adds private profiles * updates docs * adds federated comments * adds trail and comments actvitiypub routes * adds mentions to editor * adds mentions to trails, comments, summit logs * updates theme * updates theme * update docs * update docs * updates docs * fixes various frontend problems * fixes activitypub follows api * updates docs * updates docs --------- Co-authored-by: Christian Beutel <>
This commit is contained in:
@@ -113,10 +113,7 @@
|
||||
}
|
||||
|
||||
function colorKey(a: typeof currentMonthArray, i: number) {
|
||||
return $_(
|
||||
a[i]?.log?.expand?.trails_via_summit_logs?.at(0)?.expand?.category
|
||||
?.name ?? "",
|
||||
);
|
||||
return $_(a[i]?.log?.expand?.trail?.expand?.category?.name ?? "");
|
||||
}
|
||||
|
||||
function handleDateClick(date?: Date) {
|
||||
@@ -170,7 +167,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
<style lang="postcss">
|
||||
@reference "tailwindcss";
|
||||
@reference "../../../css/app.css";
|
||||
|
||||
.calendar-weekday {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@
|
||||
|
||||
<div class="my-4" id="slider" bind:this={sliderContainer}></div>
|
||||
|
||||
<style>
|
||||
<style lang="postcss">
|
||||
@reference "tailwindcss";
|
||||
@reference "../../../css/app.css";
|
||||
|
||||
:global(.noUi-horizontal) {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500/50 cursor-pointer"
|
||||
class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500 opacity-50 cursor-pointer"
|
||||
onclick={() => (open = false)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key == "Escape") open = false;
|
||||
|
||||
@@ -29,28 +29,50 @@
|
||||
e.preventDefault();
|
||||
|
||||
isOpen = !isOpen;
|
||||
|
||||
if (isOpen) {
|
||||
await tick();
|
||||
const toggleRect = dropdownToggleElement.getBoundingClientRect();
|
||||
|
||||
const toggleRect = dropdownToggleElement.getBoundingClientRect();
|
||||
const dropdownRect = dropdownElement.getBoundingClientRect();
|
||||
dropdownElement.style.visibility = "";
|
||||
|
||||
// Viewport dimensions
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - toggleRect.bottom;
|
||||
const spaceAbove = toggleRect.top;
|
||||
const spaceBelowViewport = viewportHeight - toggleRect.bottom;
|
||||
const spaceAboveViewport = toggleRect.top;
|
||||
|
||||
if (
|
||||
// Container dimensions
|
||||
const scrollContainer =
|
||||
dropdownElement.closest(".scroll-x-only") ?? document.body;
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const spaceBelowContainer =
|
||||
containerRect.bottom - toggleRect.bottom;
|
||||
const spaceAboveContainer = toggleRect.top - containerRect.top;
|
||||
|
||||
// Effective space
|
||||
const spaceBelow = Math.min(
|
||||
spaceBelowViewport,
|
||||
spaceBelowContainer,
|
||||
);
|
||||
const spaceAbove = Math.min(
|
||||
spaceAboveViewport,
|
||||
spaceAboveContainer,
|
||||
);
|
||||
|
||||
// Determine direction
|
||||
const openUpward =
|
||||
spaceBelow < dropdownRect.height &&
|
||||
spaceAbove > dropdownRect.height
|
||||
) {
|
||||
spaceAbove > dropdownRect.height;
|
||||
|
||||
if (openUpward) {
|
||||
dropdownElement.classList.remove("rounded-b-xl");
|
||||
dropdownElement.classList.add("rounded-t-xl");
|
||||
dropdownElement.style.top = `${-8-dropdownRect.height}px`;
|
||||
dropdownElement.style.top = `${-8 - dropdownRect.height}px`;
|
||||
} else {
|
||||
dropdownElement.classList.remove("rounded-t-xl");
|
||||
dropdownElement.classList.add("rounded-b-xl");
|
||||
dropdownElement.style.top = `${toggleRect.height +8}px`;
|
||||
dropdownElement.style.top = `${toggleRect.height + 8}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
501
web/src/lib/components/base/editor.svelte
Normal file
501
web/src/lib/components/base/editor.svelte
Normal file
@@ -0,0 +1,501 @@
|
||||
<script lang="ts">
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Link } from "@tiptap/extension-link";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Underline } from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { mount, onDestroy, onMount, unmount, untrack } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { z, ZodError } from "zod";
|
||||
import { type DropdownItem } from "./dropdown.svelte";
|
||||
import Modal from "./modal.svelte";
|
||||
import {
|
||||
default as DropdownList,
|
||||
default as SearchList,
|
||||
} from "./search_list.svelte";
|
||||
import { type SelectItem } from "./select.svelte";
|
||||
import TextField from "./text_field.svelte";
|
||||
import Toggle from "./toggle.svelte";
|
||||
import type { SearchItem } from "./search.svelte";
|
||||
import type { SuggestionProps } from "@tiptap/suggestion";
|
||||
import type { Actor } from "$lib/models/activitypub/actor";
|
||||
import { searchActors } from "$lib/stores/search_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
|
||||
let element: HTMLElement;
|
||||
let editor: Editor | undefined = $state();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
label?: string;
|
||||
error?: string | string[] | null;
|
||||
placeholder?: string;
|
||||
extraClasses?: string;
|
||||
searchListPosition?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
label = "",
|
||||
error = "",
|
||||
placeholder = "",
|
||||
extraClasses = "",
|
||||
searchListPosition = "absolute"
|
||||
}: Props = $props();
|
||||
|
||||
const fontSizes: SelectItem[] = [
|
||||
{ text: $_("paragraph"), value: "p" },
|
||||
{ text: `${$_("heading")} 1`, value: "h1" },
|
||||
{ text: `${$_("heading")} 2`, value: "h2" },
|
||||
{ text: `${$_("heading")} 3`, value: "h3" },
|
||||
{ text: `${$_("heading")} 4`, value: "h4" },
|
||||
{ text: `${$_("heading")} 5`, value: "h5" },
|
||||
];
|
||||
|
||||
let currentFontSize: string = $state("p");
|
||||
let linkURL: string = $state("");
|
||||
let linkURLError: string = $state("");
|
||||
let linkText: string = $state("");
|
||||
let openLinkInNewTab: boolean = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
value;
|
||||
untrack(() => {
|
||||
if (value !== editor?.getHTML()) {
|
||||
editor?.commands.setContent(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element: element,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder,
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
renderHTML({ node, options }) {
|
||||
return [
|
||||
"a",
|
||||
mergeAttributes(
|
||||
{ href: `/profile/@${options.HTMLAttributes["data-label"]}` },
|
||||
options.HTMLAttributes,
|
||||
),
|
||||
options.renderText?.({
|
||||
options: options,
|
||||
node,
|
||||
}),
|
||||
];
|
||||
},
|
||||
suggestion: {
|
||||
allowToIncludeChar: true,
|
||||
items: async ({ query }) => {
|
||||
try {
|
||||
const actors: Actor[] = await searchActors(
|
||||
query,
|
||||
true,
|
||||
);
|
||||
return actors.map((a) => ({
|
||||
text: a.preferred_username!,
|
||||
description: `@${a.username}${a.isLocal ? "" : "@" + a.domain}`,
|
||||
value: a,
|
||||
icon:
|
||||
a.icon ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${a.username}&backgroundType=gradientLinear`,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: "Error during search",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: DropdownList;
|
||||
const componentState: any = $state({
|
||||
items: [],
|
||||
id: "mention-list",
|
||||
});
|
||||
|
||||
function updatePosition({
|
||||
clientRect,
|
||||
}: SuggestionProps) {
|
||||
const searchListElement =
|
||||
document.getElementById("mention-list");
|
||||
if (!clientRect || !searchListElement) return;
|
||||
const box = clientRect();
|
||||
if (!box) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchListElement.style.position = searchListPosition;
|
||||
searchListElement.style.top = `${box.bottom + window.scrollY + 4}px`;
|
||||
searchListElement.style.left = `${box.left + window.scrollX}px`;
|
||||
searchListElement.style.zIndex = "1001";
|
||||
}
|
||||
|
||||
function onActorClick(props: SuggestionProps) {
|
||||
return (_: Event, item: SearchItem) => {
|
||||
props.command({
|
||||
id: item.value.iri,
|
||||
label: `${item.value.username}${item.value.isLocal ? "" : "@" + item.value.domain}`,
|
||||
});
|
||||
};
|
||||
}
|
||||
return {
|
||||
onStart: (props) => {
|
||||
props.clientRect;
|
||||
componentState.items = props.items;
|
||||
componentState.onclick =
|
||||
onActorClick(props);
|
||||
|
||||
component = mount(SearchList, {
|
||||
target: element,
|
||||
props: componentState,
|
||||
});
|
||||
|
||||
updatePosition(props);
|
||||
},
|
||||
onUpdate(props) {
|
||||
componentState.items = props.items;
|
||||
componentState.onclick =
|
||||
onActorClick(props);
|
||||
updatePosition(props);
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
this.onExit?.({} as unknown as any)
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
onExit() {
|
||||
unmount(component);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
defaultProtocol: "https",
|
||||
protocols: ["http", "https"],
|
||||
isAllowedUri: (url, ctx) => {
|
||||
try {
|
||||
const parsedUrl = url.includes(":")
|
||||
? new URL(url)
|
||||
: new URL(`${ctx.defaultProtocol}://${url}`);
|
||||
|
||||
if (!ctx.defaultValidate(parsedUrl.href)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const disallowedProtocols = [
|
||||
"ftp",
|
||||
"file",
|
||||
"mailto",
|
||||
];
|
||||
const protocol = parsedUrl.protocol.replace(
|
||||
":",
|
||||
"",
|
||||
);
|
||||
|
||||
if (disallowedProtocols.includes(protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedProtocols = ctx.protocols.map((p) =>
|
||||
typeof p === "string" ? p : p.scheme,
|
||||
);
|
||||
|
||||
if (!allowedProtocols.includes(protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: value,
|
||||
onTransaction: ({ editor: newEditor }) => {
|
||||
// force re-render so `editor.isActive` works as expected
|
||||
editor = undefined;
|
||||
editor = newEditor;
|
||||
},
|
||||
onUpdate: (props) => {
|
||||
value = editor?.getHTML() ?? "";
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: `prose dark:prose-invert text-content bg-input-background border border-input-border rounded-md p-3 resize-none transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 ${extraClasses}`,
|
||||
},
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
if (editor.isActive("paragraph")) {
|
||||
currentFontSize = "p";
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
if (editor.isActive("heading", { level: i })) {
|
||||
currentFontSize = "h" + i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (editor) {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
function openLinkModal() {
|
||||
linkURL = editor?.getAttributes("link").href ?? "";
|
||||
openLinkInNewTab = editor?.getAttributes("link").target === "_blank";
|
||||
if (linkURL) {
|
||||
editor?.chain().focus().extendMarkRange("link").run();
|
||||
}
|
||||
linkText =
|
||||
editor?.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to,
|
||||
"",
|
||||
) ?? "";
|
||||
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
function setLink() {
|
||||
linkURLError = "";
|
||||
|
||||
if (linkURL === "") {
|
||||
linkURLError = $_("required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
z.string().url().parse(linkURL);
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "text",
|
||||
text: linkText || linkURL,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: linkURL,
|
||||
target: openLinkInNewTab ? "_blank" : null,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.run();
|
||||
|
||||
modal.closeModal();
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof ZodError &&
|
||||
e.errors[0].code === "invalid_string"
|
||||
) {
|
||||
linkURLError = $_("not-a-valid-url");
|
||||
}
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function unsetLink() {
|
||||
editor?.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
modal.closeModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="editor-wrapper">
|
||||
{#if label.length}
|
||||
<p class="text-sm font-medium mb-1">
|
||||
{label}
|
||||
</p>
|
||||
{/if}
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-wrap items-center py-2 gap-y-2">
|
||||
<div class="mr-2">
|
||||
<!-- <Select
|
||||
items={fontSizes}
|
||||
bind:value={currentFontSize}
|
||||
onchange={(value: string) => {
|
||||
if (value.startsWith("p")) {
|
||||
editor?.chain().focus().setParagraph().run();
|
||||
} else {
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.setHeading({
|
||||
level: parseInt(value.substring(1)) as Level,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}}
|
||||
></Select> -->
|
||||
</div>
|
||||
<div class="flex gap-2 border-r border-input-border pr-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
class:ring-2={editor?.isActive("bold")}
|
||||
aria-label="Bold"
|
||||
>
|
||||
<i class="fas fa-bold"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
class:ring-2={editor?.isActive("italic")}
|
||||
aria-label="Italic"
|
||||
>
|
||||
<i class="fas fa-italic"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
class:ring-2={editor?.isActive("underline")}
|
||||
aria-label="Underline"
|
||||
>
|
||||
<i class="fas fa-underline"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 border-r border-input-border px-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
onclick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
class:ring-2={editor?.isActive("bulletList")}
|
||||
aria-label="Bullet List"
|
||||
>
|
||||
<i class="fas fa-list-ul"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
onclick={() =>
|
||||
editor?.chain().focus().toggleOrderedList().run()}
|
||||
class:ring-2={editor?.isActive("orderedList")}
|
||||
aria-label="Numbered List"
|
||||
>
|
||||
<i class="fas fa-list-ol"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 border-r border-input-border px-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
class:ring-2={editor?.isActive("blockquote")}
|
||||
aria-label="Quote"
|
||||
>
|
||||
<i class="fas fa-quote-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
onclick={() => openLinkModal()}
|
||||
class:ring-2={editor?.isActive("link")}
|
||||
aria-label="Link"
|
||||
>
|
||||
<i class="fas fa-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div bind:this={element}></div>
|
||||
|
||||
{#if error}
|
||||
<span class="editor-error text-xs text-red-400">
|
||||
{error instanceof Array ? $_(error[0]) : error}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Modal
|
||||
id="editor-modal"
|
||||
title={"Insert/edit link"}
|
||||
size="md:min-w-lg"
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<TextField label={"URL"} bind:value={linkURL} error={linkURLError}
|
||||
></TextField>
|
||||
<TextField label={$_("text")} bind:value={linkText}></TextField>
|
||||
<Toggle
|
||||
label={$_("open-in-new-tab", { values: { n: 2 } })}
|
||||
bind:value={openLinkInNewTab}
|
||||
></Toggle>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
{#if editor?.getAttributes("link").href}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary shrink-0"
|
||||
onclick={() => unsetLink()}
|
||||
aria-label="Link"
|
||||
>
|
||||
<i class="fas fa-link-slash"></i>
|
||||
<span>{$_("unlink")}</span>
|
||||
</button>
|
||||
<div class="basis-full"></div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
onclick={() => modal.closeModal()}>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
name="save"
|
||||
onclick={() => setLink()}>{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
:global(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -42,7 +42,7 @@
|
||||
{id}
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
class="w-full {size} max-h-full rounded-xl text-content"
|
||||
class="{size} max-h-full rounded-xl text-content"
|
||||
>
|
||||
<!-- Modal content -->
|
||||
<div class="bg-background shadow rounded-xl">
|
||||
@@ -73,8 +73,9 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
<style lang="postcss">
|
||||
@reference "tailwindcss";
|
||||
dialog::backdrop {
|
||||
@apply bg-gray-500/50;
|
||||
@apply bg-gray-500 opacity-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import TextField from "./text_field.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import SearchList from "./search_list.svelte";
|
||||
import TextField from "./text_field.svelte";
|
||||
|
||||
interface Props {
|
||||
maxSearchLength?: number;
|
||||
@@ -118,34 +119,6 @@
|
||||
></TextField>
|
||||
|
||||
{#if dropDownOpen}
|
||||
<ul
|
||||
class="menu absolute bg-menu-background border border-input-border rounded-xl shadow-md overflow-x-hidden overflow-y-scroll max-h-72 w-full"
|
||||
class:none={!dropDownOpen}
|
||||
style="z-index: 1001"
|
||||
>
|
||||
{#each items as item}
|
||||
<!-- 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"
|
||||
onmousedown={(e) => handleItemClick(e, item)}
|
||||
onkeydown={(e) => handleItemClick(e, item)}
|
||||
>
|
||||
{#if prepend}{@render prepend({ item })}{:else}
|
||||
<i class="fa fa-{item.icon} basis-8 shrink-0"></i>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<p>{item.text}</p>
|
||||
{#if item.description}
|
||||
<p class="text-sm text-gray-500">
|
||||
{item.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<SearchList {items} extraClasses="w-full" {prepend} onclick={handleItemClick}></SearchList>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
67
web/src/lib/components/base/search_list.svelte
Normal file
67
web/src/lib/components/base/search_list.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { isURL } from "$lib/util/file_util";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { SearchItem } from "./search.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
interface Props {
|
||||
id?: string;
|
||||
items?: SearchItem[];
|
||||
element?: HTMLUListElement;
|
||||
onclick?: (e: Event, item: SearchItem) => void;
|
||||
prepend?: Snippet<[any]>;
|
||||
extraClasses?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
items = [],
|
||||
element = $bindable(),
|
||||
onclick,
|
||||
prepend,
|
||||
extraClasses,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul
|
||||
{id}
|
||||
class="menu absolute bg-menu-background border border-input-border rounded-xl shadow-md overflow-x-hidden overflow-y-scroll max-h-72 {extraClasses}"
|
||||
style="z-index: 1001"
|
||||
>
|
||||
{#if items.length == 0}
|
||||
<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"
|
||||
>
|
||||
{$_("no-results")}
|
||||
</li>
|
||||
{/if}
|
||||
{#each items as item}
|
||||
<!-- 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"
|
||||
onmousedown={(e) => onclick?.(e, item)}
|
||||
onkeydown={(e) => onclick?.(e, item)}
|
||||
>
|
||||
{#if prepend}{@render prepend({ item })}
|
||||
{:else if isURL(item.icon)}
|
||||
<img
|
||||
class="rounded-full w-8 mr-4 aspect-square"
|
||||
src={item.icon}
|
||||
alt="avatar"
|
||||
/>
|
||||
{:else}
|
||||
<i class="fa fa-{item.icon} basis-8 shrink-0"></i>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<p>{item.text}</p>
|
||||
{#if item.description}
|
||||
<p class="text-sm text-gray-500">
|
||||
{item.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -37,7 +37,7 @@
|
||||
{/if}
|
||||
<select
|
||||
{name}
|
||||
class="block bg-input-background h-10 px-4 border-r-8 border-transparent outline outline-1 outline-input-border rounded-md focus:outline-input-border-focus transition-colors"
|
||||
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:text-gray-500={disabled}
|
||||
{disabled}
|
||||
bind:value
|
||||
|
||||
53
web/src/lib/components/base/skeleton_table.svelte
Normal file
53
web/src/lib/components/base/skeleton_table.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<div class="animate-pulse space-y-4 p-4">
|
||||
<!-- Table Header Skeleton -->
|
||||
<div class="flex items-center justify-between gap-4 px-2">
|
||||
<div class="h-5 w-20 bg-menu-item-background-focus rounded"></div>
|
||||
<div class="h-5 w-16 bg-menu-item-background-focus rounded"></div>
|
||||
<div class="h-5 w-24 bg-menu-item-background-focus rounded"></div>
|
||||
<div class="h-5 w-24 bg-menu-item-background-focus rounded"></div>
|
||||
<div class="h-5 w-16 bg-menu-item-background-focus rounded"></div>
|
||||
<div class="h-5 w-12 bg-menu-item-background-focus rounded"></div>
|
||||
<div class="h-5 w-8 bg-menu-item-background-focus rounded"></div>
|
||||
</div>
|
||||
|
||||
<!-- Repeatable Skeleton Rows -->
|
||||
<div class="space-y-2">
|
||||
{#each { length: 5 } as _, index}
|
||||
<!-- Row 1 -->
|
||||
<div class="flex items-center justify-between gap-4 px-2">
|
||||
<!-- Image placeholder -->
|
||||
<div
|
||||
class="h-10 w-10 bg-menu-item-background-focus rounded-lg"
|
||||
></div>
|
||||
<!-- Date -->
|
||||
<div
|
||||
class="h-4 w-20 bg-menu-item-background-focus rounded"
|
||||
></div>
|
||||
<!-- Distance -->
|
||||
<div
|
||||
class="h-4 w-16 bg-menu-item-background-focus rounded"
|
||||
></div>
|
||||
<!-- Elevation gain -->
|
||||
<div
|
||||
class="h-4 w-24 bg-menu-item-background-focus rounded"
|
||||
></div>
|
||||
<!-- Elevation loss -->
|
||||
<div
|
||||
class="h-4 w-24 bg-menu-item-background-focus rounded"
|
||||
></div>
|
||||
<!-- Duration -->
|
||||
<div
|
||||
class="h-4 w-12 bg-menu-item-background-focus rounded"
|
||||
></div>
|
||||
<!-- Author -->
|
||||
<div
|
||||
class="h-6 w-6 bg-menu-item-background-focus rounded-full"
|
||||
></div>
|
||||
<!-- Map icon -->
|
||||
<div
|
||||
class="h-5 w-5 bg-menu-item-background-focus rounded-md"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,13 +44,16 @@
|
||||
});
|
||||
|
||||
export function set(value: number) {
|
||||
sliderContainer.noUiSlider.set(value)
|
||||
sliderContainer.noUiSlider.set(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="my-4" id="slider" bind:this={sliderContainer}></div>
|
||||
|
||||
<style>
|
||||
<style lang="postcss">
|
||||
@reference "tailwindcss";
|
||||
@reference "../../../css/app.css";
|
||||
|
||||
:global(.noUi-target) {
|
||||
@apply border border-input-border shadow-none;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{#each toastStore.toasts as t, i}
|
||||
<div
|
||||
class="fixed px-4 py-3 shadow-xl rounded-xl border right-4 bg-white text-gray-500 flex items-center max-w-md"
|
||||
class="fixed px-4 py-3 shadow-xl rounded-xl border border-input-border right-4 bg-white text-gray-500 flex items-center max-w-md"
|
||||
style="z-index: 1001; bottom: {16 + i * 72}px"
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 250 }}
|
||||
|
||||
Reference in New Issue
Block a user