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:
@@ -1,6 +1,49 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "@fortawesome/fontawesome-free/css/all.min.css";
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/preflight.css" layer(base);
|
||||
@import "tailwindcss/utilities.css";
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-primary: rgba(var(--primary));
|
||||
--color-background: rgba(var(--background));
|
||||
--color-background-inverse: rgba(var(--background-inverse));
|
||||
--color-content: rgba(var(--content));
|
||||
--color-content-inverse: rgba(var(--content-inverse));
|
||||
|
||||
--color-primary: rgba(var(--primary));
|
||||
--color-primary-hover: rgba(var(--primary-hover));
|
||||
--color-secondary-hover: rgba(var(--secondary-hover));
|
||||
--color-input-background: rgba(var(--input-background));
|
||||
--color-input-background-error: rgba(var(--input-background-error));
|
||||
--color-input-border: rgba(var(--input-border));
|
||||
--color-input-border-focus: rgba(var(--input-border-focus));
|
||||
--color-input-ring: rgba(var(--input-ring));
|
||||
--color-menu-background: rgba(var(--menu-background));
|
||||
--color-menu-item-background-hover: rgba(var(--menu-item-background-hover));
|
||||
--color-menu-item-background-focus: rgba(var(--menu-item-background-focus));
|
||||
--color-footer-background: rgba(var(--footer-background));
|
||||
--color-separator: rgba(var(--separator));
|
||||
|
||||
--font-sans: "IBMPlexSans"
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IBMPlexSans";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss';
|
||||
@reference "./app.css";
|
||||
|
||||
.btn-primary {
|
||||
@apply min-h-10 text-white rounded-lg px-4 py-2 bg-primary font-semibold transition-all hover:bg-primary-hover focus:ring-4 ring-input-ring
|
||||
@@ -125,4 +124,9 @@
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.mention {
|
||||
@apply bg-blue-100 dark:bg-slate-700 rounded-md;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
--input-border: 209, 213, 219;
|
||||
--input-border-focus: 36, 39, 52;
|
||||
--input-ring: 148, 163, 184;
|
||||
--input-ring-inverse: 108, 115, 150;
|
||||
|
||||
--menu-background: 255, 255, 255;
|
||||
--menu-item-background-hover: 243, 244, 246;
|
||||
@@ -31,28 +30,29 @@
|
||||
|
||||
.dark {
|
||||
--primary: 36, 39, 52;
|
||||
--primary-hover: 44, 48, 64;
|
||||
--secondary-hover: 36, 39, 52;
|
||||
--primary-hover: 55, 60, 80;
|
||||
--secondary-hover: 55, 65, 81;
|
||||
--secondary-hover-inverse: 243, 244, 246;
|
||||
|
||||
--background: 7, 10, 36;
|
||||
--background: 18, 20, 28;
|
||||
--background-inverse: 255, 255, 255;
|
||||
|
||||
--content: 255, 255, 255;
|
||||
--content-inverse: 0, 0, 0;
|
||||
|
||||
--input-background: 36, 39, 52;
|
||||
--input-background-error: 69, 10, 10;
|
||||
--input-border: 43, 46, 61;
|
||||
--input-border-focus: 108, 115, 150;
|
||||
--input-ring: 108, 115, 150;
|
||||
--input-background: 38, 40, 49;
|
||||
--input-background-error: 76, 17, 17;
|
||||
--input-border: 50, 54, 64;
|
||||
--input-border-focus: 148, 163, 184;
|
||||
--input-ring: 148, 163, 184;
|
||||
|
||||
--menu-background: 33, 36, 54;
|
||||
--menu-item-background-hover: 43, 46, 69;
|
||||
--menu-item-background-focus: 25, 26, 41;
|
||||
--menu-background: 30, 32, 40;
|
||||
--menu-item-background-hover: 55, 65, 81;
|
||||
--menu-item-background-focus: 75, 85, 99;
|
||||
|
||||
--footer-background: 26, 29, 51;
|
||||
--footer-background: 24, 26, 36;
|
||||
|
||||
--separator: 43, 46, 61;
|
||||
--separator: 55, 65, 81;
|
||||
|
||||
background: rgba(var(--background));
|
||||
color: rgba(var(--content));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { json, redirect, text, type Handle } from '@sveltejs/kit'
|
||||
import { sequence } from '@sveltejs/kit/hooks'
|
||||
import { MeiliSearch } from 'meilisearch'
|
||||
import { locale } from 'svelte-i18n'
|
||||
import type { Actor } from '$lib/models/activitypub/actor'
|
||||
|
||||
|
||||
function csrf(allowedPaths: string[]): Handle {
|
||||
@@ -49,6 +50,8 @@ function isFormContentType(request: Request) {
|
||||
|
||||
|
||||
const auth: Handle = async ({ event, resolve }) => {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
|
||||
const pb = new PocketBase(envPub.PUBLIC_POCKETBASE_URL)
|
||||
// load the store data from the request cookie string
|
||||
pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '')
|
||||
@@ -77,9 +80,11 @@ const auth: Handle = async ({ event, resolve }) => {
|
||||
|
||||
let meiliApiKey: string = "";
|
||||
let settings: Settings | undefined;
|
||||
let actor: Actor | undefined;
|
||||
if (pb.authStore.record) {
|
||||
meiliApiKey = pb.authStore.record.token
|
||||
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.record.id}"`, { requestKey: null })
|
||||
actor = await pb.collection("activitypub_actors").getFirstListItem(`user='${pb.authStore.record.id}'`)
|
||||
} else {
|
||||
const response = await pb.send("/public/search/token", { method: "GET", fetch: event.fetch });
|
||||
meiliApiKey = response.token;
|
||||
@@ -89,6 +94,9 @@ const auth: Handle = async ({ event, resolve }) => {
|
||||
event.locals.ms = ms
|
||||
event.locals.pb = pb
|
||||
event.locals.user = pb.authStore.record
|
||||
if (event.locals.user) {
|
||||
event.locals.user.actor = actor?.id
|
||||
}
|
||||
event.locals.settings = settings
|
||||
|
||||
const lang = settings?.language ?? event.request.headers.get('accept-language')?.split(',')[0]
|
||||
|
||||
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}
|
||||
>
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Löschen",
|
||||
"delete-account": "Konto löschen",
|
||||
"delete-list-confirm": "Möchtest Du diese Liste wirklich löschen? Die Routen in der Liste sind danach weiterhin verfügbar.",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "Möchtest Du diese Route wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"describe-your-trail": "Beschreibe deine Route",
|
||||
"description": "Beschreibung",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Fehler beim Login",
|
||||
"error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden",
|
||||
"error-exporting-trail": "Fehler beim Exportieren der Route",
|
||||
"error-liking-trail": "Error liking this trail",
|
||||
"error-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot",
|
||||
"error-posting-comment": "Fehler beim Posten des Kommentars",
|
||||
"error-printing-map": "Fehler beim Drucken der Karte",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixe Geschwindigkeit",
|
||||
"focus-map-on": "Karte fokussieren auf",
|
||||
"follow": "Folgen",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Follower",
|
||||
"following": "Folgt",
|
||||
"forgot-your-password": "Passwort vergessen?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "Deutsch",
|
||||
"get-position-from-exif": "Koordinaten aus EXIF Daten",
|
||||
"grid": "Gitter",
|
||||
"heading": "Heading",
|
||||
"height": "Höhe",
|
||||
"help": "Hilfe",
|
||||
"hero_section_0_text": "Entdecke spannende Routen, speichere deine Favoriten und erlebe die Schönheit der Natur. Finde dein nächstes Abenteuer!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Sprache",
|
||||
"latitude": "Breitengrad",
|
||||
"license": "Lizenz",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "Link kopiert!",
|
||||
"list": "{n, plural, =1 {Liste} other {Listen}}",
|
||||
"list-not-shared": "Mit niemandem geteilt",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Keine gültige Email-Adresse",
|
||||
"not-a-valid-url": "Keine gültige URL",
|
||||
"not-completed": "Nicht abgeschlossen",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} hat eine neue Liste erstellt",
|
||||
"notification-list-share": "{user} hat eine Liste mit Dir geteilt",
|
||||
"notification-new-follower": "Du hast einen neuen Follower",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} hat einen Kommentar zu Deiner Route \"{trail}\" hinterlassen",
|
||||
"notification-trail-create": "{user} hat eine neue Route erstellt",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} hat eine Route mit Dir geteilt",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"object-share-error": "",
|
||||
"off": "Aus",
|
||||
"only-me": "Nur ich",
|
||||
"open-in-new-tab": "",
|
||||
"or": "oder",
|
||||
"orientation": "Ausrichtung",
|
||||
"paper-size": "Papierformat",
|
||||
"paragraph": "Paragraph",
|
||||
"password": "Passwort",
|
||||
"password-confirm": "Passwort bestätigen",
|
||||
"password-reset-sent": "Email zum Zurücksetzen des Passworts versandt",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Radius",
|
||||
"read-more": "Mehr",
|
||||
"register": "Registrieren",
|
||||
"remote-users-cannot-edit": "Remote users cannot edit",
|
||||
"removed-trail-from": "Route entfernt aus",
|
||||
"required": "Pflichtfeld",
|
||||
"reset-password": "Passwort zurücksetzen",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Route suchen",
|
||||
"select-list": "Liste auswählen",
|
||||
"settings": "Einstellungen",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "Ein Benutzer, dem Du folgst, hat eine Liste erstellt",
|
||||
"settings-notification-list-share": "Jemand hat eine Liste mit Dir geteilt",
|
||||
"settings-notification-new-follower": "Du hast einen neuen Follower",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Jemand hat einen Kommentar zu Deiner Route hinterlassen",
|
||||
"settings-notification-trail-create": "Ein Benutzer, dem Du folgst, hat eine Route erstellt",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Jemand hat eine Route mit Dir geteilt",
|
||||
"settings-privacy-account-private": "Nur Du kannst dein Profil sehen. Du wirst nicht in den Suchergebnissen angezeigt. Andere Benutzer können Dir nicht folgen oder Routen mit Dir teilen. Du kannst weiterhin Routen oder Listen veröffentlichen.",
|
||||
"settings-privacy-account-public": "Jeder kann Dein Profil sehen. Du erscheinst in den Suchergebnissen. Andere Benutzer können Dir folgen und Routen mit Dir teilen.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Routen für dich",
|
||||
"unchanged": "unverändert",
|
||||
"units": "Einheiten",
|
||||
"unlink": "Unlink",
|
||||
"upload-file": "Datei hochladen",
|
||||
"upload-gpx": "GPX hochladen",
|
||||
"upload-new-file": "Neue Datei hochladen",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Delete",
|
||||
"delete-account": "Delete Account",
|
||||
"delete-list-confirm": "Do you really want to delete this list? The trails in the list will still be available.",
|
||||
"delete-summit-log-confirm": "Do you really want to delete this summit log? This action cannot be undone.",
|
||||
"delete-trail-confirm": "Do you really want to delete this trail? This action cannot be undone.",
|
||||
"describe-your-trail": "Describe your trail",
|
||||
"description": "Description",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Error during login",
|
||||
"error-during-password-reset": "Unable to send password reset email",
|
||||
"error-exporting-trail": "Error exporting trail",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||
"error-posting-comment": "Error posting comment",
|
||||
"error-printing-map": "Error printing map",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Focus map on",
|
||||
"follow": "Follow",
|
||||
"follow-request-pending": "Request pending",
|
||||
"followers": "Followers",
|
||||
"following": "Following",
|
||||
"forgot-your-password": "Forgot your password?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "German",
|
||||
"get-position-from-exif": "Get coordinates from EXIF data",
|
||||
"grid": "Grid",
|
||||
"heading": "",
|
||||
"height": "Height",
|
||||
"help": "Help",
|
||||
"hero_section_0_text": "Explore exciting trails, save your favorites, and experience the beauty of nature. Find your next adventure!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Language",
|
||||
"latitude": "Latitude",
|
||||
"license": "License",
|
||||
"like-status": "Like Status",
|
||||
"liked": "Liked",
|
||||
"likes": "Likes",
|
||||
"link-copied": "Link copied!",
|
||||
"list": "{n, plural, =1 {List} other {Lists}}",
|
||||
"list-not-shared": "Not shared with anyone",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Not a valid email address",
|
||||
"not-a-valid-url": "Not a valid URL",
|
||||
"not-completed": "Not completed",
|
||||
"notification-comment-mention": "{user} mentioned you in a comment",
|
||||
"notification-list-create": "{user} created a new list",
|
||||
"notification-list-share": "{user} shared a list with you",
|
||||
"notification-new-follower": "You have a new follower",
|
||||
"notification-summit-log-create": "{user} created a summit log on your trail \"{trail}\"",
|
||||
"notification-summit-log-mention": "{user} mentioned you in a summit log",
|
||||
"notification-trail-comment": "{user} left a comment on your trail \"{trail}\"",
|
||||
"notification-trail-create": "{user} created a new trail",
|
||||
"notification-trail-like": "{user} liked your trail \"{trail}\"",
|
||||
"notification-trail-mention": "{user} mentioned you in a trail",
|
||||
"notification-trail-share": "{user} shared a trail with you",
|
||||
"notifications": "Notifications",
|
||||
"object-share-error": "A {object} must be public to be shared across instances.",
|
||||
"off": "Off",
|
||||
"only-me": "Only me",
|
||||
"open-in-new-tab": "Open in new tab",
|
||||
"or": "or",
|
||||
"orientation": "Orientation",
|
||||
"paper-size": "Paper size",
|
||||
"paragraph": "",
|
||||
"password": "Password",
|
||||
"password-confirm": "Confirm password",
|
||||
"password-reset-sent": "A password reset email has been sent",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Radius",
|
||||
"read-more": "Read more",
|
||||
"register": "Register",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "Removed trail from",
|
||||
"required": "Required",
|
||||
"reset-password": "Reset Password",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Search trails",
|
||||
"select-list": "Select List",
|
||||
"settings": "Settings",
|
||||
"settings-notification-comment-mention": "Someone mentioned you in a comment",
|
||||
"settings-notification-list-create": "A user who you follow has created a list",
|
||||
"settings-notification-list-share": "Someone shared a list with you",
|
||||
"settings-notification-new-follower": "You have a new follower",
|
||||
"settings-notification-summit-log-create": "Someone created a summit log on your trail",
|
||||
"settings-notification-summit-log-mention": "Someone mentioned you in a summit log",
|
||||
"settings-notification-trail-comment": "Someone left a comment on your trail",
|
||||
"settings-notification-trail-create": "A user who you follow has created a trail",
|
||||
"settings-notification-trail-like": "Somone liked your trail",
|
||||
"settings-notification-trail-mention": "Someone mentioned you in a trail",
|
||||
"settings-notification-trail-share": "Someone shared a trail with you",
|
||||
"settings-privacy-account-private": "Only you can see your profile. You will not appear in search results. Other users cannot follow you or share trails with you. You can still publish trails or lists.",
|
||||
"settings-privacy-account-public": "Everyone can see your profile. You appear in search results. Other users can follow you and share trails with you.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Trails for you",
|
||||
"unchanged": "unchanged",
|
||||
"units": "Units",
|
||||
"unlink": "",
|
||||
"upload-file": "Upload file",
|
||||
"upload-gpx": "Upload GPX",
|
||||
"upload-new-file": "Upload new file",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Borrar",
|
||||
"delete-account": "Borrar Cuenta",
|
||||
"delete-list-confirm": "¿Seguro quieres borrar esta lista? Las rutas en la lista seguirán estando disponibles.",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "¿Seguro que quieres borrar esta ruta? Esta acción no puede restablecerse.",
|
||||
"describe-your-trail": "Describe tu ruta",
|
||||
"description": "Descripción",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Error durante el acceso",
|
||||
"error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico",
|
||||
"error-exporting-trail": "Error exportando la ruta",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||
"error-posting-comment": "Error publicando el comentario",
|
||||
"error-printing-map": "Error durante la impresión del mapa",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Centrar mapa sobre",
|
||||
"follow": "Seguir",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Seguidores",
|
||||
"following": "Siguiendo",
|
||||
"forgot-your-password": "¿Contraseña olvidada?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "Alemán",
|
||||
"get-position-from-exif": "Obtener las coordenadas de los datos EXIF",
|
||||
"grid": "Cuadricula",
|
||||
"heading": "",
|
||||
"height": "Height",
|
||||
"help": "Ayuda",
|
||||
"hero_section_0_text": "Explorar rutas emocionantes, guarda tus favoritas y disfruta de la belleza de la natura. ¡Encuentra tu próxima aventura!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Idioma",
|
||||
"latitude": "Latitud",
|
||||
"license": "Licencia",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "¡Enlace copiado!",
|
||||
"list": "{n, plural, one {}=1 {Lista} other {Listas}}",
|
||||
"list-not-shared": "No compartido con ninguno",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Correo electrónico no válido",
|
||||
"not-a-valid-url": "Not a valid URL",
|
||||
"not-completed": "No completado",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} ha creado una nueva lista",
|
||||
"notification-list-share": "{user} ha compartido una nueva lista contigo",
|
||||
"notification-new-follower": "Tienes un nuevo seguidor",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} ha dejado un comentario sobre tu ruta \"{trail}\"",
|
||||
"notification-trail-create": "{user} ha creado una nueva ruta",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} ha compartido una ruta contigo",
|
||||
"notifications": "Notificaciones",
|
||||
"object-share-error": "",
|
||||
"off": "Apagado",
|
||||
"only-me": "Solo yo",
|
||||
"open-in-new-tab": "",
|
||||
"or": "o",
|
||||
"orientation": "Orientación",
|
||||
"paper-size": "Tamaño del papel",
|
||||
"paragraph": "",
|
||||
"password": "Contraseña",
|
||||
"password-confirm": "Confirmar contraseña",
|
||||
"password-reset-sent": "Correo electrónico para restablecer la contraseña enviado",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Radio",
|
||||
"read-more": "Leer más",
|
||||
"register": "Registrar",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "Ruta borrada de",
|
||||
"required": "Obligatorio",
|
||||
"reset-password": "Restablecer Contraseña",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Buscar ruta",
|
||||
"select-list": "Seleccionar Lista",
|
||||
"settings": "Configuración",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "Un usuario al que sigues ha creado una nueva lista",
|
||||
"settings-notification-list-share": "Alguien ha compartido una lista contigo",
|
||||
"settings-notification-new-follower": "Tienes un nuevo seguidor",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Alguien ha dejado un comentario sobre tu ruta",
|
||||
"settings-notification-trail-create": "Un usuario al que sigues ha creado una nueva ruta",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Alguien ha compartido una ruta contigo",
|
||||
"settings-privacy-account-private": "Solo tu puedes ver tu perfil. No aparecerás en los resultados de búsquedas. Otros usuarios no pueden seguirte o compartir rutas contigo. Todavía sí puedes publicar tus rutas o listas.",
|
||||
"settings-privacy-account-public": "Todos pueden ver tu perfil. Aparecerás en los resultados de búsquedas. Otros usuarios pueden seguirte y compartir contigo rutas.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Rutas para ti",
|
||||
"unchanged": "unchanged",
|
||||
"units": "Unidades",
|
||||
"unlink": "",
|
||||
"upload-file": "Cargar archivo",
|
||||
"upload-gpx": "Cargar GPX",
|
||||
"upload-new-file": "Upload new file",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Supprimer",
|
||||
"delete-account": "Supprimer le compte",
|
||||
"delete-list-confirm": "Voulez-vous vraiment supprimer cette liste ? Les itinéraires de la liste seront toujours disponibles.",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "Voulez-vous vraiment supprimer cet itinéraire ? Cette action ne peut être annulée.",
|
||||
"describe-your-trail": "Décrivez votre itinéraire",
|
||||
"description": "Description",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Erreur durant la connexion",
|
||||
"error-during-password-reset": "Impossible d'envoyer l'e-mail de réinitialisation du mot de passe",
|
||||
"error-exporting-trail": "Erreur lors de l'export de l'itinéraire",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Erreur de connexion à Komoot",
|
||||
"error-posting-comment": "Erreur lors de la publication du commentaire",
|
||||
"error-printing-map": "Erreur d'impression de la carte",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Centrer la carte sur",
|
||||
"follow": "Suivre",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Abonné(e)s",
|
||||
"following": "Abonnements",
|
||||
"forgot-your-password": "Mot de passe oublié ?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "Allemand",
|
||||
"get-position-from-exif": "Obtenir les coordonnées à partir des données EXIF",
|
||||
"grid": "Grille",
|
||||
"heading": "",
|
||||
"height": "Height",
|
||||
"help": "Aide",
|
||||
"hero_section_0_text": "Explorez des itinéraires passionnants, sauvegardez vos favoris et découvrez la beauté de la nature. Trouvez votre prochaine aventure !",
|
||||
@@ -160,8 +164,8 @@
|
||||
"import": "Importer",
|
||||
"import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...",
|
||||
"include-description": "Inclure la description",
|
||||
"integration-description-komoot": "Synchronisez vos Tours Komoot avec Wanderer à intervalles réguliers.",
|
||||
"integration-description-strava": "Synchronisez vos itinéraires et vos activités Strava avec Wanderer à intervalles réguliers.",
|
||||
"integration-description-komoot": "Synchronisez vos Tours Komoot avec wanderer à intervalles réguliers.",
|
||||
"integration-description-strava": "Synchronisez vos itinéraires et vos activités Strava avec wanderer à intervalles réguliers.",
|
||||
"integration-disabled": "Intégration désactivée",
|
||||
"integration-enabled": "Intégration activée",
|
||||
"integrations": "Intégrations",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Langue",
|
||||
"latitude": "Latitude",
|
||||
"license": "Licence",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "Lien copié !",
|
||||
"list": "{n, plural, =1 {Liste} other {Listes}}",
|
||||
"list-not-shared": "Non partagé avec quiconque",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Adresse email invalide",
|
||||
"not-a-valid-url": "URL non valide",
|
||||
"not-completed": "Non terminés",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} a créé une nouvelle liste",
|
||||
"notification-list-share": "{user} à partagé une liste avec vous",
|
||||
"notification-new-follower": "Vous avez un nouvel abonné",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} a laissé un commentaire sur votre itinéraire {trail}",
|
||||
"notification-trail-create": "{user} a créé un nouvel itinéraire",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} a partagé un itinéraire avec vous",
|
||||
"notifications": "Notifications",
|
||||
"object-share-error": "",
|
||||
"off": "Désactivé",
|
||||
"only-me": "Seulement moi",
|
||||
"open-in-new-tab": "",
|
||||
"or": "ou",
|
||||
"orientation": "Orientation",
|
||||
"paper-size": "Taille de papier",
|
||||
"paragraph": "",
|
||||
"password": "Mot de passe",
|
||||
"password-confirm": "Confirmer le mot de passe",
|
||||
"password-reset-sent": "Un e-mail de réinitialisation de votre mot de passe a été envoyé",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Rayon",
|
||||
"read-more": "Voir plus",
|
||||
"register": "Créer un compte",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "Enlever l'itinéraire de",
|
||||
"required": "Requis",
|
||||
"reset-password": "Réinitialiser le mot de passe",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Chercher un itinéraire",
|
||||
"select-list": "Liste de choix",
|
||||
"settings": "Paramètres",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "Un utilisateur que vous suivez à créé une nouvelle liste",
|
||||
"settings-notification-list-share": "Quelqu'un à partagé une liste avec vous",
|
||||
"settings-notification-new-follower": "Vous avez un nouvel abonné",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Quelqu'un a commenté votre itinéraire",
|
||||
"settings-notification-trail-create": "Un utilisateur que vous suivez a créé un nouvel itinéraire",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Quelqu'un a partagé un itinéraire avec vous",
|
||||
"settings-privacy-account-private": "Vous seul pouvez voir votre profil. Vous n'apparaîtrez pas dans les résultats de recherche. Les autres utilisateurs ne pourront pas vous suivre ni partager d'itinéraire avec vous. Vous pourrez tout de même publier des itinéraires ou des listes.",
|
||||
"settings-privacy-account-public": "Tout le monde peut voir votre profil. Vous apparaîtrez dans les résultats de recherche. Les autres utilisateurs pourront vous suivre et partager des itinéraires avec vous.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Itinéraire pour vous",
|
||||
"unchanged": "pas de modification",
|
||||
"units": "Unités",
|
||||
"unlink": "",
|
||||
"upload-file": "Importer un fichier",
|
||||
"upload-gpx": "Envoyer un GPX",
|
||||
"upload-new-file": "Importer un fichier",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Törlés",
|
||||
"delete-account": "Fiók törlése",
|
||||
"delete-list-confirm": "Tényleg törölni akarja ezt a listát? A listán szereplő nyomvonalak továbbra is elérhetőek maradnak.",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "Tényleg törölni akarja ezt a nyomvonalat? Ezt a műveletet nem lehet visszavonni!",
|
||||
"describe-your-trail": "Nyomvonal leírása",
|
||||
"description": "Leírás",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Hiba bejelentkezés közben",
|
||||
"error-during-password-reset": "Unable to send password reset email",
|
||||
"error-exporting-trail": "Error exporting trail",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||
"error-posting-comment": "Error posting comment",
|
||||
"error-printing-map": "Error printing map",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Focus map on",
|
||||
"follow": "Follow",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Followers",
|
||||
"following": "Following",
|
||||
"forgot-your-password": "Forgot your password?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "Német",
|
||||
"get-position-from-exif": "Get coordinates from EXIF data",
|
||||
"grid": "Grid",
|
||||
"heading": "Cím",
|
||||
"height": "Height",
|
||||
"help": "Help",
|
||||
"hero_section_0_text": "Fedezze fel az izgalmas útvonalakat, mentse el kedvenceit, és tapasztalja meg a természet szépségét. Találja meg a következő kalandját!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Nyelf",
|
||||
"latitude": "Szélesség",
|
||||
"license": "License",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "Link copied!",
|
||||
"list": "{n, plural, =1 {Lista} other {Listák}}",
|
||||
"list-not-shared": "Not shared with anyone",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Érvénytelen e-mail cím",
|
||||
"not-a-valid-url": "Not a valid URL",
|
||||
"not-completed": "Nem teljesített",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} created a new list",
|
||||
"notification-list-share": "{user} shared a list with you",
|
||||
"notification-new-follower": "You have a new follower",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} left a comment on your trail \"{trail}\"",
|
||||
"notification-trail-create": "{user} created a new trail",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} shared a trail with you",
|
||||
"notifications": "Notifications",
|
||||
"object-share-error": "",
|
||||
"off": "Off",
|
||||
"only-me": "Only me",
|
||||
"open-in-new-tab": "",
|
||||
"or": "vagy",
|
||||
"orientation": "Orientation",
|
||||
"paper-size": "Paper size",
|
||||
"paragraph": "",
|
||||
"password": "Jelszó",
|
||||
"password-confirm": "Confirm password",
|
||||
"password-reset-sent": "A password reset email has been sent",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Átmérő",
|
||||
"read-more": "Read more",
|
||||
"register": "Regisztráció",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "Eltávolított nyomvonal a",
|
||||
"required": "Kötelező",
|
||||
"reset-password": "Reset Password",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Nyomvonalak keresése",
|
||||
"select-list": "Lista kiválasztása",
|
||||
"settings": "Beállítások",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "A user who you follow has created a list",
|
||||
"settings-notification-list-share": "Someone shared a list with you",
|
||||
"settings-notification-new-follower": "You have a new follower",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Someone left a comment on your trail",
|
||||
"settings-notification-trail-create": "A user who you follow has created a trail",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Someone shared a trail with you",
|
||||
"settings-privacy-account-private": "Only you can see your profile. You will not appear in search results. Other users cannot follow you or share trails with you. You can still publish trails or lists.",
|
||||
"settings-privacy-account-public": "Everyone can see your profile. You appear in search results. Other users can follow you and share trails with you.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Útvonalak önnek",
|
||||
"unchanged": "unchanged",
|
||||
"units": "Mértékegységek",
|
||||
"unlink": "",
|
||||
"upload-file": "Fájl feltöltése",
|
||||
"upload-gpx": "GPX feltöltése",
|
||||
"upload-new-file": "Upload new file",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Elimina",
|
||||
"delete-account": "Elimina account",
|
||||
"delete-list-confirm": "Vuoi davvero cancellare questa lista? I percorsi presenti nella lista saranno ancora disponibili.",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "Vuoi davvero cancellare questo percorso? Questa azione non può essere revocata.",
|
||||
"describe-your-trail": "Descrivi il tuo percorso",
|
||||
"description": "Descrizione",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Errore durante il login",
|
||||
"error-during-password-reset": "Impossibile inviare email per ripristinare la password",
|
||||
"error-exporting-trail": "Errore durante l'esportazione del percorso",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||
"error-posting-comment": "Errore pubblicando il commento",
|
||||
"error-printing-map": "Errore durante la stampa della mappa",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Focus sulla mappa",
|
||||
"follow": "Seguire",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Seguaci",
|
||||
"following": "Seguendo",
|
||||
"forgot-your-password": "Password dimenticata?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "Tedesco",
|
||||
"get-position-from-exif": "Ottieni posizione da dati EXIF",
|
||||
"grid": "Griglia",
|
||||
"heading": "",
|
||||
"height": "Height",
|
||||
"help": "Aiuto",
|
||||
"hero_section_0_text": "Scopri percorsi emozionanti, salva i tuoi preferiti e vivi la bellezza della natura. Trova la tua prossima avventura!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Lingua",
|
||||
"latitude": "Latitudine",
|
||||
"license": "Licenza",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "Link copiato",
|
||||
"list": "{n, plural, =1 {Lista} other {Liste}}",
|
||||
"list-not-shared": "Non condivisa",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Indirizzo email non valido",
|
||||
"not-a-valid-url": "Not a valid URL",
|
||||
"not-completed": "Non completato",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} ha creato una nuova lista",
|
||||
"notification-list-share": "{user} ha condiviso una lista con te",
|
||||
"notification-new-follower": "Hai un nuovo seguace",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} ha lasciato un commento sul tuo percorso \"{trail}\"",
|
||||
"notification-trail-create": "{user} ha creato un nuovo percorso",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} ha condiviso un percorso con te",
|
||||
"notifications": "Notifiche",
|
||||
"object-share-error": "",
|
||||
"off": "Spento",
|
||||
"only-me": "Solo io",
|
||||
"open-in-new-tab": "",
|
||||
"or": "o",
|
||||
"orientation": "Orientamento",
|
||||
"paper-size": "Formato carta",
|
||||
"paragraph": "",
|
||||
"password": "Password",
|
||||
"password-confirm": "Confermare password",
|
||||
"password-reset-sent": "Un email per ripristinare la password è stato inviato",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Raggio",
|
||||
"read-more": "Per saperne di più",
|
||||
"register": "Registrati",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "Percorso rimosso da",
|
||||
"required": "Obbligatorio",
|
||||
"reset-password": "Ripristinare Password",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Cerca percorsi",
|
||||
"select-list": "Seleziona lista",
|
||||
"settings": "Impostazioni",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "Un utente che segui ha creato una lista",
|
||||
"settings-notification-list-share": "Qualcuno ha condiviso una lista con te",
|
||||
"settings-notification-new-follower": "Hai un nuovo seguace",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Qualcuno ha lasciato un commento sul tuo percorso",
|
||||
"settings-notification-trail-create": "Un utente che segui ha creato un percorso",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Qualcuno ha condiviso un percorso con te",
|
||||
"settings-privacy-account-private": "Solo tu puoi vedere il tuo profilo. Non apparirai nei risultati della ricerca. Altri utenti non potranno seguirti o condividere i percorsi con te. Tuttavia potrai comunque pubblicare percorsi o liste.",
|
||||
"settings-privacy-account-public": "Tutti possono vedere il tuo profilo. Appare nei risultati della ricerca. Altri utenti possono seguirti e condividere con te i percorsi.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Percorsi per te",
|
||||
"unchanged": "unchanged",
|
||||
"units": "Unità",
|
||||
"unlink": "",
|
||||
"upload-file": "Carica file",
|
||||
"upload-gpx": "Carica file GPX",
|
||||
"upload-new-file": "Upload new file",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Verwijderen",
|
||||
"delete-account": "Account verwijderen",
|
||||
"delete-list-confirm": "Weet je zeker dat je deze lijst wilt verwijderen? De wandelroutes worden bewaard.",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "Weet je zeker dat je deze wandelroute wilt verwijderen? Deze actie is onomkeerbaar.",
|
||||
"describe-your-trail": "Omschrijf je route",
|
||||
"description": "Omschrijving",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Het inloggen is mislukt",
|
||||
"error-during-password-reset": "Unable to send password reset email",
|
||||
"error-exporting-trail": "Error exporting trail",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||
"error-posting-comment": "Error posting comment",
|
||||
"error-printing-map": "Fout bij afdrukken van kaart",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Focus map on",
|
||||
"follow": "Follow",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Followers",
|
||||
"following": "Following",
|
||||
"forgot-your-password": "Forgot your password?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "Duits",
|
||||
"get-position-from-exif": "Get coordinates from EXIF data",
|
||||
"grid": "Rooster",
|
||||
"heading": "",
|
||||
"height": "Height",
|
||||
"help": "Help",
|
||||
"hero_section_0_text": "Verken uitdagende wandelroutes, bewaar je favoriete en ervaar de pracht van de natuur. Op naar het volgende avontuur!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Taal",
|
||||
"latitude": "Breedtegraad",
|
||||
"license": "Licentie",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "Link copied!",
|
||||
"list": "{n, plural, =1 {Lijst} other {Lijsten}}",
|
||||
"list-not-shared": "Not shared with anyone",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Het e-mailadres is ongeldig",
|
||||
"not-a-valid-url": "Not a valid URL",
|
||||
"not-completed": "Niet voltooid",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} created a new list",
|
||||
"notification-list-share": "{user} shared a list with you",
|
||||
"notification-new-follower": "You have a new follower",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} left a comment on your trail \"{trail}\"",
|
||||
"notification-trail-create": "{user} created a new trail",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} shared a trail with you",
|
||||
"notifications": "Notifications",
|
||||
"object-share-error": "",
|
||||
"off": "Off",
|
||||
"only-me": "Only me",
|
||||
"open-in-new-tab": "",
|
||||
"or": "of",
|
||||
"orientation": "Oriëntatie",
|
||||
"paper-size": "Papierformaat",
|
||||
"paragraph": "",
|
||||
"password": "Wachtwoord",
|
||||
"password-confirm": "Confirm password",
|
||||
"password-reset-sent": "A password reset email has been sent",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Straal",
|
||||
"read-more": "Read more",
|
||||
"register": "Registreren",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "De wandelroute is verwijderd van",
|
||||
"required": "Verplicht",
|
||||
"reset-password": "Reset Password",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Zoeken naar wandelroutes",
|
||||
"select-list": "Kies een lijst",
|
||||
"settings": "Instellingen",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "A user who you follow has created a list",
|
||||
"settings-notification-list-share": "Someone shared a list with you",
|
||||
"settings-notification-new-follower": "You have a new follower",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Someone left a comment on your trail",
|
||||
"settings-notification-trail-create": "A user who you follow has created a trail",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Someone shared a trail with you",
|
||||
"settings-privacy-account-private": "Only you can see your profile. You will not appear in search results. Other users cannot follow you or share trails with you. You can still publish trails or lists.",
|
||||
"settings-privacy-account-public": "Everyone can see your profile. You appear in search results. Other users can follow you and share trails with you.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Wandelroutes voor jou",
|
||||
"unchanged": "unchanged",
|
||||
"units": "Eenheden",
|
||||
"unlink": "",
|
||||
"upload-file": "Bestand uploaden",
|
||||
"upload-gpx": "GPX-bestand uploaden",
|
||||
"upload-new-file": "Upload new file",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Usuń",
|
||||
"delete-account": "Usuń Konto",
|
||||
"delete-list-confirm": "Czy na pewno chcesz usunąć tę listę? Ścieżki na liście zostaną zachowane.",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "Czy na pewno chcesz usunąć tą Ścieżkę? Ta akcja jest nieodwracalna.",
|
||||
"describe-your-trail": "Opisz swoją ścieżkę",
|
||||
"description": "Opis",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Błąd podczas logowania",
|
||||
"error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła",
|
||||
"error-exporting-trail": "Błąd podczas eksportowania szlaku",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Błąd zapisu do komoot",
|
||||
"error-posting-comment": "Błąd wysyłania komentarza",
|
||||
"error-printing-map": "Błąd podczas drukowania mapy",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Skoncentruj mapę na",
|
||||
"follow": "Obserwuj",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Obserwujący",
|
||||
"following": "Obserwowane",
|
||||
"forgot-your-password": "Zapomniałeś hasła?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "Niemiecki",
|
||||
"get-position-from-exif": "Odczytaj współrzędne z danych EXIF",
|
||||
"grid": "Siatka",
|
||||
"heading": "",
|
||||
"height": "Height",
|
||||
"help": "Pomoc",
|
||||
"hero_section_0_text": "Eksploruj ekscytujące szlaki, zapisz swoje ulubione, doświadcz piękna naturalnego świata. Znajdź swoją następną przygodę!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Język",
|
||||
"latitude": "Szerokość",
|
||||
"license": "Licencja",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "Link skopiowany!",
|
||||
"list": "{n, plural, =1 {Lista} other {Listy}}",
|
||||
"list-not-shared": "Nikomu nie udostępniony",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Nieprawidłowy adres email",
|
||||
"not-a-valid-url": "Wadliwy URL",
|
||||
"not-completed": "Nie dokończono",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} utworzył(a) nową listę",
|
||||
"notification-list-share": "{user} udostępnił(a) tobie listę",
|
||||
"notification-new-follower": "Masz nowego obserwującego",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} skomentował(a) twój szlak \"{trail}\"",
|
||||
"notification-trail-create": "{user} utworzył nowy szlak",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} udostępnił(a) tobie szlak",
|
||||
"notifications": "Powiadomienia",
|
||||
"object-share-error": "",
|
||||
"off": "Brak",
|
||||
"only-me": "Tylko ja",
|
||||
"open-in-new-tab": "",
|
||||
"or": "lub",
|
||||
"orientation": "Orientacja",
|
||||
"paper-size": "Rozmiar papieru",
|
||||
"paragraph": "",
|
||||
"password": "Hasło",
|
||||
"password-confirm": "Potwierdź hasło",
|
||||
"password-reset-sent": "E-mail z resetowaniem hasła został wysłany",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Promień",
|
||||
"read-more": "Czytaj dalej",
|
||||
"register": "Zarejestruj",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "Usunięto szlak z",
|
||||
"required": "Wymagane",
|
||||
"reset-password": "Resetuj hasło",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Szukaj szlaków",
|
||||
"select-list": "Wybierz Listę",
|
||||
"settings": "Ustawienia",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "Użytkownik, którego obserwujesz, utworzył listę",
|
||||
"settings-notification-list-share": "Ktoś udostępnił tobie listę",
|
||||
"settings-notification-new-follower": "Masz nowego obserwującego",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Ktoś skomentował twój szlak",
|
||||
"settings-notification-trail-create": "Użytkownik, którego obserwujesz, utworzył szlak",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Ktoś udostępnił tobie szlak",
|
||||
"settings-privacy-account-private": "Tylko ty możesz zobaczyć swój profil. Nie będziesz się pojawiać w wynikach wyszukiwania. Inni użytkownicy nie mogą cię obserwować ani udostępniać ci szlaków. Wciąż możesz publikować szlaki i listy.",
|
||||
"settings-privacy-account-public": "Wszyscy mogą zobaczyć twój profil. Będziesz się pojawiać w wynikach wyszukiwania. Inni użytkownicy mogą cię obserwować i udostępniać ci szlaki.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Szlaki dla ciebie",
|
||||
"unchanged": "bez zmian",
|
||||
"units": "Jednostki",
|
||||
"unlink": "",
|
||||
"upload-file": "Przesyłanie pliku",
|
||||
"upload-gpx": "Importuj GPX",
|
||||
"upload-new-file": "Prześlij nowy plik",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "Excluir",
|
||||
"delete-account": "Excluir conta",
|
||||
"delete-list-confirm": "Você quer mesmo excluir esta lista? As trilhas na lista ainda estarão disponíveis.",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "Você realmente quer excluir esta trilha? Esta ação não pode ser desfeita.",
|
||||
"describe-your-trail": "Descreva sua trilha",
|
||||
"description": "Descrição",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "Erro durante o ‘login’",
|
||||
"error-during-password-reset": "Unable to send password reset email",
|
||||
"error-exporting-trail": "Erro na exportação do percurso",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||
"error-posting-comment": "Error posting comment",
|
||||
"error-printing-map": "Erro na impressão do mapa",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Centrar mapa em",
|
||||
"follow": "Follow",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Followers",
|
||||
"following": "Following",
|
||||
"forgot-your-password": "Forgot your password?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "Alemão",
|
||||
"get-position-from-exif": "Obter coordenadas dos dados EXIF",
|
||||
"grid": "Grelha",
|
||||
"heading": "",
|
||||
"height": "Height",
|
||||
"help": "Ajuda",
|
||||
"hero_section_0_text": "Explore trilhas emocionantes, salve seus favoritos e experimente a beleza da natureza. Encontre sua próxima aventura!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "Língua",
|
||||
"latitude": "Latitude",
|
||||
"license": "Licença",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "Link copiado!",
|
||||
"list": "{n, plural, =1 {Lista} other {Listas}}",
|
||||
"list-not-shared": "Não partilhado com ninguém",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "Não um endereço de e-mail válido",
|
||||
"not-a-valid-url": "Not a valid URL",
|
||||
"not-completed": "Não preenchido",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} created a new list",
|
||||
"notification-list-share": "{user} shared a list with you",
|
||||
"notification-new-follower": "You have a new follower",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} left a comment on your trail \"{trail}\"",
|
||||
"notification-trail-create": "{user} created a new trail",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} shared a trail with you",
|
||||
"notifications": "Notifications",
|
||||
"object-share-error": "",
|
||||
"off": "Desligar",
|
||||
"only-me": "Only me",
|
||||
"open-in-new-tab": "",
|
||||
"or": "ou",
|
||||
"orientation": "Orientação",
|
||||
"paper-size": "Tamanho do papel",
|
||||
"paragraph": "",
|
||||
"password": "Senha",
|
||||
"password-confirm": "Confirm password",
|
||||
"password-reset-sent": "A password reset email has been sent",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "Raio",
|
||||
"read-more": "Ler mais",
|
||||
"register": "Registo",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "Trilha removida de",
|
||||
"required": "Obrigatório",
|
||||
"reset-password": "Reset Password",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "Procurar trilhos",
|
||||
"select-list": "Selecionar lista",
|
||||
"settings": "Definições",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "A user who you follow has created a list",
|
||||
"settings-notification-list-share": "Someone shared a list with you",
|
||||
"settings-notification-new-follower": "You have a new follower",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Someone left a comment on your trail",
|
||||
"settings-notification-trail-create": "A user who you follow has created a trail",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Someone shared a trail with you",
|
||||
"settings-privacy-account-private": "Only you can see your profile. You will not appear in search results. Other users cannot follow you or share trails with you. You can still publish trails or lists.",
|
||||
"settings-privacy-account-public": "Everyone can see your profile. You appear in search results. Other users can follow you and share trails with you.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "Trilhos para si",
|
||||
"unchanged": "unchanged",
|
||||
"units": "Unidades",
|
||||
"unlink": "",
|
||||
"upload-file": "Subir arquivo",
|
||||
"upload-gpx": "Carregar GPX",
|
||||
"upload-new-file": "Upload new file",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"delete": "删除",
|
||||
"delete-account": "注销账户",
|
||||
"delete-list-confirm": "您确认要删除当前列表?列表中所有行程仍然继续保留不会被删除。",
|
||||
"delete-summit-log-confirm": "",
|
||||
"delete-trail-confirm": "您确认想要删除当前行程?此操作不可撤回。",
|
||||
"describe-your-trail": "行程描述",
|
||||
"description": "描述",
|
||||
@@ -109,6 +110,7 @@
|
||||
"error-during-login": "登录错误",
|
||||
"error-during-password-reset": "Unable to send password reset email",
|
||||
"error-exporting-trail": "导出路线失败",
|
||||
"error-liking-trail": "",
|
||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||
"error-posting-comment": "Error posting comment",
|
||||
"error-printing-map": "打印地图失败",
|
||||
@@ -134,6 +136,7 @@
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "地图聚焦于",
|
||||
"follow": "Follow",
|
||||
"follow-request-pending": "",
|
||||
"followers": "Followers",
|
||||
"following": "Following",
|
||||
"forgot-your-password": "忘记密码?",
|
||||
@@ -144,6 +147,7 @@
|
||||
"german": "德语",
|
||||
"get-position-from-exif": "从EXIF数据获取坐标",
|
||||
"grid": "网格",
|
||||
"heading": "",
|
||||
"height": "Height",
|
||||
"help": "帮助",
|
||||
"hero_section_0_text": "探索激动人心的探险,收藏最爱行程,体验美丽地大自然。找到你的下一次冒险!",
|
||||
@@ -172,6 +176,9 @@
|
||||
"language": "语言",
|
||||
"latitude": "维度",
|
||||
"license": "开源协议",
|
||||
"like-status": "",
|
||||
"liked": "",
|
||||
"likes": "",
|
||||
"link-copied": "链接已复制",
|
||||
"list": "{n, plural, =1 {列表} other {列表}}",
|
||||
"list-not-shared": "未与任何人分享",
|
||||
@@ -224,18 +231,26 @@
|
||||
"not-a-valid-email-address": "无效电子邮箱地址",
|
||||
"not-a-valid-url": "Not a valid URL",
|
||||
"not-completed": "未完成",
|
||||
"notification-comment-mention": "",
|
||||
"notification-list-create": "{user} created a new list",
|
||||
"notification-list-share": "{user} shared a list with you",
|
||||
"notification-new-follower": "You have a new follower",
|
||||
"notification-summit-log-create": "",
|
||||
"notification-summit-log-mention": "",
|
||||
"notification-trail-comment": "{user} left a comment on your trail \"{trail}\"",
|
||||
"notification-trail-create": "{user} created a new trail",
|
||||
"notification-trail-like": "",
|
||||
"notification-trail-mention": "",
|
||||
"notification-trail-share": "{user} shared a trail with you",
|
||||
"notifications": "Notifications",
|
||||
"object-share-error": "",
|
||||
"off": "关闭",
|
||||
"only-me": "Only me",
|
||||
"open-in-new-tab": "",
|
||||
"or": "或",
|
||||
"orientation": "方向",
|
||||
"paper-size": "纸张大小",
|
||||
"paragraph": "",
|
||||
"password": "密码",
|
||||
"password-confirm": "确认密码",
|
||||
"password-reset-sent": "A password reset email has been sent",
|
||||
@@ -256,6 +271,7 @@
|
||||
"radius": "半径",
|
||||
"read-more": "阅读更多",
|
||||
"register": "注册",
|
||||
"remote-users-cannot-edit": "",
|
||||
"removed-trail-from": "路线已删除自",
|
||||
"required": "必填",
|
||||
"reset-password": "重置密码",
|
||||
@@ -272,11 +288,16 @@
|
||||
"search-trails": "搜索路线",
|
||||
"select-list": "选择列表",
|
||||
"settings": "设置",
|
||||
"settings-notification-comment-mention": "",
|
||||
"settings-notification-list-create": "A user who you follow has created a list",
|
||||
"settings-notification-list-share": "Someone shared a list with you",
|
||||
"settings-notification-new-follower": "You have a new follower",
|
||||
"settings-notification-summit-log-create": "",
|
||||
"settings-notification-summit-log-mention": "",
|
||||
"settings-notification-trail-comment": "Someone left a comment on your trail",
|
||||
"settings-notification-trail-create": "A user who you follow has created a trail",
|
||||
"settings-notification-trail-like": "",
|
||||
"settings-notification-trail-mention": "",
|
||||
"settings-notification-trail-share": "Someone shared a trail with you",
|
||||
"settings-privacy-account-private": "Only you can see your profile. You will not appear in search results. Other users cannot follow you or share trails with you. You can still publish trails or lists.",
|
||||
"settings-privacy-account-public": "Everyone can see your profile. You appear in search results. Other users can follow you and share trails with you.",
|
||||
@@ -317,6 +338,7 @@
|
||||
"trails-for-you": "推荐路线",
|
||||
"unchanged": "unchanged",
|
||||
"units": "单位",
|
||||
"unlink": "",
|
||||
"upload-file": "上传文件",
|
||||
"upload-gpx": "上传 GPX",
|
||||
"upload-new-file": "Upload new file",
|
||||
|
||||
14
web/src/lib/models/activitypub/activity.ts
Normal file
14
web/src/lib/models/activitypub/activity.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { User } from "../user";
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
iri: string;
|
||||
type: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bto: string[];
|
||||
bcc: string[];
|
||||
object: any;
|
||||
actor: string;
|
||||
published: string;
|
||||
}
|
||||
25
web/src/lib/models/activitypub/actor.ts
Normal file
25
web/src/lib/models/activitypub/actor.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { User } from "../user";
|
||||
|
||||
export interface Actor {
|
||||
id?: string;
|
||||
username: string;
|
||||
preferred_username: string;
|
||||
domain?: string;
|
||||
summary?: string;
|
||||
published?: string;
|
||||
followerCount?: number,
|
||||
followingCount?: number,
|
||||
iri: string;
|
||||
inbox: string;
|
||||
outbox?: string;
|
||||
icon?: string;
|
||||
followers?: string;
|
||||
following?: string;
|
||||
isLocal: boolean;
|
||||
public_key: string;
|
||||
last_fetched: string;
|
||||
user?: string
|
||||
expand?: {
|
||||
user?: User
|
||||
}
|
||||
}
|
||||
12
web/src/lib/models/activitypub/webfinger_response.ts
Normal file
12
web/src/lib/models/activitypub/webfinger_response.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface WebfingerResponse {
|
||||
subject: string
|
||||
aliases: string[]
|
||||
links: Link[]
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
rel: string
|
||||
type: string
|
||||
href: string
|
||||
}
|
||||
|
||||
@@ -3,15 +3,13 @@ import type { Comment } from "../comment";
|
||||
|
||||
const CommentCreateSchema = z.object({
|
||||
text: z.string(),
|
||||
rating: z.number().optional(),
|
||||
author: z.string().length(15),
|
||||
trail: z.string().length(15),
|
||||
trail: z.string(),
|
||||
|
||||
}) satisfies ZodType<Comment>
|
||||
|
||||
const CommentUpdateSchema = z.object({
|
||||
text: z.string().optional(),
|
||||
rating: z.number().optional(),
|
||||
}) satisfies ZodType<Partial<Comment>>
|
||||
|
||||
export { CommentCreateSchema, CommentUpdateSchema }
|
||||
@@ -2,10 +2,9 @@ import { z, ZodType } from "zod";
|
||||
import type { Follow } from "../follow";
|
||||
|
||||
const FollowCreateSchema = z.object({
|
||||
follower: z.string().length(15),
|
||||
followee: z.string().length(15),
|
||||
|
||||
}) satisfies ZodType<Follow>
|
||||
})
|
||||
|
||||
|
||||
export { FollowCreateSchema };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z, ZodType } from "zod";
|
||||
import type { ListShare } from "../list_share";
|
||||
|
||||
const ListShareCreateSchema = z.object({
|
||||
user: z.string().length(15),
|
||||
actor: z.string().url(),
|
||||
list: z.string().length(15),
|
||||
permission: z.enum(["view", "edit"])
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@ const SummitLogCreateSchema = z.object({
|
||||
elevation_loss: z.number().nonnegative().optional(),
|
||||
duration: z.number().nonnegative().optional(),
|
||||
author: z.string().length(15),
|
||||
trail: z.string().length(15).optional(),
|
||||
photos: z.array(z.string()).default([])
|
||||
}) satisfies ZodType<Partial<SummitLog>>
|
||||
|
||||
const SummitLogUpdateSchema = z.object({
|
||||
date: z.string().date().refine((val) => !val || !isNaN(Date.parse(val)), "invalid-date").optional(),
|
||||
date: z.string().date().refine((val) => !val || !isNaN(Date.parse(val)), "invalid-date").optional(),
|
||||
text: z.string().optional(),
|
||||
gpx: z.string().optional(),
|
||||
distance: z.number().nonnegative().optional(),
|
||||
|
||||
10
web/src/lib/models/api/trail_like_schema.ts
Normal file
10
web/src/lib/models/api/trail_like_schema.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z, ZodType } from "zod";
|
||||
import type { TrailLike } from "../trail_like";
|
||||
|
||||
const TrailLikeCreateSchema = z.object({
|
||||
actor: z.string().length(15),
|
||||
trail: z.string().length(15),
|
||||
|
||||
}) satisfies ZodType<TrailLike>
|
||||
|
||||
export { TrailLikeCreateSchema };
|
||||
@@ -19,7 +19,7 @@ const TrailCreateSchema = z.object({
|
||||
photos: z.array(z.string()).default([]),
|
||||
thumbnail: z.number().int().nonnegative().optional(),
|
||||
waypoints: z.array(z.string()).default([]),
|
||||
summit_logs: z.array(z.string()).default([]),
|
||||
like_count: z.number().int().min(0).optional().default(0),
|
||||
category: z.string().length(15).optional().or(z.literal('')),
|
||||
tags: z.array(z.string()).default([]),
|
||||
gpx: z.string().optional(),
|
||||
@@ -45,7 +45,7 @@ const TrailUpdateSchema = z.object({
|
||||
"photos+": z.string().optional(),
|
||||
thumbnail: z.number().int().nonnegative().optional(),
|
||||
waypoints: z.array(z.string()).optional(),
|
||||
summit_logs: z.array(z.string()).optional(),
|
||||
like_count: z.number().int().min(0).optional().default(0),
|
||||
category: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
gpx: z.string().optional(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z, ZodType } from "zod";
|
||||
import type { TrailShare } from "../trail_share";
|
||||
|
||||
const TrailShareCreateSchema = z.object({
|
||||
user: z.string().length(15),
|
||||
actor: z.string().url(),
|
||||
trail: z.string().length(15),
|
||||
permission: z.enum(["view", "edit"])
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import type { UserAnonymous } from "./user";
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
|
||||
export class Comment {
|
||||
id?: string;
|
||||
text: string;
|
||||
rating?: number;
|
||||
author: string;
|
||||
trail: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
iri?: string;
|
||||
expand?: {
|
||||
author: UserAnonymous
|
||||
author: Actor
|
||||
}
|
||||
|
||||
constructor(text: string, rating: number, author: string, trail: string) {
|
||||
constructor(text: string, author: string, trail: string) {
|
||||
this.text = text;
|
||||
this.rating = rating;
|
||||
this.author = author;
|
||||
this.trail = trail;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { User, UserAnonymous } from "./user";
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
|
||||
export class Follow {
|
||||
id?: string;
|
||||
follower: string;
|
||||
followee: string;
|
||||
status: "pending" | "accepted";
|
||||
expand?: {
|
||||
follower: UserAnonymous,
|
||||
followee: UserAnonymous
|
||||
follower: Actor,
|
||||
followee: Actor
|
||||
}
|
||||
|
||||
constructor(follower: string, followee: string) {
|
||||
this.follower = follower;
|
||||
this.followee = followee;
|
||||
this.status = "pending"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import type { Trail } from "./trail";
|
||||
import type { ListShare } from "./list_share";
|
||||
import type { UserAnonymous } from "./user";
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
|
||||
export class List {
|
||||
id?: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
description?: string;
|
||||
elevation_gain?: number;
|
||||
elevation_loss?: number;
|
||||
distance?: number;
|
||||
duration?: number;
|
||||
avatar?: string;
|
||||
trails?: string[];
|
||||
iri?: string;
|
||||
expand?: {
|
||||
trails?: Trail[]
|
||||
list_share_via_list?: ListShare[]
|
||||
author?: UserAnonymous;
|
||||
author?: Actor;
|
||||
|
||||
}
|
||||
author: string;
|
||||
@@ -22,7 +28,7 @@ export class List {
|
||||
this.public = params?.public ?? false
|
||||
this.expand = { trails: trails };
|
||||
this.trails = trails.map(t => t.id!);
|
||||
this.description = params?.description;
|
||||
this.description = params?.description ?? "";
|
||||
this.avatar = params?.avatar;
|
||||
this.author = params?.author ?? "000000000000000";
|
||||
}
|
||||
@@ -35,35 +41,4 @@ export interface ListFilter {
|
||||
public?: boolean;
|
||||
shared?: boolean;
|
||||
sortOrder?: "+" | "-"
|
||||
}
|
||||
|
||||
export const enum ExpandType {
|
||||
None = 0,
|
||||
Trails = 1 << 0,
|
||||
Waypoints = 1 << 1,
|
||||
TrailCategories = 1 << 2,
|
||||
ListShares = 1 << 3,
|
||||
All = ~(~0 << 4),
|
||||
}
|
||||
|
||||
export function ExpandTypeToString(e: ExpandType) : string {
|
||||
|
||||
if (e == ExpandType.None)
|
||||
return "";
|
||||
|
||||
var ret = "";
|
||||
if ((e & ExpandType.Trails) === ExpandType.Trails) {
|
||||
ret += "trails,";
|
||||
}
|
||||
if ((e & ExpandType.Waypoints) === ExpandType.Waypoints) {
|
||||
ret += "trails.waypoints,";
|
||||
}
|
||||
if ((e & ExpandType.TrailCategories) === ExpandType.TrailCategories) {
|
||||
ret += "trails.category,";
|
||||
}
|
||||
if ((e & ExpandType.ListShares) === ExpandType.ListShares) {
|
||||
ret += "list_share_via_list,";
|
||||
}
|
||||
|
||||
return ret.slice(0, -1);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
import type { User } from "./user";
|
||||
|
||||
export class ListShare {
|
||||
id?: string;
|
||||
user: string;
|
||||
actor: string;
|
||||
list: string;
|
||||
permission: "view" | "edit"
|
||||
expand?: {
|
||||
user: User
|
||||
actor: Actor
|
||||
}
|
||||
|
||||
constructor(user: string, list: string, permission: "view" | "edit") {
|
||||
this.user = user;
|
||||
this.actor = user;
|
||||
this.list = list;
|
||||
this.permission = permission
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { UserAnonymous } from "./user";
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
|
||||
enum NotificationType {
|
||||
trailCreate = "trail_create",
|
||||
trailShare = "trail_share",
|
||||
listCreate = "list_create",
|
||||
trailLike = "trail_like",
|
||||
listShare = "list_share",
|
||||
newFollower = "new_follower",
|
||||
trailComment = "trail_comment"
|
||||
trailComment = "trail_comment",
|
||||
summitLogCreate = "summit_log_create",
|
||||
commentMention = "comment_mention",
|
||||
trailMention = "trail_mention",
|
||||
summitLogMention = "summit_log_mention",
|
||||
|
||||
};
|
||||
|
||||
interface Notification {
|
||||
@@ -18,9 +22,9 @@ interface Notification {
|
||||
author: string
|
||||
created: string;
|
||||
expand: {
|
||||
recipient: UserAnonymous;
|
||||
author: UserAnonymous;
|
||||
recipient: Actor;
|
||||
author: Actor;
|
||||
}
|
||||
}
|
||||
|
||||
export { type Notification, NotificationType }
|
||||
export { NotificationType, type Notification };
|
||||
|
||||
15
web/src/lib/models/profile.ts
Normal file
15
web/src/lib/models/profile.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { DateTime } from "activitypub-types";
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
username: string;
|
||||
preferredUsername: string;
|
||||
acct: string;
|
||||
bio: string;
|
||||
followers: number;
|
||||
following: number;
|
||||
uri: string;
|
||||
icon: string;
|
||||
createdAt: DateTime;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
import type { Trail } from "./trail";
|
||||
import type { UserAnonymous } from "./user";
|
||||
|
||||
class SummitLog {
|
||||
id?: string;
|
||||
@@ -14,11 +14,14 @@ class SummitLog {
|
||||
elevation_loss?: number
|
||||
duration?: number
|
||||
author: string;
|
||||
trail?: string;
|
||||
iri?: string;
|
||||
created?: string;
|
||||
|
||||
expand?: {
|
||||
gpx_data?: string;
|
||||
trails_via_summit_logs?: Trail[];
|
||||
author?: UserAnonymous
|
||||
trail?: Trail;
|
||||
author?: Actor
|
||||
}
|
||||
|
||||
constructor(date: string, params?: { id?: string, text?: string, distance?: number, elevation_loss?: number, elevation_gain?: number, duration?: number, photos?: string[] }) {
|
||||
@@ -41,6 +44,7 @@ interface SummitLogFilter {
|
||||
category: string[],
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
trail?: string;
|
||||
}
|
||||
|
||||
export { SummitLog, type SummitLogFilter };
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
interface Activity {
|
||||
interface TimelineItem {
|
||||
id: string;
|
||||
trail_id: string;
|
||||
trail_author_username: string;
|
||||
trail_author_domain: string;
|
||||
trail_iri: string;
|
||||
name: string,
|
||||
description: string;
|
||||
date: string;
|
||||
@@ -15,4 +18,4 @@ interface Activity {
|
||||
created: string;
|
||||
}
|
||||
|
||||
export { type Activity }
|
||||
export { type TimelineItem }
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
import type { Category } from "./category";
|
||||
import type { Comment } from "./comment";
|
||||
import type { SummitLog } from "./summit_log";
|
||||
import type { Tag } from "./tag";
|
||||
import type { TrailLike } from "./trail_like";
|
||||
import type { TrailShare } from "./trail_share";
|
||||
import type { UserAnonymous } from "./user";
|
||||
import type { Waypoint } from "./waypoint";
|
||||
|
||||
class Trail {
|
||||
@@ -26,17 +27,21 @@ class Trail {
|
||||
category?: string;
|
||||
tags: string[];
|
||||
waypoints: string[];
|
||||
summit_logs: string[];
|
||||
polyline?: string;
|
||||
domain?: string;
|
||||
iri?: string;
|
||||
like_count: number;
|
||||
expand?: {
|
||||
tags?: Tag[]
|
||||
category?: Category;
|
||||
waypoints?: Waypoint[]
|
||||
summit_logs?: SummitLog[]
|
||||
author?: UserAnonymous
|
||||
summit_logs_via_trail?: SummitLog[]
|
||||
author?: Actor
|
||||
comments_via_trail?: Comment[]
|
||||
gpx_data?: string
|
||||
trail_share_via_trail?: TrailShare[]
|
||||
trail_like_via_trail?: TrailLike[]
|
||||
|
||||
}
|
||||
description?: string;
|
||||
author: string;
|
||||
@@ -83,13 +88,13 @@ class Trail {
|
||||
this.thumbnail = params?.thumbnail ?? 0;
|
||||
this.photos = params?.photos ?? [];
|
||||
this.waypoints = [];
|
||||
this.summit_logs = [];
|
||||
this.tags = []
|
||||
this.gpx = params?.gpx;
|
||||
this.like_count = 0
|
||||
this.expand = {
|
||||
category: params?.category,
|
||||
waypoints: params?.waypoints ?? [],
|
||||
summit_logs: params?.summit_logs ?? [],
|
||||
summit_logs_via_trail: params?.summit_logs ?? [],
|
||||
comments_via_trail: params?.comments ?? [],
|
||||
trail_share_via_trail: params?.shares ?? []
|
||||
}
|
||||
@@ -125,6 +130,7 @@ interface TrailFilter {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
completed?: boolean;
|
||||
liked?: boolean;
|
||||
sort: "name" | "distance" | "elevation_gain" | "created";
|
||||
sortOrder: "+" | "-"
|
||||
}
|
||||
@@ -168,8 +174,12 @@ interface TrailSearchResult {
|
||||
public: boolean;
|
||||
thumbnail: string;
|
||||
polyline?: string;
|
||||
likes?: string[];
|
||||
like_count: number;
|
||||
shares?: string[];
|
||||
tags?: string[]
|
||||
domain?: string;
|
||||
iri?: string;
|
||||
gpx: string;
|
||||
_geo: {
|
||||
lat: number,
|
||||
@@ -196,12 +206,16 @@ export const defaultTrailSearchAttributes = [
|
||||
"created",
|
||||
"public",
|
||||
"thumbnail",
|
||||
"domain",
|
||||
"gpx",
|
||||
"tags",
|
||||
"like_count",
|
||||
"shares",
|
||||
"iri",
|
||||
"_geo",]
|
||||
|
||||
|
||||
export { Trail };
|
||||
|
||||
export type { TrailBoundingBox, TrailFilter, TrailFilterValues, TrailSearchResult };
|
||||
|
||||
|
||||
17
web/src/lib/models/trail_like.ts
Normal file
17
web/src/lib/models/trail_like.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
import type { Trail } from "./trail";
|
||||
|
||||
export class TrailLike {
|
||||
id?: string;
|
||||
actor: string;
|
||||
trail: string;
|
||||
expand?: {
|
||||
actor: Actor,
|
||||
trail?: Trail
|
||||
}
|
||||
|
||||
constructor(actor: string, trail: string) {
|
||||
this.actor = actor;
|
||||
this.trail = trail;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { User, UserAnonymous } from "./user";
|
||||
import type { Actor } from "./activitypub/actor";
|
||||
|
||||
export class TrailShare {
|
||||
id?: string;
|
||||
user: string;
|
||||
actor: string;
|
||||
trail: string;
|
||||
permission: "view" | "edit"
|
||||
expand?: {
|
||||
user: UserAnonymous
|
||||
actor: Actor
|
||||
}
|
||||
|
||||
constructor(user: string, trail: string, permission: "view" | "edit") {
|
||||
this.user = user;
|
||||
constructor(actor: string, trail: string, permission: "view" | "edit") {
|
||||
this.actor = actor;
|
||||
this.trail = trail;
|
||||
this.permission = permission
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export type User = AuthRecord & {
|
||||
email?: string,
|
||||
password: string,
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
@@ -15,6 +14,7 @@ export type UserAnonymous = {
|
||||
id: string,
|
||||
username?: string,
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
created?: string;
|
||||
private: boolean;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Activity } from "$lib/models/activity";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { type ListResult } from "pocketbase";
|
||||
|
||||
let activities: Activity[] = [];
|
||||
|
||||
export async function activities_index(author: string, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/activity?' + new URLSearchParams({
|
||||
"perPage": perPage.toString(),
|
||||
page: page.toString(),
|
||||
filter: `author="${author}"`,
|
||||
sort: "-created"
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const fetchedActivities: ListResult<Activity> = await r.json();
|
||||
|
||||
const result = page > 1 ? [...activities, ...fetchedActivities.items] : fetchedActivities.items
|
||||
|
||||
activities = result;
|
||||
|
||||
return { ...fetchedActivities, items: result };
|
||||
|
||||
}
|
||||
@@ -4,13 +4,22 @@ import { APIError } from "$lib/util/api_util";
|
||||
import { type ListResult } from "pocketbase";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import { currentUser } from "./user_store";
|
||||
import { isURL } from "$lib/util/file_util";
|
||||
|
||||
export const comments: Writable<Comment[]> = writable([])
|
||||
|
||||
export async function comments_index(trail: Trail) {
|
||||
let r = await fetch('/api/v1/comment?' + new URLSearchParams({
|
||||
filter: `trail="${trail.id}"`,
|
||||
sort: "-created"
|
||||
export async function comments_index(trailId: string, handle?: string) {
|
||||
let filter: string;
|
||||
if (isURL(trailId)) {
|
||||
filter = `trail="${trailId}"||trail.iri="${trailId}"||trail="${trailId.substring(trailId.length - 15)}"`
|
||||
} else {
|
||||
filter = `trail="${trailId}"`
|
||||
}
|
||||
let r = await fetch(`/api/v1/comment?` + new URLSearchParams({
|
||||
filter,
|
||||
expand: "author",
|
||||
sort: "-created",
|
||||
...(handle ? { handle } : {})
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -33,9 +42,11 @@ export async function comments_create(comment: Comment) {
|
||||
throw Error("Unauthenticated")
|
||||
}
|
||||
|
||||
comment.author = user.authStore.record!.id;
|
||||
comment.author = user.actor
|
||||
|
||||
let r = await fetch('/api/v1/comment', {
|
||||
let r = await fetch('/api/v1/comment?' + new URLSearchParams({
|
||||
expand: "author",
|
||||
}), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(comment),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { Actor } from "$lib/models/activitypub/actor";
|
||||
import type { Follow } from "$lib/models/follow";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { type ListResult } from "pocketbase";
|
||||
|
||||
let follows: Follow[] = [];
|
||||
let follows: Actor[] = [];
|
||||
|
||||
export async function follows_index(data: { follower?: string, followee?: string }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/follow?' + new URLSearchParams({
|
||||
filter: data.follower ? `follower='${data.follower}'` : data.followee ? `followee='${data.followee}'` : '',
|
||||
export async function follows_index(data: { username: string, type: "followers" | "following" }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
|
||||
const r = await f(`/api/v1/follow?` + new URLSearchParams({
|
||||
handle: data.username,
|
||||
type: data.type,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
requestKey: "page"
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -19,11 +21,11 @@ export async function follows_index(data: { follower?: string, followee?: string
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const fetchedFollows: ListResult<Follow> = await r.json();
|
||||
const fetchedFollows: ListResult<Actor> = await r.json();
|
||||
|
||||
const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items
|
||||
|
||||
follows = result;
|
||||
follows = result;
|
||||
|
||||
return { ...fetchedFollows, items: result };
|
||||
}
|
||||
@@ -45,25 +47,10 @@ export async function follows_a_b(a: string, b: string, f: (url: RequestInfo | U
|
||||
return response.items.at(0);
|
||||
}
|
||||
|
||||
export async function follows_counts(id: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/follow/count/' + id, {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const response: { followers: number, following: number } = await r.json();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function follows_create(follow: Follow) {
|
||||
export async function follows_create(followee: string) {
|
||||
let r = await fetch('/api/v1/follow', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(follow),
|
||||
body: JSON.stringify({ followee }),
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const shares: Writable<ListShare[]> = writable([])
|
||||
export async function list_share_index(list: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/list-share?' + new URLSearchParams({
|
||||
filter: `list='${list}'`,
|
||||
expand: "actor"
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { ExpandType, ExpandTypeToString, List, type ListFilter } from "$lib/models/list";
|
||||
import { List, type ListFilter } from "$lib/models/list";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import type { Hits } from "meilisearch";
|
||||
import { type AuthRecord, type ListResult } from "pocketbase";
|
||||
import { type AuthRecord, type ListResult, type RecordModel } from "pocketbase";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import type { ListSearchResult } from "./search_store";
|
||||
import { fetchGPX } from "./trail_store";
|
||||
import { currentUser } from "./user_store";
|
||||
import { objectToFormData } from "$lib/util/file_util";
|
||||
|
||||
let lists: List[] = []
|
||||
export const list: Writable<List | null> = writable(null)
|
||||
export const listTrail: Writable<Trail | null> = writable(null);
|
||||
|
||||
export async function lists_index(filter?: ListFilter, page: number = 1, perPage: number = 5,
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
e: ExpandType = ExpandType.All) {
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const user = get(currentUser)
|
||||
|
||||
|
||||
const filterText = filter ? buildFilterText(user, filter) : ""
|
||||
|
||||
const r = await f('/api/v1/list?' + new URLSearchParams({
|
||||
@@ -23,7 +24,6 @@ export async function lists_index(filter?: ListFilter, page: number = 1, perPage
|
||||
perPage: perPage.toString(),
|
||||
page: page.toString(),
|
||||
filter: filterText,
|
||||
expand: ExpandTypeToString(e),
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -43,8 +43,8 @@ export async function lists_index(filter?: ListFilter, page: number = 1, perPage
|
||||
}
|
||||
|
||||
|
||||
export async function lists_search_filter(filter: ListFilter, page: number = 1, perPage: number = 5, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<ListResult<List>> {
|
||||
const user = get(currentUser)
|
||||
export async function lists_search_filter(filter: ListFilter, page: number = 1, perPage: number = 5, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch, user?: AuthRecord) {
|
||||
user ??= get(currentUser)
|
||||
|
||||
const filterText = buildSearchFilterText(user, filter)
|
||||
|
||||
@@ -65,43 +65,21 @@ export async function lists_search_filter(filter: ListFilter, page: number = 1,
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const searchResult: { page: number, totalPages: number, hits: Hits<Record<string, any>> } = await r.json();
|
||||
const result: { page: number, totalPages: number, hits: Hits<ListSearchResult> } = await r.json();
|
||||
|
||||
|
||||
const listIds = searchResult.hits.map((h: Record<string, any>) => h.id);
|
||||
const resultLists: List[] = await searchResultToLists(result.hits)
|
||||
|
||||
if (listIds.length == 0) {
|
||||
return { items: [], page: searchResult.page, perPage, totalItems: 0, totalPages: searchResult.totalPages };
|
||||
}
|
||||
return { items: resultLists, ...result };
|
||||
|
||||
r = await f('/api/v1/list?' + new URLSearchParams({
|
||||
expand: "trails,trails.waypoints,trails.category,list_share_via_list",
|
||||
filter: `'${listIds.join(',')}'~id`,
|
||||
sort: filter.sort && filter.sortOrder ? `${filter.sortOrder}${filter.sort}` : ''
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const fetchedLists: ListResult<List> = await r.json();
|
||||
|
||||
const result = page > 1 ? [...lists, ...fetchedLists.items] : fetchedLists.items
|
||||
|
||||
lists = result;
|
||||
|
||||
return { ...fetchedLists, items: result };
|
||||
|
||||
}
|
||||
|
||||
export async function lists_show(id: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch
|
||||
, e: ExpandType = ExpandType.All) {
|
||||
export async function lists_show(id: string, handle?: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
|
||||
const r = await f(`/api/v1/list/${id}?` + new URLSearchParams({
|
||||
expand: ExpandTypeToString(e)
|
||||
expand: "author,trails,trails.author,list_share_via_list.actor",
|
||||
...(handle ? { handle } : {})
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -118,9 +96,6 @@ export async function lists_show(id: string, f: (url: RequestInfo | URL, config?
|
||||
trail.expand.gpx_data = gpxData;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
list.set(response);
|
||||
|
||||
return response;
|
||||
@@ -132,11 +107,17 @@ export async function lists_create(list: List, avatar?: File) {
|
||||
if (!user) {
|
||||
throw Error("Unauthenticated")
|
||||
}
|
||||
list.author = user.id;
|
||||
list.author = user.actor;
|
||||
|
||||
let r = await fetch('/api/v1/list', {
|
||||
const formData = objectToFormData(list)
|
||||
|
||||
if (avatar) {
|
||||
formData.append("avatar", avatar);
|
||||
}
|
||||
|
||||
let r = await fetch('/api/v1/list/form', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(list),
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
@@ -146,45 +127,18 @@ export async function lists_create(list: List, avatar?: File) {
|
||||
|
||||
const model: List = await r.json();
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (avatar) {
|
||||
formData.append("avatar", avatar);
|
||||
}
|
||||
|
||||
r = await fetch(`/api/v1/list/${model.id!}/file`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
return await r.json();
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function lists_update(list: List, avatar?: File) {
|
||||
let r = await fetch('/api/v1/list/' + list.id, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(list),
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const model: List = await r.json();
|
||||
|
||||
const formData = new FormData();
|
||||
const formData = objectToFormData(list)
|
||||
|
||||
if (avatar) {
|
||||
formData.append("avatar", avatar);
|
||||
}
|
||||
|
||||
r = await fetch(`/api/v1/list/${model.id!}/file`, {
|
||||
let r = await fetch('/api/v1/list/form/' + list.id, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
@@ -193,6 +147,9 @@ export async function lists_update(list: List, avatar?: File) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const model: List = await r.json();
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function lists_add_trail(list: List, trail: Trail) {
|
||||
@@ -275,17 +232,17 @@ function buildSearchFilterText(user: AuthRecord, filter: ListFilter): string {
|
||||
if (filter.public !== undefined) {
|
||||
filterText += `(public = ${filter.public}`
|
||||
|
||||
if (!filter.author?.length || filter.author == user?.id) {
|
||||
filterText += ` OR author = ${user?.id}`
|
||||
if (!filter.author?.length || filter.author == user?.actor) {
|
||||
filterText += ` OR author = ${user?.actor}`
|
||||
}
|
||||
filterText += ")"
|
||||
}
|
||||
|
||||
if (filter.shared !== undefined) {
|
||||
if (filter.shared === true) {
|
||||
filterText += ` OR shares = ${user?.id}`
|
||||
filterText += ` OR shares = ${user?.actor}`
|
||||
} else {
|
||||
filterText += ` AND NOT shares = ${user?.id}`
|
||||
filterText += ` AND NOT shares = ${user?.actor}`
|
||||
|
||||
}
|
||||
}
|
||||
@@ -293,4 +250,44 @@ function buildSearchFilterText(user: AuthRecord, filter: ListFilter): string {
|
||||
}
|
||||
|
||||
return filterText
|
||||
}
|
||||
|
||||
export async function searchResultToLists(hits: Hits<ListSearchResult>): Promise<List[]> {
|
||||
const lists: List[] = []
|
||||
for (const h of hits) {
|
||||
const l: List & RecordModel = {
|
||||
collectionId: "lists",
|
||||
collectionName: "lists",
|
||||
updated: new Date(h.created * 1000).toISOString(),
|
||||
author: h.author,
|
||||
name: h.name,
|
||||
public: h.public,
|
||||
description: h.description,
|
||||
id: h.id,
|
||||
trails: Array(h.trails).fill("000000000000000"),
|
||||
avatar: h.avatar,
|
||||
elevation_gain: h.elevation_gain,
|
||||
elevation_loss: h.elevation_loss,
|
||||
distance: h.distance,
|
||||
duration: h.duration,
|
||||
iri: h.iri,
|
||||
expand: {
|
||||
author: {
|
||||
icon: h.author_avatar,
|
||||
id: h.author,
|
||||
username: h.author_name,
|
||||
} as any,
|
||||
list_share_via_list: h.shares?.map(s => ({
|
||||
permission: "view",
|
||||
list: h.id,
|
||||
actor: s,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
lists.push(l)
|
||||
}
|
||||
|
||||
return lists
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export async function notifications_index(data: { recipient: string, seen?: bool
|
||||
const r = await f('/api/v1/notification?' + new URLSearchParams({
|
||||
filter: `created<=@month&&recipient='${data.recipient}'` + (data.seen !== undefined ? `&&seen=${data.seen}` : ''),
|
||||
sort: '+seen,-created',
|
||||
expand: 'author',
|
||||
page: page.toString(),
|
||||
"perPage": perPage.toString()
|
||||
}), {
|
||||
|
||||
136
web/src/lib/stores/profile_store.ts
Normal file
136
web/src/lib/stores/profile_store.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { TimelineItem } from "$lib/models/timeline";
|
||||
import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailSearchResult } from "$lib/models/trail";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import type { Hits } from "meilisearch";
|
||||
import type { ListResult } from "pocketbase";
|
||||
import { searchResultToTrailList } from "./trail_store";
|
||||
import type { SummitLog, SummitLogFilter } from "$lib/models/summit_log";
|
||||
import { buildFilterText } from "./summit_log_store";
|
||||
import type { ListFilter } from "$lib/models/list";
|
||||
import type { ListSearchResult } from "./search_store";
|
||||
import { searchResultToLists } from "./list_store";
|
||||
|
||||
let timeline: TimelineItem[] = []
|
||||
|
||||
|
||||
export async function profile_show(handle: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let r = await f('/api/v1/profile/' + handle, {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const response = await r.json()
|
||||
return response;
|
||||
|
||||
}
|
||||
|
||||
export async function profile_lists_index(handle: string, filter: ListFilter, page: number = 1, perPage: number = 6, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let r = await f(`/api/v1/profile/${handle}/lists`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: filter.q,
|
||||
options: {
|
||||
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`],
|
||||
hitsPerPage: perPage,
|
||||
page: page
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const result: { page: number, totalPages: number, hits: Hits<ListSearchResult> } = await r.json();
|
||||
|
||||
if (result.hits.length == 0) {
|
||||
return { items: [], ...result };
|
||||
}
|
||||
|
||||
const resultLists = await searchResultToLists(result.hits)
|
||||
|
||||
return { items: resultLists, ...result };
|
||||
|
||||
}
|
||||
|
||||
|
||||
export async function profile_timeline_index(handle: string, page: number, perPage: number = 1, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let r = await f(`/api/v1/profile/${handle}/timeline?` + new URLSearchParams({
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: '-created'
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const fetchedTimeline: ListResult<TimelineItem> = await r.json()
|
||||
|
||||
const result = page > 1 ? [...timeline, ...fetchedTimeline.items] : fetchedTimeline.items
|
||||
|
||||
timeline = result;
|
||||
|
||||
return { ...fetchedTimeline, items: result };
|
||||
|
||||
}
|
||||
|
||||
export async function profile_trails_index(handle: string, filter: TrailFilter, page: number = 1, perPage: number = 1, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let r = await f(`/api/v1/profile/${handle}/trails`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: filter.q,
|
||||
options: {
|
||||
attributesToRetrieve: defaultTrailSearchAttributes,
|
||||
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`],
|
||||
hitsPerPage: 12,
|
||||
page: page
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const result: { page: number, totalPages: number, hits: Hits<TrailSearchResult> } = await r.json();
|
||||
|
||||
if (result.hits.length == 0) {
|
||||
return { items: [], ...result };
|
||||
}
|
||||
|
||||
const resultTrails: Trail[] = await searchResultToTrailList(result.hits)
|
||||
|
||||
return { items: resultTrails, ...result };
|
||||
|
||||
}
|
||||
|
||||
export async function profile_stats_index(handle: string, filter: SummitLogFilter, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const filterText = buildFilterText(filter);
|
||||
|
||||
const r = await f(`/api/v1/profile/${handle}/stats?` + new URLSearchParams({
|
||||
filter: filterText,
|
||||
perPage: "-1",
|
||||
expand: "trail.category,author",
|
||||
sort: "+date",
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const result: ListResult<SummitLog> = await r.json();
|
||||
|
||||
return result.items;
|
||||
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { env } from "$env/dynamic/public";
|
||||
import { defaultTrailSearchAttributes } from "$lib/models/trail";
|
||||
import type { Actor } from "$lib/models/activitypub/actor";
|
||||
import { defaultTrailSearchAttributes, type TrailSearchResult } from "$lib/models/trail";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import type { Hits, MultiSearchParams, MultiSearchResponse, MultiSearchResult, SearchParams, SearchResponse } from "meilisearch";
|
||||
import type { ListResult } from "pocketbase";
|
||||
|
||||
export type LocationSearchResult = {
|
||||
name: string;
|
||||
@@ -12,37 +14,23 @@ export type LocationSearchResult = {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type TrailSearchResult = {
|
||||
id: string;
|
||||
_geo: {
|
||||
lat: number,
|
||||
lon: number
|
||||
}
|
||||
author: string;
|
||||
category: string;
|
||||
completed: boolean;
|
||||
created: number;
|
||||
date: number;
|
||||
description: string;
|
||||
difficulty: "easy" | "moderate" | "difficult"
|
||||
distance: number;
|
||||
duration: number
|
||||
elevation_gain: number;
|
||||
elevation_loss: number
|
||||
location: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
polyline?: string;
|
||||
}
|
||||
|
||||
export type ListSearchResult = {
|
||||
id: string;
|
||||
author: string;
|
||||
author_name: string;
|
||||
author_avatar: string;
|
||||
avatar?: string;
|
||||
created: number;
|
||||
description: string;
|
||||
name: string;
|
||||
elevation_gain: number;
|
||||
elevation_loss: number;
|
||||
distance: number,
|
||||
duration: number,
|
||||
public: boolean;
|
||||
trails: string[]
|
||||
trails: number
|
||||
shares?: string[];
|
||||
iri?: string;
|
||||
}
|
||||
|
||||
type NominatimResponse = {
|
||||
@@ -202,4 +190,21 @@ export async function searchMulti(options: MultiSearchParams): Promise<MultiSear
|
||||
|
||||
|
||||
return response.results
|
||||
}
|
||||
|
||||
export async function searchActors(q: string, includeSelf: boolean = true): Promise<Actor[]> {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/search/actor?q=${q}&includeSelf=${includeSelf}`,)
|
||||
|
||||
if (!r.ok) {
|
||||
return []
|
||||
}
|
||||
const response: ListResult<Actor> = await r.json()
|
||||
|
||||
return response.items
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,19 @@ import { APIError } from "$lib/util/api_util";
|
||||
import { type AuthRecord, type ListResult } from "pocketbase";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import { currentUser } from "./user_store";
|
||||
import { isURL, objectToFormData } from "$lib/util/file_util";
|
||||
|
||||
export const summitLog: Writable<SummitLog> = writable(new SummitLog(new Date().toISOString().substring(0, 10)));
|
||||
export const summitLogs: Writable<SummitLog[]> = writable([]);
|
||||
|
||||
export async function summit_logs_index(author: string, filter?: SummitLogFilter, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
|
||||
let filterText = `author='${author}'`
|
||||
filterText += filter ? "&&" + buildFilterText(filter) : "";
|
||||
export async function summit_logs_index(filter?: SummitLogFilter, handle?: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
|
||||
const r = await f('/api/v1/summit-log?' + new URLSearchParams({
|
||||
filter: filterText,
|
||||
...(filter ? { filter: buildFilterText(filter) } : {}),
|
||||
perPage: "-1",
|
||||
expand: "trails_via_summit_logs.category",
|
||||
expand: "trail.category,author",
|
||||
sort: "+date",
|
||||
...(handle ? { handle } : {})
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -28,18 +27,6 @@ export async function summit_logs_index(author: string, filter?: SummitLogFilter
|
||||
|
||||
const fetchedSummitLogs: ListResult<SummitLog> = await r.json();
|
||||
|
||||
// for (const log of fetchedSummitLogs.items) {
|
||||
// if (!log.gpx) {
|
||||
// continue
|
||||
// }
|
||||
// const gpxData: string = await fetchGPX(log as any, f);
|
||||
|
||||
// if (!log.expand) {
|
||||
// log.expand = {};
|
||||
// }
|
||||
// log.expand.gpx_data = gpxData;
|
||||
// }
|
||||
|
||||
summitLogs.set(fetchedSummitLogs.items);
|
||||
|
||||
return fetchedSummitLogs;
|
||||
@@ -51,11 +38,27 @@ export async function summit_logs_create(summitLog: SummitLog, f: (url: RequestI
|
||||
throw Error("Unauthenticated")
|
||||
}
|
||||
|
||||
summitLog.author = user.id
|
||||
summitLog.author = user.actor
|
||||
|
||||
let r = await f('/api/v1/summit-log', {
|
||||
const formData = objectToFormData(summitLog, ["expand"])
|
||||
|
||||
if (summitLog._gpx && summitLog._gpx instanceof File) {
|
||||
formData.append("gpx", summitLog._gpx)
|
||||
}
|
||||
|
||||
|
||||
if (summitLog._photos && summitLog._photos.length) {
|
||||
|
||||
for (const photo of summitLog._photos) {
|
||||
formData.append("photos", photo)
|
||||
}
|
||||
}
|
||||
|
||||
let r = await f('/api/v1/summit-log/form?' + new URLSearchParams({
|
||||
expand: "author"
|
||||
}), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(summitLog),
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
@@ -65,43 +68,6 @@ export async function summit_logs_create(summitLog: SummitLog, f: (url: RequestI
|
||||
|
||||
let model: SummitLog = await r.json();
|
||||
|
||||
if (summitLog._gpx && summitLog._gpx instanceof File) {
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append("gpx", summitLog._gpx)
|
||||
|
||||
r = await f(`/api/v1/summit-log/${model.id!}/file`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
}
|
||||
|
||||
if (summitLog._photos && summitLog._photos.length) {
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
for (const photo of summitLog._photos) {
|
||||
formData.append("photos", photo)
|
||||
}
|
||||
|
||||
r = await fetch(`/api/v1/summit-log/${model.id!}/file`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
@@ -111,19 +77,9 @@ export async function summit_logs_update(oldSummitLog: SummitLog, newSummitLog:
|
||||
throw Error("Unauthenticated")
|
||||
}
|
||||
|
||||
newSummitLog.author = user.id
|
||||
newSummitLog.author = user.actor
|
||||
|
||||
let r = await fetch('/api/v1/summit-log/' + newSummitLog.id, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newSummitLog),
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
const formData = objectToFormData(newSummitLog, ["expand"])
|
||||
|
||||
for (const photo of newSummitLog._photos ?? []) {
|
||||
formData.append("photos", photo)
|
||||
@@ -139,7 +95,9 @@ export async function summit_logs_update(oldSummitLog: SummitLog, newSummitLog:
|
||||
formData.append("gpx", newSummitLog._gpx);
|
||||
}
|
||||
|
||||
r = await fetch(`/api/v1/summit-log/${newSummitLog.id!}/file`, {
|
||||
let r = await fetch('/api/v1/summit-log/form/' + newSummitLog.id + '?' + new URLSearchParams({
|
||||
expand: "author"
|
||||
}), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
@@ -165,11 +123,11 @@ export async function summit_logs_delete(summitLog: SummitLog) {
|
||||
|
||||
}
|
||||
|
||||
function buildFilterText(filter: SummitLogFilter,): string {
|
||||
export function buildFilterText(filter: SummitLogFilter,): string {
|
||||
let filterText: string = "";
|
||||
|
||||
if (filter.category.length > 0) {
|
||||
filterText += `trails_via_summit_logs.category!=null&&'${filter.category.join(",")}'~trails_via_summit_logs.category`;
|
||||
filterText += `trail.category!=null&&'${filter.category.join(",")}'~trail.category`;
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
@@ -180,6 +138,17 @@ function buildFilterText(filter: SummitLogFilter,): string {
|
||||
filterText += `${filter.category.length || filter.startDate ? '&&' : ''}date<='${filter.endDate}'`
|
||||
}
|
||||
|
||||
if (filter.trail) {
|
||||
if (filter.category.length || filter.startDate || filter.endDate) {
|
||||
filterText += "&&"
|
||||
}
|
||||
if (isURL(filter.trail)) {
|
||||
filterText += `trail='${filter.trail}'||trail.iri='${filter.trail}'||trail='${filter.trail.substring(filter.trail.length - 15)}'`;
|
||||
} else {
|
||||
filterText += `trail='${filter.trail}'`
|
||||
}
|
||||
}
|
||||
|
||||
return filterText;
|
||||
|
||||
}
|
||||
56
web/src/lib/stores/trail_like_store.ts
Normal file
56
web/src/lib/stores/trail_like_store.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { TrailLike } from "$lib/models/trail_like";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { type ListResult } from "pocketbase";
|
||||
|
||||
export async function trail_like_index(data: { trail?: string, actor?: string }, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let filter = ""
|
||||
if (data.trail) {
|
||||
filter += `trail='${data.trail}'`
|
||||
} else if (data.actor) {
|
||||
filter += `actor='${data.actor}'`
|
||||
}
|
||||
|
||||
const r = await f('/api/v1/trail-like?' + new URLSearchParams({
|
||||
filter: filter,
|
||||
expand: "actor"
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const response: ListResult<TrailLike> = await r.json();
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function trail_like_create(like: TrailLike) {
|
||||
let r = await fetch('/api/v1/trail-like', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(like),
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const response: TrailLike = await r.json()
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function trail_like_delete(like: TrailLike) {
|
||||
const r = await fetch('/api/v1/trail-like/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(like)
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,19 @@ import { writable, type Writable } from "svelte/store";
|
||||
export const shares: Writable<TrailShare[]> = writable([])
|
||||
|
||||
|
||||
export async function trail_share_index(data: { trail?: string, user?: string }, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
export async function trail_share_index(data: { trail?: string, actor?: string, actorIRI?: string }, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let filter = ""
|
||||
if (data.trail) {
|
||||
filter += `trail='${data.trail}'`
|
||||
} else if (data.actor) {
|
||||
filter += `actor='${data.actor}'`
|
||||
} else if (data.actorIRI) {
|
||||
filter += `actor.iri='${data.actorIRI}'`
|
||||
}
|
||||
|
||||
const r = await f('/api/v1/trail-share?' + new URLSearchParams({
|
||||
filter: data.trail ? `trail='${data.trail}'` : data.user ? `user='${data.user}'` : '',
|
||||
filter: filter,
|
||||
expand: "actor"
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailFilter
|
||||
import type { Waypoint } from "$lib/models/waypoint";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { deepEqual } from "$lib/util/deep_util";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { getFileURL, objectToFormData } from "$lib/util/file_util";
|
||||
import * as M from "maplibre-gl";
|
||||
import type { Hits } from "meilisearch";
|
||||
import { type AuthRecord, type ListResult, type RecordModel } from "pocketbase";
|
||||
@@ -22,7 +22,7 @@ export const editTrail: Writable<Trail> = writable(new Trail(""));
|
||||
export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/trail?' + new URLSearchParams({
|
||||
"perPage": perPage.toString(),
|
||||
expand: "category,waypoints,summit_logs,tags",
|
||||
expand: "category,waypoints,summit_logs_via_trail,tags",
|
||||
sort: random ? "@random" : "",
|
||||
}), {
|
||||
method: 'GET',
|
||||
@@ -50,9 +50,9 @@ export async function trails_recommend(size: number, f: (url: RequestInfo | URL,
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
const response: Trail[] = await r.json()
|
||||
const response: Hits<TrailSearchResult> = await r.json()
|
||||
|
||||
return response;
|
||||
return searchResultToTrailList(response);
|
||||
|
||||
}
|
||||
|
||||
@@ -132,9 +132,12 @@ export async function trails_search_bounding_box(northEast: M.LngLat, southWest:
|
||||
|
||||
}
|
||||
|
||||
export async function trails_show(id: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
export async function trails_show(id: string, handle?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
|
||||
const r = await f(`/api/v1/trail/${id}?` + new URLSearchParams({
|
||||
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
|
||||
expand: "category,waypoints,summit_logs_via_trail,summit_logs_via_trail.author,trail_share_via_trail.actor,trail_like_via_trail,tags,author",
|
||||
...(handle ? { handle } : {})
|
||||
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -157,7 +160,7 @@ export async function trails_show(id: string, loadGPX?: boolean, f: (url: Reques
|
||||
response.expand.gpx_data = gpxData;
|
||||
|
||||
|
||||
for (const log of response.expand.summit_logs ?? []) {
|
||||
for (const log of response.expand.summit_logs_via_trail ?? []) {
|
||||
const gpxData: string = await fetchGPX(log, f);
|
||||
|
||||
if (!log.expand) {
|
||||
@@ -188,10 +191,6 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
|
||||
}, f, user);
|
||||
trail.waypoints.push(model.id!);
|
||||
}
|
||||
for (const summitLog of trail.expand?.summit_logs ?? []) {
|
||||
const model = await summit_logs_create(summitLog, f);
|
||||
trail.summit_logs.push(model.id!);
|
||||
}
|
||||
for (const tag of trail.expand?.tags ?? []) {
|
||||
if (!tag.id) {
|
||||
const model = await tags_create(tag)
|
||||
@@ -201,34 +200,22 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
|
||||
}
|
||||
}
|
||||
|
||||
trail.author = user.id
|
||||
trail.author = user.actor
|
||||
|
||||
let r = await f(`/api/v1/trail?` + new URLSearchParams({
|
||||
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
|
||||
}), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...trail }),
|
||||
})
|
||||
const formData = objectToFormData(trail)
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
|
||||
let model: Trail = await r.json();
|
||||
|
||||
const formData = new FormData()
|
||||
if (gpx) {
|
||||
formData.append("gpx", gpx);
|
||||
formData.set("gpx", gpx);
|
||||
}
|
||||
|
||||
for (const photo of photos) {
|
||||
formData.append("photos", photo)
|
||||
formData.set("photos", photo)
|
||||
}
|
||||
|
||||
r = await f(`/api/v1/trail/${model.id!}/file`, {
|
||||
method: 'POST',
|
||||
let r = await f(`/api/v1/trail/form?` + new URLSearchParams({
|
||||
expand: "category,waypoints,summit_logs_via_trail,trail_share_via_trail,tags",
|
||||
}), {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
@@ -237,15 +224,19 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const modelWithFiles: Trail = await r.json();
|
||||
let model: Trail = await r.json();
|
||||
|
||||
model.photos = modelWithFiles.photos;
|
||||
for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) {
|
||||
summitLog.trail = model.id!;
|
||||
await summit_logs_create(summitLog, f);
|
||||
}
|
||||
|
||||
return model;
|
||||
|
||||
}
|
||||
|
||||
export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: File[], gpx?: File | Blob | null) {
|
||||
newTrail.author = oldTrail.author
|
||||
|
||||
const waypointUpdates = compareObjectArrays<Waypoint>(oldTrail.expand?.waypoints ?? [], newTrail.expand?.waypoints ?? []);
|
||||
|
||||
@@ -269,15 +260,15 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
|
||||
const success = await waypoints_delete(deletedWaypoint);
|
||||
}
|
||||
|
||||
const summitLogUpdates = compareObjectArrays<SummitLog>(oldTrail.expand?.summit_logs ?? [], newTrail.expand?.summit_logs ?? []);
|
||||
const summitLogUpdates = compareObjectArrays<SummitLog>(oldTrail.expand?.summit_logs_via_trail ?? [], newTrail.expand?.summit_logs_via_trail ?? []);
|
||||
|
||||
for (const summitLog of summitLogUpdates.added) {
|
||||
summitLog.trail = newTrail.id!
|
||||
const model = await summit_logs_create(summitLog);
|
||||
newTrail.summit_logs.push(model.id!);
|
||||
}
|
||||
|
||||
for (const updatedSummitLog of summitLogUpdates.updated) {
|
||||
const oldSummitLog = oldTrail.expand?.summit_logs?.find(w => w.id == updatedSummitLog.id);
|
||||
const oldSummitLog = oldTrail.expand?.summit_logs_via_trail?.find(w => w.id == updatedSummitLog.id);
|
||||
|
||||
const model = await summit_logs_update(oldSummitLog!, updatedSummitLog);
|
||||
}
|
||||
@@ -301,22 +292,8 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
|
||||
newTrail.tags = newTrail.tags.filter(t => t != tag.id);
|
||||
}
|
||||
|
||||
let r = await fetch(`/api/v1/trail/${newTrail.id}?` + new URLSearchParams({
|
||||
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
|
||||
}), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...newTrail, expand: undefined }),
|
||||
})
|
||||
const formData = objectToFormData(newTrail, ["expand"])
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
|
||||
let model: Trail = await r.json();
|
||||
|
||||
const formData = new FormData()
|
||||
if (gpx) {
|
||||
formData.append("gpx", gpx);
|
||||
}
|
||||
@@ -327,14 +304,16 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const deletedPhotos = oldTrail.photos.filter(oldPhoto => !newTrail.photos.find(newPhoto => newPhoto === oldPhoto));
|
||||
|
||||
for (const deletedPhoto of deletedPhotos) {
|
||||
formData.append("photos-", deletedPhoto.replace(/^.*[\\/]/, ''));
|
||||
}
|
||||
|
||||
r = await fetch(`/api/v1/trail/${newTrail.id!}/file`, {
|
||||
|
||||
let r = await fetch(`/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
|
||||
expand: "category,waypoints,summit_logs_via_trail,trail_share_via_trail,tags",
|
||||
}), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
@@ -344,10 +323,14 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const response: Trail = await r.json();
|
||||
|
||||
model.photos = response.photos
|
||||
model.gpx = response.gpx
|
||||
let model: Trail = await r.json();
|
||||
|
||||
for (const log of model.expand?.summit_logs_via_trail ?? []) {
|
||||
if (!log.expand) {
|
||||
log.expand = {};
|
||||
}
|
||||
}
|
||||
|
||||
trail.set(model);
|
||||
|
||||
@@ -361,8 +344,8 @@ export async function trails_delete(trail: Trail) {
|
||||
await waypoints_delete(waypoint);
|
||||
}
|
||||
}
|
||||
if (trail.expand?.summit_logs) {
|
||||
for (const summit_log of trail.expand.summit_logs) {
|
||||
if (trail.expand?.summit_logs_via_trail) {
|
||||
for (const summit_log of trail.expand.summit_logs_via_trail) {
|
||||
await summit_logs_delete(summit_log);
|
||||
}
|
||||
}
|
||||
@@ -455,14 +438,14 @@ export async function fetchGPX(trail: { gpx?: string } & Record<string, any>, f:
|
||||
return gpxData
|
||||
}
|
||||
|
||||
async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
|
||||
export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
|
||||
const trails: Trail[] = []
|
||||
for (const h of hits) {
|
||||
const t: Trail & RecordModel = {
|
||||
collectionId: "trails",
|
||||
collectionName: "trails",
|
||||
updated: new Date(h.created * 1000).toISOString(),
|
||||
author: h.author,
|
||||
author: h.author_name,
|
||||
name: h.name,
|
||||
photos: h.thumbnail ? [h.thumbnail] : [],
|
||||
public: h.public,
|
||||
@@ -484,26 +467,35 @@ async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<T
|
||||
location: h.location,
|
||||
gpx: h.gpx,
|
||||
polyline: h.polyline,
|
||||
domain: h.domain,
|
||||
iri: h.iri,
|
||||
thumbnail: 0,
|
||||
like_count: h.like_count,
|
||||
expand: {
|
||||
author: {
|
||||
collectionId: "users",
|
||||
private: false,
|
||||
collectionId: "activitypub_actors",
|
||||
isLocal: (h.domain?.length ?? 0) == 0,
|
||||
id: h.author,
|
||||
avatar: h.author_avatar,
|
||||
username: h.author_name
|
||||
icon: h.author_avatar,
|
||||
username: h.author_name,
|
||||
domain: h.domain,
|
||||
} as any,
|
||||
trail_share_via_trail: h.shares?.map(s => ({
|
||||
permission: "view",
|
||||
trail: h.id,
|
||||
user: s,
|
||||
actor: s,
|
||||
})),
|
||||
trail_like_via_trail: h.likes?.map(l => ({
|
||||
trail: h.id,
|
||||
actor: l,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
trails.push(t)
|
||||
}
|
||||
|
||||
return trails
|
||||
}
|
||||
|
||||
@@ -569,17 +561,17 @@ function buildFilterText(user: AuthRecord, filter: TrailFilter, includeGeo: bool
|
||||
if (filter.public !== undefined) {
|
||||
filterText += `(public = ${filter.public}`
|
||||
|
||||
if (!filter.author?.length || filter.author == user?.id) {
|
||||
filterText += ` OR author = ${user?.id}`
|
||||
if (!filter.author?.length || filter.author == user?.actor) {
|
||||
filterText += ` OR author = ${user?.actor}`
|
||||
}
|
||||
filterText += ")"
|
||||
}
|
||||
|
||||
if (filter.shared !== undefined) {
|
||||
if (filter.shared === true) {
|
||||
filterText += ` OR shares = ${user?.id}`
|
||||
filterText += ` OR shares = ${user?.actor}`
|
||||
} else {
|
||||
filterText += ` AND NOT shares = ${user?.id}`
|
||||
filterText += ` AND NOT shares = ${user?.actor}`
|
||||
|
||||
}
|
||||
}
|
||||
@@ -587,6 +579,10 @@ function buildFilterText(user: AuthRecord, filter: TrailFilter, includeGeo: bool
|
||||
}
|
||||
*/
|
||||
|
||||
if (filter.liked === true) {
|
||||
filterText += ` AND likes = ${user?.actor}`
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
filterText += ` AND date >= ${new Date(filter.startDate).getTime() / 1000}`
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export type Upload = {
|
||||
file: File;
|
||||
status: "enqueued" | "uploading" | "cancelled" | "success" | "error" | "duplicate";
|
||||
error?: string;
|
||||
duplicate?: { id: string, name: string };
|
||||
duplicate?: { id: string, domain: string, name: string };
|
||||
progress: number;
|
||||
function: (f: File, ignoreDuplicates?: boolean, onProgress?: (p: number) => void) => Promise<unknown>
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export async function processUploadQueue(batchSize: number = 3, ignoreDuplicates
|
||||
const u = batch[i];
|
||||
if (r instanceof APIError && r.message == "Duplicate trail") {
|
||||
u.status = "duplicate"
|
||||
u.duplicate = { id: r.detail.id, name: r.detail.name };
|
||||
u.duplicate = { id: r.detail.id, domain: r.detail.domain, name: r.detail.name };
|
||||
} else if (r instanceof APIError) {
|
||||
u.status = "error"
|
||||
u.error = r.message
|
||||
|
||||
@@ -45,14 +45,13 @@ export async function users_show(id: string, f: (url: RequestInfo | URL, config?
|
||||
let r = await f(`/api/v1/user/anonymous/${id}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
const response: UserAnonymous = await r.json()
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
const response: UserAnonymous = await r.json()
|
||||
|
||||
return response;
|
||||
|
||||
}
|
||||
|
||||
export async function users_auth_methods(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<AuthMethodsList> {
|
||||
|
||||
26
web/src/lib/util/activitypub_util.ts
Normal file
26
web/src/lib/util/activitypub_util.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
|
||||
export function splitUsername(handle: string, localDomain?: string) {
|
||||
const cleaned = handle.replace(/^@/, "").trim();
|
||||
|
||||
if (!cleaned.includes("@")) {
|
||||
return [cleaned, localDomain];
|
||||
}
|
||||
|
||||
let [user, domain] = cleaned.split("@");
|
||||
|
||||
return [user, domain]
|
||||
}
|
||||
|
||||
export function handleFromRecordWithIRI(record: any) {
|
||||
if (!record.expand?.author) {
|
||||
throw new Error("object has no author info")
|
||||
}
|
||||
|
||||
if (!record.iri) {
|
||||
return `@${record.expand.author.username}`
|
||||
}
|
||||
const url = new URL(record.iri ?? "")
|
||||
|
||||
return `@${record.expand.author.username}@${url.hostname}`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { error, type NumericRange, type RequestEvent } from "@sveltejs/kit";
|
||||
import { error, json, type NumericRange, type RequestEvent } from "@sveltejs/kit";
|
||||
import { ClientResponseError, type ListResult } from "pocketbase";
|
||||
import { ZodError, type ZodSchema } from "zod";
|
||||
import { RecordListOptionsSchema, RecordIdSchema, RecordOptionsSchema } from "$lib/models/api/base_schema";
|
||||
@@ -19,6 +19,8 @@ export class APIError extends Error {
|
||||
|
||||
export enum Collection {
|
||||
users = "users",
|
||||
activitypub_activities = "activitypub_activities",
|
||||
activitypub_actors = "activitypub_actors",
|
||||
categories = "categories",
|
||||
comments = "comments",
|
||||
follows = "follows",
|
||||
@@ -28,15 +30,15 @@ export enum Collection {
|
||||
notifications = "notifications",
|
||||
settings = "settings",
|
||||
summit_logs = "summit_logs",
|
||||
trail_like = "trail_like",
|
||||
trail_share = "trail_share",
|
||||
trails = "trails",
|
||||
tags = "tags",
|
||||
waypoints = "waypoints",
|
||||
activities = "activities",
|
||||
follow_counts = "follow_counts",
|
||||
timeline = "timeline",
|
||||
trails_bounding_box = "trails_bounding_box",
|
||||
trails_filter = "trails_filter",
|
||||
users_anonymous = "users_anonymous"
|
||||
users_anonymous = "users_anonymous",
|
||||
}
|
||||
|
||||
export async function list<T>(event: RequestEvent, collection: Collection) {
|
||||
@@ -81,7 +83,7 @@ export async function create<T>(event: RequestEvent, schema: ZodSchema, collecti
|
||||
const data = await event.request.json();
|
||||
const safeData = schema.parse(data);
|
||||
|
||||
const r = await event.locals.pb.collection(Collection[collection]).create<T>(safeData, {...safeSearchParams, requestKey: null})
|
||||
const r = await event.locals.pb.collection(Collection[collection]).create<T>(safeData, { ...safeSearchParams, requestKey: null })
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -101,6 +103,33 @@ export async function update<T>(event: RequestEvent, schema: ZodSchema, collecti
|
||||
return r
|
||||
}
|
||||
|
||||
export async function uploadCreate<T>(event: RequestEvent, collection: Collection) {
|
||||
const searchParams = Object.fromEntries(event.url.searchParams);
|
||||
const safeSearchParams = RecordOptionsSchema.parse(searchParams);
|
||||
|
||||
const data = await event.request.formData();
|
||||
|
||||
|
||||
const r = await event.locals.pb.collection(Collection[collection]).create<T>(data, safeSearchParams)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
export async function uploadUpdate<T>(event: RequestEvent, collection: Collection) {
|
||||
const searchParams = Object.fromEntries(event.url.searchParams);
|
||||
const safeSearchParams = RecordOptionsSchema.parse(searchParams);
|
||||
|
||||
const data = await event.request.formData();
|
||||
if (!data.has('id')) {
|
||||
throw new Error("data has no id")
|
||||
}
|
||||
|
||||
|
||||
const r = await event.locals.pb.collection(Collection[collection]).update<T>(data.get('id')!.toString(), data, safeSearchParams)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
export async function upload<T>(event: RequestEvent, collection: Collection) {
|
||||
const params = event.params
|
||||
const safeParams = RecordIdSchema.parse(params);
|
||||
@@ -123,12 +152,12 @@ export async function remove(event: RequestEvent, collection: Collection) {
|
||||
|
||||
export function handleError(e: any) {
|
||||
if (e instanceof ZodError) {
|
||||
return error(400, { message: "invalid_params", detail: e.issues } as any)
|
||||
return json({ message: "invalid_params", detail: e.issues }, { status: 400 })
|
||||
} else if (e instanceof ClientResponseError && e.status > 0) {
|
||||
return error(e.status as NumericRange<400, 599>, {...e.response, message: e.message, detail: e.originalError.data } as any)
|
||||
return json({ ...e.response, message: e.message, detail: e.originalError.data }, { status: e.status })
|
||||
} else if (e instanceof SyntaxError) {
|
||||
return error(400, "invalid_json")
|
||||
return json({ message: "invalid_json" }, { status: 400 })
|
||||
} else {
|
||||
return error(500, e.toString())
|
||||
return json({ message: e }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
const privateRoutes = [
|
||||
"/settings",
|
||||
"/trail/edit/new",
|
||||
@@ -5,7 +7,25 @@ const privateRoutes = [
|
||||
"/lists/edit/new",
|
||||
]
|
||||
|
||||
const publicRoutes = [
|
||||
"/",
|
||||
"/login",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/user",
|
||||
"/api/v1/category",
|
||||
"/register",
|
||||
"/auth"
|
||||
|
||||
]
|
||||
|
||||
export function isRouteProtected(path: string) {
|
||||
|
||||
if (env.PUBLIC_PRIVATE_INSTANCE == "true") {
|
||||
return !publicRoutes.some(allowedPath =>
|
||||
path === allowedPath || path.startsWith(allowedPath + '/')
|
||||
);
|
||||
}
|
||||
|
||||
return privateRoutes.some(allowedPath =>
|
||||
path === allowedPath || path.startsWith(allowedPath + '/')
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user