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:
Flomp
2025-06-21 10:09:22 +02:00
committed by GitHub
parent 742a6d9d30
commit 9276690d60
356 changed files with 57150 additions and 35470 deletions

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import type { Actor } from "$lib/models/activitypub/actor";
import { searchActors } from "$lib/stores/search_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
import { _ } from "svelte-i18n";
import Search, { type SearchItem } from "./base/search.svelte";
interface Props {
label?: string;
value?: string;
includeSelf?: boolean;
clearAfterSelect?: boolean;
onclear?: () => void;
onclick?: (item: SearchItem) => void;
}
let {
label = "",
value = $bindable(""),
includeSelf = true,
clearAfterSelect = true,
onclear,
onclick,
}: Props = $props();
let searchItems: SearchItem[] = $state([]);
async function updateActors(q: string) {
if (!q.length) {
searchItems = [];
onclear?.();
return;
}
try {
const actors: Actor[] = await searchActors(q, includeSelf);
searchItems = actors.map((a) => ({
text: a.preferred_username!,
description: `@${a.username}${a.isLocal ? "" : "@" + a.domain}`,
value: a,
icon: "user",
}));
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: "Error during search",
});
}
}
function onClick(item: SearchItem) {
value = item.value.username ?? value;
onclick?.(item);
searchItems = [];
}
</script>
<Search
onupdate={(q) => updateActors(q)}
onclick={(item) => onClick(item)}
placeholder={`${$_("username")}...`}
items={searchItems}
{clearAfterSelect}
{label}
bind:value
>
{#snippet prepend({ item })}
<img
class="rounded-full w-8 aspect-square mr-2"
src={item.value.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${item.value.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{/snippet}
</Search>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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`;
}
}
}

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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

View 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>

View File

@@ -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;
}

View File

@@ -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 }}

View File

@@ -1,16 +1,17 @@
<script lang="ts">
import type { Comment } from "$lib/models/comment";
import { getFileURL } from "$lib/util/file_util";
import TextField from "../base/text_field.svelte";
import { fade } from "svelte/transition";
import { formatTimeSince } from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import { fade } from "svelte/transition";
import TextField from "../base/text_field.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import Editor from "../base/editor.svelte";
interface Props {
comment: Comment;
mode?: "show" | "edit";
ondelete?: (comment: Comment) => void
onedit?: (data: {comment: Comment, text: string}) => void
ondelete?: (comment: Comment) => void;
onedit?: (data: { comment: Comment; text: string }) => void;
}
let { comment, mode = "show", ondelete, onedit }: Props = $props();
@@ -24,15 +25,15 @@
});
let avatarSrc = $derived(
comment.expand?.author.avatar
? getFileURL(comment.expand.author, comment.expand.author.avatar)
comment.expand?.author.icon
? comment.expand?.author.icon
: `https://api.dicebear.com/7.x/initials/svg?seed=${comment.expand?.author.username ?? ""}&backgroundType=gradientLinear`,
);
let timeSince = $derived(formatTimeSince(new Date(comment.created ?? "")));
function deleteComment() {
ondelete?.(comment)
ondelete?.(comment);
}
function toggleEdit() {
@@ -48,38 +49,24 @@
in:fade={{ duration: 150 }}
out:fade={{ duration: 150 }}
>
{#if comment.expand?.author.private}
<a
href="/profile/{handleFromRecordWithIRI(comment)}"
class="text-sm font-semibold shrink-0"
>
<img
class="rounded-full w-10 aspect-square shrink-0"
class="rounded-full w-10 aspect-square"
src={avatarSrc}
alt="avatar"
/>
{:else}
<a
href="/profile/{comment.expand?.author.id}"
class="text-sm font-semibold shrink-0"
>
<img
class="rounded-full w-10 aspect-square"
src={avatarSrc}
alt="avatar"
/>
</a>
{/if}
</a>
<div>
<div class="flex items-center">
<p class="">
{#if comment.expand?.author.private}
<span class="text-sm font-semibold"
>{comment.expand?.author.username}</span
>
{:else}
<a
href="/profile/{comment.expand?.author.id}"
class="text-sm font-semibold"
>{comment.expand?.author.username}</a
>
{/if}
<a
href="/profile/{handleFromRecordWithIRI(comment)}"
class="text-sm font-semibold"
>{handleFromRecordWithIRI(comment)}</a
>
<span class="text-xs text-gray-500 ml-2"
>{$_(`n-${timeSince.unit}-ago`, {
values: { n: timeSince.value },
@@ -103,15 +90,20 @@
type="button"
class="btn-icon text-xs"
style="font-size: 0.75rem;"
onclick={deleteComment}><i class="fa fa-trash"></i></button
onclick={() => {
if (editing) {
editing = false;
} else {
deleteComment();
}
}}><i class="fa {editing ? 'fa-close' : 'fa-trash'}"></i></button
>
{/if}
</div>
{#if editing}
<TextField extraClasses="mt-2" bind:value={editedComment}
></TextField>
<Editor extraClasses="mt-2" bind:value={editedComment}></Editor>
{:else}
<p class="whitespace-pre-wrap text-sm">{comment.text}</p>
{@html comment.text}
{/if}
</div>
</div>

View File

@@ -13,7 +13,7 @@
<h5 class="font-semibold">Resources</h5>
<ul class="mt-4 text-sm">
<li>
<a href="https://wanderer.to/getting-started/installation/"
<a href="https://wanderer.to/run/installation/"
>{$_("documentation")}</a
>
</li>
@@ -36,7 +36,7 @@
<a href="https://wanderer.to/#features">{$_("features")}</a>
</li>
<li>
<a href="https://wanderer.to/getting-started/changelog/"
<a href="https://wanderer.to/run/changelog/"
>{$_("changelog")}</a
>
</li>

View File

@@ -7,10 +7,13 @@
import {
formatDistance,
formatElevation,
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import { currentUser } from "$lib/stores/user_store";
interface Props {
list: List;
@@ -19,27 +22,9 @@
let { list, active = false }: Props = $props();
let cumulativeDistance = $derived(list.expand?.trails?.reduce(
(s, b) => s + b.distance!,
0,
));
let cumulativeElevationGain = $derived(list.expand?.trails?.reduce(
(s, b) => s + b.elevation_gain!,
0,
));
let cumulativeElevationLoss = $derived(list.expand?.trails?.reduce(
(s, b) => s + b.elevation_loss!,
0,
));
let cumulativeDuration = $derived(list.expand?.trails?.reduce(
(s, b) => s + b.duration!,
0,
));
let listIsShared = $derived((list.expand?.list_share_via_list?.length ?? 0) > 0);
let listIsShared = $derived(
(list.expand?.list_share_via_list?.length ?? 0) > 0,
);
</script>
<div
@@ -58,16 +43,21 @@
<div class="self-start min-w-0 basis-full transition-transform">
<div class="flex items-center gap-3">
<h5 class="text-xl font-semibold overflow-hidden overflow-ellipsis">
<h5 class="text-xl font-semibold overflow-hidden overflow-ellipsis shrink-0 max-w-3/4 whitespace-nowrap">
{list.name}
</h5>
{#if list.public}
<span class="tooltip" data-title={$_("public")}>
<i class="fa fa-globe"></i>
</span>
{/if}
{#if listIsShared}
<ShareInfo type="list" subject={list}></ShareInfo>
<div class="basis-full"></div>
{#if $currentUser}
{#if list.public}
<span class="tooltip" data-title={$_("public")}>
<i class="fa fa-globe"></i>
</span>
{/if}
{#if list.expand?.list_share_via_list?.length}
<span class="tooltip" data-title={$_("shared")}>
<i class="fa fa-share-nodes"></i>
</span>
{/if}
{/if}
</div>
{#if list.expand?.author}
@@ -75,14 +65,11 @@
{$_("by")}
<img
class="rounded-full w-5 aspect-square mx-1 inline"
src={getFileURL(
list.expand.author,
list.expand.author.avatar,
) ||
src={list.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${list.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{list.expand?.author.username}
{handleFromRecordWithIRI(list)}
</p>
{/if}
<div
@@ -90,27 +77,27 @@
>
<span
><i class="fa fa-left-right mr-2"></i>{formatDistance(
cumulativeDistance,
list.distance,
)}</span
>
<span
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
cumulativeDuration,
list.duration,
)}</span
>
<span
><i class="fa fa-arrow-trend-up mr-2"></i>{formatElevation(
cumulativeElevationGain,
list.elevation_gain,
)}</span
>
<span
><i class="fa fa-arrow-trend-down mr-2"></i>{formatElevation(
cumulativeElevationLoss,
list.elevation_loss,
)}</span
>
</div>
<p class="text-sm text-gray-500 mb-2">
{list.expand?.trails?.length ?? 0}
{list.trails?.length ?? 0}
{$_("trail", {
values: { n: list.expand?.trails?.length ?? 0 },
})}
@@ -120,7 +107,11 @@
? ''
: 'max-h-24 overflow-hidden text-ellipsis'}"
>
{!active ? list.description?.substring(0, 100) : list.description}
{formatHTMLAsText(
!active
? list.description?.substring(0, 100)
: list.description,
)}
{#if (list.description?.length ?? 0) > 100 && !active}
...
{/if}

View File

@@ -15,6 +15,7 @@
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import ShareInfo from "../share_info.svelte";
import TrailListItem from "../trail/trail_list_item.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
interface Props {
list: List;
onclick?: (data: { trail: Trail; index: number }) => void;
@@ -43,15 +44,17 @@
);
let allowEdit = $derived(
list.author == $currentUser?.id ||
list.author == $currentUser?.actor ||
list.expand?.list_share_via_list?.some(
(s) => s.permission == "edit",
),
);
let allowShare = $derived(
list.author == $currentUser?.id &&
!list.expand?.trails?.some((t) => t.author !== $currentUser?.id),
list.author == $currentUser?.actor &&
!list.expand?.trails?.some(
(t) => !t.public && t.author !== $currentUser?.actor,
),
);
let dropdownItems = $derived([
@@ -61,7 +64,7 @@
...(allowEdit
? [{ text: $_("edit"), value: "edit", icon: "pen" }]
: []),
...(list.author == $currentUser?.id
...(list.author == $currentUser?.actor
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
: []),
]);
@@ -88,7 +91,7 @@
<div class="relative">
{#if (list.public || listIsShared) && $currentUser}
<div
class="flex absolute top-4 right-6 {list.public && listIsShared
class="flex absolute top-4 left-6 {list.public && listIsShared
? 'w-16'
: 'w-8'} h-8 rounded-full items-center justify-center bg-white text-primary"
>
@@ -139,20 +142,13 @@
{$_("by")}
<img
class="rounded-full w-8 aspect-square mx-1 inline"
src={getFileURL(
list.expand.author,
list.expand.author.avatar,
) ||
src={list.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${list.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{#if !list.expand.author.private}
<a class="underline" href="/profile/{list.expand.author.id}"
>{list.expand.author.username}</a
>
{:else}
<span>{list.expand.author.username}</span>
{/if}
<a class="underline" href="/profile/{handleFromRecordWithIRI(list)}"
>{handleFromRecordWithIRI(list)}</a
>
</p>
{/if}
<hr />
@@ -181,20 +177,22 @@
>
</div>
<hr class="mb-4" />
<p
class="text-gray-500 whitespace-pre-wrap {fullDescription
? ''
: 'max-h-24 overflow-hidden text-ellipsis'}"
>
{!fullDescription
? list.description?.substring(0, 100)
: list.description}
{#if (list.description?.length ?? 0) > 100 && !fullDescription}
<button onclick={() => (fullDescription = true)}>
... <span class="underline">{$_("read-more")}</span></button
>
{/if}
</p>
{#if list.description}
<p
class="text-gray-500 whitespace-pre-wrap {fullDescription
? ''
: 'max-h-24 overflow-hidden text-ellipsis'} prose dark:prose-invert"
>
{@html !fullDescription
? list.description?.substring(0, 100)
: list.description}
{#if (list.description?.length ?? 0) > 100 && !fullDescription}
<button onclick={() => (fullDescription = true)}>
... <span class="underline">{$_("read-more")}</span></button
>
{/if}
</p>
{/if}
<h5 class="text-xl font-semibold my-4">
{list.trails?.length ?? 0}
{$_("trail", { values: { n: list.trails?.length ?? 0 } })}

View File

@@ -39,7 +39,7 @@
<Modal
id="list-modal"
title={$_("select-list")}
size="max-w-sm"
size="md:min-w-sm"
bind:this={modal}
>
{#snippet children({ openModal })}

View File

@@ -12,30 +12,28 @@
} from "$lib/stores/list_share_store";
import {
trail_share_create,
trail_share_delete,
trail_share_index,
} from "$lib/stores/trail_share_store";
import { getFileURL } from "$lib/util/file_util";
import { currentUser } from "$lib/stores/user_store";
import { _ } from "svelte-i18n";
import ActorSearch from "../actor_search.svelte";
import Button from "../base/button.svelte";
import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte";
import UserSearch from "../user_search.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
interface Props {
list: List;
onsave?: () => void,
onupdate?: (list: List) => void
onsave?: () => void;
onupdate?: (list: List) => void;
}
let {
list,
onsave,
onupdate
}: Props = $props();
let { list, onsave, onupdate }: Props = $props();
let modal: Modal;
let displayShareError = $state(false);
export function openModal() {
openShareModal();
}
@@ -56,7 +54,7 @@
function copyURLToClipboard() {
navigator.clipboard.writeText(
window.location.href.split("?")[0] + "?list=" + list.id,
`${window.location.origin}/lists/${handleFromRecordWithIRI(list)}/${list.id}`,
);
copyButtonText = $_("link-copied");
@@ -64,25 +62,36 @@
}
function close() {
onsave?.()
onsave?.();
modal.closeModal!();
}
async function shareTrails(userId: string) {
const existingTrailShares = await trail_share_index({ user: userId });
async function shareTrails(actorIRI: string) {
const existingTrailShares = await trail_share_index({
actorIRI: actorIRI,
});
const trailIds = existingTrailShares.map((s) => s.trail);
for (const trailId of list.trails ?? []) {
if (!trailIds.includes(trailId)) {
const share = new TrailShare(userId, trailId, "view");
for (const trail of list.expand?.trails ?? []) {
if (
!trailIds.includes(trail.id!) &&
!trail.public &&
trail.author == $currentUser?.actor
) {
const share = new TrailShare(actorIRI, trail.id!, "view");
await trail_share_create(share);
}
}
}
async function shareList(item: SelectItem) {
const share = new ListShare(item.value.id, list.id!, "view");
if (!item.value.isLocal && !list.public) {
displayShareError = true;
return;
}
displayShareError = false;
const share = new ListShare(item.value.iri, list.id!, "view");
await list_share_create(share);
await shareTrails(item.value.id);
await shareTrails(item.value.iri);
fetchShares();
}
@@ -94,18 +103,6 @@
await list_share_update(share);
}
async function deleteTrailShares(userId: string) {
const existingTrailShares = await trail_share_index({ user: userId });
for (const trailId of list.trails ?? []) {
const shareToDelete = existingTrailShares.find(
(s) => s.trail == trailId,
);
if (shareToDelete) {
await trail_share_delete(shareToDelete);
}
}
}
async function deleteShare(share: ListShare) {
await list_share_delete(share);
// await deleteTrailShares(share.user);
@@ -127,18 +124,26 @@
<Modal
id="share-modal"
title={$_("share-this-list")}
size="max-w-sm overflow-visible"
size="md:min-w-sm overflow-visible"
bind:this={modal}
>
{#snippet content()}
<div>
<p class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500">
<p
class="p-4 bg-amber-100 rounded-xl mb-4 text-sm text-gray-500 max-w-sm"
>
{$_("list-share-warning")}
</p>
<UserSearch
includeSelf={false}
onclick={(item) => shareList(item)}
></UserSearch>
{#if displayShareError}
<p class="p-4 bg-red-100 rounded-xl mb-4 text-sm text-gray-500">
<i class="fa fa-warning mr-2"></i>
{$_("object-share-error", {
values: { object: $_("trail", { values: { n: 1 } }) },
})}
</p>
{/if}
<ActorSearch includeSelf={false} onclick={(item) => shareList(item)}
></ActorSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#if $shares.length == 0}
@@ -151,22 +156,26 @@
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
src={share.expand.actor.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.actor.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<p>
{`@${share.expand.actor.username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
</p>
<span
class="basis-full text-sm text-center text-gray-500"
>{$_("can")}</span
>
<div class="shrink-0">
<div
class="shrink-0"
class:tooltip={!share.expand.actor.isLocal}
data-title={$_("remote-users-cannot-edit")}
>
<Select
bind:value={share.permission}
items={permissionSelectItems}
disabled={!share.expand.actor.isLocal}
onchange={(value) =>
updateSharePermission(share, value)}
></Select>

View File

@@ -72,7 +72,7 @@
case "/map":
childPosition = 3;
break;
case "/lists":
case "/lists/[[handle]]/[[id]]":
childPosition = 4;
break;
default:
@@ -96,7 +96,7 @@
function handleDropdownClick(item: { text: string; value: any }) {
if (item.value == "profile") {
goto(`/profile/${$currentUser?.id}`);
goto(`/profile/@${$currentUser?.username?.toLowerCase()}`);
} else if (item.value == "logout") {
logout();
window.location.href = "/";
@@ -142,15 +142,21 @@
<div class="basis-full"></div>
<hr class="border-input-border" />
<div class="flex gap-4 items-center justify-between m-4">
<a class="shrink-0" href="/profile/{user.id}">
<a
class="shrink-0"
href="/profile/@{user.username.toLowerCase()}"
>
<img
class="rounded-full w-10 aspect-square"
src={getFileURL(user, user.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username}&backgroundType=gradientLinear`}
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username.toLowerCase()}&backgroundType=gradientLinear`}
alt="avatar"
/>
</a>
<a href="/profile/{user.id}" style="width: calc(100% - 104px)">
<a
href="/profile/@{user.username.toLowerCase()}"
style="width: calc(100% - 104px)"
>
<p class="text-sm overflow-hidden text-ellipsis">
{user.username}
</p>
@@ -241,7 +247,7 @@
<img
class="rounded-full w-full h-full"
src={getFileURL(user, user.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username}&backgroundType=gradientLinear`}
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username.toLowerCase()}&backgroundType=gradientLinear`}
alt="avatar"
/>
</button>

View File

@@ -3,33 +3,27 @@
NotificationType,
type Notification,
} from "$lib/models/notification";
import { getFileURL } from "$lib/util/file_util";
import { formatTimeSince } from "$lib/util/format_util";
import { _ } from "svelte-i18n";
interface Props {
notification: Notification;
onclick?: (data: {notification: Notification, link: string | null}) => void
onclick?: (data: {
notification: Notification;
link: string | null;
}) => void;
}
let { notification, onclick }: Props = $props();
const avatarSrc = notification.expand?.author.avatar
? getFileURL(
notification.expand.author,
notification.expand.author.avatar,
)
const avatarSrc = notification.expand?.author.icon
? notification.expand.author.icon
: `https://api.dicebear.com/7.x/initials/svg?seed=${notification.expand?.author.username ?? ""}&backgroundType=gradientLinear`;
const timeSince = formatTimeSince(new Date(notification.created ?? ""));
function getTitle(n: Notification) {
switch (n.type) {
case NotificationType.listCreate:
return $_("notification-list-create", {
values: { user: n.expand.author.username },
});
case NotificationType.listShare:
return $_("notification-list-share", {
values: { user: n.expand.author.username },
@@ -40,53 +34,84 @@
return $_("notification-trail-comment", {
values: {
user: n.expand.author.username,
trail: n.metadata?.trail,
trail: n.metadata?.trail_name,
},
});
case NotificationType.trailCreate:
return $_("notification-trail-create", {
values: { user: n.expand.author.username },
});
case NotificationType.trailShare:
return $_("notification-trail-share", {
values: { user: n.expand.author.username },
});
case NotificationType.summitLogCreate:
return $_("notification-summit-log-create", {
values: {
user: n.expand.author.username,
trail: n.metadata?.trail_name,
},
});
case NotificationType.trailLike:
return $_("notification-trail-like", {
values: {
user: n.expand.author.username,
trail: n.metadata?.trail_name,
},
});
case NotificationType.commentMention:
return $_("notification-comment-mention", {
values: {
user: n.expand.author.username,
trail: n.metadata?.trail_name,
},
});
case NotificationType.trailMention:
return $_("notification-trail-mention", {
values: {
user: n.expand.author.username,
trail: n.metadata?.trail_name,
},
});
case NotificationType.summitLogMention:
return $_("notification-summit-log-mention", {
values: {
user: n.expand.author.username,
trail: n.metadata?.trail_name,
},
});
}
}
function getDescription(n: Notification) {
switch (n.type) {
case NotificationType.listCreate:
return n.metadata?.list ?? "";
case NotificationType.listShare:
return n.metadata?.list ?? "";
case NotificationType.newFollower:
return n.expand.author.username ?? "";
case NotificationType.trailComment:
return n.metadata?.comment ?? "";
case NotificationType.trailCreate:
return n.metadata?.trail ?? "";
case NotificationType.trailShare:
return n.metadata?.trail ?? "";
default:
return "";
}
}
function getLink(n: Notification) {
switch (n.type) {
case NotificationType.listCreate:
return `/lists?list=${n.metadata?.id}`;
case NotificationType.listShare:
return `/lists?list=${n.metadata?.id}`;
return `/lists/${n.metadata?.author}/${n.metadata?.id}`;
case NotificationType.newFollower:
return n.expand.author.private === true
? null
: `/profile/${n.author}`;
return `/profile/${n.metadata?.follower}`;
case NotificationType.trailComment:
return `/trail/view/${n.metadata?.id}?t=4`;
case NotificationType.trailCreate:
return `/trail/view/${n.metadata?.id}`;
return `/trail/view/${n.metadata?.trail_author}/${n.metadata?.trail_id}?t=2`;
case NotificationType.trailShare:
return `/trail/view/${n.metadata?.id}`;
return `/trail/view/${n.metadata?.author}/${n.metadata?.id}`;
case NotificationType.summitLogCreate:
return `/trail/view/${n.metadata?.trail_author}/${n.metadata?.trail_id}`;
case NotificationType.trailLike:
return `/trail/view/${n.metadata?.trail_author}/${n.metadata?.trail_id}`;
case NotificationType.commentMention:
return `/trail/view/${n.metadata?.trail_author}/${n.metadata?.trail_id}?t=2`;
case NotificationType.trailMention:
return `/trail/view/${n.metadata?.trail_author}/${n.metadata?.trail_id}`;
case NotificationType.summitLogMention:
return `/trail/view/${n.metadata?.trail_author}/${n.metadata?.trail_id}`;
}
}
@@ -107,10 +132,10 @@
<div>
<p
class="text-sm {notification.seen
? 'font-medium'
? 'font-normal'
: 'font-semibold'} mr-3"
>
{title}:
{title}
</p>
<p class="text-sm line-clamp-1">{description}</p>
<p class="text-xs text-gray-500">

View File

@@ -30,11 +30,9 @@
notifications.reduce((value, n) => (value += n.seen ? 0 : 1), 0),
);
onMount(() => {
if (!notifications.length && page.data.notifications?.items?.length) {
notifications = page.data.notifications.items;
}
});
$effect(() => {
notifications = page.data.notifications.items
})
async function toggleMenu(e: MouseEvent) {
e.stopPropagation();
@@ -66,7 +64,7 @@
scrollTop + clientHeight >= scrollHeight * 0.8 &&
pagination.page !== pagination.totalPages &&
!loadingNextPage
) {
) {
await loadNextPage();
}
}
@@ -79,7 +77,7 @@
}
pagination.page += 1;
const result = await notifications_index(
{ recipient: $currentUser.id },
{ recipient: $currentUser.actor },
pagination.page,
);

View File

@@ -1,39 +1,34 @@
<script lang="ts">
import type { Activity } from "$lib/models/activity";
import type { UserAnonymous } from "$lib/models/user";
import type { Actor } from "$lib/models/activitypub/actor";
import type { TimelineItem } from "$lib/models/timeline";
import { getFileURL, isVideoURL } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
interface Props {
activity: Activity;
user: UserAnonymous;
activity: TimelineItem;
actor: Actor;
}
let { activity, user }: Props = $props();
let { activity, actor }: Props = $props();
let fullDescription: boolean = $state(false);
</script>
<div class="activity-card p-6 space-y-6 rounded-xl border border-input-border">
<div class="activity-card p-6 ounded-xl border border-input-border">
<div class="flex gap-x-4 items-start">
<img
class="rounded-full w-10 aspect-square overflow-hidden"
src={getFileURL(user, user.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username}&backgroundType=gradientLinear`}
src={actor.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${actor.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div>
{#if !user.private}
<a class="underline" href="/profile/{user.id}"
>{user.username}</a
>
{:else}
<span>{user.username}</span>
{/if}
<span class="font-semibold">{actor.preferred_username}</span>
{activity.type === "trail"
? $_("planned-a-trail")
: $_("completed-a-trail")}
@@ -74,9 +69,9 @@
<div
class="grid gap-[1px] {activity.photos.length > 1
? 'grid-cols-[8fr_5fr]'
: 'grid-cols-1'}"
: 'grid-cols-1'} mt-6"
>
{#each activity.photos.slice(0,3) as photo, i}
{#each activity.photos.slice(0, 3) as photo, i}
{#if isVideoURL(photo)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
@@ -109,10 +104,12 @@
</div>
{/if}
{#if activity.description.length}
<p class="text-sm whitespace-pre-wrap">
{!fullDescription
? activity.description?.substring(0, 100)
: activity.description}
<p class="text-sm whitespace-pre-wrap mt-6">
{formatHTMLAsText(
!fullDescription
? activity.description?.substring(0, 100)
: activity.description,
)}
{#if (activity.description?.length ?? 0) > 100 && !fullDescription}
<button
onclick={(e) => {

View File

@@ -44,7 +44,7 @@
<Modal
id="password-modal"
size="max-w-xl"
size="md:max-w-xl"
title={$_("share-profile")}
bind:this={modal}
>

View File

@@ -42,7 +42,7 @@
<Modal
id="email-modal"
size="max-w-xl"
size="md:min-w-md"
title={$_("change-email")}
bind:this={modal}
>

View File

@@ -50,7 +50,7 @@
<Modal
id="komoot-settings-modal"
size="max-w-lg"
size="md:max-w-lg"
title={"komoot " + $_("settings")}
bind:this={modal}
>

View File

@@ -46,7 +46,7 @@
<Modal
id="strava-settings-modal"
size="max-w-lg"
size="md:min-w-lg"
title={"strava " + $_("settings")}
bind:this={modal}
>

View File

@@ -95,7 +95,7 @@
<Modal
id="password-modal"
size="max-w-sm"
size="md:min-w-sm"
title={$_("change-password")}
bind:this={modal}
>

View File

@@ -183,7 +183,7 @@
<button
class="underline"
onclick={() =>
goto(`/trail/view/${u.duplicate!.id}`)}
goto(`/trail/view/@${u.duplicate?.domain}/${u.duplicate!.id}`)}
>{u.duplicate.name}</button
>
</p>

View File

@@ -72,7 +72,7 @@
<Modal
id="url-import-modal"
size="max-w-xl"
size="md:min-w-sm"
title={$_("from-url")}
bind:this={modal}
>
@@ -88,7 +88,7 @@
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
<button type="button" class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<Button primary type="submit" form="url-import-form" {loading}>{$_("import")}</Button>

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import type { Actor } from "$lib/models/activitypub/actor";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import type { UserAnonymous } from "$lib/models/user";
import { currentUser, users_show } from "$lib/stores/user_store";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import { fly } from "svelte/transition";
@@ -22,29 +24,7 @@
let showInfo: boolean = $state(false);
let loading: boolean = $state(false);
let infoLoaded: boolean = $state(false);
let subjectIsOwned: boolean = subject.author == $currentUser?.id;
let author: UserAnonymous | undefined = $state();
async function fetchInfo() {
if (!infoLoaded) {
loading = true;
loading = false;
if (subjectIsOwned) {
for (const share of shareData ?? []) {
share.expand = {
user: await users_show(share.user),
};
}
} else {
author = await users_show(subject.author!);
}
infoLoaded = true;
}
showInfo = true;
}
let subjectIsOwned: boolean = subject.author == $currentUser?.actor;
</script>
<div class="relative trail-share-info">
@@ -53,18 +33,18 @@
class:text-xl={large}
role="button"
tabindex="0"
onmouseenter={fetchInfo}
onmouseenter={() => (showInfo = true)}
onmouseleave={() => (showInfo = false)}
></i>
{#if showInfo}
<ul
class="menu absolute z-10 top-8 bg-menu-background border border-input-border rounded-xl shadow-md overflow-hidden p-3 space-y-3 text-content -translate-x-3/4"
class="menu absolute z-10 top-8 bg-menu-background border border-input-border rounded-xl shadow-md overflow-hidden p-3 space-y-3 text-content"
in:fly={{ y: -10, duration: 150 }}
out:fly={{ y: -10, duration: 150 }}
>
{#if loading}
<div class="spinner spinner-dark"></div>
{:else if infoLoaded && shareData}
{:else if shareData}
{#if subjectIsOwned}
{#each shareData as share}
{#if share.expand}
@@ -72,15 +52,12 @@
<div class="flex items-center mr-8">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
src={share.expand.actor.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.actor.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p class="font-semibold text-base">
{share.expand.user.username}
{`@${share.expand.actor.username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
</p>
<span class="mx-2 text-sm text-gray-500"
>{$_("can")}</span
@@ -96,7 +73,7 @@
</li>
{/if}
{/each}
{:else if author}
{:else if subject.expand?.author}
<li>
<p class="text-xs text-gray-500 mb-2">
{$_("shared-by")}
@@ -104,12 +81,12 @@
<div class="flex items-center mr-8">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(author, author.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${author.username}&backgroundType=gradientLinear`}
src={subject.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${subject.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p class="font-semibold">
{author.username}
{handleFromRecordWithIRI(subject)}
</p>
</div>
<p class="text-xs text-gray-500 mt-2">

View File

@@ -6,12 +6,18 @@
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import { theme } from "$lib/stores/theme_store";
import { getFileURL, isVideoURL, readAsDataURLAsync } from "$lib/util/file_util";
import {
getFileURL,
isVideoURL,
readAsDataURLAsync,
} from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
let thumbnail: string = $state(
$theme === "light" ? emptyStateTrailLight : emptyStateTrailDark,
@@ -91,6 +97,18 @@
<Dropdown items={dropdownItems} {onchange}></Dropdown>
{/if}
</div>
{#if log.expand?.author}
<p class="text-xs text-gray-500 mb-3">
{$_("by")}
<img
class="rounded-full w-5 aspect-square mx-1 inline"
src={log.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${log.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{handleFromRecordWithIRI(log)}
</p>
{/if}
{#if log.distance || log.elevation_gain || log.elevation_loss || log.duration}
<div
class="flex mt-1 gap-x-4 text-sm text-gray-500 flex-wrap mb-2"
@@ -115,7 +133,7 @@
>
</div>
{/if}
<span class="whitespace-pre-wrap">{log.text}</span>
<span class="whitespace-pre-wrap">{formatHTMLAsText(log.text)}</span>
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@
import PhotoPicker from "../trail/photo_picker.svelte";
import TrailPicker from "../trail/trail_picker.svelte";
import type { SummitLog } from "$lib/models/summit_log";
import Editor from "../base/editor.svelte";
interface Props {
children?: Snippet<[any]>;
onsave?: (summitLog: SummitLog) => void;
@@ -108,6 +109,7 @@
<Modal
id="summit-log-modal"
title={$data.id ? $_("edit-entry") : $_("add-entry")}
size="md:min-w-2xl"
bind:this={modal}
>
{#snippet children({ openModal })}
@@ -134,7 +136,7 @@
showThumbnailControls={false}
></PhotoPicker>
</div>
<div class="flex gap-4">
<div class="flex gap-4 items-end">
{#if $data.expand}
<TrailPicker
bind:trailFile={$data._gpx}
@@ -144,12 +146,13 @@
></TrailPicker>
{/if}
<div class="basis-full">
<Textarea
name="text"
extraClasses="h-28"
<Editor
bind:value={$data.text}
extraClasses="min-h-28"
label={$_("text")}
error={$errors.text}
></Textarea>
searchListPosition="fixed"
></Editor>
</div>
</div>
</form>

View File

@@ -10,23 +10,32 @@
import { _ } from "svelte-i18n";
import MapWithElevationMaplibre from "../trail/map_with_elevation_maplibre.svelte";
import { fetchGPX } from "$lib/stores/trail_store";
import { currentUser } from "$lib/stores/user_store";
interface Props {
summitLogs?: SummitLog[];
handle: string;
showCategory?: boolean;
showTrail?: boolean;
showAuthor?: boolean;
showRoute?: boolean;
showPhotos?: boolean;
showMenu?: boolean;
ondelete?: (summitLog: SummitLog) => void;
onedit?: (summitLog: SummitLog) => void;
}
let {
summitLogs = [],
handle,
showCategory = false,
showTrail = false,
showAuthor = false,
showRoute = false,
showPhotos = false,
showMenu = false,
ondelete,
onedit,
}: Props = $props();
let mapModal: Modal;
@@ -64,7 +73,7 @@
}
</script>
<table class="w-full">
<table class="w-full table-auto">
<thead class="text-left text-gray-500">
<tr class="text-sm">
{#if showPhotos && summitLogs.some((l) => l.photos.length)}
@@ -98,12 +107,16 @@
{$_("map")}
</th>
{/if}
{#if showMenu}
<th> </th>
{/if}
</tr>
</thead>
<tbody>
{#each summitLogs as log, i}
<SummitLogTableRow
{log}
{handle}
onopen={(log) => openMap(log)}
ontext={(log) => openText(log)}
{showCategory}
@@ -113,6 +126,9 @@
summitLogs.some((l) => l.photos.length)}
showDescription={summitLogs.some((l) => l.text?.length)}
showRoute={showRoute && summitLogs.some((l) => l.gpx)}
showMenu={showMenu && log.author == $currentUser?.actor}
{ondelete}
{onedit}
></SummitLogTableRow>
{/each}
</tbody>
@@ -122,7 +138,7 @@
{/if}
<Modal
id="summit-log-table-map-modal"
size="max-w-4xl"
size="md:min-w-2xl lg:min-w-4xl"
title=""
bind:this={mapModal}
>
@@ -137,17 +153,17 @@
</Modal>
<Modal
id="summit-log-table-text-modal"
size="max-w-xl"
size="md:min-w-xl"
title={$_("description")}
bind:this={textModal}
>
{#snippet content()}
<p class="whitespace-pre-wrap">{currentText}</p>
<div class="prose dark:prose-invert">{@html currentText}</div>
{/snippet}
</Modal>
<style>
th {
padding: 0rem 0.5rem;
padding: 0.5rem 0.5rem;
}
</style>

View File

@@ -5,38 +5,59 @@
import {
formatDistance,
formatElevation,
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import PhotoGallery from "../photo_gallery.svelte";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
interface Props {
log: SummitLog;
handle: string;
showCategory?: boolean;
showTrail?: boolean;
showRoute?: boolean;
showAuthor?: boolean;
showDescription?: boolean;
showPhotos?: boolean;
showMenu?: boolean;
ontext?: (summitLog: SummitLog) => void;
onopen?: (summitLog: SummitLog) => void;
ondelete?: (summitLog: SummitLog) => void;
onedit?: (summitLog: SummitLog) => void;
}
let {
log,
handle,
showCategory = false,
showTrail = false,
showRoute = false,
showAuthor = false,
showDescription = false,
showPhotos = false,
showMenu = false,
onopen,
ontext,
ondelete,
onedit,
}: Props = $props();
let gallery: PhotoGallery;
let imgSrc: string[] = $state([]);
let dropdownItems: DropdownItem[] = [
{
text: $_("edit"),
value: "edit",
},
{
text: $_("delete"),
value: "delete",
},
];
$effect(() => {
if (log.photos?.length) {
imgSrc = log.photos
@@ -65,6 +86,14 @@
showDescription,
].reduce((b, v) => (v ? b + 1 : b), 7);
}
function handleDropdownClick(item: DropdownItem): void {
if (item.value == "edit") {
onedit?.(log);
} else if (item.value == "delete") {
ondelete?.(log);
}
}
</script>
<tr class="text-sm">
@@ -130,10 +159,7 @@
</td>
{#if showCategory}
<td>
{$_(
log.expand?.trails_via_summit_logs?.at(0)?.expand?.category
?.name ?? "-",
)}
{$_(log.expand?.trail?.expand?.category?.name ?? "-")}
</td>
{/if}
{#if showTrail}
@@ -141,8 +167,7 @@
<a
aria-label="Go to trail"
class="btn-icon aspect-square"
href="/trail/view/{log.expand?.trails_via_summit_logs?.at(0)
?.id ?? ''}"
href="/trail/view/{handle}/{log.expand?.trail?.id ?? ''}"
><i class="fa fa-arrow-up-right-from-square px-[3px]"></i></a
>
</td>
@@ -154,7 +179,7 @@
><p
class="rounded-full bg-menu-item-background-hover hover:bg-menu-item-background-focus text-ellipsis max-w-28 whitespace-nowrap overflow-hidden px-3 py-1"
>
{log.text}
{formatHTMLAsText(log.text)}
</p></button
>
{/if}
@@ -164,43 +189,54 @@
<td>
<p
class="tooltip flex justify-center"
data-title={log.expand.author.username}
data-title="{log.expand.author.username}{log.expand.author
.isLocal
? ''
: '@' + log.expand.author.domain}"
>
{#if !log.expand.author.private}
<a href="/profile/{log.expand.author.id}">
<img
class="rounded-full w-7 aspect-square"
src={getFileURL(
log.expand?.author,
log.expand?.author.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${log.expand?.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
</a>
{:else}
<a
href="/profile/@{log.expand.author.username?.toLowerCase()}{log
.expand.author.isLocal
? ''
: '@' + log.expand.author.domain}"
>
<img
class="rounded-full w-7 aspect-square"
src={getFileURL(
log.expand?.author,
log.expand?.author.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${log.expand?.author.username}&backgroundType=gradientLinear`}
src={log.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${log.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{/if}
</a>
</p>
</td>
{/if}
{#if showRoute && log.gpx}
{#if showRoute}
<td>
<button
aria-label="Open route"
onclick={openRoute}
class="btn-icon"
>
<i class="fa fa-map-location-dot px-[3px] text-xl"></i></button
>
{#if log.gpx}
<button
aria-label="Open route"
onclick={openRoute}
class="btn-icon"
>
<i class="fa fa-map-location-dot px-[3px] text-xl"
></i></button
>
{/if}
</td>
{/if}
{#if showMenu}
<td>
<Dropdown onchange={handleDropdownClick} items={dropdownItems}>
{#snippet children({ toggleMenu: openDropdown })}
<button
aria-label="Open dropdown"
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-6 w-6"
onclick={openDropdown}
>
<i class="fa fa-ellipsis-vertical"></i>
</button>
{/snippet}
</Dropdown>
</td>
{/if}
</tr>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import type { Trail } from "$lib/models/trail";
import { TrailLike } from "$lib/models/trail_like";
import { show_toast } from "$lib/stores/toast_store.svelte";
import {
trail_like_create,
trail_like_delete,
} from "$lib/stores/trail_like_store";
import { currentUser } from "$lib/stores/user_store";
import confetti from "canvas-confetti";
import { _ } from "svelte-i18n";
interface Props {
trail: Trail;
}
let likeButton: HTMLButtonElement;
let { trail }: Props = $props();
let likeCount = $state(trail.like_count);
let loading = $state(false);
async function like(e: Event) {
e.stopPropagation();
e.preventDefault();
if (!$currentUser || !trail.id) {
return;
}
loading = true;
try {
if (trailLike) {
await trail_like_delete(trailLike);
likeCount -= 1;
trailLike = undefined;
} else {
trailLike = await trail_like_create(
new TrailLike($currentUser.actor, trail.id),
);
likeCount += 1;
const rect = likeButton.getBoundingClientRect();
const x = (rect.left + rect.width / 2) / window.innerWidth;
const y = (rect.top + rect.height / 2) / window.innerHeight;
confetti({
particleCount: 60,
startVelocity: 20,
spread: 40,
origin: { x, y },
colors: ["#fb2c36"],
scalar: 1.2,
});
}
} catch (e) {
show_toast({
icon: "close",
text: $_("error-liking-trail"),
type: "error",
});
} finally {
loading = false;
}
}
let trailLike = $state(
trail.expand?.trail_like_via_trail?.find(
(l) => l.actor === $currentUser?.actor,
),
);
</script>
<button
class="like-button rounded-full w-12 py-2 bg-background {$currentUser ? 'hover:bg-secondary-hover' : ''} transition-colors relative"
aria-label="like"
type="button"
bind:this={likeButton}
disabled={!$currentUser}
onclick={like}
data-title={$_("likes")}
>
<i
class="{trailLike ? 'fa' : 'fa-regular'} fa-heart text-3xl"
class:text-red-500={trailLike !== undefined}
class:loading
></i>
<p class="font-semibold">{likeCount}</p>
</button>
<style>
.like-button > i.loading {
animation: heartbeat 1s infinite;
}
@keyframes heartbeat {
0%,
100% {
transform: scale(1);
}
20% {
transform: scale(1.1);
}
40% {
transform: scale(0.95);
}
60% {
transform: scale(1.05);
}
}
</style>

View File

@@ -13,12 +13,10 @@
createPopupFromTrail,
FontawesomeMarker,
} from "$lib/util/maplibre_util";
import { polylineToGeoJSON } from "$lib/util/polyline_util";
import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control";
import { FullscreenControl } from "$lib/vendor/maplibre-fullscreen/fullscreen-control";
import MaplibreGraticule from "$lib/vendor/maplibre-graticule/maplibre-graticule";
import { StyleSwitcherControl } from "$lib/vendor/maplibre-style-switcher/style-switcher-control";
import { T } from "@threlte/core";
import type { Feature, FeatureCollection, GeoJSON } from "geojson";
import * as M from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
@@ -1135,6 +1133,17 @@
});
function handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
const isInputField =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
if (isInputField) {
return;
}
if (e.key == "m") {
if (trails.length === 1) {
removeCaretLayer();
@@ -1144,6 +1153,17 @@
}
function handleKeyup(e: KeyboardEvent) {
const target = e.target as HTMLElement;
const isInputField =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
if (isInputField) {
return;
}
if (e.key == "m") {
if (trails.length === 1) {
addTrailLayer(trails[0], trails[0].id!, 0, data[0]);
@@ -1160,7 +1180,10 @@
<svelte:window on:keydown={handleKeydown} on:keyup={handleKeyup} />
<div id="map" bind:this={mapContainer}></div>
<style>
<style lang="postcss">
@reference "tailwindcss";
@reference "../../../css/app.css";
#map {
width: 100%;
height: 100%;

View File

@@ -30,7 +30,11 @@
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
? getFileURL(
trail,
trail.photos.at(trail.thumbnail ?? 0) ?? trail.photos[0],
"600x0"
)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
@@ -71,6 +75,20 @@
/>
{/if}
</div>
{#if $currentUser && trail.like_count > 0}
<div
class="flex absolute items-center justify-center top-4 left-4 bg-background w-8 h-8 rounded-full"
>
<span class="tooltip" data-title={$_("likes")}>
<i class="fa fa-heart"></i>
</span>
<div
class="absolute pointer-events-none left-5 -top-1 text-xs rounded-full bg-menu-background px-1 text-center"
>
{trail.like_count}
</div>
</div>
{/if}
{#if (trail.public || trailIsShared) && $currentUser}
<div
class="flex absolute top-4 right-4 {trail.public && trailIsShared
@@ -111,20 +129,20 @@
{$_("by")}
<img
class="rounded-full w-5 aspect-square mx-1 inline"
src={getFileURL(
trail.expand.author,
trail.expand.author.avatar,
) ||
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{trail.expand.author.username}
{trail.expand.author.username}{trail.expand.author.isLocal
? ""
: "@" + trail.expand.author.domain}
</p>
{/if}
{#if trail.expand?.tags?.length}
<div class="flex flex-wrap gap-1 mb-3">
{#each trail.expand?.tags ?? [] as t}
<Chip text={t.name} closable={false} primary={false}></Chip>
<Chip text={t.name} closable={false} primary={false}
></Chip>
{/each}
</div>
{:else if trail.tags?.length}

View File

@@ -23,10 +23,11 @@
interface Props {
trail: Trail;
handle: string;
mode: "overview" | "map" | "list";
}
let { trail, mode }: Props = $props();
let { trail, handle, mode }: Props = $props();
let confirmModal: ConfirmModal;
let listSelectModal: ListSelectModal;
@@ -35,8 +36,10 @@
let lists: List[] = $state([]);
const isOwned: boolean = trail.author == $currentUser?.actor;
const allowEdit =
trail.author == $currentUser?.id ||
isOwned ||
trail.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
);
@@ -61,16 +64,16 @@
]
: []),
{ text: $_("print"), value: "print", icon: "print" },
...(trail.author != $currentUser?.id
? []
: [{ text: $_("add-to-list"), value: "list", icon: "bookmark" }]),
...(trail.author != $currentUser?.id
? []
: [{ text: $_("share"), value: "share", icon: "share" }]),
...(isOwned
? [{ text: $_("add-to-list"), value: "list", icon: "bookmark" }]
: []),
...(isOwned
? [{ text: $_("share"), value: "share", icon: "share" }]
: []),
...(allowEdit
? [{ text: $_("edit"), value: "edit", icon: "pen" }]
: []),
...(trail.author == $currentUser?.id
...(isOwned
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
: []),
];
@@ -79,13 +82,13 @@
if (item.value == "show") {
goto(
mode == "overview"
? `/map/trail/${trail.id!}`
: `/trail/view/${trail.id!}`,
? `/map/trail/${handle}/${trail.id!}`
: `/trail/view/${handle}/${trail.id!}`,
);
} else if (item.value == "list") {
lists = (
await lists_index(
{ q: "", author: $currentUser?.id ?? "" },
{ q: "", author: $currentUser?.actor ?? "" },
1,
-1,
)
@@ -99,7 +102,7 @@
)
?.focus();
} else if (item.value == "print") {
goto(`/map/trail/${trail.id}/print`);
goto(`/map/trail/${handle}/${trail.id}/print`);
} else if (item.value == "share") {
trailShareModal.openModal();
} else if (item.value == "download") {
@@ -155,7 +158,7 @@
}
if (exportSettings.summitLog) {
let summitLogString = "";
for (const summitLog of trail.expand?.summit_logs ?? []) {
for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) {
summitLogString += `${summitLog.date},${summitLog.text}\n`;
}
zip.file(

View File

@@ -37,7 +37,7 @@
}
</script>
<Modal id="export-modal" {title} size="max-w-sm" bind:this={modal}>
<Modal id="export-modal" {title} size="md:min-w-sm" bind:this={modal}>
{#snippet content()}
<div>
<div class="mb-3">

View File

@@ -8,6 +8,7 @@
import { getIconForLocation } from "$lib/util/icon_util";
import { _ } from "svelte-i18n";
import { slide } from "svelte/transition";
import ActorSearch from "../actor_search.svelte";
import Combobox, { type ComboboxItem } from "../base/combobox.svelte";
import Datepicker from "../base/datepicker.svelte";
import DoubleSlider from "../base/double_slider.svelte";
@@ -17,7 +18,6 @@
import Search, { type SearchItem } from "../base/search.svelte";
import type { SelectItem } from "../base/select.svelte";
import Slider from "../base/slider.svelte";
import UserSearch from "../user_search.svelte";
interface Props {
categories: Category[];
@@ -39,7 +39,7 @@
let categorySelectItems = $derived(
categories.map((c) => ({
value: c.id,
value: c.name,
text: c.name,
})),
);
@@ -62,7 +62,7 @@
let tagItems: ComboboxItem[] = $state([]);
async function update() {
async function update() {
onupdate?.(filter);
}
@@ -87,6 +87,11 @@
update();
}
function setLikedFilter(e: Event) {
filter.liked = (e.target as HTMLInputElement).checked;
update();
}
function setCompletedFilter(item: RadioItem) {
switch (item.value) {
case "no_preference":
@@ -217,7 +222,7 @@
<hr class="my-4 border-separator" />
{#if $currentUser}
<UserSearch
<ActorSearch
onclick={(item) => setAuthorFilter(item)}
onclear={() => {
filter.author = "";
@@ -225,23 +230,7 @@
}}
clearAfterSelect={false}
label={$_("author")}
></UserSearch>
<hr class="my-4 border-separator" />
<p class="text-sm font-medium">{$_("visibilty-status")}</p>
<div class="flex items-center mt-2 mb-4">
<input
id="private-checkbox"
type="checkbox"
checked={filter.private}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
onchange={setPrivateFilter}
/>
<label for="private-checkbox" class="ms-2 text-sm"
>{$_("private")}</label
>
</div>
></ActorSearch>
<div class="flex items-center my-4">
<input
id="public-checkbox"
@@ -370,7 +359,18 @@
onchange={update}
></Datepicker>
</div>
<hr class="my-4 border-separator" />
<p class="text-sm font-medium pb-4">{$_("like-status")}</p>
<input
id="liked-checkbox"
type="checkbox"
checked={filter.liked}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
onchange={setLikedFilter}
/>
<label for="liked-checkbox" class="ms-2 text-sm"
>{$_("liked")}</label
>
<hr class="my-4 border-separator" />
<p class="text-sm font-medium pb-4">{$_("completion-status")}</p>
<RadioGroup

View File

@@ -42,9 +42,25 @@
import SummitLogTable from "../summit_log/summit_log_table.svelte";
import MapWithElevationMaplibre from "./map_with_elevation_maplibre.svelte";
import TrailTimeline from "./trail_timeline.svelte";
import {
summit_logs_create,
summit_logs_delete,
summit_logs_index,
summit_logs_update,
summitLog,
summitLogs,
} from "$lib/stores/summit_log_store";
import { SummitLog } from "$lib/models/summit_log";
import SummitLogModal from "../summit_log/summit_log_modal.svelte";
import SkeletonTable from "../base/skeleton_table.svelte";
import ConfirmModal from "../confirm_modal.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import LikeButton from "./like_button.svelte";
import Editor from "../base/editor.svelte";
interface Props {
initTrail: Trail;
handle: string;
mode?: "overview" | "map" | "list";
markers?: M.Marker[];
activeTab?: number;
@@ -52,11 +68,15 @@
let {
initTrail,
handle,
mode = "map",
markers = [],
activeTab = 0,
}: Props = $props();
let summitLogModal: SummitLogModal;
let confirmModal: ConfirmModal;
let trail = $state(initTrail);
const tabs = [
@@ -72,15 +92,17 @@
let newComment: Comment = $state({
text: "",
rating: 0,
author: "",
trail: trail.id ?? "",
trail: handle + "/" + (trail.id ?? ""),
});
let commentsLoading: boolean = $state(activeTab == 2);
let commentCreateLoading: boolean = $state(false);
let commentDeleteLoading: boolean = false;
let summitLogsLoading: boolean = $state(activeTab == 0);
let summitLogCreateLoading: boolean = $state(false);
let fullDescription: boolean = $state(false);
onMount(async () => {});
@@ -100,13 +122,23 @@
}
async function toggleMapFullScreen() {
goto(`/map/trail/${trail.id!}`);
goto(`/map/trail/${handle}/${trail.id!}`);
}
async function fetchComments() {
commentsLoading = true;
await comments_index(trail);
commentsLoading = false;
const trailId = trail.iri ? trail.iri : trail.id!;
try {
await comments_index(trailId, handle);
} catch (e) {
show_toast({
type: "error",
icon: "close",
text: "Error loading comments.",
});
} finally {
commentsLoading = false;
}
}
async function createComment() {
@@ -114,15 +146,12 @@
return;
}
commentCreateLoading = true;
newComment.author = $currentUser.id;
newComment.author = $currentUser.actor;
newComment.trail = trail.id;
try {
const c = await comments_create(newComment);
newComment.text = "";
c.expand = {
author: { ...$currentUser!, private: false },
};
const newCommentList = [c, ...$comments];
comments.set(newCommentList);
@@ -167,6 +196,70 @@
fetchComments();
}
});
$effect(() => {
if (activeTab == 0) {
fetchSummitLog();
}
});
async function fetchSummitLog() {
summitLogsLoading = true;
const trailId = trail.iri ? trail.iri : trail.id;
try {
await summit_logs_index({ trail: trailId, category: [] }, handle);
} catch (e) {
show_toast({
type: "error",
icon: "close",
text: "Error loading summit logs.",
});
} finally {
summitLogsLoading = false;
}
}
function beforeSummitLogModalOpen(currentSummitLog?: SummitLog) {
currentSummitLog ??= new SummitLog(
new Date().toISOString().split("T")[0],
);
summitLog.set(currentSummitLog);
summitLogModal.openModal();
}
async function saveSummitLog(log: SummitLog) {
summitLogCreateLoading = true;
if (log.id) {
let oldLogIndex = $summitLogs.findIndex((l) => l.id === log.id);
if (oldLogIndex < 0) {
return;
}
const updatedLog = await summit_logs_update(
$summitLogs[oldLogIndex],
log,
);
$summitLogs[oldLogIndex] = updatedLog;
} else {
log.trail = trail.id;
const newLog = await summit_logs_create(log);
summitLogs.set([...$summitLogs, newLog]);
}
summitLogCreateLoading = false;
}
function beforeConfirmModalOpen(currentSummitLog: SummitLog) {
summitLog.set(currentSummitLog);
confirmModal.openModal();
}
async function deleteSummitLog() {
await summit_logs_delete($summitLog);
const newSummitLogList = $summitLogs.filter(
(l) => l.id !== $summitLog.id,
);
summitLogs.set(newSummitLogList);
}
</script>
<div
@@ -283,22 +376,13 @@
{$_("by")}
<img
class="rounded-full w-8 aspect-square mx-1 inline"
src={getFileURL(
trail.expand.author,
trail.expand.author.avatar,
) ||
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{#if !trail.expand.author.private}
<a
class="underline"
href="/profile/{trail.expand.author.id}"
>{trail.expand.author.username}</a
>
{:else}
<span>{trail.expand.author.username}</span>
{/if}
<a class="underline" href="/profile/{handle}"
>{handleFromRecordWithIRI(trail)}</a
>
</p>
{/if}
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-2 mr-8">
@@ -314,8 +398,11 @@
</h3>
</div>
</div>
{#if ($currentUser && $currentUser.id == trail.author) || trail.expand?.trail_share_via_trail?.length || trail.public}
<TrailDropdown {trail} {mode}></TrailDropdown>
{#if ($currentUser && $currentUser.actor == trail.author) || trail.expand?.trail_share_via_trail?.length || trail.public}
<div class="flex flex-col items-center gap-y-2">
<LikeButton {trail}></LikeButton>
<TrailDropdown {trail} {mode} {handle}></TrailDropdown>
</div>
{/if}
</div>
</section>
@@ -386,8 +473,10 @@
{$_("description")}
</h4>
{#if trail.description?.length}
<article class="text-justify whitespace-pre-line text-sm">
{!fullDescription
<article
class="text-justify whitespace-pre-line text-sm prose dark:prose-invert"
>
{@html !fullDescription
? trail.description?.substring(0, 300)
: trail.description}
{#if (trail.description?.length ?? 0) > 300 && !fullDescription}
@@ -422,17 +511,39 @@
onmouseleave={closeMarkerPopup}
></TrailTimeline>
<div class="mb-6 mt-12">
<div class="mb-6 mt-12 flex justify-between flex-wrap gap-y-4">
<Tabs {tabs} bind:activeTab></Tabs>
{#if activeTab == 0 && mode != "list"}
<Button
secondary
type="button"
loading={summitLogCreateLoading}
disabled={summitLogCreateLoading}
onclick={() => beforeSummitLogModalOpen()}
><i class="fa fa-plus mr-2"></i>{$_(
"add-entry",
)}</Button
>
{/if}
</div>
{#if activeTab == 0}
<div class="overflow-x-auto">
<SummitLogTable
summitLogs={trail.expand?.summit_logs}
showAuthor
showRoute
showPhotos
></SummitLogTable>
<div
class="overflow-x-auto overflow-y-clip pb-3 scroll-x-only min-h-[175px]"
>
{#if summitLogsLoading}
<SkeletonTable></SkeletonTable>
{:else}
<SummitLogTable
{handle}
summitLogs={$summitLogs}
showAuthor
showRoute
showPhotos
showMenu
onedit={(log) => beforeSummitLogModalOpen(log)}
ondelete={(log) => beforeConfirmModalOpen(log)}
></SummitLogTable>
{/if}
</div>
{/if}
{#if activeTab == 1}
@@ -483,15 +594,15 @@
$currentUser,
$currentUser.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username?.toLowerCase()}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div class="basis-full">
<Textarea
<Editor
bind:value={newComment.text}
rows={2}
extraClasses="min-h-24 max-w-full"
placeholder="Add comment..."
></Textarea>
></Editor>
</div>
</div>
<div class="flex justify-end mt-3">
@@ -521,7 +632,7 @@
<CommentCard
{comment}
mode={comment.author ==
$currentUser?.id
$currentUser?.actor
? "edit"
: "show"}
ondelete={deleteComment}
@@ -557,6 +668,16 @@
</section>
</div>
<SummitLogModal bind:this={summitLogModal} onsave={(log) => saveSummitLog(log)}
></SummitLogModal>
<ConfirmModal
id="confirm-summit-log-delete-modal"
text={$_("delete-summit-log-confirm")}
bind:this={confirmModal}
onconfirm={deleteSummitLog}
></ConfirmModal>
<style>
.trail-info-panel img {
object-fit: cover;

View File

@@ -49,6 +49,7 @@
{ text: $_("difficulty"), value: "difficulty" },
{ text: $_("elevation-gain"), value: "elevation_gain" },
{ text: $_("elevation-loss"), value: "elevation_loss" },
{ text: $_("likes"), value: "like_count" },
{ text: $_("creation-date"), value: "created" },
{ text: $_("date"), value: "date" },
];
@@ -98,7 +99,7 @@
onupdate?.(filter);
}
function handleSortUpdate(sort: any) {
function handleSortUpdate(sort: any) {
if (!filter) {
return;
}
@@ -190,7 +191,9 @@
<a
class="max-w-full flex-1"
class:basis-full={selectedDisplayOption === "list"}
href="/trail/view/{trail.id}"
href="/trail/view/@{trail.author}{trail.domain
? `@${trail.domain}`
: ''}/{trail.id}"
>
{#if selectedDisplayOption === "cards"}
<TrailCard fullWidth={fullWidthCards} {trail}

View File

@@ -8,10 +8,12 @@
import {
formatDistance,
formatElevation,
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
interface Props {
trail: Trail;
@@ -22,7 +24,11 @@
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
? getFileURL(
trail,
trail.photos.at(trail.thumbnail ?? 0) ?? trail.photos[0],
"600x0",
)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
@@ -50,18 +56,32 @@
{/if}
</div>
<div class="min-w-0 basis-full">
<div class="flex items-center gap-4">
<div class="flex items-center justify-between">
<h4 class="font-semibold text-lg">
{trail.name}
</h4>
{#if trail.public && $currentUser}
<span class="tooltip ml-3" data-title={$_("public")}>
<i class="fa fa-globe"></i>
</span>
{/if}
{#if trail.expand?.trail_share_via_trail?.length}
<ShareInfo type="trail" subject={trail}></ShareInfo>
{/if}
<div class="flex items-center shrink-0 gap-3">
{#if trail.public && $currentUser}
<span class="tooltip" data-title={$_("public")}>
<i class="fa fa-globe"></i>
</span>
{/if}
{#if trail.expand?.trail_share_via_trail?.length}
<ShareInfo type="trail" subject={trail}></ShareInfo>
{/if}
{#if $currentUser && trail.like_count > 0}
<div class="flex items-start gap-x-[2px]">
<span class="tooltip" data-title={$_("likes")}>
<i class="fa fa-heart"></i>
</span>
<div
class="pointer-events-none left-4 -top-1 text-xs rounded-full bg-content text-content-inverse px-1 text-center"
>
{trail.like_count}
</div>
</div>
{/if}
</div>
</div>
{#if trail.date}
<p class="text-xs text-gray-500 mb-3">
@@ -78,14 +98,11 @@
{$_("by")}
<img
class="rounded-full w-5 aspect-square mx-1 inline"
src={getFileURL(
trail.expand.author,
trail.expand.author.avatar,
) ||
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{trail.expand.author.username}
{handleFromRecordWithIRI(trail)}
</p>
{/if}
<div class="flex flex-wrap gap-x-8">
@@ -123,7 +140,7 @@
<p
class="mt-3 text-sm whitespace-nowrap min-w-0 max-w-full overflow-hidden text-ellipsis"
>
{trail.description}
{formatHTMLAsText(trail.description ?? "")}
</p>
{/if}
</div>

View File

@@ -9,12 +9,11 @@
trail_share_index,
trail_share_update,
} from "$lib/stores/trail_share_store";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import ActorSearch from "../actor_search.svelte";
import Button from "../base/button.svelte";
import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte";
import UserSearch from "../user_search.svelte";
interface Props {
trail: Trail;
@@ -25,6 +24,8 @@
let modal: Modal;
let displayShareError = $state(false);
export function openModal() {
openShareModalLocal();
}
@@ -55,7 +56,12 @@
}
async function shareTrail(item: SelectItem) {
const share = new TrailShare(item.value.id, trail.id!, "view");
if (!item.value.isLocal && !trail.public) {
displayShareError = true;
return;
}
displayShareError = false;
const share = new TrailShare(item.value.iri, trail.id!, "view");
await trail_share_create(share);
fetchShares();
}
@@ -83,13 +89,23 @@
<Modal
id="share-modal"
title={$_("share-this-trail")}
size="max-w-sm overflow-visible"
size="md:min-w-sm overflow-visible"
bind:this={modal}
>
{#snippet content()}
<div>
<UserSearch includeSelf={false} onclick={(item) => shareTrail(item)}
></UserSearch>
{#if displayShareError}
<p class="p-4 bg-red-100 rounded-xl mb-4 text-sm text-gray-500">
<i class="fa fa-warning mr-2"></i>
{$_("object-share-error", {
values: { object: $_("trail", { values: { n: 1 } }) },
})}
</p>
{/if}
<ActorSearch
includeSelf={false}
onclick={(item) => shareTrail(item)}
></ActorSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#if $shares.length == 0}
@@ -102,22 +118,26 @@
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
src={share.expand.actor.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.actor.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<p>
{`@${share.expand.actor.username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
</p>
<span
class="basis-full text-sm text-center text-gray-500"
class="basis-full text-sm text-gray-500 text-end"
>{$_("can")}</span
>
<div class="shrink-0">
<div
class="shrink-0"
class:tooltip={!share.expand.actor.isLocal}
data-title={$_("remote-users-cannot-edit")}
>
<Select
value={share.permission}
items={permissionSelectItems}
disabled={!share.expand.actor.isLocal}
onchange={(value) =>
updateSharePermission(share, value)}
></Select>

View File

@@ -15,7 +15,7 @@
tableHeader: SelectItem[];
trails?: Trail[] | null;
filter?: TrailFilter | null;
onsort?: (value: any) => void
onsort?: (value: any) => void;
}
let { tableHeader, trails = null, filter = null, onsort }: Props = $props();
@@ -71,7 +71,12 @@
{#each trails as trail}
<tr
class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors"
onclick={() => goto(`/trail/view/${trail.id}`)}
onclick={() =>
goto(
`/trail/view/@${trail.author}${
trail.domain ? `@${trail.domain}` : ""
}/${trail.id}`,
)}
>
<td
class="flex justify-between items-center text-sm relative"
@@ -83,12 +88,9 @@
{#if trail.expand && trail.expand.author}
<div class="author-icon">
<img
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} ${trail.expand.author.username}`}
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} ${trail.expand.author.username}${trail.expand.author.isLocal ? "" : "@" + trail.expand.author.domain}`}
class="rounded-full w-5 aspect-square mx-1 inline"
src={getFileURL(
trail.expand.author,
trail.expand.author.avatar,
) ||
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
@@ -122,6 +124,9 @@
<td class="p-4 text-sm">
{formatElevation(trail.elevation_gain)}
</td>
<td class="p-4 text-sm">
{trail.like_count}
</td>
<td class="p-4 text-sm">
{#if trail.created}
{new Date(trail.created).toLocaleDateString(

View File

@@ -101,6 +101,7 @@
<Modal
id="waypoint-modal"
size="md:min-w-2xl"
title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")}
bind:this={modal}
>