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:
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
|
||||
}
|
||||
}
|
||||
};
|
||||
170
web/src/routes/profile/[handle]/+layout.svelte
Normal file
170
web/src/routes/profile/[handle]/+layout.svelte
Normal 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>
|
||||
159
web/src/routes/profile/[handle]/+page.svelte
Normal file
159
web/src/routes/profile/[handle]/+page.svelte
Normal 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>
|
||||
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}}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
416
web/src/routes/profile/[handle]/stats/+page.svelte
Normal file
416
web/src/routes/profile/[handle]/stats/+page.svelte
Normal 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>
|
||||
33
web/src/routes/profile/[handle]/stats/+page.ts
Normal file
33
web/src/routes/profile/[handle]/stats/+page.ts
Normal 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 }
|
||||
}
|
||||
};
|
||||
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>
|
||||
42
web/src/routes/profile/[handle]/trails/+page.ts
Normal file
42
web/src/routes/profile/[handle]/trails/+page.ts
Normal 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 }
|
||||
}
|
||||
};
|
||||
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
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user