peak search, denser catalog, profiles, filename parsing, themed map controls
This commit is contained in:
+12
@@ -16,6 +16,10 @@
|
||||
--good: #0ca30c;
|
||||
--good-text: #006300;
|
||||
--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),
|
||||
@@ -38,6 +42,10 @@
|
||||
--accent-track: #184f95;
|
||||
--good: #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'] {
|
||||
@@ -57,6 +65,10 @@
|
||||
--accent-track: #184f95;
|
||||
--good: #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
@@ -4,7 +4,13 @@ import { validateSession } from '$lib/server/auth';
|
||||
const AUTH_PATHS = new Set(['/login', '/signup']);
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -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
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { theme } from '$lib/theme.svelte';
|
||||
import type { FeatureCollection, Point } from 'geojson';
|
||||
|
||||
export interface MapTrack {
|
||||
id?: number;
|
||||
@@ -45,30 +46,35 @@
|
||||
let container: HTMLDivElement;
|
||||
let map: import('maplibre-gl').Map | undefined;
|
||||
let lib: typeof import('maplibre-gl') | undefined;
|
||||
let markers: import('maplibre-gl').Marker[] = [];
|
||||
let currentDark: boolean | undefined;
|
||||
let pendingTerrain: unknown = null;
|
||||
|
||||
function renderPeaks() {
|
||||
if (!lib || !map) return;
|
||||
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!);
|
||||
});
|
||||
export function flyTo(lat: number, lon: number, zoom = 13) {
|
||||
map?.flyTo({ center: [lon, lat], zoom });
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
void peaks;
|
||||
renderPeaks();
|
||||
const source = map?.getSource('peaks') as import('maplibre-gl').GeoJSONSource | undefined;
|
||||
source?.setData(peaksGeojson());
|
||||
});
|
||||
|
||||
// swap map style when the theme changes
|
||||
@@ -134,6 +140,39 @@
|
||||
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
|
||||
if (pendingTerrain) {
|
||||
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
|
||||
@@ -165,6 +204,33 @@
|
||||
|
||||
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) {
|
||||
const report = () => {
|
||||
if (!map) return;
|
||||
@@ -181,8 +247,6 @@
|
||||
map.once('load', report);
|
||||
}
|
||||
|
||||
renderPeaks();
|
||||
|
||||
const bounds = new maplibre.LngLatBounds();
|
||||
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]);
|
||||
@@ -203,27 +267,46 @@
|
||||
.map {
|
||||
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) {
|
||||
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>
|
||||
|
||||
+63
-3
@@ -72,6 +72,14 @@ db.exec(`
|
||||
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 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.
|
||||
@@ -181,6 +189,58 @@ export function listAllActivities(): (Omit<ActivityRow, 'points'> & { username:
|
||||
.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(): {
|
||||
users: number;
|
||||
activities: number;
|
||||
@@ -207,17 +267,17 @@ export function peaksInView(
|
||||
userId: number,
|
||||
bbox: { minLat: number; minLon: number; maxLat: number; maxLon: number },
|
||||
zoom: number,
|
||||
limit = 300
|
||||
limit = 1000
|
||||
): (PeakRow & { climbed_at: string | null; ascent_activity_id: number | null })[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT p.*, a.climbed_at, a.activity_id ascent_activity_id
|
||||
FROM peaks p
|
||||
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.lon BETWEEN @minLon AND @maxLon
|
||||
ORDER BY p.score DESC
|
||||
ORDER BY (a.climbed_at IS NOT NULL) DESC, p.score DESC
|
||||
LIMIT @limit`
|
||||
)
|
||||
.all({ userId, zoom: Math.floor(zoom), ...bbox, limit }) as (PeakRow & {
|
||||
|
||||
@@ -19,7 +19,7 @@ export const MAX_MINZOOM = 14;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* from that zoom on. Everything that never wins shows from zoom 14.
|
||||
* @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);
|
||||
for (const peak of sorted) peak.minzoom = MAX_MINZOOM;
|
||||
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();
|
||||
for (const peak of sorted) {
|
||||
const key = `${Math.floor(peak.lon / cell)}:${Math.floor(peak.lat / cell)}`;
|
||||
|
||||
@@ -33,8 +33,25 @@
|
||||
|
||||
<header class="topbar">
|
||||
<nav class="topbar-inner">
|
||||
<a href="/" class="wordmark">
|
||||
<img src="/logo.png" alt="Streba" height="34" />
|
||||
<a href="/" class="wordmark" aria-label="Streba - home">
|
||||
<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>
|
||||
{#if data.user}
|
||||
<div class="nav-links">
|
||||
@@ -43,7 +60,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
{:else}
|
||||
@@ -103,9 +120,24 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wordmark img {
|
||||
.wordmark svg {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
@@ -165,6 +197,13 @@
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
border: none;
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
<div>
|
||||
<h1>{a.name}</h1>
|
||||
<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>
|
||||
</div>
|
||||
{#if data.canDelete}
|
||||
@@ -94,6 +95,10 @@
|
||||
.who {
|
||||
font-weight: 600;
|
||||
color: var(--accent-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
.who:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.kpis {
|
||||
display: grid;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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
@@ -22,6 +22,39 @@
|
||||
let viewPeaks: ViewPeak[] = $state([]);
|
||||
let tab: 'view' | 'mine' = $state('view');
|
||||
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);
|
||||
|
||||
@@ -101,11 +134,36 @@
|
||||
Zoom in to reveal less prominent summits; click a peak to cross it off.
|
||||
</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">
|
||||
<button class="filter-btn" class:on={tab === 'view'} onclick={() => (tab = 'view')}>
|
||||
In view {loading ? '…' : `(${viewPeaks.length})`}
|
||||
In view {loading ? '…' : `(${toClimb.length})`}
|
||||
</button>
|
||||
<button class="filter-btn" class:on={tab === 'mine'} onclick={() => (tab = 'mine')}>
|
||||
My ascents ({ascents.length})
|
||||
@@ -113,25 +171,27 @@
|
||||
</div>
|
||||
|
||||
{#if tab === 'view'}
|
||||
{#if viewPeaks.length === 0}
|
||||
<div class="card empty">No notable peaks in this view - try panning to the Alps or zooming in.</div>
|
||||
{#if toClimb.length === 0}
|
||||
<div class="card empty">No peaks left to climb in this view - pan around or zoom in.</div>
|
||||
{:else}
|
||||
<div class="card grid">
|
||||
{#each viewPeaks as peak (peak.id)}
|
||||
<button class="peak" class:done={peak.climbed_at} onclick={() => toggle(peak.id)}>
|
||||
<span class="check" aria-hidden="true">{peak.climbed_at ? '✓' : ''}</span>
|
||||
{#each toClimb.slice(0, LIST_CAP) as peak (peak.id)}
|
||||
<button class="peak" onclick={() => toggle(peak.id)}>
|
||||
<span class="check" aria-hidden="true"></span>
|
||||
<span class="info">
|
||||
<span class="name">{peak.name}</span>
|
||||
<span class="sub">
|
||||
{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>
|
||||
</button>
|
||||
{/each}
|
||||
</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}
|
||||
{:else if ascents.length === 0}
|
||||
<div class="card empty">
|
||||
@@ -163,6 +223,71 @@
|
||||
{/if}
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -3,6 +3,25 @@ import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
|
||||
import { matchPeaks, parseGpx } from '$lib/server/gpx';
|
||||
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 = {
|
||||
default: async ({ request, locals }) => {
|
||||
const userId = locals.user!.id;
|
||||
@@ -25,11 +44,12 @@ export const actions: Actions = {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const gpx = parseGpx(await file.text());
|
||||
const parsed = parseFilename(file.name);
|
||||
const result = insert.run({
|
||||
user_id: userId,
|
||||
name: gpx.name ?? file.name.replace(/\.gpx$/i, ''),
|
||||
type: gpx.type ?? 'outdoor',
|
||||
date: gpx.date,
|
||||
name: parsed?.name ?? gpx.name ?? file.name.replace(/\.gpx$/i, ''),
|
||||
type: parsed?.type ?? gpx.type ?? 'outdoor',
|
||||
date: gpx.date ?? parsed?.date ?? null,
|
||||
distance_m: gpx.distance_m,
|
||||
duration_s: gpx.duration_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);
|
||||
}
|
||||
|
||||
uploaded.push({ id: activityId, name: gpx.name ?? file.name, newPeaks });
|
||||
uploaded.push({
|
||||
id: activityId,
|
||||
name: parsed?.name ?? gpx.name ?? file.name,
|
||||
newPeaks
|
||||
});
|
||||
} catch (err) {
|
||||
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)
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user