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:
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
55
web/src/routes/.well-known/webfinger/+server.ts
Normal file
55
web/src/routes/.well-known/webfinger/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
32
web/src/routes/api/v1/activitypub/activity/[id]/+server.ts
Normal file
32
web/src/routes/api/v1/activitypub/activity/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
28
web/src/routes/api/v1/activitypub/comment/[id]/+server.ts
Normal file
28
web/src/routes/api/v1/activitypub/comment/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
28
web/src/routes/api/v1/activitypub/trail/[id]/+server.ts
Normal file
28
web/src/routes/api/v1/activitypub/trail/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
65
web/src/routes/api/v1/activitypub/user/[handle]/+server.ts
Normal file
65
web/src/routes/api/v1/activitypub/user/[handle]/+server.ts
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ export async function GET(event: RequestEvent) {
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
12
web/src/routes/api/v1/list/form/+server.ts
Normal file
12
web/src/routes/api/v1/list/form/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
12
web/src/routes/api/v1/list/form/[id]/+server.ts
Normal file
12
web/src/routes/api/v1/list/form/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
35
web/src/routes/api/v1/profile/[handle]/+server.ts
Normal file
35
web/src/routes/api/v1/profile/[handle]/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
43
web/src/routes/api/v1/profile/[handle]/lists/+server.ts
Normal file
43
web/src/routes/api/v1/profile/[handle]/lists/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
49
web/src/routes/api/v1/profile/[handle]/stats/+server.ts
Normal file
49
web/src/routes/api/v1/profile/[handle]/stats/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
48
web/src/routes/api/v1/profile/[handle]/timeline/+server.ts
Normal file
48
web/src/routes/api/v1/profile/[handle]/timeline/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
43
web/src/routes/api/v1/profile/[handle]/trails/+server.ts
Normal file
43
web/src/routes/api/v1/profile/[handle]/trails/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
46
web/src/routes/api/v1/search/actor/+server.ts
Normal file
46
web/src/routes/api/v1/search/actor/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
web/src/routes/api/v1/summit-log/form/+server.ts
Normal file
14
web/src/routes/api/v1/summit-log/form/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
13
web/src/routes/api/v1/summit-log/form/[id]/+server.ts
Normal file
13
web/src/routes/api/v1/summit-log/form/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
23
web/src/routes/api/v1/trail-like/+server.ts
Normal file
23
web/src/routes/api/v1/trail-like/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
21
web/src/routes/api/v1/trail-like/[id]/+server.ts
Normal file
21
web/src/routes/api/v1/trail-like/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
21
web/src/routes/api/v1/trail-like/delete/+server.ts
Normal file
21
web/src/routes/api/v1/trail-like/delete/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@ export async function POST(event: RequestEvent) {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
20
web/src/routes/api/v1/trail/form/+server.ts
Normal file
20
web/src/routes/api/v1/trail/form/+server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
web/src/routes/api/v1/trail/form/[id]/+server.ts
Normal file
21
web/src/routes/api/v1/trail/form/[id]/+server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function PUT(event: RequestEvent) {
|
||||
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const load: ServerLoad = async ({ params, locals, fetch }) => {
|
||||
author: "",
|
||||
public: true,
|
||||
shared: true,
|
||||
liked: false,
|
||||
private: true,
|
||||
near: {
|
||||
radius: 2000,
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
@@ -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>
|
||||
@@ -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>, {
|
||||
29
web/src/routes/profile/[handle]/+error.svelte
Normal file
29
web/src/routes/profile/[handle]/+error.svelte
Normal 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>
|
||||
29
web/src/routes/profile/[handle]/+layout.server.ts
Normal file
29
web/src/routes/profile/[handle]/+layout.server.ts
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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()}
|
||||
@@ -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>
|
||||
29
web/src/routes/profile/[handle]/+page.ts
Normal file
29
web/src/routes/profile/[handle]/+page.ts
Normal 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}}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
};
|
||||
77
web/src/routes/profile/[handle]/trails/+page.svelte
Normal file
77
web/src/routes/profile/[handle]/trails/+page.svelte
Normal 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>
|
||||
@@ -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 }
|
||||
}
|
||||
};
|
||||
97
web/src/routes/profile/[handle]/users/[type]/+page.svelte
Normal file
97
web/src/routes/profile/[handle]/users/[type]/+page.svelte
Normal 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>
|
||||
22
web/src/routes/profile/[handle]/users/[type]/+page.ts
Normal file
22
web/src/routes/profile/[handle]/users/[type]/+page.ts
Normal 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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 }
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user