From addef8b78ea4084e69d646919e2ce028a8fb2f71 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Wed, 22 Jul 2026 11:19:09 +0200 Subject: [PATCH] public home page with community activity overview --- README.md | 3 + src/hooks.server.ts | 10 +- src/lib/components/ActivityItem.svelte | 9 ++ src/lib/server/db.ts | 40 ++++++- src/routes/+layout.svelte | 9 ++ src/routes/+page.server.ts | 36 +++---- src/routes/+page.svelte | 119 ++++++++++++--------- src/routes/activities/+page.server.ts | 11 +- src/routes/activities/+page.svelte | 18 +++- src/routes/activities/[id]/+page.server.ts | 8 +- src/routes/activities/[id]/+page.svelte | 28 +++-- 11 files changed, 198 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 0ed7487..8c3725f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ The GPX analyser, reborn. A self-hosted web app for analysing GPX tracks and bagging Alpine peaks. - **Accounts** - anyone can sign up; activities and climbed peaks are per user. + The home page is a public community overview: every member's tracks on one + worldmap plus a recent-activity feed. Uploading and peak bagging need an + account. - **Upload** GPX files (drag & drop, multiple at once) - distance, ascent, moving time and an elevation profile are computed on the spot. - **Activities** - every track on a map with stats and an interactive diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 4e97c81..435eb99 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,17 +1,21 @@ import { error, redirect, type Handle } from '@sveltejs/kit'; import { validateSession } from '$lib/server/auth'; -const PUBLIC_PATHS = new Set(['/login', '/signup']); +const AUTH_PATHS = new Set(['/login', '/signup']); + +function isPublic(path: string): boolean { + return path === '/' || AUTH_PATHS.has(path) || /^\/activities\/\d+$/.test(path); +} export const handle: Handle = async ({ event, resolve }) => { event.locals.user = validateSession(event.cookies.get('session')); const path = event.url.pathname; - if (!event.locals.user && !PUBLIC_PATHS.has(path)) { + if (!event.locals.user && !isPublic(path)) { if (path.startsWith('/api/')) error(401, 'Not signed in'); redirect(303, '/login'); } - if (event.locals.user && PUBLIC_PATHS.has(path)) { + if (event.locals.user && AUTH_PATHS.has(path)) { redirect(303, '/'); } diff --git a/src/lib/components/ActivityItem.svelte b/src/lib/components/ActivityItem.svelte index 311219f..9c55b8c 100644 --- a/src/lib/components/ActivityItem.svelte +++ b/src/lib/components/ActivityItem.svelte @@ -13,6 +13,7 @@ moving_s: number | null; duration_s: number | null; elev_gain_m: number; + username?: string; }; } = $props(); @@ -23,6 +24,10 @@ {activity.type}
+ {#if activity.username} + {activity.username} + · + {/if} {fmtDate(activity.date)} · {fmtDistance(activity.distance_m)} @@ -74,4 +79,8 @@ .dot { color: var(--text-muted); } + .who { + font-weight: 600; + color: var(--accent-strong); + } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 4b3ca2d..394af08 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -160,10 +160,42 @@ export function listActivities(userId: number): Omit[] { .all(userId) as Omit[]; } -export function getActivity(id: number, userId: number): ActivityRow | undefined { - return db.prepare('SELECT * FROM activities WHERE id = ? AND user_id = ?').get(id, userId) as - | ActivityRow - | undefined; +export function getActivity(id: number): (ActivityRow & { username: string }) | undefined { + return db + .prepare( + `SELECT a.*, u.username FROM activities a JOIN users u ON u.id = a.user_id WHERE a.id = ?` + ) + .get(id) as (ActivityRow & { username: string }) | undefined; +} + +/** All users' activities, newest first, with uploader attribution. */ +export function listAllActivities(): (Omit & { username: string })[] { + return db + .prepare( + `SELECT a.id, a.user_id, a.name, a.type, a.date, a.distance_m, a.duration_s, a.moving_s, + a.elev_gain_m, a.elev_loss_m, a.elev_min_m, a.elev_max_m, a.bounds, a.created_at, + u.username + FROM activities a JOIN users u ON u.id = a.user_id + ORDER BY a.date DESC, a.id DESC` + ) + .all() as (Omit & { username: string })[]; +} + +export function communityTotals(): { + users: number; + activities: number; + distance_m: number; + elev_gain_m: number; +} { + return db + .prepare( + `SELECT (SELECT count(*) FROM users) users, + count(*) activities, + coalesce(sum(distance_m), 0) distance_m, + coalesce(sum(elev_gain_m), 0) elev_gain_m + FROM activities` + ) + .get() as { users: number; activities: number; distance_m: number; elev_gain_m: number }; } export function deleteActivity(id: number, userId: number): void { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 613af10..bbd0019 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -40,6 +40,11 @@ {data.user.username} + {:else} + {/if} @@ -103,6 +108,10 @@ background: var(--accent-wash); color: var(--accent-strong); } + .signup-btn { + padding: 0.35rem 0.85rem; + font-size: 0.85rem; + } .user { display: flex; align-items: center; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index fb2904f..a824108 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,35 +1,33 @@ -import { countAscents, db, listActivities, listAscents } from '$lib/server/db'; +import { communityTotals, countAscents, db, listAllActivities, listAscents } from '$lib/server/db'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = ({ locals }) => { - const userId = locals.user!.id; - const activities = listActivities(userId); - - // all of the user's tracks, thinned for the overview map + // every user's tracks, thinned for the overview map const rows = db - .prepare('SELECT id, name, points FROM activities WHERE user_id = ?') - .all(userId) as { id: number; name: string; points: string }[]; + .prepare( + `SELECT a.id, a.name, a.points, u.username + FROM activities a JOIN users u ON u.id = a.user_id` + ) + .all() as { id: number; name: string; points: string; username: string }[]; const tracks = rows.map((row) => { const points = JSON.parse(row.points) as { lat: number; lon: number }[]; const step = Math.max(1, Math.floor(points.length / 300)); const latlngs = points .filter((_, i) => i % step === 0 || i === points.length - 1) .map((p) => [p.lat, p.lon] as [number, number]); - return { id: row.id, name: row.name, latlngs }; + return { id: row.id, name: `${row.name} · ${row.username}`, latlngs }; }); - const ascents = listAscents(userId); - + const user = locals.user; return { - activities, + activities: listAllActivities(), tracks, - totals: { - count: activities.length, - distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0), - elev_gain_m: activities.reduce((sum, a) => sum + a.elev_gain_m, 0), - moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0) - }, - peaksClimbed: countAscents(userId), - highestAscent: ascents[0] ?? null + totals: communityTotals(), + mine: user + ? { + peaksClimbed: countAscents(user.id), + highestAscent: listAscents(user.id)[0] ?? null + } + : null }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 55964c4..e39bf0b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,91 +2,98 @@ import Map from '$lib/components/Map.svelte'; import StatTile from '$lib/components/StatTile.svelte'; import ActivityItem from '$lib/components/ActivityItem.svelte'; - import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format'; + import { fmtDistance, fmtElevation } from '$lib/format'; let { data } = $props(); - Dashboard · Streba + Streba -

Dashboard

-

Every track you've recorded, in one place.

- -{#if data.totals.count === 0} -
-

Welcome to Streba.

-

Upload your first GPX track to get going, or start ticking off Alpine peaks right away.

-
- Upload a GPX file - Browse the peaks +{#if data.user} +

Dashboard

+

What everyone on Streba has been up to.

+{:else} +
+
+

The GPX analyser

+

+ Upload your tracks, analyse every climb, and bag Alpine peaks - together. +

+
+
-{:else} -
- - - - -
+{/if} -

Worldmap

- +
+ + + + +
-
-
-

Recent activities

+

Worldmap

+ + +
+
+

Recent activities

+ {#if data.activities.length === 0} +
+

Nothing here yet - be the first to upload a track.

+ {#if data.user}Upload a GPX file{/if} +
+ {:else}
- {#each data.activities.slice(0, 6) as activity (activity.id)} + {#each data.activities.slice(0, 8) as activity (activity.id)} {/each}
-
+ {/if} +
+ {#if data.mine}
-

Peak bagging

+

Your peak bagging

- {data.peaksClimbed} - peak{data.peaksClimbed === 1 ? '' : 's'} climbed + {data.mine.peaksClimbed} + peak{data.mine.peaksClimbed === 1 ? '' : 's'} climbed
- {#if data.highestAscent} + {#if data.mine.highestAscent}

- Highest so far: {data.highestAscent.name} - ({Math.round(data.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m) + Highest so far: {data.mine.highestAscent.name} + ({Math.round(data.mine.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m)

{/if} Open the peak map
-
-{/if} + {/if} +