peak search, denser catalog, profiles, filename parsing, themed map controls

This commit is contained in:
Vincent van der Wal
2026-07-22 12:07:01 +02:00
parent b288daf251
commit b89de57967
17 changed files with 767 additions and 64 deletions
+12
View File
@@ -16,6 +16,10 @@
--good: #0ca30c; --good: #0ca30c;
--good-text: #006300; --good-text: #006300;
--critical: #d03b3b; --critical: #d03b3b;
--logo-wash-1: #b3e0ec;
--logo-wash-2: #96d6c6;
--logo-text: #56555a;
--map-icon-filter: none;
} }
/* dark tokens apply when the OS prefers dark (unless the user forced light), /* dark tokens apply when the OS prefers dark (unless the user forced light),
@@ -38,6 +42,10 @@
--accent-track: #184f95; --accent-track: #184f95;
--good: #0ca30c; --good: #0ca30c;
--good-text: #0ca30c; --good-text: #0ca30c;
--logo-wash-1: #1d5766;
--logo-wash-2: #1e6355;
--logo-text: #eef2f0;
--map-icon-filter: invert(1) brightness(1.1);
} }
} }
:root[data-theme='dark'] { :root[data-theme='dark'] {
@@ -57,6 +65,10 @@
--accent-track: #184f95; --accent-track: #184f95;
--good: #0ca30c; --good: #0ca30c;
--good-text: #0ca30c; --good-text: #0ca30c;
--logo-wash-1: #1d5766;
--logo-wash-2: #1e6355;
--logo-text: #eef2f0;
--map-icon-filter: invert(1) brightness(1.1);
} }
* { * {
+7 -1
View File
@@ -4,7 +4,13 @@ import { validateSession } from '$lib/server/auth';
const AUTH_PATHS = new Set(['/login', '/signup']); const AUTH_PATHS = new Set(['/login', '/signup']);
function isPublic(path: string): boolean { function isPublic(path: string): boolean {
return path === '/' || AUTH_PATHS.has(path) || /^\/activities\/\d+$/.test(path); return (
path === '/' ||
AUTH_PATHS.has(path) ||
/^\/activities\/\d+$/.test(path) ||
path.startsWith('/users/') ||
path.startsWith('/avatars/')
);
} }
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
let {
avatar,
username,
size = 48
}: { avatar: string | null; username: string; size?: number } = $props();
</script>
{#if avatar}
<img class="avatar" src="/avatars/{avatar}" alt="{username}'s avatar" width={size} height={size} />
{:else}
<div class="avatar fallback" style:width="{size}px" style:height="{size}px" style:font-size="{size * 0.42}px" aria-hidden="true">
{username.slice(0, 1).toUpperCase()}
</div>
{/if}
<style>
.avatar {
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.fallback {
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-wash);
color: var(--accent-strong);
font-weight: 700;
}
</style>
+122 -39
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { theme } from '$lib/theme.svelte'; import { theme } from '$lib/theme.svelte';
import type { FeatureCollection, Point } from 'geojson';
export interface MapTrack { export interface MapTrack {
id?: number; id?: number;
@@ -45,30 +46,35 @@
let container: HTMLDivElement; let container: HTMLDivElement;
let map: import('maplibre-gl').Map | undefined; let map: import('maplibre-gl').Map | undefined;
let lib: typeof import('maplibre-gl') | undefined; let lib: typeof import('maplibre-gl') | undefined;
let markers: import('maplibre-gl').Marker[] = [];
let currentDark: boolean | undefined; let currentDark: boolean | undefined;
let pendingTerrain: unknown = null; let pendingTerrain: unknown = null;
function renderPeaks() { export function flyTo(lat: number, lon: number, zoom = 13) {
if (!lib || !map) return; map?.flyTo({ center: [lon, lat], zoom });
for (const m of markers) m.remove();
markers = peaks.map((p) => {
const el = document.createElement('div');
el.className = `peak-marker ${p.climbed ? 'climbed' : ''}`;
el.textContent = p.climbed ? '✓' : '▲';
el.title = `${p.name} · ${p.elevation_m.toLocaleString('en-US')} m`;
el.addEventListener('click', (e) => {
e.stopPropagation();
onpeakclick?.(p.id);
});
return new lib!.Marker({ element: el }).setLngLat([p.lon, p.lat]).addTo(map!);
});
} }
// re-render markers when climbed state changes function peaksGeojson(): FeatureCollection {
return {
type: 'FeatureCollection',
features: peaks.map((p) => ({
type: 'Feature',
id: p.id,
properties: {
id: p.id,
name: p.name,
elevation_m: p.elevation_m,
climbed: p.climbed
},
geometry: { type: 'Point', coordinates: [p.lon, p.lat] }
}))
};
}
// push new data into the peaks layer when peaks/climbed state changes
$effect(() => { $effect(() => {
void peaks; void peaks;
renderPeaks(); const source = map?.getSource('peaks') as import('maplibre-gl').GeoJSONSource | undefined;
source?.setData(peaksGeojson());
}); });
// swap map style when the theme changes // swap map style when the theme changes
@@ -134,6 +140,39 @@
paint: { 'line-color': dark ? '#3987e5' : '#2a78d6', 'line-width': 2.5 } paint: { 'line-color': dark ? '#3987e5' : '#2a78d6', 'line-width': 2.5 }
}); });
// peaks as native layers - scales to thousands of points
map.addSource('peaks', { type: 'geojson', data: peaksGeojson() });
map.addLayer({
id: 'peaks-circles',
type: 'circle',
source: 'peaks',
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 5, 12, 9],
'circle-color': [
'case',
['get', 'climbed'],
'#0ca30c',
dark ? '#1a1a19' : '#fcfcfb'
],
'circle-stroke-width': 1.5,
'circle-stroke-color': ['case', ['get', 'climbed'], '#0ca30c', dark ? '#898781' : '#898781']
}
});
map.addLayer({
id: 'peaks-glyphs',
type: 'symbol',
source: 'peaks',
layout: {
'text-field': ['case', ['get', 'climbed'], '✓', '▲'],
'text-size': ['interpolate', ['linear'], ['zoom'], 5, 7, 12, 10],
'text-allow-overlap': true,
'text-ignore-placement': true
},
paint: {
'text-color': ['case', ['get', 'climbed'], '#ffffff', dark ? '#c3c2b7' : '#52514e']
}
});
// restore 3D terrain across style swaps // restore 3D terrain across style swaps
if (pendingTerrain) { if (pendingTerrain) {
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification); map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
@@ -165,6 +204,33 @@
map.on('style.load', () => addLayers(currentDark ?? false)); map.on('style.load', () => addLayers(currentDark ?? false));
// peak interaction: click to toggle, hover for tooltip
const popup = new maplibre.Popup({
closeButton: false,
closeOnClick: false,
offset: 12,
className: 'peak-tip'
});
map.on('click', 'peaks-circles', (e) => {
const feature = e.features?.[0];
if (feature) onpeakclick?.(feature.properties.id as number);
});
map.on('mousemove', 'peaks-circles', (e) => {
const feature = e.features?.[0];
if (!feature || !map) return;
map.getCanvas().style.cursor = 'pointer';
const p = feature.properties;
popup
.setLngLat((feature.geometry as Point).coordinates as [number, number])
.setText(`${p.name} · ${Number(p.elevation_m).toLocaleString('en-US')} m`)
.addTo(map);
});
map.on('mouseleave', 'peaks-circles', () => {
if (!map) return;
map.getCanvas().style.cursor = '';
popup.remove();
});
if (onviewport) { if (onviewport) {
const report = () => { const report = () => {
if (!map) return; if (!map) return;
@@ -181,8 +247,6 @@
map.once('load', report); map.once('load', report);
} }
renderPeaks();
const bounds = new maplibre.LngLatBounds(); const bounds = new maplibre.LngLatBounds();
for (const track of tracks) for (const [lat, lon] of track.latlngs) bounds.extend([lon, lat]); for (const track of tracks) for (const [lat, lon] of track.latlngs) bounds.extend([lon, lat]);
for (const p of peaks) bounds.extend([p.lon, p.lat]); for (const p of peaks) bounds.extend([p.lon, p.lat]);
@@ -203,27 +267,46 @@
.map { .map {
width: 100%; width: 100%;
} }
:global(.peak-marker) {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--surface-1);
border: 1.5px solid var(--baseline);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
cursor: pointer;
}
:global(.peak-marker.climbed) {
background: var(--good);
border-color: var(--good);
color: #fff;
}
:global(.maplibregl-map) { :global(.maplibregl-map) {
font: inherit; font: inherit;
} }
/* theme the maplibre controls */
:global(.maplibregl-ctrl-group) {
background: var(--surface-1);
border-radius: 0.5rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
:global(.maplibregl-ctrl-group:not(:empty)) {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
:global(.maplibregl-ctrl-group button + button) {
border-top: 1px solid var(--border);
}
:global(.maplibregl-ctrl button .maplibregl-ctrl-icon) {
filter: var(--map-icon-filter);
}
:global(.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon) {
filter: none;
}
:global(.maplibregl-ctrl-attrib) {
background: color-mix(in srgb, var(--surface-1) 80%, transparent);
color: var(--text-muted);
}
:global(.maplibregl-ctrl-attrib a) {
color: var(--text-secondary);
}
:global(.maplibregl-popup.peak-tip .maplibregl-popup-content) {
background: var(--surface-1);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.3rem 0.6rem;
font-size: 0.82rem;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
:global(.maplibregl-popup.peak-tip .maplibregl-popup-tip) {
border-top-color: var(--surface-1);
border-bottom-color: var(--surface-1);
}
</style> </style>
+63 -3
View File
@@ -72,6 +72,14 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_ascents_user ON ascents(user_id); CREATE INDEX IF NOT EXISTS idx_ascents_user ON ascents(user_id);
`); `);
// lightweight migrations for columns added after the initial schema
const userCols = (db.prepare('PRAGMA table_info(users)').all() as { name: string }[]).map(
(c) => c.name
);
for (const col of ['bio', 'location', 'avatar']) {
if (!userCols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} TEXT`);
}
// Seed the catalog with the curated list while no OSM import has run. // Seed the catalog with the curated list while no OSM import has run.
// Seed rows use osm_id "seed:<name>"; scripts/import-peaks.js upgrades them // Seed rows use osm_id "seed:<name>"; scripts/import-peaks.js upgrades them
// to real OSM nodes (preserving ascents) and fills in the rest of the Alps. // to real OSM nodes (preserving ascents) and fills in the rest of the Alps.
@@ -181,6 +189,58 @@ export function listAllActivities(): (Omit<ActivityRow, 'points'> & { username:
.all() as (Omit<ActivityRow, 'points'> & { username: string })[]; .all() as (Omit<ActivityRow, 'points'> & { username: string })[];
} }
export interface ProfileRow {
id: number;
username: string;
created_at: string;
bio: string | null;
location: string | null;
avatar: string | null;
}
export function getProfile(username: string): ProfileRow | undefined {
return db
.prepare('SELECT id, username, created_at, bio, location, avatar FROM users WHERE username = ?')
.get(username) as ProfileRow | undefined;
}
export function updateProfile(
userId: number,
fields: { bio: string | null; location: string | null; avatar?: string }
): void {
if (fields.avatar !== undefined) {
db.prepare('UPDATE users SET bio = ?, location = ?, avatar = ? WHERE id = ?').run(
fields.bio,
fields.location,
fields.avatar,
userId
);
} else {
db.prepare('UPDATE users SET bio = ?, location = ? WHERE id = ?').run(
fields.bio,
fields.location,
userId
);
}
}
export function userStats(userId: number): {
activities: number;
distance_m: number;
elev_gain_m: number;
moving_s: number;
} {
return db
.prepare(
`SELECT count(*) activities,
coalesce(sum(distance_m), 0) distance_m,
coalesce(sum(elev_gain_m), 0) elev_gain_m,
coalesce(sum(coalesce(moving_s, duration_s, 0)), 0) moving_s
FROM activities WHERE user_id = ?`
)
.get(userId) as { activities: number; distance_m: number; elev_gain_m: number; moving_s: number };
}
export function communityTotals(): { export function communityTotals(): {
users: number; users: number;
activities: number; activities: number;
@@ -207,17 +267,17 @@ export function peaksInView(
userId: number, userId: number,
bbox: { minLat: number; minLon: number; maxLat: number; maxLon: number }, bbox: { minLat: number; minLon: number; maxLat: number; maxLon: number },
zoom: number, zoom: number,
limit = 300 limit = 1000
): (PeakRow & { climbed_at: string | null; ascent_activity_id: number | null })[] { ): (PeakRow & { climbed_at: string | null; ascent_activity_id: number | null })[] {
return db return db
.prepare( .prepare(
`SELECT p.*, a.climbed_at, a.activity_id ascent_activity_id `SELECT p.*, a.climbed_at, a.activity_id ascent_activity_id
FROM peaks p FROM peaks p
LEFT JOIN ascents a ON a.peak_id = p.id AND a.user_id = @userId LEFT JOIN ascents a ON a.peak_id = p.id AND a.user_id = @userId
WHERE p.minzoom <= @zoom WHERE (p.minzoom <= @zoom OR a.climbed_at IS NOT NULL)
AND p.lat BETWEEN @minLat AND @maxLat AND p.lat BETWEEN @minLat AND @maxLat
AND p.lon BETWEEN @minLon AND @maxLon AND p.lon BETWEEN @minLon AND @maxLon
ORDER BY p.score DESC ORDER BY (a.climbed_at IS NOT NULL) DESC, p.score DESC
LIMIT @limit` LIMIT @limit`
) )
.all({ userId, zoom: Math.floor(zoom), ...bbox, limit }) as (PeakRow & { .all({ userId, zoom: Math.floor(zoom), ...bbox, limit }) as (PeakRow & {
+2 -2
View File
@@ -19,7 +19,7 @@ export const MAX_MINZOOM = 14;
/** /**
* Assign each peak the lowest zoom level at which it should appear. * Assign each peak the lowest zoom level at which it should appear.
* For every zoom 4..13 the map is divided into a grid (4 cells per tile * For every zoom 4..13 the map is divided into a grid (8 cells per tile
* width); the highest-scoring peak in a cell "wins" it and becomes visible * width); the highest-scoring peak in a cell "wins" it and becomes visible
* from that zoom on. Everything that never wins shows from zoom 14. * from that zoom on. Everything that never wins shows from zoom 14.
* @template {{ lat: number, lon: number, score: number, minzoom?: number }} P * @template {{ lat: number, lon: number, score: number, minzoom?: number }} P
@@ -30,7 +30,7 @@ export function assignMinzoom(peaks) {
const sorted = [...peaks].sort((a, b) => b.score - a.score); const sorted = [...peaks].sort((a, b) => b.score - a.score);
for (const peak of sorted) peak.minzoom = MAX_MINZOOM; for (const peak of sorted) peak.minzoom = MAX_MINZOOM;
for (let z = 4; z <= 13; z++) { for (let z = 4; z <= 13; z++) {
const cell = 360 / (Math.pow(2, z) * 4); const cell = 360 / (Math.pow(2, z) * 8);
const occupied = new Set(); const occupied = new Set();
for (const peak of sorted) { for (const peak of sorted) {
const key = `${Math.floor(peak.lon / cell)}:${Math.floor(peak.lat / cell)}`; const key = `${Math.floor(peak.lon / cell)}:${Math.floor(peak.lat / cell)}`;
+43 -4
View File
@@ -33,8 +33,25 @@
<header class="topbar"> <header class="topbar">
<nav class="topbar-inner"> <nav class="topbar-inner">
<a href="/" class="wordmark"> <a href="/" class="wordmark" aria-label="Streba - home">
<img src="/logo.png" alt="Streba" height="34" /> <svg viewBox="0 0 256 60" width="122" height="29" role="img" aria-label="Streba">
<defs>
<filter id="logo-wc" x="-15%" y="-30%" width="130%" height="160%">
<feGaussianBlur stdDeviation="3.5" />
</filter>
</defs>
<g filter="url(#logo-wc)">
<path
class="wash-1"
d="M18 30 C14 18 40 8 78 10 C120 4 170 7 200 12 C228 10 246 18 246 27 C246 34 236 38 224 40 C180 46 80 46 40 42 C24 40 20 36 18 30 Z"
/>
<path
class="wash-2"
d="M30 36 C40 26 90 28 130 27 C180 25 226 27 238 33 C246 39 234 48 210 50 C160 55 70 54 44 48 C30 45 26 42 30 36 Z"
/>
</g>
<text x="128" y="43" text-anchor="middle" class="logo-text">Streba</text>
</svg>
</a> </a>
{#if data.user} {#if data.user}
<div class="nav-links"> <div class="nav-links">
@@ -43,7 +60,7 @@
{/each} {/each}
</div> </div>
<form class="user" method="POST" action="/logout"> <form class="user" method="POST" action="/logout">
<span class="username">{data.user.username}</span> <a class="username" href="/profile">{data.user.username}</a>
<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} {:else}
@@ -103,9 +120,24 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.wordmark img { .wordmark svg {
display: block; display: block;
} }
.wash-1 {
fill: var(--logo-wash-1);
opacity: 0.85;
}
.wash-2 {
fill: var(--logo-wash-2);
opacity: 0.8;
}
.logo-text {
font-family: 'Inter Variable', system-ui, sans-serif;
font-size: 36px;
font-weight: 320;
letter-spacing: 1.5px;
fill: var(--logo-text);
}
.nav-links { .nav-links {
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
@@ -165,6 +197,13 @@
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none;
padding: 0.3rem 0.5rem;
border-radius: 0.4rem;
}
.username:hover {
background: var(--wash);
color: var(--text-primary);
} }
.logout { .logout {
border: none; border: none;
+6 -1
View File
@@ -22,7 +22,8 @@
<div> <div>
<h1>{a.name}</h1> <h1>{a.name}</h1>
<p class="page-sub"> <p class="page-sub">
<span class="who">{a.username}</span> · {fmtDate(a.date)} · <span class="type">{a.type}</span> <a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(a.date)} ·
<span class="type">{a.type}</span>
</p> </p>
</div> </div>
{#if data.canDelete} {#if data.canDelete}
@@ -94,6 +95,10 @@
.who { .who {
font-weight: 600; font-weight: 600;
color: var(--accent-strong); color: var(--accent-strong);
text-decoration: none;
}
.who:hover {
text-decoration: underline;
} }
.kpis { .kpis {
display: grid; display: grid;
+20
View File
@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ url, locals }) => {
const q = (url.searchParams.get('q') ?? '').trim();
if (q.length < 2) return json({ peaks: [] });
const peaks = db
.prepare(
`SELECT p.id, p.name, p.elevation_m, p.lat, p.lon, a.climbed_at
FROM peaks p
LEFT JOIN ascents a ON a.peak_id = p.id AND a.user_id = ?
WHERE p.name LIKE '%' || ? || '%'
ORDER BY p.score DESC
LIMIT 15`
)
.all(locals.user!.id, q);
return json({ peaks });
};
+24
View File
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import path from 'node:path';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
export const GET: RequestHandler = ({ params }) => {
// param is our own stored filename ("<userId>.<ext>") - refuse anything else
if (!/^\d+\.(jpg|png|webp|gif)$/.test(params.file)) error(404, 'Not found');
const file = path.join(process.env.STREBA_DATA_DIR ?? 'data', 'avatars', params.file);
if (!fs.existsSync(file)) error(404, 'Not found');
return new Response(fs.readFileSync(file), {
headers: {
'Content-Type': TYPES[path.extname(params.file)] ?? 'application/octet-stream',
'Cache-Control': 'no-cache'
}
});
};
+135 -10
View File
@@ -22,6 +22,39 @@
let viewPeaks: ViewPeak[] = $state([]); let viewPeaks: ViewPeak[] = $state([]);
let tab: 'view' | 'mine' = $state('view'); let tab: 'view' | 'mine' = $state('view');
let loading = $state(false); let loading = $state(false);
let mapRef: Map | undefined = $state();
// search
let query = $state('');
let results: ViewPeak[] = $state([]);
let searchOpen = $state(false);
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function onquery() {
clearTimeout(searchTimer);
if (query.trim().length < 2) {
results = [];
searchOpen = false;
return;
}
searchTimer = setTimeout(async () => {
const res = await fetch(`/api/peaks/search?q=${encodeURIComponent(query.trim())}`);
if (res.ok) {
results = (await res.json()).peaks;
searchOpen = true;
}
}, 200);
}
function goTo(peak: ViewPeak) {
searchOpen = false;
query = peak.name;
mapRef?.flyTo(peak.lat, peak.lon, 12.5);
}
const LIST_CAP = 150;
// the "In view" list only offers peaks still to climb; climbed ones live in "My ascents"
const toClimb = $derived(viewPeaks.filter((p) => !p.climbed_at));
const highest = $derived(ascents[0] ?? null); const highest = $derived(ascents[0] ?? null);
@@ -101,11 +134,36 @@
Zoom in to reveal less prominent summits; click a peak to cross it off. Zoom in to reveal less prominent summits; click a peak to cross it off.
</p> </p>
<Map peaks={mapPeaks} height="480px" onpeakclick={toggle} {onviewport} /> <div class="search">
<input
type="search"
placeholder="Search peaks - e.g. Matterhorn"
bind:value={query}
oninput={onquery}
onfocus={() => results.length && (searchOpen = true)}
/>
{#if searchOpen && results.length > 0}
<ul class="results card">
{#each results as peak (peak.id)}
<li>
<button onclick={() => goTo(peak)}>
<span class="r-name" class:done={peak.climbed_at}>{peak.name}</span>
<span class="r-ele">
{peak.elevation_m ? `${Math.round(peak.elevation_m).toLocaleString('en-US')} m` : ''}
{peak.climbed_at ? ' ✓' : ''}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<Map bind:this={mapRef} peaks={mapPeaks} height="480px" onpeakclick={toggle} {onviewport} />
<div class="tabs" role="group" aria-label="Peak lists"> <div class="tabs" role="group" aria-label="Peak lists">
<button class="filter-btn" class:on={tab === 'view'} onclick={() => (tab = 'view')}> <button class="filter-btn" class:on={tab === 'view'} onclick={() => (tab = 'view')}>
In view {loading ? '…' : `(${viewPeaks.length})`} In view {loading ? '…' : `(${toClimb.length})`}
</button> </button>
<button class="filter-btn" class:on={tab === 'mine'} onclick={() => (tab = 'mine')}> <button class="filter-btn" class:on={tab === 'mine'} onclick={() => (tab = 'mine')}>
My ascents ({ascents.length}) My ascents ({ascents.length})
@@ -113,25 +171,27 @@
</div> </div>
{#if tab === 'view'} {#if tab === 'view'}
{#if viewPeaks.length === 0} {#if toClimb.length === 0}
<div class="card empty">No notable peaks in this view - try panning to the Alps or zooming in.</div> <div class="card empty">No peaks left to climb in this view - pan around or zoom in.</div>
{:else} {:else}
<div class="card grid"> <div class="card grid">
{#each viewPeaks as peak (peak.id)} {#each toClimb.slice(0, LIST_CAP) as peak (peak.id)}
<button class="peak" class:done={peak.climbed_at} onclick={() => toggle(peak.id)}> <button class="peak" onclick={() => toggle(peak.id)}>
<span class="check" aria-hidden="true">{peak.climbed_at ? '✓' : ''}</span> <span class="check" aria-hidden="true"></span>
<span class="info"> <span class="info">
<span class="name">{peak.name}</span> <span class="name">{peak.name}</span>
<span class="sub"> <span class="sub">
{peak.elevation_m ? `${Math.round(peak.elevation_m).toLocaleString('en-US')} m` : 'elevation unknown'} {peak.elevation_m ? `${Math.round(peak.elevation_m).toLocaleString('en-US')} m` : 'elevation unknown'}
{#if peak.climbed_at}
· climbed {fmtDate(peak.climbed_at)}
{/if}
</span> </span>
</span> </span>
</button> </button>
{/each} {/each}
</div> </div>
{#if toClimb.length > LIST_CAP}
<p class="cap-note">
Showing the {LIST_CAP} most notable of {toClimb.length} peaks on the map - zoom in for more.
</p>
{/if}
{/if} {/if}
{:else if ascents.length === 0} {:else if ascents.length === 0}
<div class="card empty"> <div class="card empty">
@@ -163,6 +223,71 @@
{/if} {/if}
<style> <style>
.search {
position: relative;
margin-bottom: 0.75rem;
max-width: 420px;
}
.search input {
width: 100%;
padding: 0.55rem 0.85rem;
border: 1px solid var(--border);
border-radius: 0.55rem;
background: var(--surface-1);
color: var(--text-primary);
font: inherit;
font-size: 0.9rem;
}
.search input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.results {
position: absolute;
top: calc(100% + 0.3rem);
left: 0;
right: 0;
z-index: 1000;
list-style: none;
margin: 0;
padding: 0.25rem;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
.results button {
width: 100%;
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.45rem 0.65rem;
border: none;
border-radius: 0.4rem;
background: none;
font: inherit;
font-size: 0.88rem;
color: inherit;
text-align: left;
cursor: pointer;
}
.results button:hover {
background: var(--wash);
}
.r-name {
font-weight: 500;
}
.r-name.done {
text-decoration: line-through;
text-decoration-color: var(--text-muted);
}
.r-ele {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.cap-note {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--text-muted);
}
.tabs { .tabs {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
+45
View File
@@ -0,0 +1,45 @@
import fs from 'node:fs';
import path from 'node:path';
import { fail } from '@sveltejs/kit';
import { getProfile, updateProfile } from '$lib/server/db';
import type { Actions, PageServerLoad } from './$types';
const AVATAR_TYPES: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif'
};
const MAX_AVATAR_BYTES = 4 * 1024 * 1024;
export const load: PageServerLoad = ({ locals }) => {
return { profile: getProfile(locals.user!.username)! };
};
export const actions: Actions = {
default: async ({ request, locals }) => {
const userId = locals.user!.id;
const form = await request.formData();
const bio = String(form.get('bio') ?? '').trim().slice(0, 1000) || null;
const location = String(form.get('location') ?? '').trim().slice(0, 100) || null;
const file = form.get('avatar');
if (file instanceof File && file.size > 0) {
const ext = AVATAR_TYPES[file.type];
if (!ext) return fail(400, { error: 'Avatar must be a JPEG, PNG, WebP or GIF image.' });
if (file.size > MAX_AVATAR_BYTES) return fail(400, { error: 'Avatar must be under 4 MB.' });
const dir = path.join(process.env.STREBA_DATA_DIR ?? 'data', 'avatars');
fs.mkdirSync(dir, { recursive: true });
const filename = `${userId}${ext}`;
// drop a previous avatar with a different extension
const old = getProfile(locals.user!.username)?.avatar;
if (old && old !== filename) fs.rmSync(path.join(dir, old), { force: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from(await file.arrayBuffer()));
updateProfile(userId, { bio, location, avatar: filename });
} else {
updateProfile(userId, { bio, location });
}
return { saved: true };
}
};
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Avatar from '$lib/components/Avatar.svelte';
let { data, form } = $props();
let saving = $state(false);
</script>
<svelte:head>
<title>Your profile · Streba</title>
</svelte:head>
<h1>Your profile</h1>
<p class="page-sub">
This is how you appear to others -
<a href="/users/{data.profile.username}">view your public page</a>.
</p>
<form
class="card"
method="POST"
enctype="multipart/form-data"
use:enhance={() => {
saving = true;
return async ({ update }) => {
saving = false;
await update({ reset: false });
};
}}
>
<div class="avatar-row">
<Avatar avatar={data.profile.avatar} username={data.profile.username} size={72} />
<label class="file-label">
Change avatar
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp,image/gif" />
</label>
</div>
<label>
Location
<input name="location" maxlength="100" placeholder="e.g. Innsbruck" value={data.profile.location ?? ''} />
</label>
<label>
Bio
<textarea name="bio" rows="4" maxlength="1000" placeholder="A few words about you and your mountains…"
>{data.profile.bio ?? ''}</textarea
>
</label>
{#if form?.error}<p class="error">{form.error}</p>{/if}
{#if form?.saved}<p class="saved">Profile saved.</p>{/if}
<button class="btn" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</form>
<style>
form {
max-width: 520px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.avatar-row {
display: flex;
align-items: center;
gap: 1rem;
}
.file-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
}
input:not([type]),
textarea {
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--page);
color: var(--text-primary);
font: inherit;
resize: vertical;
}
input:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.error {
color: var(--critical);
font-size: 0.85rem;
margin: 0;
}
.saved {
color: var(--good-text);
font-size: 0.85rem;
margin: 0;
}
.btn {
align-self: flex-start;
}
.page-sub a {
color: var(--accent-strong);
}
</style>
+28 -4
View File
@@ -3,6 +3,25 @@ import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
import { matchPeaks, parseGpx } from '$lib/server/gpx'; import { matchPeaks, parseGpx } from '$lib/server/gpx';
import type { Actions } from './$types'; import type { Actions } from './$types';
/**
* Parse export-style filenames like
* "2018-01-11 141322 - Run - Afternoon Run.gpx" (Date - Type - Name).
*/
function parseFilename(
filename: string
): { date: string; type: string; name: string } | null {
const base = filename.replace(/\.gpx$/i, '');
const parts = base.split(' - ');
if (parts.length < 3) return null;
const dateMatch = parts[0].match(/^(\d{4}-\d{2}-\d{2})(\s+\d{4,6})?$/);
if (!dateMatch) return null;
return {
date: dateMatch[1],
type: parts[1].trim().toLowerCase(),
name: parts.slice(2).join(' - ').trim()
};
}
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, locals }) => { default: async ({ request, locals }) => {
const userId = locals.user!.id; const userId = locals.user!.id;
@@ -25,11 +44,12 @@ export const actions: Actions = {
for (const file of files) { for (const file of files) {
try { try {
const gpx = parseGpx(await file.text()); const gpx = parseGpx(await file.text());
const parsed = parseFilename(file.name);
const result = insert.run({ const result = insert.run({
user_id: userId, user_id: userId,
name: gpx.name ?? file.name.replace(/\.gpx$/i, ''), name: parsed?.name ?? gpx.name ?? file.name.replace(/\.gpx$/i, ''),
type: gpx.type ?? 'outdoor', type: parsed?.type ?? gpx.type ?? 'outdoor',
date: gpx.date, date: gpx.date ?? parsed?.date ?? null,
distance_m: gpx.distance_m, distance_m: gpx.distance_m,
duration_s: gpx.duration_s, duration_s: gpx.duration_s,
moving_s: gpx.moving_s, moving_s: gpx.moving_s,
@@ -49,7 +69,11 @@ export const actions: Actions = {
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name); if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
} }
uploaded.push({ id: activityId, name: gpx.name ?? file.name, newPeaks }); uploaded.push({
id: activityId,
name: parsed?.name ?? gpx.name ?? file.name,
newPeaks
});
} catch (err) { } catch (err) {
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`); errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
} }
@@ -0,0 +1,17 @@
import { error } from '@sveltejs/kit';
import { countAscents, getProfile, listActivities, listAscents, userStats } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ params }) => {
const profile = getProfile(params.username);
if (!profile) error(404, 'No such user');
const ascents = listAscents(profile.id);
return {
profile,
stats: userStats(profile.id),
peaksReached: countAscents(profile.id),
highestAscent: ascents[0] ?? null,
activities: listActivities(profile.id).slice(0, 10)
};
};
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar.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();
const p = $derived(data.profile);
const memberSince = $derived(
new Date(p.created_at + 'Z').toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
);
</script>
<svelte:head>
<title>{p.username} · Streba</title>
</svelte:head>
<div class="head card">
<Avatar avatar={p.avatar} username={p.username} size={88} />
<div class="who">
<h1>{p.username}</h1>
<p class="meta">
{#if p.location}{p.location} · {/if}member since {memberSince}
</p>
{#if p.bio}<p class="bio">{p.bio}</p>{/if}
</div>
</div>
<div class="kpis">
<StatTile label="Activities" value={String(data.stats.activities)} />
<StatTile label="Distance" value={fmtDistance(data.stats.distance_m)} />
<StatTile label="Ascent" value={fmtElevation(data.stats.elev_gain_m)} />
<StatTile label="Moving time" value={fmtDuration(data.stats.moving_s)} />
</div>
{#if data.peaksReached > 0}
<div class="card peak-strip">
{data.peaksReached} peak{data.peaksReached === 1 ? '' : 's'} reached{#if data.highestAscent},
highest: <strong>{data.highestAscent.name}</strong>
({Math.round(data.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m){/if}.
</div>
{/if}
<h2>Recent activities</h2>
{#if data.activities.length === 0}
<div class="card empty">No activities yet.</div>
{:else}
<div class="card list">
{#each data.activities as activity (activity.id)}
<ActivityItem {activity} />
{/each}
</div>
{/if}
<style>
.head {
display: flex;
gap: 1.25rem;
align-items: flex-start;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.who h1 {
margin: 0;
}
.meta {
margin: 0.15rem 0 0;
color: var(--text-muted);
font-size: 0.85rem;
}
.bio {
margin: 0.6rem 0 0;
color: var(--text-secondary);
font-size: 0.92rem;
white-space: pre-line;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.peak-strip {
margin-top: 1.25rem;
padding: 0.85rem 1.15rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
.peak-strip strong {
color: var(--text-primary);
}
.list :global(.item:not(:last-child)) {
border-bottom: 1px solid var(--border);
}
.empty {
padding: 1.5rem;
text-align: center;
color: var(--text-secondary);
}
</style>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB