adds activity timeline

This commit is contained in:
Christian Beutel
2024-12-14 17:42:00 +01:00
parent 85729e9728
commit e315c4d58a
19 changed files with 1452 additions and 15 deletions

View File

@@ -18,7 +18,7 @@
});
onMount(() => {
switchTabs(0);
switchTabs(activeTab ?? 0);
});
function switchTabs(index: number) {
@@ -26,7 +26,7 @@
const childElement = tabs?.children[index + 1] as HTMLElement;
const newWidth = childElement?.getBoundingClientRect().width ?? 0;
const newPosition = childElement.offsetLeft;
const newPosition = childElement?.offsetLeft;
indicatorWidth.set(newWidth);
indicatorPosition.set(newPosition);

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import type { Activity } from "$lib/models/activity";
import type { UserAnonymous } from "$lib/models/user";
import { getFileURL } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
export let activity: Activity;
export let user: UserAnonymous;
let fullDescription: boolean = false;
</script>
<div class="activity-card p-6 space-y-6 shadow-md rounded-xl">
<div class="flex gap-x-4 items-start">
<img
class="rounded-full w-10 aspect-square overflow-hidden"
src={getFileURL(user, user.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div>
<a class="underline" href="/profile/{user.id}"> {user.username} </a>
{activity.type === "trail" ? "planned" : "completed"}
a trail
<p class="text-xs text-gray-500 mb-3">
{new Date(activity.date).toLocaleDateString(undefined, {
month: "long",
day: "2-digit",
year: "numeric",
timeZone: "UTC",
})}
</p>
</div>
</div>
<h3 class="text-2xl font-semibold !mt-2">{activity.name}</h3>
<div class="flex flex-wrap mt-1 gap-x-4 gap-y-2 text-sm text-gray-500">
<span
><i class="fa fa-left-right mr-2"></i>{formatDistance(
activity.distance,
)}</span
>
<span
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
activity.duration,
)}</span
>
<span
><i class="fa fa-arrow-trend-up mr-2"></i>{formatElevation(
activity.elevation_gain,
)}</span
>
<span
><i class="fa fa-arrow-trend-down mr-2"></i>{formatElevation(
activity.elevation_loss,
)}</span
>
</div>
{#if activity.photos.length}
<div
class="grid {activity.photos.length > 1
? 'grid-cols-[8fr_5fr]'
: 'grid-cols-1'}"
>
{#each activity.photos as photo, i}
<img
class="object-cover h-full max-h-80 w-full"
class:row-span-2={i == 0 && activity.photos.length > 2}
src={getFileURL(
{ collectionId: activity.type + "s", id: activity.id },
photo,
)}
alt=""
/>
{/each}
</div>
{/if}
{#if activity.description.length}
<p class="text-sm whitespace-pre-wrap">
{!fullDescription
? activity.description?.substring(0, 100)
: activity.description}
{#if (activity.description?.length ?? 0) > 100 && !fullDescription}
<button on:click={() => (fullDescription = true)}>
... <span class="underline">{$_("read-more")}</span></button
>
{/if}
</p>
{/if}
</div>

View File

@@ -37,6 +37,7 @@
export let trail: Trail;
export let mode: "overview" | "map" | "list" = "map";
export let markers: M.Marker[] = [];
export let activeTab = 0;
const tabs = [
$_("description"),
@@ -49,7 +50,6 @@
const trailIsShared =
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
let activeTab = 0;
let thumbnail = trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail])

View File

@@ -38,7 +38,7 @@
</script>
<div
class="table-container w-full border border-input-border rounded-xl overflow-x-scroll"
class="table-container w-full border border-input-border rounded-xl overflow-x-scroll overflow-y-clip"
>
<table class="w-full">
<thead>

View File

@@ -0,0 +1,17 @@
interface Activity {
id: string;
trail_id: string;
name: string,
description: string;
date: string;
gpx: string;
photos: string[];
distance: number;
duration: number;
elevation_gain: number;
elevation_loss: number;
type: "trail" | "summit_log"
author: string;
}
export { type Activity }

View File

@@ -0,0 +1,27 @@
import type { Activity } from "$lib/models/activity";
import { ClientResponseError, type ListResult } from "pocketbase";
let activities: Activity[] = [];
export async function activities_index(author: string, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/activity?' + new URLSearchParams({
"per-page": perPage.toString(),
page: page.toString(),
filter: `author="${author}"`
}), {
method: 'GET',
})
if (!r.ok) {
throw new ClientResponseError(await r.json())
}
const fetchedActivities: ListResult<Activity> = await r.json();
const result = page > 1 ? [...activities, ...fetchedActivities.items] : fetchedActivities.items
activities = result;
return { ...fetchedActivities, items: result };
}

View File

@@ -0,0 +1,18 @@
import type { Activity } from "$lib/models/activity";
import { pb } from "$lib/pocketbase";
import { error, json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
const page = event.url.searchParams.get("page") ?? "0";
const perPage = event.url.searchParams.get("per-page") ?? "10";
const filter = event.url.searchParams.get("filter") ?? "";
try {
const r = await pb.collection('activities')
.getList<Activity>(parseInt(page), parseInt(perPage), { filter: filter ?? "" })
return json(r)
} catch (e: any) {
throw error(e.status, e);
}
}

View File

@@ -143,7 +143,7 @@
mapWithElevation.unHighlightTrail(trail.id!);
}
async function onListScroll(e: any) {
async function onListScroll(e: Event) {
const container = e.target as HTMLDivElement;
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;

View File

@@ -1,15 +1,26 @@
import { users_show } from "$lib/stores/user_store";
import { error, type ServerLoad } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
export const load: ServerLoad = async ({ params, locals, fetch }) => {
if(!params.id) {
if (!params.id) {
error(404, "Not found")
}
const user = await users_show(params.id, fetch);
try {
const user = await users_show(params.id, fetch);
const isOwnProfile = params.id === locals.user?.id;
const isOwnProfile = params.id === locals.user?.id;
return { user, isOwnProfile }
} catch (e) {
if (e instanceof ClientResponseError && e.status == 404) {
error(404, {
message: 'Not found'
});
}
throw e
}
return { user, isOwnProfile }
};

View File

@@ -1,9 +1,43 @@
<script lang="ts">
import ActivityCard from "$lib/components/profile/activity_card.svelte";
import { activities_index } from "$lib/stores/activity_store.js";
import { getFileURL } from "$lib/util/file_util.js";
import { _ } from "svelte-i18n";
export let data;
let loading: boolean = false;
$: pagination = {
page: data.activities.page,
totalPages: data.activities.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;
data.activities = await activities_index(data.user.id, pagination.page);
}
</script>
<svelte:window on:scroll={onListScroll} />
<svelte:head>
<title>{$_("profile")} | wanderer</title>
</svelte:head>
<div class="mx-8 space-y-6">
<div class="space-y-4">
<h4 class="text-xl font-semibold">
@@ -58,7 +92,18 @@
{/each}
</div>
</div>
<div>
<div class="space-y-4">
<h4 class="text-xl font-semibold">Timeline</h4>
{#each data.activities.items as activity}
<a
class="block"
href={activity.type == "trail"
? `/trail/view/${activity.id}`
: `/trail/view/${activity.trail_id}?t=3`}
>
<ActivityCard {activity} user={data.user}></ActivityCard>
</a>
{/each}
</div>
</div>

View File

@@ -1,8 +1,10 @@
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)
return { lists: lists.items }
const activities = await activities_index(params.id!, 1, 10, fetch);
return { lists: lists.items, activities: activities }
};

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import TrailList from "$lib/components/trail/trail_list.svelte";
import { trails_search_filter } from "$lib/stores/trail_store";
import { _ } from "svelte-i18n";
export let data;
@@ -23,7 +24,9 @@
data.trails = await trails_search_filter(data.filter, page);
}
</script>
<svelte:head>
<title>{$_("profile")} | wanderer</title>
</svelte:head>
<TrailList
{pagination}
{loading}

View File

@@ -7,8 +7,8 @@
type SelectItem,
} from "$lib/components/base/select.svelte";
import ConfirmModal from "$lib/components/confirm_modal.svelte";
import EmailModal from "$lib/components/profile/email_modal.svelte";
import PasswordModal from "$lib/components/profile/password_modal.svelte";
import EmailModal from "$lib/components/settings/email_modal.svelte";
import PasswordModal from "$lib/components/settings/password_modal.svelte";
import { show_toast } from "$lib/stores/toast_store";
import {
currentUser,

View File

@@ -2,11 +2,16 @@
import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte";
import { trail } from "$lib/stores/trail_store";
import { _ } from "svelte-i18n";
import { page } from "$app/stores";
export let data;
</script>
<TrailInfoPanel trail={data.trail} mode="overview"></TrailInfoPanel>
<TrailInfoPanel
activeTab={parseInt($page.url.searchParams.get("t") ?? 0)}
trail={data.trail}
mode="overview"
></TrailInfoPanel>
<svelte:head>
<title>{$trail.name} | {$_("trail", { values: { n: 1 } })} | wanderer</title