Federation (#327)

* initial commit federation

* more federation

* more federation

* more federation

* completes follow, accept, undo

* add trail create activity

* process trail create activity

* more trail create activity

* adds missing endpoints

* adds update and delete activity

* adds comment activities

* adds activities back

* adds summit logs activities

* deletes follow counts table

* fixes migrations

* fixes migrations

* fixes migrations

* fixes migrations

* adds remote profiles

* ctd

* ctd

* we are getting closer...

* adds public summit logs

* fixes federated trails in lists

* adds remote lists

* adds list activites

* fixes lists

* adds notifications

* adds iri redirect

* fixes comments and summitlogs

* fixes follows and profiles

* adds asynchronous send

* fixes list search

* adds encryption key

* bug fixes

* fixes activity signing

* removes custon activity object types

* adds html editor

* fixes html editor

* fixes display issues on mastodon

* adds federated sharing

* finishes announcements

* fixes small summit log issues

* adds trail likes

* finalizes likes

* fixes images for komoot

* add disable federation option

* adds private profiles

* updates docs

* adds federated comments

* adds trail and comments actvitiypub routes

* adds mentions to editor

* adds mentions to trails, comments, summit logs

* updates theme

* updates theme

* update docs

* update docs

* updates docs

* fixes various frontend problems

* fixes activitypub follows api

* updates docs

* updates docs

---------

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

View File

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

View File

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

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte";
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
import ProfileShareModal from "$lib/components/profile/profile_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 { _ } 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();
let profileShareModal: ProfileShareModal;
let followLoading: boolean = $state(false);
const profileLinks: DropdownItem[] = $derived([
{
text: $_("profile"),
value: `/profile/${page.params.handle}`,
icon: "user",
},
{
text: $_("trail", { values: { n: 2 } }),
value: `/profile/${page.params.handle}/trails`,
icon: "route",
},
{
text: $_("statistics"),
value: `/profile/${page.params.handle}/stats`,
icon: "chart-pie",
},
]);
let activeIndex = $derived(
profileLinks.findIndex((l) => l.value === page.url.pathname),
);
async function follow() {
if (!$currentUser) {
return;
}
followLoading = true;
if (data.follow) {
await follows_delete(data.follow);
} else {
await follows_create(data.profile.id);
}
await invalidateAll();
followLoading = false;
}
</script>
<div
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.profile}
<div class="flex items-center gap-x-6 px-6 mt-6">
<img
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.profile.preferredUsername ??
data.profile.username}
</h4>
<p class="text-sm text-gray-500 mb-4 break-all">
{data.profile.acct}
</p>
</div>
</div>
{/if}
<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.profile.acct}/users/followers"
>
<p class="font-semibold">{data.profile.followers}</p>
<p>{$_("followers")}</p>
</a>
<a
class:font-bold={page.url.pathname.endsWith("following")}
href="/profile/{data.profile.acct}/users/following"
>
<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>
<hr class="mt-4 border-input-border" />
<div class="mt-6 mb-4">
{#each profileLinks as link, i}
<a
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
>
{/each}
</div>
{#if data.isOwnProfile}
<div class="px-4 mb-4 flex flex-col gap-2">
<button
class="btn-secondary basis-full"
onclick={() => profileShareModal.openModal()}
>{$_("share-profile")}</button
>
<a
class="btn-secondary text-center basis-full"
href="/settings/profile">{$_("settings")}</a
>
</div>
<ProfileShareModal bind:this={profileShareModal}
></ProfileShareModal>
{/if}
</div>
<div class="md:mr-6">
{@render children?.()}
</div>
</div>

View File

@@ -0,0 +1,159 @@
<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 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 timeline = $derived(data.timeline);
let loading: boolean = false;
let pagination = $derived({
page: 1,
totalPages: data.timeline.totalItems,
});
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;
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>
<svelte:window onscroll={onListScroll} />
<svelte:head>
<title>{$_("profile")} | wanderer</title>
</svelte:head>
<div class="space-y-6">
<div class="space-y-4">
<h4 class="text-xl font-semibold">
{$_("about")}
{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.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.profile.preferredUsername },
})}
</p>
{/if}
</div>
<div class="space-y-4">
<h4 class="text-xl font-semibold">
{$_("list", { values: { n: 2 } })}
</h4>
<div class="flex gap-x-4 overflow-x-scroll">
{#if !data.lists.length && data.isOwnProfile}
<a class="btn-primary inline-block" href="/lists/edit/new"
>+ {$_("new-list")}</a
>
{:else if !data.lists.length}
<p class="w-full text-center text-gray-500 text-sm">
{$_("empty-lists", {
values: { username: data.profile.preferredUsername },
})}
</p>
{/if}
{#each data.lists as list}
<a
href="/lists/{page.params.handle}/{list.id}"
class="relative w-64 h-48 rounded-xl overflow-hidden group shrink-0"
>
<img
class="w-full h-full object-cover transition-transform group-hover:scale-110"
src={list.avatar
? getFileURL(list, list.avatar)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark}
alt="avatar"
/>
<div
class="absolute bottom-0 w-full h-2/3 bg-gradient-to-b from-transparent to-black opacity-50"
></div>
<h5
class="absolute bottom-4 left-4 font-semibold text-white"
>
{list.name}
</h5>
</a>
{/each}
</div>
</div>
<div class="space-y-4">
<h4 class="text-xl font-semibold">Timeline</h4>
{#if !timeline.items?.length && data.isOwnProfile}
<a class="btn-primary inline-block" href="/trails/edit/new"
>+ {$_("new-trail")}</a
>
{:else if !timeline.items?.length}
<p class="w-full text-center text-gray-500 text-sm">
{$_("empty-activities", {
values: { username: data.profile.preferredUsername },
})}
</p>
{/if}
{#each timeline.items as item}
<div
class="py-1 cursor-pointer"
role="presentation"
onclick={() => handleTimeLineItemClick(item)}
>
<ActivityCard activity={item} actor={data.actor}></ActivityCard>
</div>
{/each}
</div>
</div>

View File

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

View File

@@ -0,0 +1,416 @@
<script lang="ts">
import { page } from "$app/state";
import Calendar from "$lib/components/base/calendar.svelte";
import Datepicker from "$lib/components/base/datepicker.svelte";
import MultiSelect from "$lib/components/base/multi_select.svelte";
import Select, {
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 { profile_stats_index } from "$lib/stores/profile_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import {
formatDistance,
formatElevation,
formatSpeed,
formatTimeHHMM,
} from "$lib/util/format_util";
import Bar from "$lib/vendor/svelte-chartjs/bar.svelte";
import Pie from "$lib/vendor/svelte-chartjs/pie.svelte";
import {
ArcElement,
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
Title,
Tooltip,
} from "chart.js";
import { _ } from "svelte-i18n";
let { data } = $props();
ChartJS.register(
Title,
Tooltip,
Legend,
ArcElement,
CategoryScale,
LinearScale,
BarElement,
);
let summitLogs: SummitLog[] = $state(data.logs);
const filter = $state(data.filter);
const categorySelectItems: SelectItem[] = $categories.map((c) => ({
value: c.id,
text: c.name,
}));
const barChartSelectItems: SelectItem[] = [
{
text: $_("distance"),
value: "distance",
},
{
text: $_("duration"),
value: "duration",
},
{
text: $_("elevation-gain"),
value: "elevation_gain",
},
{
text: $_("elevation-loss"),
value: "elevation_loss",
},
];
let barChartSelectedValue = $state(barChartSelectItems[0].value);
const categoryColors = [
"#fb8500",
"#ffb703",
"#06d6a0",
"#219ebc",
"#8ecae6",
"#ffafcc",
];
const conversionFactors = {
distance: 0.621371,
elevation_gain: 3.28084,
elevation_loss: 3.28084,
};
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 categoryChartData = $derived({
labels: categoryLabels,
datasets: [
{
data: categoryValues,
backgroundColor: categoryColors,
},
],
});
function barChartUnit() {
const unit = page.data.settings?.unit ?? "metric";
switch (barChartSelectedValue) {
case "duration":
return "h";
case "elevation_gain":
case "elevation_loss":
return unit == "metric" ? "m" : "ft";
case "distance":
return unit == "metric" ? "km" : "mi";
}
}
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") {
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));
let barChartData = $derived({
labels: barChartLabels,
datasets: [
{
label: barChartSelectItems.find(
(i) => i.value === barChartSelectedValue,
)?.text,
data: barChartValues,
backgroundColor: "#3388ff",
borderRadius: 15,
},
],
});
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 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 averageSpeed = $derived(
totalDuration > 0
? summitLogs.reduce(
(sum, log) =>
sum + (log.distance && log.duration ? log.distance : 0),
0,
) / totalDuration
: undefined,
);
function updateFilterCategory(categories: SelectItem[]) {
filter.category = categories.map((c) => c.value);
loadSummitLogs();
}
function handleDateClick(date: Date) {
const datePlusN = date;
datePlusN.setDate(date.getDate() + 1);
filter.startDate = datePlusN.toISOString().slice(0, 10);
datePlusN.setDate(date.getDate() + 1);
filter.endDate = datePlusN.toISOString().slice(0, 10);
loadSummitLogs();
}
async function loadSummitLogs() {
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>
<svelte:head>
<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="flex flex-wrap lg:flex-nowrap col-start-1 lg:col-start-2 gap-x-4 justify-end"
>
<MultiSelect
onchange={updateFilterCategory}
label={$_("categories")}
items={categorySelectItems}
placeholder={`${$_("filter-categories")}...`}
></MultiSelect>
<Datepicker
onchange={loadSummitLogs}
bind:value={filter.startDate}
label={$_("after")}
></Datepicker>
<Datepicker
onchange={loadSummitLogs}
bind:value={filter.endDate}
label={$_("before")}
></Datepicker>
</div>
<div class="space-y-4 grow-0 lg:mr-4">
<div class="border border-input-border rounded-xl p-6">
<Calendar
onclick={handleDateClick}
logs={summitLogs}
colorMap={categoryColorMap}
></Calendar>
</div>
<div class="border border-input-border rounded-xl p-6 space-y-4">
<span class="text-gray-500 font-semibold text-lg"
><i class="fa fa-person-hiking mr-3"></i>{$_(
"categories",
)}</span
>
<Pie
data={categoryChartData}
options={{
responsive: true,
plugins: {
legend: {
position: "bottom",
},
},
}}
/>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
>
<span class="text-gray-500 font-semibold text-lg self-start"
><i class="fa fa-hashtag mr-3"></i>{$_("activity", {
values: { n: 2 },
})}</span
>
<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"
>
<span class="text-gray-500 font-semibold text-lg self-start"
><i class="fa fa-left-right mr-3"></i>{$_("distance")}</span
>
<p class="text-3xl font-bold">{formatDistance(totalDistance)}</p>
</div>
<div
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
>
<span class="text-gray-500 font-semibold text-lg self-start"
><i class="fa fa-clock mr-3"></i>{$_("duration")}</span
>
<p class="text-3xl font-bold">
{formatTimeHHMM(totalDuration / 60)}
</p>
</div>
<div
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
>
<span class="text-gray-500 font-semibold text-lg self-start"
><i class="fa fa-arrow-trend-down mr-3"></i>{$_(
"average-speed",
)}</span
>
<p class="text-3xl font-bold">{formatSpeed(averageSpeed)}</p>
</div>
<div
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
>
<span class="text-gray-500 font-semibold text-lg self-start"
><i class="fa fa-arrow-trend-up mr-3"></i>{$_(
"elevation-gain",
)}</span
>
<p class="text-3xl font-bold">
{formatElevation(totalElevationGain)}
</p>
</div>
<div
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
>
<span class="text-gray-500 font-semibold text-lg self-start"
><i class="fa fa-arrow-trend-down mr-3"></i>{$_(
"elevation-loss",
)}</span
>
<p class="text-3xl font-bold">
{formatElevation(totalElevationLoss)}
</p>
</div>
<div
class="h-full lg:col-span-2 space-y-2 border border-input-border rounded-xl p-6"
>
<div class="flex justify-between">
<span class="text-gray-500 font-semibold text-lg"
><i class="fa fa-calendar mr-3"></i>{$_("activity", {
values: { n: 1 },
})}</span
>
<Select
bind:value={barChartSelectedValue}
items={barChartSelectItems}
></Select>
</div>
<Bar
data={barChartData}
options={{
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: (item) =>
`${item.dataset.label}: ${item.formattedValue} ${barChartUnit()}`,
},
},
},
scales: {
y: {
ticks: {
callback: function (value, index, ticks) {
return value + " " + barChartUnit();
},
},
},
},
}}
></Bar>
</div>
</div>
<div
class="col-span-1 lg:col-span-2 border border-input-border rounded-xl p-6 space-y-6"
>
<span class="text-gray-500 font-semibold text-lg"
><i class="fa fa-table mr-3"></i>{$_("all-activities")}</span
>
<div class=" overflow-x-auto">
<SummitLogTable
summitLogs={data.logs}
handle={page.params.handle}
showCategory
showTrail
showRoute
></SummitLogTable>
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
import type { SummitLogFilter } from "$lib/models/summit_log";
import { categories_index } from "$lib/stores/category_store";
import { profile_stats_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 date = new Date()
date.setUTCHours(6)
const y = date.getFullYear()
const m = date.getMonth();
const firstDay = new Date(y, m, 2);
const lastDay = new Date(y, m + 1, 1);
const categories = await categories_index(fetch)
const filter: SummitLogFilter = {
startDate: firstDay.toISOString().slice(0, 10),
endDate: lastDay.toISOString().slice(0, 10),
category: []
}
try {
const logs = await profile_stats_index(params.handle, filter, fetch);
return { filter, logs }
} catch (e) {
return { logs: [], filter }
}
};

View File

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

View File

@@ -0,0 +1,42 @@
import type { TrailFilter } from "$lib/models/trail";
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()
const filter: TrailFilter = {
q: "",
category: [],
tags: [],
difficulty: ["easy", "moderate", "difficult"],
author: actor.id,
public: true,
shared: true,
liked: false,
private: true,
near: {
radius: 2000,
},
distanceMin: 0,
distanceMax: 0,
distanceLimit: 0,
elevationGainMin: 0,
elevationGainMax: 0,
elevationGainLimit: 0,
elevationLossMin: 0,
elevationLossMax: 0,
elevationLossLimit: 0,
sort: "created",
sortOrder: "+",
};
try {
const trails = await profile_trails_index(params.handle, filter, 1, 12, fetch)
return { trails, filter }
} catch (e) {
return { trails: { hits: {}, items: [], page: 1, totalPages: 0 }, filter }
}
};

View File

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

View File

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