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:
77
web/src/lib/components/actor_search.svelte
Normal file
77
web/src/lib/components/actor_search.svelte
Normal 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>
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 } })}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<Modal
|
||||
id="password-modal"
|
||||
size="max-w-xl"
|
||||
size="md:max-w-xl"
|
||||
title={$_("share-profile")}
|
||||
bind:this={modal}
|
||||
>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<Modal
|
||||
id="email-modal"
|
||||
size="max-w-xl"
|
||||
size="md:min-w-md"
|
||||
title={$_("change-email")}
|
||||
bind:this={modal}
|
||||
>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<Modal
|
||||
id="komoot-settings-modal"
|
||||
size="max-w-lg"
|
||||
size="md:max-w-lg"
|
||||
title={"komoot " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
<Modal
|
||||
id="strava-settings-modal"
|
||||
size="max-w-lg"
|
||||
size="md:min-w-lg"
|
||||
title={"strava " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
<Modal
|
||||
id="password-modal"
|
||||
size="max-w-sm"
|
||||
size="md:min-w-sm"
|
||||
title={$_("change-password")}
|
||||
bind:this={modal}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
112
web/src/lib/components/trail/like_button.svelte
Normal file
112
web/src/lib/components/trail/like_button.svelte
Normal 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>
|
||||
@@ -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%;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
|
||||
<Modal
|
||||
id="waypoint-modal"
|
||||
size="md:min-w-2xl"
|
||||
title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")}
|
||||
bind:this={modal}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user