public home page with community activity overview

This commit is contained in:
Vincent van der Wal
2026-07-22 11:19:09 +02:00
parent b17f56b194
commit addef8b78e
11 changed files with 198 additions and 93 deletions
+3
View File
@@ -4,6 +4,9 @@ The GPX analyser, reborn. A self-hosted web app for analysing GPX tracks and
bagging Alpine peaks. bagging Alpine peaks.
- **Accounts** - anyone can sign up; activities and climbed peaks are per user. - **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, - **Upload** GPX files (drag & drop, multiple at once) - distance, ascent,
moving time and an elevation profile are computed on the spot. moving time and an elevation profile are computed on the spot.
- **Activities** - every track on a map with stats and an interactive - **Activities** - every track on a map with stats and an interactive
+7 -3
View File
@@ -1,17 +1,21 @@
import { error, redirect, type Handle } from '@sveltejs/kit'; import { error, redirect, type Handle } from '@sveltejs/kit';
import { validateSession } from '$lib/server/auth'; 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 }) => { export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = validateSession(event.cookies.get('session')); event.locals.user = validateSession(event.cookies.get('session'));
const path = event.url.pathname; 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'); if (path.startsWith('/api/')) error(401, 'Not signed in');
redirect(303, '/login'); redirect(303, '/login');
} }
if (event.locals.user && PUBLIC_PATHS.has(path)) { if (event.locals.user && AUTH_PATHS.has(path)) {
redirect(303, '/'); redirect(303, '/');
} }
+9
View File
@@ -13,6 +13,7 @@
moving_s: number | null; moving_s: number | null;
duration_s: number | null; duration_s: number | null;
elev_gain_m: number; elev_gain_m: number;
username?: string;
}; };
} = $props(); } = $props();
</script> </script>
@@ -23,6 +24,10 @@
<span class="type">{activity.type}</span> <span class="type">{activity.type}</span>
</div> </div>
<div class="meta"> <div class="meta">
{#if activity.username}
<span class="who">{activity.username}</span>
<span class="dot">·</span>
{/if}
<span>{fmtDate(activity.date)}</span> <span>{fmtDate(activity.date)}</span>
<span class="dot">·</span> <span class="dot">·</span>
<span>{fmtDistance(activity.distance_m)}</span> <span>{fmtDistance(activity.distance_m)}</span>
@@ -74,4 +79,8 @@
.dot { .dot {
color: var(--text-muted); color: var(--text-muted);
} }
.who {
font-weight: 600;
color: var(--accent-strong);
}
</style> </style>
+36 -4
View File
@@ -160,10 +160,42 @@ export function listActivities(userId: number): Omit<ActivityRow, 'points'>[] {
.all(userId) as Omit<ActivityRow, 'points'>[]; .all(userId) as Omit<ActivityRow, 'points'>[];
} }
export function getActivity(id: number, userId: number): ActivityRow | undefined { export function getActivity(id: number): (ActivityRow & { username: string }) | undefined {
return db.prepare('SELECT * FROM activities WHERE id = ? AND user_id = ?').get(id, userId) as return db
| ActivityRow .prepare(
| undefined; `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<ActivityRow, 'points'> & { 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<ActivityRow, 'points'> & { 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 { export function deleteActivity(id: number, userId: number): void {
+9
View File
@@ -40,6 +40,11 @@
<span class="username">{data.user.username}</span> <span class="username">{data.user.username}</span>
<button class="logout" type="submit" title="Sign out">Sign out</button> <button class="logout" type="submit" title="Sign out">Sign out</button>
</form> </form>
{:else}
<div class="nav-links">
<a href="/login" class:active={isActive('/login')}>Sign in</a>
<a href="/signup" class="btn signup-btn">Sign up</a>
</div>
{/if} {/if}
</nav> </nav>
</header> </header>
@@ -103,6 +108,10 @@
background: var(--accent-wash); background: var(--accent-wash);
color: var(--accent-strong); color: var(--accent-strong);
} }
.signup-btn {
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
}
.user { .user {
display: flex; display: flex;
align-items: center; align-items: center;
+17 -19
View File
@@ -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'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
const userId = locals.user!.id; // every user's tracks, thinned for the overview map
const activities = listActivities(userId);
// all of the user's tracks, thinned for the overview map
const rows = db const rows = db
.prepare('SELECT id, name, points FROM activities WHERE user_id = ?') .prepare(
.all(userId) as { id: number; name: string; points: string }[]; `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 tracks = rows.map((row) => {
const points = JSON.parse(row.points) as { lat: number; lon: number }[]; const points = JSON.parse(row.points) as { lat: number; lon: number }[];
const step = Math.max(1, Math.floor(points.length / 300)); const step = Math.max(1, Math.floor(points.length / 300));
const latlngs = points const latlngs = points
.filter((_, i) => i % step === 0 || i === points.length - 1) .filter((_, i) => i % step === 0 || i === points.length - 1)
.map((p) => [p.lat, p.lon] as [number, number]); .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 { return {
activities, activities: listAllActivities(),
tracks, tracks,
totals: { totals: communityTotals(),
count: activities.length, mine: user
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0), ? {
elev_gain_m: activities.reduce((sum, a) => sum + a.elev_gain_m, 0), peaksClimbed: countAscents(user.id),
moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0) highestAscent: listAscents(user.id)[0] ?? null
}, }
peaksClimbed: countAscents(userId), : null
highestAscent: ascents[0] ?? null
}; };
}; };
+67 -52
View File
@@ -2,91 +2,98 @@
import Map from '$lib/components/Map.svelte'; import Map from '$lib/components/Map.svelte';
import StatTile from '$lib/components/StatTile.svelte'; import StatTile from '$lib/components/StatTile.svelte';
import ActivityItem from '$lib/components/ActivityItem.svelte'; import ActivityItem from '$lib/components/ActivityItem.svelte';
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format'; import { fmtDistance, fmtElevation } from '$lib/format';
let { data } = $props(); let { data } = $props();
</script> </script>
<svelte:head> <svelte:head>
<title>Dashboard · Streba</title> <title>Streba</title>
</svelte:head> </svelte:head>
<h1>Dashboard</h1> {#if data.user}
<p class="page-sub">Every track you've recorded, in one place.</p> <h1>Dashboard</h1>
<p class="page-sub">What everyone on Streba has been up to.</p>
{#if data.totals.count === 0} {:else}
<div class="card empty"> <div class="hero">
<p><strong>Welcome to Streba.</strong></p> <div>
<p>Upload your first GPX track to get going, or start ticking off Alpine peaks right away.</p> <h1>The GPX analyser</h1>
<div class="empty-actions"> <p class="page-sub">
<a class="btn" href="/upload">Upload a GPX file</a> Upload your tracks, analyse every climb, and bag Alpine peaks - together.
<a class="btn ghost" href="/peaks">Browse the peaks</a> </p>
</div>
<div class="hero-actions">
<a class="btn" href="/signup">Sign up</a>
<a class="btn ghost" href="/login">Sign in</a>
</div> </div>
</div> </div>
{:else} {/if}
<div class="kpis">
<StatTile label="Activities" value={String(data.totals.count)} />
<StatTile label="Total distance" value={fmtDistance(data.totals.distance_m)} />
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
</div>
<h2>Worldmap</h2> <div class="kpis">
<Map tracks={data.tracks} height="440px" /> <StatTile label="Members" value={String(data.totals.users)} />
<StatTile label="Activities" value={String(data.totals.activities)} />
<StatTile label="Total distance" value={fmtDistance(data.totals.distance_m)} />
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
</div>
<div class="lower"> <h2>Worldmap</h2>
<section> <Map tracks={data.tracks} height="440px" />
<h2>Recent activities</h2>
<div class="lower">
<section>
<h2>Recent activities</h2>
{#if data.activities.length === 0}
<div class="card empty">
<p>Nothing here yet - be the first to upload a track.</p>
{#if data.user}<a class="btn" href="/upload">Upload a GPX file</a>{/if}
</div>
{:else}
<div class="card list"> <div class="card list">
{#each data.activities.slice(0, 6) as activity (activity.id)} {#each data.activities.slice(0, 8) as activity (activity.id)}
<ActivityItem {activity} /> <ActivityItem {activity} />
{/each} {/each}
</div> </div>
</section> {/if}
</section>
{#if data.mine}
<section> <section>
<h2>Peak bagging</h2> <h2>Your peak bagging</h2>
<div class="card peak-progress"> <div class="card peak-progress">
<div class="meter-nums"> <div class="meter-nums">
<span class="big">{data.peaksClimbed}</span> <span class="big">{data.mine.peaksClimbed}</span>
<span class="of">peak{data.peaksClimbed === 1 ? '' : 's'} climbed</span> <span class="of">peak{data.mine.peaksClimbed === 1 ? '' : 's'} climbed</span>
</div> </div>
{#if data.highestAscent} {#if data.mine.highestAscent}
<p class="highest"> <p class="highest">
Highest so far: <strong>{data.highestAscent.name}</strong> Highest so far: <strong>{data.mine.highestAscent.name}</strong>
({Math.round(data.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m) ({Math.round(data.mine.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m)
</p> </p>
{/if} {/if}
<a class="btn ghost" href="/peaks">Open the peak map</a> <a class="btn ghost" href="/peaks">Open the peak map</a>
</div> </div>
</section> </section>
</div> {/if}
{/if} </div>
<style> <style>
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
}
.hero-actions {
display: flex;
gap: 0.6rem;
padding-top: 0.35rem;
}
.kpis { .kpis {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.empty {
padding: 2.5rem;
text-align: center;
}
.empty p {
margin: 0.25rem 0;
color: var(--text-secondary);
}
.empty p strong {
color: var(--text-primary);
font-size: 1.1rem;
}
.empty-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1.25rem;
}
.lower { .lower {
display: grid; display: grid;
grid-template-columns: 3fr 2fr; grid-template-columns: 3fr 2fr;
@@ -101,6 +108,14 @@
.list :global(.item:not(:last-child)) { .list :global(.item:not(:last-child)) {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.empty {
padding: 2rem;
text-align: center;
}
.empty p {
margin: 0 0 1rem;
color: var(--text-secondary);
}
.peak-progress { .peak-progress {
padding: 1.15rem; padding: 1.15rem;
display: flex; display: flex;
+10 -1
View File
@@ -2,5 +2,14 @@ import { listActivities } from '$lib/server/db';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
return { activities: listActivities(locals.user!.id) }; const activities = listActivities(locals.user!.id);
return {
activities,
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)
}
};
}; };
+17 -1
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import ActivityItem from '$lib/components/ActivityItem.svelte'; import ActivityItem from '$lib/components/ActivityItem.svelte';
import StatTile from '$lib/components/StatTile.svelte';
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
let { data } = $props(); let { data } = $props();
</script> </script>
@@ -8,11 +10,19 @@
<title>Activities · Streba</title> <title>Activities · Streba</title>
</svelte:head> </svelte:head>
<h1>Activities</h1> <h1>Your activities</h1>
<p class="page-sub"> <p class="page-sub">
{data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}. {data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}.
</p> </p>
{#if data.activities.length > 0}
<div class="kpis">
<StatTile label="Total distance" value={fmtDistance(data.totals.distance_m)} />
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
</div>
{/if}
{#if data.activities.length === 0} {#if data.activities.length === 0}
<div class="card empty"> <div class="card empty">
<p>No activities yet.</p> <p>No activities yet.</p>
@@ -27,6 +37,12 @@
{/if} {/if}
<style> <style>
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.list :global(.item:not(:last-child)) { .list :global(.item:not(:last-child)) {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
+5 -3
View File
@@ -4,7 +4,7 @@ import { haversine } from '$lib/server/gpx';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = ({ params, locals }) => { export const load: PageServerLoad = ({ params, locals }) => {
const activity = getActivity(Number(params.id), locals.user!.id); const activity = getActivity(Number(params.id));
if (!activity) error(404, 'Activity not found'); if (!activity) error(404, 'Activity not found');
const points = JSON.parse(activity.points) as { const points = JSON.parse(activity.points) as {
@@ -26,10 +26,11 @@ export const load: PageServerLoad = ({ params, locals }) => {
if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! }); if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! });
} }
const bagged = baggedPeaks(activity.id, locals.user!.id); const bagged = baggedPeaks(activity.id, activity.user_id);
return { return {
activity: { ...activity, points: undefined }, activity: { ...activity, points: undefined },
canDelete: locals.user?.id === activity.user_id,
latlngs, latlngs,
profile, profile,
bagged: bagged.map((p) => ({ bagged: bagged.map((p) => ({
@@ -45,7 +46,8 @@ export const load: PageServerLoad = ({ params, locals }) => {
export const actions: Actions = { export const actions: Actions = {
delete: async ({ params, locals }) => { delete: async ({ params, locals }) => {
deleteActivity(Number(params.id), locals.user!.id); if (!locals.user) error(401, 'Not signed in');
deleteActivity(Number(params.id), locals.user.id);
redirect(303, '/activities'); redirect(303, '/activities');
} }
}; };
+18 -10
View File
@@ -20,17 +20,21 @@
<div class="head"> <div class="head">
<div> <div>
<h1>{a.name}</h1> <h1>{a.name}</h1>
<p class="page-sub">{fmtDate(a.date)} · <span class="type">{a.type}</span></p> <p class="page-sub">
<span class="who">{a.username}</span> · {fmtDate(a.date)} · <span class="type">{a.type}</span>
</p>
</div> </div>
<form {#if data.canDelete}
method="POST" <form
action="?/delete" method="POST"
use:enhance={({ cancel }) => { action="?/delete"
if (!confirm('Delete this activity? Peaks it bagged stay climbed.')) cancel(); use:enhance={({ cancel }) => {
}} if (!confirm('Delete this activity? Peaks it bagged stay climbed.')) cancel();
> }}
<button class="btn danger" type="submit">Delete</button> >
</form> <button class="btn danger" type="submit">Delete</button>
</form>
{/if}
</div> </div>
<div class="kpis"> <div class="kpis">
@@ -79,6 +83,10 @@
.type { .type {
text-transform: capitalize; text-transform: capitalize;
} }
.who {
font-weight: 600;
color: var(--accent-strong);
}
.kpis { .kpis {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));