public home page with community activity overview
This commit is contained in:
@@ -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
|
||||
|
||||
+7
-3
@@ -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, '/');
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
moving_s: number | null;
|
||||
duration_s: number | null;
|
||||
elev_gain_m: number;
|
||||
username?: string;
|
||||
};
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -23,6 +24,10 @@
|
||||
<span class="type">{activity.type}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
{#if activity.username}
|
||||
<span class="who">{activity.username}</span>
|
||||
<span class="dot">·</span>
|
||||
{/if}
|
||||
<span>{fmtDate(activity.date)}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtDistance(activity.distance_m)}</span>
|
||||
@@ -74,4 +79,8 @@
|
||||
.dot {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.who {
|
||||
font-weight: 600;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
+36
-4
@@ -160,10 +160,42 @@ export function listActivities(userId: number): Omit<ActivityRow, 'points'>[] {
|
||||
.all(userId) as Omit<ActivityRow, 'points'>[];
|
||||
}
|
||||
|
||||
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<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 {
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
<span class="username">{data.user.username}</span>
|
||||
<button class="logout" type="submit" title="Sign out">Sign out</button>
|
||||
</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}
|
||||
</nav>
|
||||
</header>
|
||||
@@ -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;
|
||||
|
||||
+17
-19
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
+67
-52
@@ -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();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard · Streba</title>
|
||||
<title>Streba</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Dashboard</h1>
|
||||
<p class="page-sub">Every track you've recorded, in one place.</p>
|
||||
|
||||
{#if data.totals.count === 0}
|
||||
<div class="card empty">
|
||||
<p><strong>Welcome to Streba.</strong></p>
|
||||
<p>Upload your first GPX track to get going, or start ticking off Alpine peaks right away.</p>
|
||||
<div class="empty-actions">
|
||||
<a class="btn" href="/upload">Upload a GPX file</a>
|
||||
<a class="btn ghost" href="/peaks">Browse the peaks</a>
|
||||
{#if data.user}
|
||||
<h1>Dashboard</h1>
|
||||
<p class="page-sub">What everyone on Streba has been up to.</p>
|
||||
{:else}
|
||||
<div class="hero">
|
||||
<div>
|
||||
<h1>The GPX analyser</h1>
|
||||
<p class="page-sub">
|
||||
Upload your tracks, analyse every climb, and bag Alpine peaks - together.
|
||||
</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>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<h2>Worldmap</h2>
|
||||
<Map tracks={data.tracks} height="440px" />
|
||||
<div class="kpis">
|
||||
<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">
|
||||
<section>
|
||||
<h2>Recent activities</h2>
|
||||
<h2>Worldmap</h2>
|
||||
<Map tracks={data.tracks} height="440px" />
|
||||
|
||||
<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">
|
||||
{#each data.activities.slice(0, 6) as activity (activity.id)}
|
||||
{#each data.activities.slice(0, 8) as activity (activity.id)}
|
||||
<ActivityItem {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
{#if data.mine}
|
||||
<section>
|
||||
<h2>Peak bagging</h2>
|
||||
<h2>Your peak bagging</h2>
|
||||
<div class="card peak-progress">
|
||||
<div class="meter-nums">
|
||||
<span class="big">{data.peaksClimbed}</span>
|
||||
<span class="of">peak{data.peaksClimbed === 1 ? '' : 's'} climbed</span>
|
||||
<span class="big">{data.mine.peaksClimbed}</span>
|
||||
<span class="of">peak{data.mine.peaksClimbed === 1 ? '' : 's'} climbed</span>
|
||||
</div>
|
||||
{#if data.highestAscent}
|
||||
{#if data.mine.highestAscent}
|
||||
<p class="highest">
|
||||
Highest so far: <strong>{data.highestAscent.name}</strong>
|
||||
({Math.round(data.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m)
|
||||
Highest so far: <strong>{data.mine.highestAscent.name}</strong>
|
||||
({Math.round(data.mine.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m)
|
||||
</p>
|
||||
{/if}
|
||||
<a class="btn ghost" href="/peaks">Open the peak map</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
@@ -101,6 +108,14 @@
|
||||
.list :global(.item:not(:last-child)) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty p {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.peak-progress {
|
||||
padding: 1.15rem;
|
||||
display: flex;
|
||||
|
||||
@@ -2,5 +2,14 @@ import { listActivities } from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
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)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
||||
import StatTile from '$lib/components/StatTile.svelte';
|
||||
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
@@ -8,11 +10,19 @@
|
||||
<title>Activities · Streba</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Activities</h1>
|
||||
<h1>Your activities</h1>
|
||||
<p class="page-sub">
|
||||
{data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}.
|
||||
</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}
|
||||
<div class="card empty">
|
||||
<p>No activities yet.</p>
|
||||
@@ -27,6 +37,12 @@
|
||||
{/if}
|
||||
|
||||
<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)) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { haversine } from '$lib/server/gpx';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
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');
|
||||
|
||||
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! });
|
||||
}
|
||||
|
||||
const bagged = baggedPeaks(activity.id, locals.user!.id);
|
||||
const bagged = baggedPeaks(activity.id, activity.user_id);
|
||||
|
||||
return {
|
||||
activity: { ...activity, points: undefined },
|
||||
canDelete: locals.user?.id === activity.user_id,
|
||||
latlngs,
|
||||
profile,
|
||||
bagged: bagged.map((p) => ({
|
||||
@@ -45,7 +46,8 @@ export const load: PageServerLoad = ({ params, locals }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,17 +20,21 @@
|
||||
<div class="head">
|
||||
<div>
|
||||
<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>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm('Delete this activity? Peaks it bagged stay climbed.')) cancel();
|
||||
}}
|
||||
>
|
||||
<button class="btn danger" type="submit">Delete</button>
|
||||
</form>
|
||||
{#if data.canDelete}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm('Delete this activity? Peaks it bagged stay climbed.')) cancel();
|
||||
}}
|
||||
>
|
||||
<button class="btn danger" type="submit">Delete</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
@@ -79,6 +83,10 @@
|
||||
.type {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.who {
|
||||
font-weight: 600;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user