Federation (#327)

* initial commit federation

* more federation

* more federation

* more federation

* completes follow, accept, undo

* add trail create activity

* process trail create activity

* more trail create activity

* adds missing endpoints

* adds update and delete activity

* adds comment activities

* adds activities back

* adds summit logs activities

* deletes follow counts table

* fixes migrations

* fixes migrations

* fixes migrations

* fixes migrations

* adds remote profiles

* ctd

* ctd

* we are getting closer...

* adds public summit logs

* fixes federated trails in lists

* adds remote lists

* adds list activites

* fixes lists

* adds notifications

* adds iri redirect

* fixes comments and summitlogs

* fixes follows and profiles

* adds asynchronous send

* fixes list search

* adds encryption key

* bug fixes

* fixes activity signing

* removes custon activity object types

* adds html editor

* fixes html editor

* fixes display issues on mastodon

* adds federated sharing

* finishes announcements

* fixes small summit log issues

* adds trail likes

* finalizes likes

* fixes images for komoot

* add disable federation option

* adds private profiles

* updates docs

* adds federated comments

* adds trail and comments actvitiypub routes

* adds mentions to editor

* adds mentions to trails, comments, summit logs

* updates theme

* updates theme

* update docs

* update docs

* updates docs

* fixes various frontend problems

* fixes activitypub follows api

* updates docs

* updates docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2025-06-21 10:09:22 +02:00
committed by GitHub
parent 742a6d9d30
commit 9276690d60
356 changed files with 57150 additions and 35470 deletions

View File

@@ -10,7 +10,7 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
let notifications
if (locals.user?.id) {
notifications = await notifications_index({ recipient: locals.user.id }, 1, 10, fetch);
notifications = await notifications_index({ recipient: locals.user.actor }, 1, 10, fetch);
}
return { settings: locals.settings as Settings, user: locals.user as AuthRecord, notifications, origin: env.ORIGIN }
}

View File

@@ -1,5 +1,11 @@
<script lang="ts">
import { beforeNavigate, goto } from "$app/navigation";
<script lang="ts">
import {
beforeNavigate,
goto,
invalidate,
invalidateAll
} from "$app/navigation";
import { page } from "$app/state";
import { env } from "$env/dynamic/public";
import Toast from "$lib/components/base/toast.svelte";
@@ -9,34 +15,44 @@
import UploadDialog from "$lib/components/settings/upload_dialog.svelte";
import { currentUser } from "$lib/stores/user_store";
import { isRouteProtected } from "$lib/util/authorization_util";
import "@fortawesome/fontawesome-free/css/all.min.css";
import { onMount, type Snippet } from "svelte";
import { slide } from "svelte/transition";
import "../css/app.css";
import "../css/components.css";
import "../css/theme.css";
import type { LayoutData } from "./$types";
interface Props {
data: LayoutData;
children?: Snippet;
}
import PocketBase from "pocketbase";
import { browser } from "$app/environment";
interface Props { data: LayoutData; children?: Snippet }
let { data, children }: Props = $props();
beforeNavigate((n) => {
if (!$currentUser && isRouteProtected(n.to?.url?.pathname ?? "")) {
n.cancel();
goto("/login?r=" + n.to?.url?.pathname);
}
});
onMount(() => {
if (page.data.origin != location.origin) {
showWarning = true;
}
const pb = new PocketBase(env.PUBLIC_POCKETBASE_URL);
if (browser) {
pb.authStore.loadFromCookie(document.cookie);
}
pb.collection("notifications").subscribe("*", async (e) => {
if (e.action === "create") {
await invalidateAll();
}
});
});
let hideDemoHint = $state(false);
let showWarning = $state(false);
</script>

View File

@@ -8,13 +8,16 @@
import CategoryCard from "$lib/components/category_card.svelte";
import Scene from "$lib/components/scene.svelte";
import TrailCard from "$lib/components/trail/trail_card.svelte";
import { defaultTrailSearchAttributes } from "$lib/models/trail.js";
import {
defaultTrailSearchAttributes,
type TrailSearchResult,
} from "$lib/models/trail.js";
import { categories } from "$lib/stores/category_store";
import {
searchActors,
searchMulti,
type ListSearchResult,
type LocationSearchResult,
type TrailSearchResult,
} from "$lib/stores/search_store.js";
import { theme } from "$lib/stores/theme_store";
import { currentUser } from "$lib/stores/user_store";
@@ -27,47 +30,58 @@
let searchDropdownItems: SearchItem[] = $state([]);
async function search(q: string) {
const r = await searchMulti({
queries: [
{
indexUid: "trails",
attributesToRetrieve: defaultTrailSearchAttributes,
q: q,
limit: 3,
},
{
indexUid: "lists",
q: q,
limit: 3,
},
{
indexUid: "locations",
q: q,
limit: 5,
},
],
});
if (q.startsWith("@")) {
const actors = await searchActors(q);
searchDropdownItems = actors.map((a) => ({
text: a.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`,
}));
} else {
const r = await searchMulti({
queries: [
{
indexUid: "trails",
attributesToRetrieve: defaultTrailSearchAttributes,
q: q,
limit: 3,
},
{
indexUid: "lists",
q: q,
limit: 3,
},
{
indexUid: "locations",
q: q,
limit: 5,
},
],
});
const trailItems = r[0].hits.map((t: TrailSearchResult) => ({
text: t.name,
description: `Trail ${t.location.length ? ", " + t.location : ""}`,
value: `@${t.author}${t.domain ? `@${t.domain}` : ""}/${t.id}`,
icon: "route",
}));
const listItems = r[1].hits.map((t: ListSearchResult) => ({
text: t.name,
description: `List, ${t.trails} ${$_("trail", { values: { n: t.trails } })}`,
value: t.id,
icon: "layer-group",
}));
const cityItems = r[2].hits.map((c: LocationSearchResult) => ({
text: c.name,
description: c.description,
value: c,
icon: getIconForLocation(c),
}));
const trailItems = r[0].hits.map((t: TrailSearchResult) => ({
text: t.name,
description: `Trail ${t.location.length ? ", " + t.location : ""}`,
value: t.id,
icon: "route",
}));
const listItems = r[1].hits.map((t: ListSearchResult) => ({
text: t.name,
description: `List, ${t.trails.length} ${$_("trail", { values: { n: t.trails.length } })}`,
value: t.id,
icon: "layer-group",
}));
const cityItems = r[2].hits.map((c: LocationSearchResult) => ({
text: c.name,
description: c.description,
value: c,
icon: getIconForLocation(c),
}));
searchDropdownItems = [...trailItems, ...listItems, ...cityItems];
searchDropdownItems = [...trailItems, ...listItems, ...cityItems];
}
}
function handleSearchClick(item: SearchItem) {
@@ -75,6 +89,10 @@
goto(`/trail/view/${item.value}`);
} else if (item.icon == "layer-group") {
goto(`/lists?list=${item.value}`);
} else if (item.value.username) {
goto(
`/profile/@${item.value.username}${item.value.isLocal ? "" : "@" + item.value.domain}`,
);
} else {
goto(`/map/?lat=${item.value.lat}&lon=${item.value.lon}`);
}
@@ -130,7 +148,12 @@
/>
{:else}
{#each data.trails as trail}
<a class="w-full md:max-w-72" href="/trail/view/{trail.id}">
<a
class="w-full md:max-w-72"
href="/trail/view/@{trail.author}{trail.domain
? '@' + trail.domain
: ''}/{trail.id}"
>
<TrailCard {trail} fullWidth></TrailCard></a
>
{/each}

View File

@@ -0,0 +1,55 @@
import { error, json, RequestEvent } from '@sveltejs/kit';
import type PocketBase from "PocketBase";
// @ts-ignore
import { env } from "$env/dynamic/private"
// @ts-ignore
import { env as publicEnv } from '$env/dynamic/public';
// @ts-ignore
import { handleError } from "$lib/util/api_util";
// @ts-ignore
import { splitUsername } from "$lib/util/activitypub_util";
// @ts-ignore
import type { WebfingerResponse } from '$lib/models/activitypub/webfinger_response';
export async function GET(event: RequestEvent) {
try {
const resource = event.url.searchParams.get("resource")
if (!resource || !resource.startsWith("acct:")) {
return error(400, "Bad request");
}
const hostname = new URL(env.ORIGIN).hostname;
const [username, domain] = splitUsername(resource.replace("acct:", ""))
if (hostname !== domain) {
return json({ message: "Not found" }, { status: 404 });
}
const id = `${env.ORIGIN}/api/v1/activitypub/user/${username.toLowerCase()}`
const actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`iri='${id}'`)
const r: WebfingerResponse = {
subject: resource,
aliases: [id],
links: [
{
rel: 'self',
type: 'application/activity+json',
href: id,
},
{
rel: 'http://webfinger.net/rel/profile-page',
type: 'text/html',
href: `${env.ORIGIN}/profile/@${username.toLowerCase()}`,
},
],
}
return json(r)
} catch (err) {
return handleError(err)
}
}

View File

@@ -1,13 +0,0 @@
import type { Activity } from "$lib/models/activity";
import { Collection, handleError, list } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await list<Activity>(event, Collection.activities);
return json(r)
} catch (e) {
throw handleError(e)
}
}

View File

@@ -0,0 +1,32 @@
import type { Activity } from '$lib/models/activitypub/activity';
import { Collection, handleError, show } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
import type { APActivity, APRoot } from 'activitypub-types';
export async function GET(event: RequestEvent) {
try {
const a = await show<Activity>(event, Collection.activitypub_activities)
const activity: APRoot<APActivity> = {
id: a.iri,
type: a.type,
actor: a.actor,
to: a.to,
cc: a.cc,
published: a.published,
object: a.object
}
const headers = new Headers()
headers.append("Content-Type", "application/activity+json")
return json({
"@context": [
"https://www.w3.org/ns/activitystreams",
], ...activity
}, { headers })
} catch (e: any) {
return handleError(e)
}
}

View File

@@ -0,0 +1,28 @@
import { handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
const id = event.params.id;
try {
const comment = await event.locals.pb.send("/activitypub/comment/" + id, {
method: "GET",
fetch: event.fetch,
})
const headers = new Headers()
headers.append("Content-Type", "application/activity+json")
return json({
"@context": [
"https://www.w3.org/ns/activitystreams",
],
...comment
}, { status: 200, headers });
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,28 @@
import { handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
const id = event.params.id;
try {
const trail = await event.locals.pb.send("/activitypub/trail/" + id, {
method: "GET",
fetch: event.fetch,
})
const headers = new Headers()
headers.append("Content-Type", "application/activity+json")
return json({
"@context": [
"https://www.w3.org/ns/activitystreams",
],
...trail
}, { status: 200, headers });
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,65 @@
import { env } from '$env/dynamic/private';
import type { Actor } from '$lib/models/activitypub/actor';
import type { UserAnonymous } from "$lib/models/user";
import { splitUsername } from '$lib/util/activitypub_util';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import { type APActor, type APRoot } from 'activitypub-types';
export async function GET(event: RequestEvent) {
try {
let fullUsername = event.params.handle;
if (!fullUsername) {
return json({ message: "Bad request" }, { status: 400 });
}
fullUsername = fullUsername.replace(/^@/, "");
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`username:lower='${username?.toLowerCase()}'&&isLocal=true`)
const user: UserAnonymous = await event.locals.pb.collection("users_anonymous").getOne(actor.user!)
const id = `${env.ORIGIN}/api/v1/activitypub/user/${username}`
const r: APRoot<APActor & { publicKey: { id: string, owner: string, publicKeyPem: string } }> = {
"@context": [
"https://www.w3.org/ns/activitystreams",
],
id: id,
type: "Person",
inbox: id + '/inbox',
outbox: id + '/outbox',
summary: user.bio,
name: actor.username,
preferredUsername: user.username,
followers: id + '/followers',
following: id + '/following',
url: `${env.ORIGIN}/profile/@${username}`,
published: new Date(user.created ?? "").toISOString(),
icon: {
type: "Image",
url: user.avatar ? `${env.ORIGIN}/api/v1/files/users/${user.id}/${user.avatar}` : undefined
},
publicKey: {
id: id + '#main-key',
owner: id,
publicKeyPem: actor.public_key
}
}
const headers = new Headers()
headers.append("Content-Type", "application/activity+json")
return json(r, { headers });
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,59 @@
import { env } from '$env/dynamic/private';
import type { Actor } from '$lib/models/activitypub/actor';
import type { Follow } from '$lib/models/follow';
import { splitUsername } from '$lib/util/activitypub_util';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import type { APOrderedCollectionPage, APRoot } from 'activitypub-types';
import type { ListResult } from 'pocketbase';
export async function GET(event: RequestEvent) {
try {
const page = event.url.searchParams.get("page") ?? "1"
const intPage = parseInt(page);
let fullUsername = event.params.handle;
if (!fullUsername) {
return json({ message: "Bad request" }, { status: 400 });
}
fullUsername = fullUsername.replace(/^@/, "");
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`username:lower='${username?.toLowerCase()}'&&isLocal=true`)
const followers: ListResult<Follow> = await event.locals.pb.collection("follows").getList(intPage, 10, { sort: "-created", filter: `followee='${actor.id}'&&status='accepted'`, expand: "follower" })
const id = actor.iri;
const hasNextPage = intPage * 10 < followers.totalItems;
const followersPage: APRoot<APOrderedCollectionPage> = {
"@context": [
"https://www.w3.org/ns/activitystreams",
],
type: "OrderedCollectionPage",
first: id + "/followers?page=1",
...(intPage > 1 ? { prev: `${id}/followers?page=${intPage - 1}` } : {}),
...(hasNextPage ? { next: `${id}/outbox?page=${intPage + 1}` } : {}),
partOf: id + "/followers",
totalItems: followers.totalItems,
orderedItems: followers.items.map<string>(f => f.expand!.follower.iri)
}
const headers = new Headers()
headers.append("Content-Type", "application/activity+json")
return json(followersPage, { headers });
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,60 @@
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
import type { Actor } from '$lib/models/activitypub/actor';
import type { Follow } from '$lib/models/follow';
import { splitUsername } from '$lib/util/activitypub_util';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import type { APOrderedCollectionPage, APRoot } from 'activitypub-types';
import type { ListResult } from 'pocketbase';
export async function GET(event: RequestEvent) {
try {
const page = event.url.searchParams.get("page") ?? "1"
const intPage = parseInt(page);
let fullUsername = event.params.handle;
if (!fullUsername) {
return json({ message: "Bad request" }, { status: 400 });
}
fullUsername = fullUsername.replace(/^@/, "");
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`username:lower='${username?.toLowerCase()}'&&isLocal=true`)
const followers: ListResult<Follow> = await event.locals.pb.collection("follows").getList(intPage, 10, { sort: "-created", filter: `follower='${actor.id}'&&status='accepted'`, expand: "followee" })
const id = actor.iri;
const hasNextPage = intPage * 10 < followers.totalItems;
const following: APRoot<APOrderedCollectionPage> = {
"@context": [
"https://www.w3.org/ns/activitystreams",
],
type: "OrderedCollectionPage",
first: id + "/following?page=1",
...(intPage > 1 ? { prev: `${id}/following?page=${intPage - 1}` } : {}),
...(hasNextPage ? { next: `${id}/following?page=${intPage + 1}` } : {}),
partOf: id + "/following",
totalItems: followers.totalItems,
orderedItems: followers.items.map<string>(f => f.expand!.followee.iri)
}
const headers = new Headers()
headers.append("Content-Type", "application/activity+json")
return json(following, { headers });
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,43 @@
import { env as publicEnv } from '$env/dynamic/public';
import { handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import type { APActivity } from 'activitypub-types';
export async function POST(event: RequestEvent) {
try {
const activity: APActivity = await event.request.json()
if (!activity.actor) {
return json("Bad request", { status: 400 });
}
const success = await event.locals.pb.send("/activitypub/activity/process", {
method: "POST",
fetch: event.fetch,
headers: {
'X-Forwarded-Path': event.url.pathname,
'Content-Type': event.request.headers.get("content-type")!,
signature: event.request.headers.get("signature")!,
date: event.request.headers.get("date")!,
digest: event.request.headers.get("digest")!
},
body: JSON.stringify(activity)
})
if (success === false) {
return json("Invalid header signature", { status: 400 });
}
const headers = new Headers()
headers.append("Content-Type", "application/activity+json")
return json("", { status: 200, headers });
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,71 @@
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
import type { Activity } from '$lib/models/activitypub/activity';
import type { Actor } from '$lib/models/activitypub/actor';
import { RecordListOptionsSchema } from '$lib/models/api/base_schema';
import { splitUsername } from '$lib/util/activitypub_util';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import type { APActivity, APOrderedCollectionPage, APRoot } from 'activitypub-types';
import type { ListResult } from 'pocketbase';
export async function GET(event: RequestEvent) {
try {
const safeSearchParams = RecordListOptionsSchema.parse(Object.fromEntries(event.url.searchParams));
const page = safeSearchParams.page ?? 1
const perPage = safeSearchParams.perPage ?? 10
let fullUsername = event.params.handle;
if (!fullUsername) {
return json({ message: "Bad request" }, { status: 400 });
}
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`username:lower='${username?.toLowerCase()}'&&isLocal=true`)
const filter = `actor='${actor.iri}'&&type='Create'${safeSearchParams.filter ? '&&' + safeSearchParams.filter : ''}`
const activities: ListResult<Activity> = await event.locals.pb.collection("activitypub_activities").getList(page, perPage, { sort: safeSearchParams.sort ?? "-created", filter })
const id = actor.iri;
const hasNextPage = page * perPage < activities.totalItems;
const outbox: APRoot<APOrderedCollectionPage> = {
"@context": [
"https://www.w3.org/ns/activitystreams",
],
type: "OrderedCollectionPage",
first: id + "/outbox?page=1",
...(page > 1 ? { prev: `${id}/outbox?page=${page - 1}` } : {}),
...(hasNextPage ? { next: `${id}/outbox?page=${page + 1}` } : {}),
partOf: id + "/outbox",
totalItems: activities.totalItems,
orderedItems: activities.items.map<APActivity>(a => ({
id: a.iri,
actor: a.actor,
type: a.type,
to: a.to,
cc: a.cc,
published: a.published,
object: a.object
}))
}
const headers = new Headers()
headers.append("Content-Type", "application/activity+json")
return json(outbox, { headers });
} catch (e) {
return handleError(e)
}
}

View File

@@ -13,7 +13,7 @@ export async function POST(event: RequestEvent) {
const r = await event.locals.pb.collection('users').confirmPasswordReset(safeData.token, safeData.password, safeData.passwordConfirm);
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -15,7 +15,7 @@ export async function POST(event: RequestEvent) {
const r = await event.locals.pb.collection('users').authWithPassword(safeData.email ?? safeData.username!, data.password);
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -18,7 +18,7 @@ export async function GET(event: RequestEvent) {
}
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
@@ -40,7 +40,7 @@ export async function POST(event: RequestEvent) {
)
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -12,7 +12,7 @@ export async function POST(event: RequestEvent) {
const r = await event.locals.pb.collection('users').requestPasswordReset(safeData.email);
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -7,7 +7,7 @@ export async function GET(event: RequestEvent) {
const r = await list<Category>(event, Collection.categories);
return json(r)
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -2,20 +2,70 @@ import { CommentCreateSchema } from '$lib/models/api/comment_schema';
import type { Comment } from '$lib/models/comment';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import { type ListResult } from "pocketbase";
export async function GET(event: RequestEvent) {
try {
const r = await list<Comment>(event, Collection.comments);
for (const comment of r.items) {
if (!comment.expand?.author) {
comment.expand = {
author: await event.locals.pb.collection('users_anonymous').getOne(comment.author)
}
if (!event.url.searchParams.has("handle")) {
const comments = await list<Comment>(event, Collection.comments);
return json(comments)
} else {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const localComments = await list<Comment>(event, Collection.comments);
if (actor.isLocal) {
return json(localComments)
}
const deduplicationMap: Record<string, Comment> = {}
localComments.items.forEach(c => {
if (c.iri) {
const id = c.iri.substring(c.iri.length - 15)
deduplicationMap[id] = c
} else if (c.id) {
deduplicationMap[c.id] = c
}
})
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/comment`
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
}
const remoteComments: ListResult<Comment> = await response.json()
remoteComments.items = remoteComments.items.filter(c => {
const iriId = c.iri?.substring(c.iri.length - 15) ?? ""
if (deduplicationMap[c.id!] != undefined) {
deduplicationMap[c.id!] = {...c, author: deduplicationMap[c.id!].author}
return false
} else if (deduplicationMap[iriId] != undefined) {
deduplicationMap[iriId] = {...c, author: deduplicationMap[iriId].author}
return false
}
return true
})
const allCommentItems = <ListResult<Comment>>{
items: localComments.items.concat(remoteComments.items),
page: localComments.page,
perPage: localComments.perPage,
totalItems: localComments.items.length + remoteComments.items.length,
totalPages: Math.ceil((localComments.items.length + remoteComments.items.length) / localComments.perPage)
}
allCommentItems.items = allCommentItems.items.sort((a, b) => {
return new Date(b.created ?? 0).getTime() - new Date(a.created ?? 0).getTime()
})
return json(allCommentItems)
}
return json(r)
} catch (e) {
throw handleError(e)
return handleError(e)
}
}
@@ -24,6 +74,6 @@ export async function PUT(event: RequestEvent) {
const r = await create<Comment>(event, CommentCreateSchema, Collection.comments)
return json(r);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -6,14 +6,9 @@ import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await show<Comment>(event, Collection.comments)
if (!r.expand) {
r.expand = {} as any
}
r.expand!.author = await event.locals.pb.collection('users_anonymous').getOne(r.author)
return json(r)
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -22,7 +17,7 @@ export async function POST(event: RequestEvent) {
const r = await update<Comment>(event, CommentUpdateSchema, Collection.comments)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -31,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.comments)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -1,4 +1,4 @@
import { error, type RequestEvent } from "@sveltejs/kit";
import { error, json, type RequestEvent } from "@sveltejs/kit";
import { z } from "zod";
export async function GET(event: RequestEvent) {
@@ -10,7 +10,15 @@ export async function GET(event: RequestEvent) {
}).safeParse(event.params)
if (!safeParams.success) {
throw error(400, safeParams.error)
throw json({ message: safeParams.error }, { status: 400 })
}
const safeSearchParams = z.object({
thumb: z.string().regex(/[0-9]*x[0-9]*[tbf]?/).optional()
}).safeParse(Object.fromEntries(event.url.searchParams))
if (!safeSearchParams.success) {
throw json({ message: safeSearchParams.error }, { status: 400 })
}
const parts = [];
@@ -20,7 +28,7 @@ export async function GET(event: RequestEvent) {
parts.push(encodeURIComponent(safeParams.data.record));
parts.push(encodeURIComponent(safeParams.data.file));
let fileURL = event.locals.pb.buildURL(parts.join("/"));
let fileURL = event.locals.pb.buildURL(parts.join("/") + '?' + new URLSearchParams(safeSearchParams.data));
try {
const r = await event.fetch(fileURL)

View File

@@ -1,30 +1,78 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { FollowCreateSchema } from '$lib/models/api/follow_schema';
import type { Follow } from '$lib/models/follow';
import type { UserAnonymous } from '$lib/models/user';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { APIError, Collection, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import type { APOrderedCollectionPage } from 'activitypub-types';
import { ClientResponseError, type ListResult } from "pocketbase";
export async function GET(event: RequestEvent) {
try {
const r = await list<Follow>(event, Collection.follows);
for (const follow of r.items) {
const follower = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(follow.follower, { requestKey: null })
const followee = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(follow.followee, { requestKey: null })
follow.expand = {
follower, followee
if (!event.url.searchParams.has("handle")) {
const follows = await list<Follow>(event, Collection.follows);
return json(follows)
} else {
const handle = event.url.searchParams.get("handle");
const type = event.url.searchParams.get("type");
if (!handle || (type !== "followers" && type !== "following")) {
throw new APIError(400, "invalid params")
}
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
const page = event.url.searchParams.get("page") ?? "1"
const headers = new Headers()
headers.set("Accept", 'application/ld+json')
const r = await event.fetch(actor[type as "followers" | "following"]! + '?' + new URLSearchParams({ page }), { headers })
if (!r.ok) {
const errorResponse = await r.json()
throw new ClientResponseError({ status: r.status, response: errorResponse });
}
const followers: APOrderedCollectionPage = await r.json()
const followerActors: Actor[] = []
for (const f of followers.orderedItems ?? []) {
try {
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${f}`, { method: "GET", fetch: event.fetch, });
followerActors.push(actor)
} catch (e) {
continue
}
}
const result: ListResult<Actor> = {
items: followerActors,
page: parseInt(page),
perPage: 10,
totalItems: actor.followerCount ?? 0,
totalPages: Math.ceil((actor.followerCount ?? 0) / 10)
}
return json(result)
}
return json(r)
} catch (e) {
throw handleError(e)
return handleError(e)
}
}
export async function PUT(event: RequestEvent) {
try {
const r = await create<Follow>(event, FollowCreateSchema, Collection.follows)
return json(r);
const data = await event.request.json();
const safeData = FollowCreateSchema.parse(data);
const followerActor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`user = '${event.locals.user.id}'`)
const followeeActor: Actor = await event.locals.pb.collection("activitypub_actors").getOne(safeData.followee);
const follow = await event.locals.pb.collection("follows").create({ follower: followerActor.id, followee: followeeActor.id, status: followeeActor.isLocal ? "accepted" : "pending" })
return json(follow);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -7,7 +7,7 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.follows)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -1,11 +0,0 @@
import { Collection, handleError, show } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await show(event, Collection.follow_counts)
return json(r)
} catch (e: any) {
throw handleError(e)
}
}

View File

@@ -9,7 +9,7 @@ export async function GET(event: RequestEvent) {
return json(r)
} catch (e) {
throw handleError(e)
return handleError(e)
}
}
@@ -18,6 +18,6 @@ export async function PUT(event: RequestEvent) {
const r = await create<Comment>(event, IntegrationCreateSchema, Collection.integrations)
return json(r);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
const r = await show<Integration>(event, Collection.integrations)
return json(r)
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
const r = await update<Integration>(event, IntegrationUpdateSchema, Collection.integrations)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.integrations)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -8,6 +8,6 @@ export async function GET(event: RequestEvent) {
});
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -1,30 +1,30 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { ListShareCreateSchema } from '$lib/models/api/list_share_schema';
import type { ListShare } from '$lib/models/list_share';
import type { User } from '$lib/models/user';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
try {
const r = await list<ListShare>(event, Collection.list_share);
for (const share of r.items) {
const anonymous_user = await event.locals.pb.collection('users_anonymous').getOne<User>(share.user)
share.expand = {
user: anonymous_user
}
}
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
export async function PUT(event: RequestEvent) {
try {
const r = await create<ListShare>(event, ListShareCreateSchema, Collection.list_share)
const data = await event.request.json();
const safeData = ListShareCreateSchema.parse(data);
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${safeData.actor}`, { method: "GET", fetch: event.fetch, });
safeData.actor = actor.id!;
const r = await event.locals.pb.collection(Collection.list_share).create<ListShare>(safeData)
return json(r);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
const r = await show<ListShare>(event, Collection.list_share)
return json(r)
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
const r = await update<ListShare>(event, ListShareUpdateSchema, Collection.list_share)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.list_share)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -10,14 +10,11 @@ export async function GET(event: RequestEvent) {
if (!t.author || !event.locals.pb.authStore.record) {
continue;
}
if (!t.expand) {
t.expand = {}
}
t.expand!.author = await event.locals.pb.collection("users_anonymous").getOne(t.author);
}
return json(r)
} catch (e) {
throw handleError(e)
return handleError(e)
}
}
@@ -26,6 +23,6 @@ export async function PUT(event: RequestEvent) {
const r = await create<List>(event, ListCreateSchema, Collection.lists)
return json(r);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -1,19 +1,82 @@
import { ListUpdateSchema } from "$lib/models/api/list_schema";
import type { List } from "$lib/models/list";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { objectToFormData } from "$lib/util/file_util";
import { json, type RequestEvent } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
export async function GET(event: RequestEvent) {
try {
const r = await show<List>(event, Collection.lists)
if (!r.expand) {
r.expand = {} as any
}
r.expand!.author = await event.locals.pb.collection('users_anonymous').getOne(r.author!)
if (!event.url.searchParams.has("handle")) {
const l = await show<List>(event, Collection.lists)
return json(l)
} else {
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/list/${event.params.id}`
let dbList: List | undefined;
try {
dbList = await event.locals.pb.collection("lists").getFirstListItem(`iri='${url}'||id='${event.params.id}'`, {
...Object.fromEntries(event.url.searchParams)
})
} catch (e) {
if (!(e instanceof ClientResponseError) || e.status != 404) {
throw e
}
}
if (actor.isLocal) {
return json(dbList)
} else {
const response = await event.fetch((dbList?.iri ?? url) + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
const cachedList = await event.locals.pb.collection("lists").getOne(`${event.params.id}`)
return json(cachedList)
}
const l: List = await response.json()
l.avatar = l.avatar ? `${origin}/api/v1/files/lists/${l.id}/${l.avatar}` : undefined
l.author = actor.id!
l.expand!.author = actor
l.iri = dbList?.iri ?? url;
l.expand?.trails?.forEach(t => {
t.gpx = t.gpx ? `${origin}/api/v1/files/trails/${t.id}/${t.gpx}` : undefined
t.photos = t.photos.map(p =>
`${origin}/api/v1/files/trails/${t.id}/${p}`
)
t.iri = t.iri || `${origin}/api/v1/trails/${t.id}`;
})
const formData = objectToFormData({ ...l, id: dbList?.id, expand: undefined, trails: [] })
if (l.avatar) {
const avatarURL = l.avatar
let response = await event.fetch(avatarURL, { method: "GET" })
const avatar = await response.blob()
formData.append("avatar", avatar)
}
if (dbList !== undefined) {
dbList = await event.locals.pb.collection("lists").update(dbList.id!, formData)
} else {
dbList = await event.locals.pb.collection("lists").create(formData)
}
l.id = dbList!.id
return json(l)
}
}
return json(r)
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -22,7 +85,7 @@ export async function POST(event: RequestEvent) {
const r = await update<List>(event, ListUpdateSchema, Collection.lists)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -31,7 +94,7 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.lists)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -7,6 +7,6 @@ export async function POST(event: RequestEvent) {
const r = await upload<List>(event, Collection.lists);
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -0,0 +1,12 @@
import type { List } from '$lib/models/list';
import { Collection, handleError, uploadCreate } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function PUT(event: RequestEvent) {
try {
const r = await uploadCreate<List>(event, Collection.lists)
return json(r);
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,12 @@
import type { List } from "$lib/models/list";
import { Collection, handleError, uploadUpdate } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function POST(event: RequestEvent) {
try {
const r = await uploadUpdate<List>(event, Collection.lists)
return json(r);
} catch (e) {
return handleError(e)
}
}

View File

@@ -1,5 +1,4 @@
import type { Notification } from '$lib/models/notification';
import type { UserAnonymous } from '$lib/models/user';
import { Collection, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
@@ -7,15 +6,8 @@ export async function GET(event: RequestEvent) {
try {
const r = await list<Notification>(event, Collection.notifications);
for (const notification of r.items) {
const recipient = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(notification.recipient, { requestKey: null })
const author = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(notification.author, { requestKey: null })
notification.expand = {
recipient, author
}
}
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -8,6 +8,6 @@ export async function POST(event: RequestEvent) {
const r = await update<Comment>(event, NotificationUpdateSchema, Collection.notifications)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -0,0 +1,35 @@
import type { Profile } from '$lib/models/profile';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
const handle = event.params.handle;
if (!handle) {
return error(400, { message: "Bad request" })
}
try {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}&follows=true`, { method: "GET", fetch: event.fetch, });
const profile: Profile = {
id: actor.id!,
username: actor.username,
preferredUsername: actor.preferred_username,
acct: handle,
createdAt: actor.published ?? "",
bio: actor.summary ?? "",
uri: actor.iri,
followers: actor.followerCount ?? 0,
following: actor.followingCount ?? 0,
icon: actor.icon ?? "",
error
}
return json({ profile, actor: actor })
} catch (e) {
if (e instanceof Error && e.message == "fetch failed") {
return error(404, "Not found")
}
return handleError(e)
}
}

View File

@@ -0,0 +1,43 @@
import type { TrailSearchResult } from '$lib/models/trail';
import type { ListSearchResult } from '$lib/stores/search_store';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import type { SearchResponse } from 'meilisearch';
import { ClientResponseError } from 'pocketbase';
export async function POST(event: RequestEvent) {
const handle = event.params.handle;
if (!handle) {
return error(400, { message: "Bad request" })
}
try {
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
const data = await event.request.json()
let r: SearchResponse<ListSearchResult>;
if (actor.isLocal) {
r = await event.locals.ms.index("lists").search(data.q, { ...data.options, filter: `author = ${actor.id}` });
} else {
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/profile/${actor.username}/lists?` + event.url.searchParams
const response = await event.fetch(url, { method: 'POST', body: JSON.stringify(data) })
if (!response.ok) {
const errorResponse = await response.json()
throw new ClientResponseError({ status: response.status, response: errorResponse });
}
r = await response.json()
r.hits.forEach(h => {
h.avatar = h.avatar ? `${origin}/api/v1/files/lists/${h.id}/${h.avatar}` : undefined;
})
}
return json(r)
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,49 @@
import { RecordListOptionsSchema } from '$lib/models/api/base_schema';
import type { SummitLog } from '$lib/models/summit_log';
import { Collection, handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import { ClientResponseError, type ListResult } from 'pocketbase';
export async function GET(event: RequestEvent) {
const handle = event.params.handle;
if (!handle) {
return error(400, { message: "Bad request" })
}
try {
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
const searchParams = Object.fromEntries(event.url.searchParams);
const safeSearchParams = RecordListOptionsSchema.parse(searchParams);
let summitLogs: ListResult<SummitLog>;
if (actor.isLocal) {
summitLogs = await event.locals.pb.collection(Collection.summit_logs)
.getList<SummitLog>(safeSearchParams.page, safeSearchParams.perPage, { ...safeSearchParams, filter: `author='${actor.id}'` })
} else {
const origin = new URL(actor.iri).origin
const summitLogURL = `${origin}/api/v1/profile/${actor.username}/stats?` + event.url.searchParams
const response = await event.fetch(summitLogURL, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
throw new ClientResponseError({ status: response.status, response: errorResponse });
}
summitLogs = await response.json()
summitLogs.items.forEach(i => {
i.photos = i.photos.map(p =>
`${origin}/api/v1/files/summit_logs/${i.id}/${p}`
)
if (i.gpx) {
i.gpx = `${origin}/api/v1/files/summit_logs/${i.id}/${i.gpx}`
}
})
}
return json(summitLogs)
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,48 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { RecordListOptionsSchema } from '$lib/models/api/base_schema';
import { type TimelineItem } from '$lib/models/timeline';
import { Collection, handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import { ClientResponseError, type ListResult } from 'pocketbase';
export async function GET(event: RequestEvent) {
const handle = event.params.handle;
if (!handle) {
return error(400, { message: "Bad request" })
}
try {
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
const searchParams = Object.fromEntries(event.url.searchParams);
const safeSearchParams = RecordListOptionsSchema.parse(searchParams);
let timeline: ListResult<TimelineItem>;
if (actor.isLocal) {
timeline = await event.locals.pb.collection(Collection.timeline)
.getList<TimelineItem>(safeSearchParams.page, safeSearchParams.perPage, { ...safeSearchParams, filter: `author='${actor.iri}'` })
} else {
const origin = new URL(actor.iri).origin
const timelineURL = `${origin}/api/v1/profile/${actor.username}/timeline?` + event.url.searchParams
const response = await event.fetch(timelineURL, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
throw new ClientResponseError({ status: response.status, response: errorResponse });
}
timeline = await response.json()
timeline.items.forEach(i => {
i.photos = i. photos.map(p =>
`${origin}/api/v1/files/${i.type}s/${i.id}/${p}`
)
})
}
return json(timeline)
} catch (e) {
console.error(e)
return handleError(e)
}
}

View File

@@ -0,0 +1,43 @@
import type { TrailSearchResult } from '$lib/models/trail';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import type { SearchResponse } from 'meilisearch';
import { ClientResponseError } from 'pocketbase';
export async function POST(event: RequestEvent) {
const handle = event.params.handle;
if (!handle) {
return error(400, { message: "Bad request" })
}
try {
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
const data = await event.request.json()
let r: SearchResponse<TrailSearchResult>;
if (actor.isLocal) {
r = await event.locals.ms.index("trails").search(data.q, { ...data.options, filter: `author = ${actor.id}` });
} else {
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/profile/${actor.username}/trails?` + event.url.searchParams
const response = await event.fetch(url, { method: 'POST', body: JSON.stringify(data) })
if (!response.ok) {
const errorResponse = await response.json()
throw new ClientResponseError({ status: response.status, response: errorResponse });
}
r = await response.json()
r.hits.forEach(h => {
h.thumbnail = `${origin}/api/v1/files/trails/${h.id}/${h.thumbnail}`;
h.domain = actor.domain
})
}
return json(r)
} catch (e) {
return handleError(e)
}
}

View File

@@ -4,11 +4,11 @@ export async function POST(event: RequestEvent) {
const data = await event.request.json()
try {
const r = await event.locals.ms.index(event.params.index as string).search(data.q, data.options);
const r = await event.locals.ms.index(event.params.index as string).search(data.q, data.options);
return json(r);
} catch (e: any) {
console.error(e);
throw error(e.httpStatus, e)
}
}

View File

@@ -0,0 +1,46 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { splitUsername } from '$lib/util/activitypub_util';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
import { ClientResponseError, type ListResult } from "pocketbase"
export async function GET(event: RequestEvent) {
try {
if (!event.url.searchParams.has("q")) {
throw new ClientResponseError({ status: 400, response: "Bad request" });
}
const q = event.url.searchParams.get("q")
const [user, domain] = splitUsername(q!)
let filter = `username~'${user}'`;
if (event.url.searchParams.get("includeSelf") == "false" && event.locals.pb.authStore.record) {
filter += `&& id != "${event.locals.pb.authStore.record.actor}"`
}
const response = await event.locals.pb.collection("activitypub_actors").getList(1, 3, { filter: filter })
try {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${q}&follows=false`, { method: "GET", fetch: event.fetch, });
if (!response.items.find(i => i.iri == actor.iri)) {
response.items.push(actor)
}
} catch (e) {
}
return json({ items: response.items })
} catch (e) {
if (e instanceof Error && e.message == "fetch failed") {
return error(404, "Not found")
}
return handleError(e)
}
}

View File

@@ -9,7 +9,7 @@ export async function GET(event: RequestEvent) {
const r = await show<Settings>(event, Collection.settings)
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
@@ -18,7 +18,7 @@ export async function POST(event: RequestEvent) {
const r = await update<Settings>(event, SettingsCreateSchema, Collection.settings)
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
@@ -27,6 +27,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.settings)
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -2,14 +2,90 @@ import { SummitLogCreateSchema } from '$lib/models/api/summit_log_schema';
import type { SummitLog } from '$lib/models/summit_log';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import { type ListResult } from "pocketbase";
export async function GET(event: RequestEvent) {
try {
const r = await list<SummitLog>(event, Collection.summit_logs);
if (!event.url.searchParams.has("handle")) {
const summitLogs = await list<SummitLog>(event, Collection.summit_logs);
removeTimeFromDates(summitLogs.items)
return json(summitLogs)
} else {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const localSummitLogs = await list<SummitLog>(event, Collection.summit_logs);
if (actor.isLocal) {
removeTimeFromDates(localSummitLogs.items)
return json(r)
} catch (e: any) {
throw handleError(e);
return json(localSummitLogs)
}
const deduplicationMap: Record<string, SummitLog> = {}
localSummitLogs.items.forEach(l => {
if (l.iri) {
const id = l.iri.substring(l.iri.length - 15)
deduplicationMap[id] = l
} else if (l.id) {
deduplicationMap[l.id] = l
}
l.date = l.date.substring(0, 10);
})
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/summit-log`
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
}
const remoteSummitLogs: ListResult<SummitLog> = await response.json()
remoteSummitLogs.items = remoteSummitLogs.items.filter(l => {
const iriId = l.iri?.substring(l.iri.length - 15) ?? ""
if (deduplicationMap[l.id!] != undefined) {
deduplicationMap[l.id!] = {...l, author: deduplicationMap[l.id!].author}
return false
} else if (deduplicationMap[iriId] != undefined) {
deduplicationMap[iriId] = {...l, author: deduplicationMap[iriId].author}
return false
}
return true
})
remoteSummitLogs.items.forEach(l => {
if (l.gpx) {
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
}
l.photos = l.photos.map(p =>
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
)
if (l.expand?.author) {
l.expand.author.isLocal = false
}
})
const allSummitLogItems = <ListResult<SummitLog>>{
items: localSummitLogs.items.concat(remoteSummitLogs.items),
page: localSummitLogs.page,
perPage: localSummitLogs.perPage,
totalItems: localSummitLogs.items.length + remoteSummitLogs.items.length,
totalPages: Math.ceil((localSummitLogs.items.length + remoteSummitLogs.items.length) / localSummitLogs.perPage)
}
allSummitLogItems.items = allSummitLogItems.items.sort((a, b) => {
return new Date(a.created ?? 0).getTime() - new Date(b.created ?? 0).getTime()
})
return json(allSummitLogItems)
}
} catch (e) {
return handleError(e)
}
}
@@ -18,6 +94,12 @@ export async function PUT(event: RequestEvent) {
const r = await create<SummitLog>(event, SummitLogCreateSchema, Collection.summit_logs)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
function removeTimeFromDates(logs: SummitLog[]) {
logs.forEach(l => l.date = l.date.substring(0, 10));
}

View File

@@ -7,15 +7,9 @@ import { error, json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await show<SummitLog>(event, Collection.summit_logs)
if (!r.expand) {
r.expand = {} as any
}
r.expand!.author = await event.locals.pb.collection("users_anonymous").getOne(r.author!);
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
@@ -24,7 +18,7 @@ export async function POST(event: RequestEvent) {
const r = await update<SummitLog>(event, SummitLogUpdateSchema, Collection.summit_logs)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -33,6 +27,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.summit_logs)
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -0,0 +1,14 @@
import type { SummitLog } from '$lib/models/summit_log';
import { Collection, handleError, uploadCreate } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function PUT(event: RequestEvent) {
try {
const r = await uploadCreate<SummitLog>(event, Collection.summit_logs)
r.date = r.date?.substring(0, 10) ?? "";
return json(r);
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,13 @@
import type { SummitLog } from "$lib/models/summit_log";
import { Collection, handleError, uploadUpdate } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function POST(event: RequestEvent) {
try {
const r = await uploadUpdate<SummitLog>(event, Collection.summit_logs)
r.date = r.date?.substring(0, 10) ?? "";
return json(r);
} catch (e) {
return handleError(e)
}
}

View File

@@ -9,7 +9,7 @@ export async function GET(event: RequestEvent) {
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
@@ -18,6 +18,6 @@ export async function PUT(event: RequestEvent) {
const r = await create<Tag>(event, TagCreateSchema, Collection.tags)
return json(r);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
const r = await show<Tag>(event, Collection.tags)
return json(r)
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
const r = await update<Tag>(event, TagUpdateSchema, Collection.tags)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.tags)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -0,0 +1,23 @@
import { TrailLikeCreateSchema } from '$lib/models/api/trail_like_schema';
import type { TrailLike } from '$lib/models/trail_like';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
try {
const r = await list<TrailLike>(event, Collection.trail_share);
return json(r)
} catch (e: any) {
handleError(e)
}
}
export async function PUT(event: RequestEvent) {
try {
const r = await create<TrailLike>(event, TrailLikeCreateSchema, Collection.trail_like)
return json(r);
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,21 @@
import type { TrailLike } from "$lib/models/trail_like";
import { Collection, handleError, remove, show } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await show<TrailLike>(event, Collection.trail_like)
return json(r)
} catch (e: any) {
return handleError(e)
}
}
export async function DELETE(event: RequestEvent) {
try {
const r = await remove(event, Collection.trail_like)
return json(r);
} catch (e: any) {
return handleError(e)
}
}

View File

@@ -0,0 +1,21 @@
import { TrailLikeCreateSchema } from '$lib/models/api/trail_like_schema';
import { Collection, handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
// this route exists so we can delete a like without knowing its ID
export async function POST(event: RequestEvent) {
try {
const data = await event.request.json();
const safeData = TrailLikeCreateSchema.parse(data);
const like = await event.locals.pb.collection(Collection.trail_like).getFirstListItem(`actor='${safeData.actor}'&&trail='${safeData.trail}'`)
const r = await event.locals.pb.collection(Collection.trail_like).delete(like.id)
return json({ 'acknowledged': r })
} catch (e: any) {
handleError(e)
}
}

View File

@@ -1,18 +1,12 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { TrailShareCreateSchema } from '$lib/models/api/trail_share_schema';
import type { TrailShare } from '$lib/models/trail_share';
import type { UserAnonymous } from '$lib/models/user';
import { Collection, handleError, list, create } from '$lib/util/api_util';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
try {
const r = await list<TrailShare>(event, Collection.trail_share);
for (const share of r.items) {
const anonymous_user = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(share.user)
share.expand = {
user: anonymous_user
}
}
return json(r)
} catch (e: any) {
handleError(e)
@@ -21,9 +15,16 @@ export async function GET(event: RequestEvent) {
export async function PUT(event: RequestEvent) {
try {
const r = await create<TrailShare>(event, TrailShareCreateSchema, Collection.trail_share)
const data = await event.request.json();
const safeData = TrailShareCreateSchema.parse(data);
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${safeData.actor}`, { method: "GET", fetch: event.fetch, });
safeData.actor = actor.id!;
const r = await event.locals.pb.collection(Collection.trail_share).create<TrailShare>(safeData)
return json(r);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
const r = await show<TrailShare>(event, Collection.trail_share)
return json(r)
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
const r = await update<TrailShare>(event, TrailShareUpdateSchema, Collection.trail_share)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.trail_share)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -19,23 +19,23 @@ export async function GET(event: RequestEvent) {
}
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
export async function PUT(event: RequestEvent) {
try {
try {
const r = await create<Trail>(event, TrailCreateSchema, Collection.trails)
enrichRecord(r);
return json(r);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}
function enrichRecord(r: Trail) {
r.date = r.date?.substring(0, 10) ?? "";
for (const log of r.expand?.summit_logs ?? []) {
for (const log of r.expand?.summit_logs_via_trail ?? []) {
log.date = log.date.substring(0, 10);
}
}

View File

@@ -1,28 +1,137 @@
import type { Trail } from "$lib/models/trail";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { error, json, type RequestEvent } from "@sveltejs/kit";
import { RecordOptionsSchema } from '$lib/models/api/base_schema';
import { TrailUpdateSchema } from '$lib/models/api/trail_schema';
import type { Trail } from "$lib/models/trail";
import { APIError, Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { objectToFormData } from "$lib/util/file_util";
import { json, type RequestEvent } from "@sveltejs/kit";
import type PocketBase from "pocketbase";
import { ClientResponseError } from "pocketbase";
export async function GET(event: RequestEvent) {
try {
const r = await show<Trail>(event, Collection.trails)
// try to get the trail simply via the
let t: Trail;
if (!event.url.searchParams.has("handle")) {
t = await show<Trail>(event, Collection.trails)
} else {
let { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
if (event.locals.pb.authStore.record) {
if (!r.expand) {
r.expand = {} as any
const safeSearchParams = RecordOptionsSchema.parse(Object.fromEntries(event.url.searchParams));
let origin = new URL(actor.iri).origin
let iri = `${origin}/api/v1/trail/${event.params.id}`
try {
t = await event.locals.pb.collection("trails").getFirstListItem(`iri='${iri}'||id='${event.params.id}'`, {
...safeSearchParams
})
} catch (e) {
if (!(e instanceof ClientResponseError) || e.status != 404) {
throw e
}
t = {
iri: iri,
author: actor.id
} as Trail
}
}
if (t.iri) {
const origin = new URL(t.iri).origin
const actor = await event.locals.pb.collection("activitypub_actors").getOne(t.author)
const localTrailId = t.id;
const localTrailIRI = t.iri;
const localLikeCount = t.like_count;
const localLikes = t.expand?.trail_like_via_trail;
const response = await event.fetch((t.iri) + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
if (t.id) {
return json(t)
} else {
throw new ClientResponseError({ status: response.status, response: errorResponse })
}
}
t = await response.json()
// this came directly from the database of the remote instance
// we need to adjust some urls to get photos, gpx etc.
if (!t.iri) {
if (t.gpx) {
t.gpx = `${origin}/api/v1/files/trails/${t.id}/${t.gpx}`
}
t.photos = t.photos.map(p =>
`${origin}/api/v1/files/trails/${t.id}/${p}`
)
t.expand?.summit_logs_via_trail?.forEach(l => {
if (l.gpx) {
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
}
l.photos = l.photos.map(p =>
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
)
if (l.expand?.author) {
l.expand.author.isLocal = false
}
})
t.expand?.waypoints?.forEach(w => {
w.photos = w.photos.map(p =>
`${origin}/api/v1/files/waypoints/${w.id}/${p}`
)
})
t.author = actor.id!
t.expand!.author = actor as any
t.id = localTrailId
t.iri = localTrailIRI
t.like_count = localLikeCount
t.expand!.trail_like_via_trail = localLikes;
}
let categoryId: string | undefined;
try {
const category = await event.locals.pb.collection("categories").getFirstListItem(`name='${t.expand?.category?.name}'`)
categoryId = category.id;
t.expand!.category = category as any
} catch (e) { }
const formData = objectToFormData({ ...t, id: t.id, gpx: undefined, expand: undefined, photos: [], waypoints: [], tags: [], category: categoryId })
if (t.photos.length) {
const photoURL = t.photos[t.thumbnail ?? 0]
let response = await event.fetch(photoURL, { method: "GET" })
const photo = await response.blob()
formData.append("photos", photo)
}
if (t.gpx) {
const gpxURL = t.gpx
const response = await event.fetch(gpxURL, { method: "GET" })
const gpx = await response.blob()
formData.append("gpx", gpx)
}
if (t.id) {
await event.locals.pb.collection("trails").update(t.id, formData)
} else {
const createdTrail = await event.locals.pb.collection("trails").create(formData)
t.id = createdTrail.id;
}
r.expand!.author = await event.locals.pb.collection("users_anonymous").getOne(r.author!);
}
// remove time from dates
await enrichRecord(event.locals.pb, r);
await enrichRecord(event.locals.pb, t);
// sort waypoints by distance
r.expand?.waypoints?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
return json(r)
t.expand?.waypoints?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
return json(t)
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -32,7 +141,7 @@ export async function POST(event: RequestEvent) {
await enrichRecord(event.locals.pb, r)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -41,7 +150,7 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.trails)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -49,14 +158,7 @@ export async function DELETE(event: RequestEvent) {
async function enrichRecord(pb: PocketBase, r: Trail) {
r.date = r.date?.substring(0, 10) ?? "";
for (const log of r.expand?.summit_logs ?? []) {
for (const log of r.expand?.summit_logs_via_trail ?? []) {
log.date = log.date.substring(0, 10);
if (!log.expand) {
log.expand = {} as any;
}
if (log.author) {
log.expand!.author = await pb.collection("users_anonymous").getOne(log.author);
}
}
}

View File

@@ -12,9 +12,9 @@ export async function GET(event: RequestEvent) {
});
}
try {
const r = await event.locals.pb.collection('trails_bounding_box').getOne<TrailBoundingBox>(event.locals.pb.authStore.record!.id)
const r = await event.locals.pb.collection('trails_bounding_box').getOne<TrailBoundingBox>(event.locals.user.actor!)
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -16,6 +16,6 @@ export async function POST(event: RequestEvent) {
},
});
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -14,9 +14,9 @@ export async function GET(event: RequestEvent) {
});
}
try {
const r = await event.locals.pb.collection('trails_filter').getOne<TrailFilterValues>(event.locals.pb.authStore.record!.id)
const r = await event.locals.pb.collection('trails_filter').getOne<TrailFilterValues>(event.locals.user.actor)
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -0,0 +1,20 @@
import type { Trail } from '$lib/models/trail';
import { Collection, handleError, uploadCreate } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function PUT(event: RequestEvent) {
try {
const r = await uploadCreate<Trail>(event, Collection.trails)
enrichRecord(r);
return json(r);
} catch (e) {
return handleError(e)
}
}
function enrichRecord(r: Trail) {
r.date = r.date?.substring(0, 10) ?? "";
for (const log of r.expand?.summit_logs_via_trail ?? []) {
log.date = log.date.substring(0, 10);
}
}

View File

@@ -0,0 +1,21 @@
import type { Trail } from "$lib/models/trail";
import { Collection, handleError, uploadUpdate } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function POST(event: RequestEvent) {
try {
const r = await uploadUpdate<Trail>(event, Collection.trails)
enrichRecord(r);
return json(r);
} catch (e) {
return handleError(e)
}
}
function enrichRecord(r: Trail) {
r.date = r.date?.substring(0, 10) ?? "";
for (const log of r.expand?.summit_logs_via_trail ?? []) {
log.date = log.date.substring(0, 10);
}
}

View File

@@ -1,5 +1,4 @@
import { TrailRecommendSchema } from '$lib/models/api/trail_schema';
import type { Trail } from '$lib/models/trail';
import { handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
@@ -7,27 +6,13 @@ export async function GET(event: RequestEvent) {
try {
const searchParams = Object.fromEntries(event.url.searchParams);
const safeSearchParams = TrailRecommendSchema.parse(searchParams);
const r = await event.locals.pb.send("/trail/recommend?" + new URLSearchParams({
size: safeSearchParams.size?.toString() ?? ""
}), {
method: "GET",
});
const result: Trail[] = r;
const numberOfTrails = (await event.locals.ms.index("trails").search("", {limit: 1})).estimatedTotalHits
const randomOffset = (safeSearchParams.size ?? 0) > numberOfTrails ? 0 : Math.floor(Math.random() * (numberOfTrails - 1) + 1)
const response = await event.locals.ms.index("trails").search("", {limit: safeSearchParams.size, offset: randomOffset})
for (const t of result) {
if (!t.author || !event.locals.pb.authStore.record) {
continue;
}
if (!t.expand) {
t.expand = {} as any
}
t.expand!.author = await event.locals.pb.collection("users_anonymous").getOne(t.author);
}
return json(result)
return json(response.hits)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -1,12 +1,13 @@
import GPX from "$lib/models/gpx/gpx";
import { haversineDistance } from "$lib/models/gpx/utils";
import type { Trail } from "$lib/models/trail";
import type { Trail, TrailSearchResult } from "$lib/models/trail";
import { trails_create } from "$lib/stores/trail_store";
import { handleError } from "$lib/util/api_util";
import { fromFile, gpx2trail } from "$lib/util/gpx_util";
import { json, type RequestEvent } from "@sveltejs/kit";
import type MeiliSearch from "meilisearch";
import type { Hits } from "meilisearch";
import { ClientResponseError } from "pocketbase";
import type PocketBase from 'pocketbase';
export async function PUT(event: RequestEvent) {
try {
@@ -28,14 +29,14 @@ export async function PUT(event: RequestEvent) {
const ignoreDuplicates = data.get("ignoreDuplicates") === "true"
if (!ignoreDuplicates) {
let duplicate: Trail | null = null;
let duplicate: TrailSearchResult | null = null;
try {
duplicate = await findDuplicate(event.locals.pb, trail)
duplicate = await findDuplicate(event.locals.ms, trail)
} catch (e: any) {
throw new ClientResponseError({ status: 500, response: { message: "Error checking for duplicates" } })
}
if (duplicate !== null) {
throw new ClientResponseError({ status: 400, response: { message: `Duplicate trail`, id: duplicate.id, name: duplicate.name }, })
throw new ClientResponseError({ status: 400, response: { message: `Duplicate trail`, id: duplicate.id, name: duplicate.name, domain: `${duplicate.author_name}${duplicate.domain ? '@' + duplicate.domain : ''}` }, })
}
}
@@ -56,17 +57,19 @@ export async function PUT(event: RequestEvent) {
trail = await trails_create(trail, [], gpxFile, event.fetch, event.locals.user);
} catch (e: any) {
console.error(e)
throw handleError(e)
return handleError(e)
}
return json(trail);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
async function findDuplicate(pb: PocketBase, t1: Trail) {
const trails: Trail[] = await pb.collection('trails').getFullList({ requestKey: null });
async function findDuplicate(ms: MeiliSearch, t1: Trail) {
const response = await ms.index("trails").search("", {});
const trails: TrailSearchResult[] = response.hits as Hits<TrailSearchResult>
const distanceThreshold = 100;
const elevationThreshhold = 50;
@@ -76,7 +79,7 @@ async function findDuplicate(pb: PocketBase, t1: Trail) {
const lengthDifference = Math.abs((t1.distance ?? 0) - (t2.distance ?? 0));
const elevationGainDifference = Math.abs((t1.elevation_gain ?? 0) - (t2.elevation_gain ?? 0));
const elevationLossDifference = Math.abs((t1.elevation_loss ?? 0) - (t2.elevation_loss ?? 0));
const startpointDifference = haversineDistance(t1.lat ?? 0, t1.lon ?? 0, t2.lat ?? 0, t2.lon ?? 0)
const startpointDifference = haversineDistance(t1.lat ?? 0, t1.lon ?? 0, t2._geo.lat ?? 0, t2._geo.lng ?? 0)
if (lengthDifference < lengthThreshhold && elevationGainDifference < elevationThreshhold && elevationLossDifference < elevationThreshhold && startpointDifference < distanceThreshold) {
return t2

View File

@@ -21,7 +21,7 @@ export async function PUT(event: RequestEvent) {
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -10,7 +10,7 @@ export async function GET(event: RequestEvent) {
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
@@ -34,7 +34,7 @@ export async function POST(event: RequestEvent) {
return json(r);
}
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
@@ -43,6 +43,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.users)
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -17,7 +17,7 @@ export async function GET(event: RequestEvent) {
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -8,6 +8,6 @@ export async function GET(event: RequestEvent) {
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
const r = await list<Waypoint>(event, Collection.waypoints);
return json(r)
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}
@@ -17,6 +17,6 @@ export async function PUT(event: RequestEvent) {
const r = await create<Waypoint>(event, WaypointCreateSchema, Collection.waypoints)
return json(r);
} catch (e) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
const r = await show<Waypoint>(event, Collection.waypoints)
return json(r)
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
const r = await update<Waypoint>(event, WaypointUpdateSchema, Collection.waypoints)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
const r = await remove(event, Collection.waypoints)
return json(r);
} catch (e: any) {
throw handleError(e)
return handleError(e)
}
}

View File

@@ -7,6 +7,6 @@ export async function POST(event: RequestEvent) {
const r = await upload<Waypoint>(event, Collection.waypoints);
return json(r);
} catch (e: any) {
throw handleError(e);
return handleError(e);
}
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import ActorSearch from "$lib/components/actor_search.svelte";
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
import Search, {
type SearchItem,
@@ -16,16 +17,16 @@
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte";
import TrailList from "$lib/components/trail/trail_list.svelte";
import UserSearch from "$lib/components/user_search.svelte";
import { List, type ListFilter } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import {
lists_delete,
lists_index,
lists_search_filter,
lists_show,
} from "$lib/stores/list_store";
import { fetchGPX } from "$lib/stores/trail_store";
import { trails_show } from "$lib/stores/trail_store";
import { currentUser } from "$lib/stores/user_store";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util.js";
import * as M from "maplibre-gl";
import { onMount } from "svelte";
@@ -39,12 +40,12 @@
{ text: $_("creation-date"), value: "created" },
];
let pagination = {
page: data.lists.page,
const pagination: { page: number; totalPages: number } = $state({
page: 1,
totalPages: data.lists.totalPages,
};
});
let lists = $state(data.lists);
let lists: List[] = $state(data.lists.items);
let confirmModal: ConfirmModal;
let listShareModal: ListShareModal;
@@ -56,10 +57,12 @@
let markers: M.Marker[] = $state([]);
let showMap: boolean = true;
let selectedList: List | null = $state(null);
let selectedList: List | null = $state(
page.params.handle && page.params.id ? data.lists.items[0] : null,
);
let selectedTrail: Trail | null = $state(null);
let loading: boolean = $state(false);
let loading: boolean = $state(true);
let loadingNextPage: boolean = false;
let filterExpanded: boolean = $state(false);
@@ -67,24 +70,20 @@
let userQuery = $state("");
let selectedTrailIndex = $derived(
selectedTrail
? ((selectedList as List | null)?.expand?.trails?.indexOf(selectedTrail) ?? null)
: null,
);
let selectedTrailIndex = $derived(selectedTrail ? 0 : null);
let selectedTrailWaypoints = $derived(
(selectedTrail as Trail | null)?.expand?.waypoints,
);
onMount(() => {
if (page.url.searchParams.get("list")) {
setCurrentList(data.lists.items[0]);
if (page.params.handle && page.params.id) {
// setCurrentList(data.lists[0]);
// only the requested list has been loaded at this point
// load all lists the next time the user presses the back button
loadAllListsOnNextBack = true;
}
loading = false;
});
async function handleDropdownClick(item: DropdownItem) {
@@ -110,20 +109,12 @@
}
async function setCurrentList(item: List) {
if (
item.expand &&
item.expand.trails &&
item.expand.trails.length > 0
) {
for (const trail of item.expand.trails) {
const gpxData: string = await fetchGPX(trail);
if (!trail.expand) {
(trail as any).expand = {};
}
trail.expand!.gpx_data = gpxData;
}
}
selectedList = item;
const fullList = await lists_show(
item.id!,
handleFromRecordWithIRI(item),
fetch,
);
selectedList = fullList;
document.getElementById("list-container")?.scrollTo({ top: 0 });
}
@@ -144,8 +135,14 @@
}
}
function selectTrail(trail: Trail) {
selectedTrail = trail;
async function selectTrail(trail: Trail) {
const fullTrail = await trails_show(
trail.iri ? trail.iri.substring(trail.iri.length - 15) : trail.id!,
handleFromRecordWithIRI(trail),
true,
);
selectedTrail = fullTrail;
mapWithElevation?.unHighlightTrail(trail.id!);
window.scrollTo({ top: 0 });
}
@@ -177,7 +174,10 @@
async function loadNextPage() {
pagination.page += 1;
lists = await lists_index(filter, pagination.page);
const response = await lists_search_filter(filter, pagination.page);
lists = response.items;
pagination.page = response.page;
pagination.totalPages = response.totalPages;
}
async function updateFilter() {
@@ -194,7 +194,10 @@
}
pagination.page = 1;
lists = await lists_search_filter(filter, pagination.page);
const response = await lists_search_filter(filter, pagination.page);
lists = response.items;
pagination.page = response.page;
pagination.totalPages = response.totalPages;
loading = false;
}
@@ -268,7 +271,7 @@
</div>
{#if filterExpanded}
<div
class="absolute bg-background z-10 shadow-lg rounded-b-xl w-full px-14"
class="absolute bg-background z-10 shadow-lg border-b border-input-border rounded-b-xl w-full px-14"
in:slide
out:slide
>
@@ -291,13 +294,13 @@
{#if $currentUser}
<hr class="my-4 border-separator" />
<UserSearch
<ActorSearch
onclick={setAuthorFilter}
onclear={clearAuthorFilter}
bind:value={userQuery}
clearAfterSelect={false}
label={$_("author")}
></UserSearch>
></ActorSearch>
<div class="flex items-center my-4">
<input
id="public-checkbox"
@@ -338,10 +341,10 @@
{#each { length: 3 } as _, index}
<SkeletonCard></SkeletonCard>
{/each}
{:else if lists.items.length == 0}
{:else if lists.length == 0}
<EmptyStateSearch width={356}></EmptyStateSearch>
{:else}
{#each lists.items as item, i}
{#each lists as item, i}
<div
class="list-list-item"
onclick={() => setCurrentList(item)}
@@ -361,14 +364,20 @@
onclick={(data) => selectTrail(data.trail)}
></ListPanel>
{:else if selectedList && selectedTrail}
<TrailInfoPanel initTrail={selectedTrail} mode="list" {markers}
<TrailInfoPanel
initTrail={selectedTrail}
mode="list"
{markers}
handle={handleFromRecordWithIRI(selectedTrail)}
></TrailInfoPanel>
{/if}
</div>
</div>
<div id="trail-map" class:hidden={!showMap}>
<MapWithElevationMaplibre
trails={selectedList?.expand?.trails ?? []}
trails={selectedTrail
? [selectedTrail]
: (selectedList?.expand?.trails ?? [])}
waypoints={selectedTrailWaypoints}
bind:map
bind:this={mapWithElevation}

View File

@@ -1,8 +1,8 @@
import { ExpandType, type List, type ListFilter } from "$lib/models/list";
import { lists_index, lists_show } from "$lib/stores/list_store";
import { browser } from "$app/environment";
import { type ListFilter } from "$lib/models/list";
import { lists_search_filter, lists_show } from "$lib/stores/list_store";
import { APIError } from "$lib/util/api_util";
import { error, type Load, type NumericRange } from "@sveltejs/kit";
import { type ListResult } from "pocketbase";
export const load: Load = async ({ params, fetch, url }) => {
const filter: ListFilter = {
@@ -14,12 +14,12 @@ export const load: Load = async ({ params, fetch, url }) => {
sortOrder: "+",
};
let lists: ListResult<List>;
if (url.searchParams.get("list")) {
let lists: Awaited<ReturnType<typeof lists_search_filter>>;
if (params.handle && params.id) {
try {
const list = await lists_show(url.searchParams.get("list") ?? "", fetch)
const list = await lists_show(params.id, params.handle, fetch)
lists = { items: [list], page: 1, perPage: 1, totalItems: 1, totalPages: 1 }
lists = { items: [list], page: 1, totalPages: 1, hits: [] }
} catch (e) {
if (e instanceof APIError) {
error(e.status as NumericRange<400, 599>, {
@@ -28,8 +28,10 @@ export const load: Load = async ({ params, fetch, url }) => {
}
throw e
}
} else if (browser) {
lists = await lists_search_filter(filter, 1, undefined, fetch);
} else {
lists = await lists_index(filter, 1, undefined, fetch);
lists = { items: [], page: 1, totalPages: 1, hits: [] }
}
return { lists, filter }

View File

@@ -6,20 +6,20 @@
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import Button from "$lib/components/base/button.svelte";
import Editor from "$lib/components/base/editor.svelte";
import Search, {
type SearchItem,
} from "$lib/components/base/search.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Textarea from "$lib/components/base/textarea.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import ConfirmModal from "$lib/components/confirm_modal.svelte";
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import { ListCreateSchema } from "$lib/models/api/list_schema.js";
import { ListShareCreateSchema } from "$lib/models/api/list_share_schema.js";
import { TrailCreateSchema } from "$lib/models/api/trail_schema.js";
import type { Trail } from "$lib/models/trail.js";
import type { Trail, TrailSearchResult } from "$lib/models/trail.js";
import { TrailShare } from "$lib/models/trail_share.js";
import { lists_create, lists_update } from "$lib/stores/list_store.js";
import { searchTrails } from "$lib/stores/search_store.js";
import { theme } from "$lib/stores/theme_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import {
@@ -37,10 +37,7 @@
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { z } from "zod";
import {
searchTrails,
type TrailSearchResult,
} from "$lib/stores/search_store.js";
import { onMount } from "svelte";
let { data } = $props();
@@ -58,13 +55,23 @@
let shareConfirmModal: ConfirmModal;
let publishConfirmModal: ConfirmModal;
let trailsOnMap: Trail[] = $state([]);
const ClientListCreateSchema = ListCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(),
avatar: z.string().or(z.instanceof(File)).optional(),
expand: z
.object({
trails: z.array(TrailCreateSchema).optional(),
list_share_via_list: z.array(ListShareCreateSchema).optional(),
list_share_via_list: z
.array(
z.object({
actor: z.string().length(15),
list: z.string().length(15),
permission: z.enum(["view", "edit"]),
}),
)
.optional(),
})
.optional(),
});
@@ -90,6 +97,10 @@
},
});
onMount(() => {
trailsOnMap = [...(data.list.expand?.trails ?? [])];
});
async function checkPrerequisites() {
if (
(data.list?.public === false && $formData.public === true) ||
@@ -103,7 +114,6 @@
shareConfirmModal.openModal();
return false;
}
return true;
}
@@ -156,7 +166,7 @@
async function search(q: string) {
try {
const r = await searchTrails(q, {
filter: `author = ${$currentUser?.id} OR public = true`,
filter: `author = ${$currentUser?.actor} OR public = true`,
sort: ["name:desc"],
limit: 3,
});
@@ -170,19 +180,21 @@
description: `${t.location ?? "-"}`,
value: t.id,
icon: "route",
}));
}));
} catch (e) {
console.error(e);
}
}
async function handleSearchClick(item: SearchItem) {
const trail = await trails_show(item.value, true);
const trail = await trails_show(item.value, undefined, true);
$formData.trails?.push(trail.id!);
$formData.expand = {
trails: [...($formData.expand?.trails ?? []), trail],
list_share_via_list: $formData.expand?.list_share_via_list,
};
trailsOnMap = [...($formData.expand?.trails ?? [])];
}
function deleteTrail(trail: Trail) {
@@ -190,20 +202,24 @@
$formData.expand!.trails = $formData.expand!.trails!.filter(
(t) => t.id !== trail.id,
);
trailsOnMap = [...($formData.expand?.trails ?? [])];
}
async function findNewTrailShares() {
const usersWithAccess: string[] = [
const actorsWithAccess: string[] = [
$formData.author!,
...($formData.expand?.list_share_via_list ?? []).map((s) => s.user),
...($formData.expand?.list_share_via_list ?? []).map(
(s) => s.actor!,
),
];
for (const userId of usersWithAccess) {
for (const actorId of actorsWithAccess) {
const existingTrailShares = await trail_share_index({
user: userId,
actor: actorId,
});
for (const trail of $formData.expand?.trails ?? []) {
if (
trail.author == userId ||
trail.author == actorId ||
trail.author != $currentUser?.id
) {
continue;
@@ -212,7 +228,7 @@
(s) => s.trail == trail.id,
);
if (!trailShare) {
newShares.push(new TrailShare(userId, trail.id!, "view"));
newShares.push(new TrailShare(actorId, trail.id!, "view"));
}
}
}
@@ -228,7 +244,7 @@
async function publishTrails() {
for (const trail of $formData.expand?.trails ?? []) {
if (trail.author !== $currentUser?.id) {
if (trail.author !== $currentUser?.actor) {
continue;
}
const updatedTrail: Trail = { ...trail, public: true };
@@ -310,11 +326,12 @@
<TextField name="name" label={$_("name")} error={$errors.name}
></TextField>
<Textarea
name="description"
<Editor
extraClasses="min-h-24"
bind:value={$formData.description}
label={$_("description")}
error={$errors.description}
></Textarea>
></Editor>
<Toggle name="public" label={$_("public")}></Toggle>
<h3 class="text-xl font-semibold">
{$_("trail", { values: { n: 2 } })}
@@ -340,7 +357,8 @@
src={trail.photos.length
? getFileURL(
trail,
trail.photos[trail.thumbnail ?? 0],
trail.photos.at(trail.thumbnail ?? 0) ??
trail.photos[0],
)
: $theme === "light"
? emptyStateTrailLight
@@ -429,7 +447,7 @@
<div id="trail-map" class="max-h-full">
<MapWithElevationMaplibre
fitBounds="animate"
trails={$formData.expand?.trails ?? []}
trails={trailsOnMap}
bind:activeTrail={activeTrailIndex}
bind:this={map}
></MapWithElevationMaplibre>

View File

@@ -15,7 +15,7 @@ export const load: Load = async ({ params, fetch, data }) => {
return { list: list, previewUrl: "" }
} else {
try {
list = await lists_show(params.id, fetch);
list = await lists_show(params.id, undefined, fetch);
const previewURL = getFileURL(list, list.avatar);
return { list: list, previewUrl: previewURL }

View File

@@ -22,13 +22,6 @@
let { data: pageData }: PageProps = $props();
const authProviders = pageData.authMethods.oauth2.providers;
let loginLabel = $state("");
if (pageData.authMethods.password) {
loginLabel = `${$_("username")}/${$_("email")}`;
}else if (pageData.authMethods.password) {
loginLabel = `${$_("username")}/${$_("email")}`;
}
const { form, errors, data } = createForm<User>({
initialValues: {
@@ -98,11 +91,11 @@
<LogoTextTwoLineLight></LogoTextTwoLineLight>
{/if}
<h4 class="text-xl font-semibold">{$_("slogan")}</h4>
{#if page.data.authMethods.password}
{#if page.data.authMethods.password.enabled}
<div class="space-y-6 w-80">
<TextField
name="username"
label={loginLabel}
label="{$_('username')}/{$_('email')}"
error={$errors.username}
></TextField>
<div class="flex flex-col">

View File

@@ -16,17 +16,15 @@
type Trail,
type TrailBoundingBox,
type TrailFilter,
type TrailSearchResult,
} from "$lib/models/trail";
import { categories } from "$lib/stores/category_store";
import {
searchMulti,
type ListSearchResult,
type LocationSearchResult,
type TrailSearchResult,
} from "$lib/stores/search_store";
import {
trails_search_bounding_box
} from "$lib/stores/trail_store";
import { trails_search_bounding_box } from "$lib/stores/trail_store";
import { getIconForLocation } from "$lib/util/icon_util";
import type { Snapshot } from "@sveltejs/kit";
import * as M from "maplibre-gl";
@@ -89,12 +87,12 @@
const trailItems = r[0].hits.map((t: TrailSearchResult) => ({
text: t.name,
description: `Trail ${t.location.length ? ", " + t.location : ""}`,
value: t.id,
value: `@${t.author}${t.domain ? `@${t.domain}` : ""}/${t.id}`,
icon: "route",
}));
const listItems = r[1].hits.map((t: ListSearchResult) => ({
text: t.name,
description: `List, ${t.trails.length} ${$_("trail", { values: { n: t.trails.length } })}`,
description: `List, ${t.trails} ${$_("trail", { values: { n: t.trails } })}`,
value: t.id,
icon: "layer-group",
}));
@@ -110,7 +108,7 @@
function handleSearchClick(item: SearchItem) {
if (item.icon == "route") {
goto(`/trail/view/${item.value}`);
goto(`/map/trail/${item.value}`);
} else if (item.icon == "layer-group") {
goto(`/lists?list=${item.value}`);
} else {
@@ -219,11 +217,19 @@
maxBoundingBox.max_lon != 0 ||
maxBoundingBox.min_lat != 0)
) {
const boundingBox: M.LngLatBoundsLike = [
[maxBoundingBox.min_lon, maxBoundingBox.max_lat],
[maxBoundingBox.max_lon, maxBoundingBox.min_lat],
];
map?.fitBounds(boundingBox, { animate: false, padding: 32 });
if (
maxBoundingBox.min_lon == maxBoundingBox.max_lon &&
maxBoundingBox.min_lat == maxBoundingBox.max_lat
) {
map?.setZoom(12);
map?.setCenter([maxBoundingBox.min_lon, maxBoundingBox.min_lat]);
} else {
const boundingBox: M.LngLatBoundsLike = [
[maxBoundingBox.min_lon, maxBoundingBox.max_lat],
[maxBoundingBox.max_lon, maxBoundingBox.min_lat],
];
map?.fitBounds(boundingBox, { animate: false, padding: 32 });
}
} else if (
settings &&
settings.mapFocus == "location" &&
@@ -331,7 +337,11 @@
<EmptyStateSearch></EmptyStateSearch>
{/if}
{#each trails as trail, i}
<a href="map/trail/{trail.id}">
<a
href="/map/trail/@{trail.author}{trail.domain
? `@${trail.domain}`
: ''}/{trail.id}"
>
<TrailCard
{trail}
fullWidth={true}

View File

@@ -15,6 +15,7 @@ export const load: ServerLoad = async ({ params, locals, fetch }) => {
author: "",
public: true,
shared: true,
liked: false,
private: true,
near: {
radius: 2000,

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from "$app/state";
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte";
import * as M from "maplibre-gl";
@@ -18,7 +19,8 @@
<main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4">
<div id="panel" class="hidden md:block">
<TrailInfoPanel initTrail={trail} {markers}></TrailInfoPanel>
<TrailInfoPanel handle={page.params.handle} initTrail={trail} {markers}
></TrailInfoPanel>
</div>
<div id="trail-details">
<MapWithElevationMaplibre

View File

@@ -1,10 +1,11 @@
import { trails_show } from "$lib/stores/trail_store";
import { splitUsername } from "$lib/util/activitypub_util";
import { APIError } from "$lib/util/api_util";
import { error, type NumericRange, type ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ params, locals, fetch }) => {
try {
const trail = await trails_show(params.id!, true, fetch)
const trail = await trails_show(params.id!, params.handle, true, fetch)
return { trail };
} catch (e) {
if (e instanceof APIError) {

View File

@@ -13,6 +13,7 @@
import {
formatDistance,
formatElevation,
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import {
@@ -343,7 +344,7 @@
if (includeDescription && $trail.description) {
doc.addPage();
doc.text($trail.description, 16, 16, {
doc.text(formatHTMLAsText($trail.description), 16, 16, {
maxWidth: width - 32,
});
doc.addImage(
@@ -460,6 +461,7 @@
showGrid = true;
}
}
</script>
<svelte:head>

View File

@@ -4,7 +4,8 @@ import { error, type Load, type NumericRange } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch }) => {
try {
await trails_show(params.id!, true, fetch)
const trail = await trails_show(params.id!, params.handle, true, fetch)
return { trail };
} catch (e) {
if (e instanceof APIError) {
error(e.status as NumericRange<400, 599>, {

View File

@@ -0,0 +1,29 @@
<script>
import { page } from "$app/state";
import errorDark from "$lib/assets/svgs/empty_states/error_dark.svg";
import errorLight from "$lib/assets/svgs/empty_states/error_light.svg";
import { theme } from "$lib/stores/theme_store";
</script>
<svelte:head>
<title>{page.status} | wanderer</title>
</svelte:head>
<div
class="m-auto text-center max-w-md"
style="height: calc(100dvh - 120px); font-size: 4rem"
>
<img
class="aspect-square rounded-xl mx-auto"
width="300"
src={$theme === "light" ? errorLight : errorDark}
alt="Default error img"
/>
<div class="text-center mt-8">
<h1 class="font-bold" style="line-height: 4rem;">
{page.status}
</h1>
<p class="text-lg text-center mt-2">
{page.error?.message}
</p>
</div>
</div>

View File

@@ -0,0 +1,29 @@
import { follows_a_b } from "$lib/stores/follow_store";
import { profile_show } from "$lib/stores/profile_store";
import { APIError } from "$lib/util/api_util";
import { error, type NumericRange, type ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ params, locals, fetch }) => {
if (!params.handle) {
error(404, "Not found")
}
try {
const { actor, profile } = await profile_show(params.handle, fetch);
const isOwnProfile = profile.id === locals.user.actor;
const follow = await follows_a_b(locals.user.actor!, profile.id, fetch) ?? null
return { profile, isOwnProfile, follow: follow, actor }
} catch (e) {
if (e instanceof APIError) {
error(e.status as NumericRange<400, 599>, {
message: e.status == 404 ? 'Not found' : e.message
});
} else {
throw e
}
}
};

View File

@@ -3,13 +3,13 @@
import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte";
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
import type { SelectItem } from "$lib/components/base/select.svelte";
import ProfileShareModal from "$lib/components/profile/profile_share_modal.svelte";
import TrailShareModal from "$lib/components/trail/trail_share_modal.svelte";
import { follows_create, follows_delete } from "$lib/stores/follow_store";
import { theme } from "$lib/stores/theme_store.js";
import { currentUser } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import errorDark from "$lib/assets/svgs/empty_states/error_dark.svg";
import errorLight from "$lib/assets/svgs/empty_states/error_light.svg";
let { data, children } = $props();
@@ -17,23 +17,23 @@
let followLoading: boolean = $state(false);
const profileLinks: DropdownItem[] = [
const profileLinks: DropdownItem[] = $derived([
{
text: $_("profile"),
value: `/profile/${page.params.id}`,
value: `/profile/${page.params.handle}`,
icon: "user",
},
{
text: $_("trail", { values: { n: 2 } }),
value: `/profile/${page.params.id}/trails`,
value: `/profile/${page.params.handle}/trails`,
icon: "route",
},
{
text: $_("statistics"),
value: `/profile/${page.params.id}/stats`,
value: `/profile/${page.params.handle}/stats`,
icon: "chart-pie",
},
];
]);
let activeIndex = $derived(
profileLinks.findIndex((l) => l.value === page.url.pathname),
@@ -48,10 +48,7 @@
if (data.follow) {
await follows_delete(data.follow);
} else {
await follows_create({
follower: $currentUser.id,
followee: data.user.id,
});
await follows_create(data.profile.id);
}
await invalidateAll();
followLoading = false;
@@ -62,67 +59,89 @@
class="grid grid-cols-1 md:grid-cols-[356px_minmax(0,_1fr)] gap-6 max-w-6xl mx-4 md:mx-auto items-start"
>
<div class="border border-input-border rounded-xl md:sticky top-8 md:ml-6">
{#if data.user}
<div class="flex items-center gap-x-6 px-6 my-6">
{#if data.profile}
<div class="flex items-center gap-x-6 px-6 mt-6">
<img
class="rounded-full w-16 aspect-square overflow-hidden"
src={getFileURL(data.user, data.user.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${data.user.username}&backgroundType=gradientLinear`}
class="rounded-full w-16 aspect-square overflow-hidden shrink-0"
src={data.profile.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${data.profile.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div>
<h4 class="text-2xl font-semibold col-start-2">
{data.user.username}
{data.profile.preferredUsername ??
data.profile.username}
</h4>
<p class="text-sm">
<span class="text-gray-500">{$_("joined")}:</span>
{new Date(data.user.created ?? "").toLocaleDateString(
undefined,
{
month: "2-digit",
day: "2-digit",
year: "numeric",
timeZone: "UTC",
},
)}
<p class="text-sm text-gray-500 mb-4 break-all">
{data.profile.acct}
</p>
</div>
</div>
{/if}
<div class="flex gap-x-6 text-sm px-6">
<div class="px-6 mb-4">
<p class="text-sm">
<span class="text-gray-500">{$_("joined")}:</span>
{new Date(data.profile.createdAt ?? "").toLocaleDateString(
undefined,
{
month: "2-digit",
day: "2-digit",
year: "numeric",
timeZone: "UTC",
},
)}
</p>
</div>
{#if data.profile.error}
<p class="px-6 py-4 text-xs bg-red-200">
<span class="font-semibold">{data.profile.acct}</span> could not
be fetched from the remote server. Showing cached data.
</p>
{/if}
<hr class="mb-4 border-input-border" />
<div class="flex gap-x-6 items-center text-sm px-6">
<a
class:font-bold={page.url.pathname.endsWith("followers")}
href="/profile/{data.user.id}/users/followers"
href="/profile/{data.profile.acct}/users/followers"
>
<p class="font-semibold">{data.followers}</p>
<p class="font-semibold">{data.profile.followers}</p>
<p>{$_("followers")}</p>
</a>
<a
class:font-bold={page.url.pathname.endsWith("following")}
href="/profile/{data.user.id}/users/following"
href="/profile/{data.profile.acct}/users/following"
>
<p class="font-semibold">{data.following}</p>
<p class="font-semibold">{data.profile.following}</p>
<p>{$_("following")}</p>
</a>
{#if !data.isOwnProfile}
<div class="px-6 mt-2">
<Button
loading={followLoading}
disabled={followLoading}
primary={!data.follow}
secondary={!!data.follow}
icon={data.follow ? "check" : ""}
onclick={() => follow()}
>
{#if data.follow}
{data.follow.status == "accepted"
? $_("following")
: $_("follow-request-pending")}
{:else}
{$_("follow")}
{/if}
</Button>
</div>
{/if}
</div>
{#if !data.isOwnProfile}
<div class="px-6 mt-4">
<Button
loading={followLoading}
disabled={followLoading}
primary={!data.follow}
secondary={!!data.follow}
icon={data.follow ? "check" : ""}
onclick={() => follow()}
>
{data.follow ? $_("following") : $_("follow")}</Button
>
</div>
{/if}
<hr class="mt-4 border-input-border" />
<div class="mt-6 mb-4">
{#each profileLinks as link, i}
<a
class="block mx-2 px-4 py-3 my-1 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors rounded-md"
class="block mx-4 px-4 py-3 my-1 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors rounded-md"
class:bg-menu-item-background-hover={i == activeIndex}
href={link.value}
><i class="fa fa-{link.icon} mr-2"></i>{link.text}</a
@@ -130,7 +149,7 @@
{/each}
</div>
{#if data.isOwnProfile}
<div class="px-6 mb-4 flex flex-col gap-2">
<div class="px-4 mb-4 flex flex-col gap-2">
<button
class="btn-secondary basis-full"
onclick={() => profileShareModal.openModal()}

View File

@@ -1,22 +1,27 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import ActivityCard from "$lib/components/profile/activity_card.svelte";
import { activities_index } from "$lib/stores/activity_store.js";
import type { TimelineItem } from "$lib/models/timeline.js";
import { profile_timeline_index } from "$lib/stores/profile_store.js";
import { theme } from "$lib/stores/theme_store.js";
import { getFileURL } from "$lib/util/file_util.js";
import { HTML } from "@threlte/extras";
import { _ } from "svelte-i18n";
import { temp } from "three/examples/jsm/nodes/Nodes.js";
let { data } = $props();
let activities = $state(data.activities);
let timeline = $derived(data.timeline);
let loading: boolean = false;
let pagination = $derived({
page: data.activities.page,
totalPages: data.activities.totalPages,
page: 1,
totalPages: data.timeline.totalItems,
});
async function onListScroll(e: Event) {
@@ -34,7 +39,23 @@
async function loadNextPage() {
pagination.page += 1;
activities = await activities_index(data.user.id, pagination.page);
data.timeline = await profile_timeline_index(
data.actor.iri,
pagination.page,
);
}
function handleTimeLineItemClick(item: TimelineItem) {
if (item.trail_iri.length) {
const originalTrailId = item.trail_iri.substring(
item.trail_iri.length - 15,
);
goto(
`/trail/view/@${item.trail_author_username}@${item.trail_author_domain}/${item.trail_id}`,
);
} else {
goto(`/trail/view/${page.params.handle}/${item.trail_id}`);
}
}
</script>
@@ -48,22 +69,24 @@
<div class="space-y-4">
<h4 class="text-xl font-semibold">
{$_("about")}
{data.user.username}
{#if data.isOwnProfile && data.settings.bio?.length}
{data.profile.preferredUsername}
{#if data.isOwnProfile && data.profile.bio.length}
<a aria-label="Edit bio" class="ml-4" href="/settings/profile"
><i class="fa fa-pen text-base"></i></a
>
{/if}
</h4>
{#if data.settings.bio?.length}
<p class="whitespace-pre-wrap text-sm">{data.settings.bio}</p>
{#if data.profile.bio.length}
<p class="prose dark:prose-invert">{@html data.profile.bio}</p>
{:else if data.isOwnProfile}
<a class="btn-primary inline-block" href="/settings/profile"
>+ {$_("add-bio")}</a
>
{:else}
<p class="w-full text-center text-gray-500 text-sm">
{$_("empty-bio", { values: { username: data.user.username } })}
{$_("empty-bio", {
values: { username: data.profile.preferredUsername },
})}
</p>
{/if}
</div>
@@ -79,13 +102,13 @@
{:else if !data.lists.length}
<p class="w-full text-center text-gray-500 text-sm">
{$_("empty-lists", {
values: { username: data.user.username },
values: { username: data.profile.preferredUsername },
})}
</p>
{/if}
{#each data.lists as list}
<a
href="/lists?list={list.id}"
href="/lists/{page.params.handle}/{list.id}"
class="relative w-64 h-48 rounded-xl overflow-hidden group shrink-0"
>
<img
@@ -112,30 +135,24 @@
</div>
<div class="space-y-4">
<h4 class="text-xl font-semibold">Timeline</h4>
{#if !activities.items.length && data.isOwnProfile}
{#if !timeline.items?.length && data.isOwnProfile}
<a class="btn-primary inline-block" href="/trails/edit/new"
>+ {$_("new-trail")}</a
>
{:else if !activities.items.length}
{:else if !timeline.items?.length}
<p class="w-full text-center text-gray-500 text-sm">
{$_("empty-activities", {
values: { username: data.user.username },
values: { username: data.profile.preferredUsername },
})}
</p>
{/if}
{#each activities.items as activity}
{#each timeline.items as item}
<div
class="py-1 cursor-pointer"
role="presentation"
class="cursor-pointer"
onclick={() => {
goto(
activity.type == "trail"
? `/trail/view/${activity.id}`
: `/trail/view/${activity.trail_id}?t=3`,
);
}}
onclick={() => handleTimeLineItemClick(item)}
>
<ActivityCard {activity} user={data.user}></ActivityCard>
<ActivityCard activity={item} actor={data.actor}></ActivityCard>
</div>
{/each}
</div>

View File

@@ -0,0 +1,29 @@
import type { ListFilter } from "$lib/models/list";
import { profile_lists_index, profile_timeline_index } from "$lib/stores/profile_store";
import { error, type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch, parent }) => {
if (!params.handle) {
error(404, "Not found")
}
const filter: ListFilter = {
q: "",
author: "",
shared: true,
public: true,
sort: "created",
sortOrder: "+",
};
try {
const lists = await profile_lists_index(params.handle, filter, 1, 6, fetch)
const timeline = await profile_timeline_index(params.handle, 1, 10, fetch);
return { lists: lists.items, timeline }
} catch(e) {
return {lists: [], timeline: {items: [], page: 1, perPage: 1, totalItems: 0, totalPages: 0}}
}
};

View File

@@ -7,11 +7,10 @@
type SelectItem,
} from "$lib/components/base/select.svelte";
import SummitLogTable from "$lib/components/summit_log/summit_log_table.svelte";
import type { SummitLog } from "$lib/models/summit_log.js";
import { categories } from "$lib/stores/category_store.js";
import {
summit_logs_index,
summitLogs,
} from "$lib/stores/summit_log_store";
import { profile_stats_index } from "$lib/stores/profile_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import {
formatDistance,
formatElevation,
@@ -44,6 +43,8 @@
BarElement,
);
let summitLogs: SummitLog[] = $state(data.logs);
const filter = $state(data.filter);
const categorySelectItems: SelectItem[] = $categories.map((c) => ({
@@ -87,25 +88,28 @@
elevation_loss: 3.28084,
};
let logCategories = $derived($summitLogs.reduce(
(acc, log) => {
const cat =
log.expand?.trails_via_summit_logs?.at(0)?.expand?.category
?.name ?? "unknown";
acc[$_(cat)] = (acc[$_(cat)] || 0) + 1;
return acc;
},
{} as Record<string, number>,
));
let logCategories = $derived(
summitLogs.reduce(
(acc, log) => {
const cat =
log.expand?.trail?.expand?.category?.name ?? "unknown";
acc[$_(cat)] = (acc[$_(cat)] || 0) + 1;
return acc;
},
{} as Record<string, number>,
),
);
let categoryLabels = $derived(Object.keys(logCategories).sort());
let categoryValues = $derived(Object.values(logCategories));
let categoryColorMap = $derived(Object.fromEntries(
categoryLabels.map((label, index) => [
label,
categoryColors[index % categoryColors.length],
]),
));
let categoryColorMap = $derived(
Object.fromEntries(
categoryLabels.map((label, index) => [
label,
categoryColors[index % categoryColors.length],
]),
),
);
let categoryChartData = $derived({
labels: categoryLabels,
@@ -131,30 +135,33 @@
}
}
let barChartDataByDate = $derived($summitLogs.reduce(
(acc, log) => {
const date = new Date(log.date).toLocaleDateString(undefined, {
month: "2-digit",
day: "2-digit",
year: "numeric",
timeZone: "UTC",
});
acc[date] =
(acc[date] || 0) + ((log as any)[barChartSelectedValue] ?? 0);
if (barChartSelectedValue === "distance") {
acc[date] = acc[date] / 1000;
} else if (barChartSelectedValue === "duration") {
acc[date] = acc[date] / 60 / 60;
}
if (page.data.settings?.unit !== "metric") {
let barChartDataByDate = $derived(
summitLogs.reduce(
(acc, log) => {
const date = new Date(log.date).toLocaleDateString(undefined, {
month: "2-digit",
day: "2-digit",
year: "numeric",
timeZone: "UTC",
});
acc[date] =
acc[date] *
(conversionFactors as any)[barChartSelectedValue];
}
return acc;
},
{} as Record<string, number>,
));
(acc[date] || 0) +
((log as any)[barChartSelectedValue] ?? 0);
if (barChartSelectedValue === "distance") {
acc[date] = acc[date] / 1000;
} else if (barChartSelectedValue === "duration") {
acc[date] = acc[date] / 60 / 60;
}
if (page.data.settings?.unit !== "metric") {
acc[date] =
acc[date] *
(conversionFactors as any)[barChartSelectedValue];
}
return acc;
},
{} as Record<string, number>,
),
);
let barChartLabels = $derived(Object.keys(barChartDataByDate)); // Dates as labels
let barChartValues = $derived(Object.values(barChartDataByDate));
@@ -173,34 +180,31 @@
],
});
let totalDistance = $derived($summitLogs.reduce(
(sum, log) => sum + (log.distance ?? 0),
0,
));
let totalDistance = $derived(
summitLogs.reduce((sum, log) => sum + (log.distance ?? 0), 0),
);
let totalDuration = $derived($summitLogs.reduce(
(sum, log) => sum + (log.duration ?? 0),
0,
));
let totalDuration = $derived(
summitLogs.reduce((sum, log) => sum + (log.duration ?? 0), 0),
);
let totalElevationGain = $derived($summitLogs.reduce(
(sum, log) => sum + (log.elevation_gain ?? 0),
0,
));
let totalElevationGain = $derived(
summitLogs.reduce((sum, log) => sum + (log.elevation_gain ?? 0), 0),
);
let totalElevationLoss = $derived($summitLogs.reduce(
(sum, log) => sum + (log.elevation_loss ?? 0),
0,
));
let totalElevationLoss = $derived(
summitLogs.reduce((sum, log) => sum + (log.elevation_loss ?? 0), 0),
);
let averageSpeed =
$derived(totalDuration > 0
? $summitLogs.reduce(
let averageSpeed = $derived(
totalDuration > 0
? summitLogs.reduce(
(sum, log) =>
sum + (log.distance && log.duration ? log.distance : 0),
0,
) / totalDuration
: undefined);
: undefined,
);
function updateFilterCategory(categories: SelectItem[]) {
filter.category = categories.map((c) => c.value);
@@ -217,8 +221,16 @@
}
async function loadSummitLogs() {
const logs = await summit_logs_index(page.params.id, filter);
summitLogs.set(logs.items);
try {
const logs = await profile_stats_index(page.params.handle, filter);
summitLogs = logs;
} catch (e) {
show_toast({
icon: "close",
text: "Error loading stats.",
type: "error",
});
}
}
</script>
@@ -226,7 +238,9 @@
<title>{$_("profile")} | wanderer</title>
</svelte:head>
<div class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,_1fr)] gap-y-4 max-w-6xl mx-auto">
<div
class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,_1fr)] gap-y-4 max-w-6xl mx-auto"
>
<div
class="flex flex-wrap lg:flex-nowrap col-start-1 lg:col-start-2 gap-x-4 justify-end"
>
@@ -251,7 +265,7 @@
<div class="border border-input-border rounded-xl p-6">
<Calendar
onclick={handleDateClick}
logs={$summitLogs}
logs={summitLogs}
colorMap={categoryColorMap}
></Calendar>
</div>
@@ -284,7 +298,7 @@
values: { n: 2 },
})}</span
>
<p class="text-3xl font-bold">{$summitLogs.length}</p>
<p class="text-3xl font-bold">{summitLogs.length}</p>
</div>
<div
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
@@ -391,7 +405,8 @@
>
<div class=" overflow-x-auto">
<SummitLogTable
summitLogs={$summitLogs}
summitLogs={data.logs}
handle={page.params.handle}
showCategory
showTrail
showRoute

View File

@@ -1,11 +1,11 @@
import type { SummitLogFilter } from "$lib/models/summit_log";
import { categories_index } from "$lib/stores/category_store";
import { summit_logs_index } from "$lib/stores/summit_log_store";
import { profile_stats_index } from "$lib/stores/profile_store";
import { error, type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch }) => {
export const load: Load = async ({ params, fetch, parent }) => {
if(!params.id) {
if (!params.handle) {
error(404, "Not found")
}
@@ -23,7 +23,11 @@ export const load: Load = async ({ params, fetch }) => {
endDate: lastDay.toISOString().slice(0, 10),
category: []
}
const logs = await summit_logs_index(params.id, filter, fetch);
try {
const logs = await profile_stats_index(params.handle, filter, fetch);
return { filter, logs }
return { filter }
} catch (e) {
return { logs: [], filter }
}
};

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import { page } from "$app/state";
import TrailList from "$lib/components/trail/trail_list.svelte";
import type { Trail, TrailFilter } from "$lib/models/trail.js";
import { profile_trails_index } from "$lib/stores/profile_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import { trails_search_filter } from "$lib/stores/trail_store";
import { _ } from "svelte-i18n";
let { data } = $props();
let loading = $state(true);
let pagination = $state({
page: data.trails.page,
totalPages: data.trails.totalPages,
});
let trails = $state(data.trails);
let filter: TrailFilter = $state(data.filter);
async function handleFilterUpdate() {
loading = true;
try {
trails = await profile_trails_index(
page.params.handle,
filter,
pagination.page,
12,
fetch,
);
} catch (e) {
show_toast({
icon: "close",
text: "Error loading trails.",
type: "error",
});
} finally {
loading = false;
}
}
async function paginate(newPage: number) {
pagination.page = newPage;
try {
trails = await profile_trails_index(
page.params.handle,
filter,
newPage,
12,
fetch,
);
} catch (e) {
show_toast({
icon: "close",
text: "Error loading trails.",
type: "error",
});
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>{$_("profile")} | wanderer</title>
</svelte:head>
<TrailList
{pagination}
{loading}
fullWidthCards={true}
trails={trails.items}
{filter}
onupdate={handleFilterUpdate}
onpagination={paginate}
></TrailList>

View File

@@ -1,16 +1,22 @@
import type { TrailFilter } from "$lib/models/trail";
import { trails_index, trails_search_filter } from "$lib/stores/trail_store";
import { type Load } from "@sveltejs/kit";
import { profile_trails_index } from "$lib/stores/profile_store";
import { error, type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch, parent }) => {
if (!params.handle) {
error(404, "Not found")
}
const { actor } = await parent()
export const load: Load = async ({ params, fetch }) => {
const filter: TrailFilter = {
q: "",
category: [],
tags: [],
difficulty: ["easy", "moderate", "difficult"],
author: params.id,
author: actor.id,
public: true,
shared: true,
liked: false,
private: true,
near: {
radius: 2000,
@@ -27,6 +33,10 @@ export const load: Load = async ({ params, fetch }) => {
sort: "created",
sortOrder: "+",
};
const trails = await trails_search_filter(filter, 1, fetch)
return { trails, filter }
try {
const trails = await profile_trails_index(params.handle, filter, 1, 12, fetch)
return { trails, filter }
} catch (e) {
return { trails: { hits: {}, items: [], page: 1, totalPages: 0 }, filter }
}
};

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { page } from "$app/state";
import { follows_index } from "$lib/stores/follow_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import { APIError } from "$lib/util/api_util.js";
import { _ } from "svelte-i18n";
let { data } = $props();
let follows = $state(data.follows);
$effect(() => {
page.params.type;
follows = data.follows;
});
let loading: boolean = $state(false);
let pagination = $derived({
page: data.follows.page,
totalPages: data.follows.totalPages,
});
async function onListScroll(e: Event) {
if (
window.innerHeight + window.scrollY >=
0.8 * document.body.offsetHeight &&
pagination.page !== pagination.totalPages &&
!loading
) {
loading = true;
await loadNextPage();
loading = false;
}
}
async function loadNextPage() {
pagination.page += 1;
try {
follows = await follows_index(
{
type: page.params.type as "followers" | "following",
username: page.params.handle!,
},
pagination.page,
10,
fetch,
);
} catch (e) {
if (e instanceof APIError) {
show_toast({
icon: "close",
text: `${e.status}: ${e.message}`,
type: "error"
})
}
}
}
</script>
<svelte:window onscroll={onListScroll} />
<div>
<h1 class="text-3xl font-semibold mb-4">{$_(page.params.type)}</h1>
<ul class="space-y-4">
{#each follows.items as follow}
<a
href="/profile/@{follow.username}{follow.isLocal
? ''
: '@' + follow.domain}"
>
<li
class="flex items-center gap-x-4 hover:bg-menu-item-background-hover p-2"
>
<img
class="rounded-full w-10 aspect-square overflow-hidden"
src={follow.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${follow.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div>
<p class="text-lg font-medium">
{follow.preferred_username}
</p>
<p class="text-sm text-gray-500 break-all">
@{follow.username}@{follow.domain}
</p>
</div>
</li>
</a>
{/each}
</ul>
{#if loading}
<div class="flex items-center justify-center mt-6">
<div class="aspect-square spinner"></div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,22 @@
import { follows_index } from "$lib/stores/follow_store";
import { APIError } from "$lib/util/api_util";
import { error, type Load } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
export const load: Load = async ({ params, fetch }) => {
if (!params.type || (params.type !== "followers" && params.type !== "following")) {
throw error(400, "Missing or wrong type");
}
try {
const follows = await follows_index({ type: params.type, username: params.handle! },
1, 10, fetch)
return { follows }
} catch (e) {
if (e instanceof APIError) {
return error(e.status, e.message)
}
throw e
}
};

View File

@@ -1,34 +0,0 @@
import { follows_a_b, follows_counts } from "$lib/stores/follow_store";
import { users_show } from "$lib/stores/user_store";
import { APIError } from "$lib/util/api_util";
import { error, type NumericRange, type ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ params, locals, fetch }) => {
if (!params.id) {
error(404, "Not found")
}
try {
const user = await users_show(params.id, fetch);
if(user.private && locals.user.id != params.id) {
error(401, {
message: "Profile is private"
})
}
const isOwnProfile = params.id === locals.user?.id;
const follow = await follows_a_b(locals.user?.id, params.id, fetch) ?? null
const followCounts = await follows_counts(params.id, fetch)
return { user, isOwnProfile, ...followCounts, follow: follow }
} catch (e) {
if (e instanceof APIError) {
error(e.status as NumericRange<400, 599>, {
message: e.status == 404 ? 'Not found' : e.message
});
} else {
throw e
}
}
};

View File

@@ -1,10 +0,0 @@
import { activities_index } from "$lib/stores/activity_store";
import { lists_index } from "$lib/stores/list_store";
import { type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch }) => {
const lists = await lists_index({ q: "", author: params.id, sort: "created", sortOrder: "-" }, 1, -1, fetch)
const activities = await activities_index(params.id!, 1, 10, fetch);
return { lists: lists.items, activities: activities }
};

View File

@@ -1,44 +0,0 @@
<script lang="ts">
import TrailList from "$lib/components/trail/trail_list.svelte";
import type { Trail, TrailFilter } from "$lib/models/trail.js";
import { trails_search_filter } from "$lib/stores/trail_store";
import { _ } from "svelte-i18n";
let { data } = $props();
let loading = $state(true);
let pagination = $state({
page: data.trails.page,
totalPages: data.trails.totalPages,
});
let trails = $state(data.trails);
let filter: TrailFilter = $state(data.filter);
async function handleFilterUpdate() {
loading = true;
trails = await trails_search_filter(filter, pagination.page);
loading = false;
}
async function paginate(page: number) {
pagination.page = page;
trails = await trails_search_filter(filter, page);
}
</script>
<svelte:head>
<title>{$_("profile")} | wanderer</title>
</svelte:head>
<TrailList
{pagination}
{loading}
fullWidthCards={true}
trails={trails.items}
{filter}
onupdate={handleFilterUpdate}
onpagination={paginate}
></TrailList>

View File

@@ -1,89 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import { follows_index } from "$lib/stores/follow_store.js";
import { getFileURL } from "$lib/util/file_util.js";
import { _ } from "svelte-i18n";
let { data } = $props();
let follows = $state(data.follows);
let loading: boolean = false;
let key = $derived(
(page.params.type == "following" ? "followee" : "follower") as
| "follower"
| "followee",
);
let pagination = $derived({
page: data.follows.page,
totalPages: data.follows.totalPages,
});
async function onListScroll(e: Event) {
if (
window.innerHeight + window.scrollY >=
0.8 * document.body.offsetHeight &&
pagination.page !== pagination.totalPages &&
!loading
) {
loading = true;
await loadNextPage();
loading = false;
}
}
async function loadNextPage() {
pagination.page += 1;
follows = await follows_index(
{ followee: page.params.id },
pagination.page,
);
}
</script>
<svelte:window onscroll={onListScroll} />
<div>
<h1 class="text-3xl font-semibold mb-4">{$_(page.params.type)}</h1>
<ul class="space-y-4">
{#each follows.items as follow}
{#if !follow.expand?.[key].private}
<a href="/profile/{follow.expand?.[key].id}">
<li
class="flex items-center gap-x-4 hover:bg-menu-item-background-hover p-4"
>
<img
class="rounded-full w-10 aspect-square overflow-hidden"
src={getFileURL(
follow.expand?.[key] ?? {},
follow.expand?.[key].avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${data.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p class="text-lg font-medium">
{follow.expand?.[key].username}
</p>
</li>
</a>
{:else}
<li
class="flex items-center gap-x-4 hover:bg-menu-item-background-hover p-4"
>
<img
class="rounded-full w-10 aspect-square overflow-hidden"
src={getFileURL(
follow.expand?.[key] ?? {},
follow.expand?.[key].avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${data.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p class="text-lg font-medium">
{follow.expand?.[key].username}
</p>
</li>
{/if}
{/each}
</ul>
</div>

View File

@@ -1,11 +0,0 @@
import { follows_index } from "$lib/stores/follow_store";
import { error, type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch }) => {
if (!params.type || (params.type !== "followers" && params.type !== "following")) {
throw error(400, "Missing or wrong type");
}
const follows = await follows_index({ [params.type == "followers" ? "followee" : "follower"]: params.id }, 1, 10, fetch)
return { follows }
};

View File

@@ -25,7 +25,7 @@
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
{
text: $_("help"),
value: "https://wanderer.to/getting-started/installation",
value: "https://wanderer.to/run/installation",
},
];

View File

@@ -107,7 +107,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`;
}
trailFolder?.file(

Some files were not shown because too many files have changed in this diff Show More