add user accounts and OSM peak catalog with zoom-based notability
This commit is contained in:
@@ -1,16 +1,19 @@
|
|||||||
# Streba
|
# Streba
|
||||||
|
|
||||||
The GPX analyser, reborn. A single-user, self-hosted web app for analysing GPX
|
The GPX analyser, reborn. A self-hosted web app for analysing GPX tracks and
|
||||||
tracks and bagging Alpine peaks.
|
bagging Alpine peaks.
|
||||||
|
|
||||||
|
- **Accounts** - anyone can sign up; activities and climbed peaks are per user.
|
||||||
- **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
|
||||||
elevation chart.
|
elevation chart.
|
||||||
- **Worldmap** - all your tracks together on the dashboard.
|
- **Worldmap** - all your tracks together on the dashboard.
|
||||||
- **Peaks** - a checklist of notable Alpine summits. Cross them off by hand,
|
- **Peaks** - the OSM peaks of the whole Alpine region on a map. Zooming out
|
||||||
or let an uploaded track bag them automatically when it passes within
|
shows only the most notable summits (elevation, Wikipedia/Wikidata
|
||||||
150 m of a summit.
|
presence and tagged prominence decide); zooming in reveals the rest. Cross
|
||||||
|
them off by hand, or let an uploaded track bag them automatically when it
|
||||||
|
passes within 150 m of a summit.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -39,6 +42,15 @@ Set `STREBA_DATA_DIR` to move the SQLite database somewhere else (defaults to
|
|||||||
|
|
||||||
## Peaks data
|
## Peaks data
|
||||||
|
|
||||||
The seed list of peaks lives in `src/lib/server/peaks-seed.ts` - edit it to
|
A fresh database is seeded with ~80 curated famous peaks so the app works out
|
||||||
taste; new entries are inserted on the next start, and your climbed state is
|
of the box. To load **all named OSM peaks of the Alps** (tens of thousands),
|
||||||
kept.
|
run the importer once (and re-run whenever you want fresh OSM data):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/import-peaks.js
|
||||||
|
```
|
||||||
|
|
||||||
|
It fetches `natural=peak` nodes from the Overpass API for the Alpine bounding
|
||||||
|
box, scores each peak for notability, precomputes the zoom level from which
|
||||||
|
it appears on the map, and merges the curated seed peaks into their OSM
|
||||||
|
counterparts without losing anyone's recorded ascents.
|
||||||
|
|||||||
Generated
+11
@@ -21,6 +21,7 @@
|
|||||||
"svelte": "^5.56.7",
|
"svelte": "^5.56.7",
|
||||||
"svelte-check": "^4.7.3",
|
"svelte-check": "^4.7.3",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
"undici": "^7.28.0",
|
||||||
"vite": "^8.1.5"
|
"vite": "^8.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2799,6 +2800,16 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"svelte": "^5.56.7",
|
"svelte": "^5.56.7",
|
||||||
"svelte-check": "^4.7.3",
|
"svelte-check": "^4.7.3",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
"undici": "^7.28.0",
|
||||||
"vite": "^8.1.5"
|
"vite": "^8.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Import all named OSM peaks in the Alpine region into the Streba database.
|
||||||
|
//
|
||||||
|
// node scripts/import-peaks.js [--db data/streba.db]
|
||||||
|
//
|
||||||
|
// Fetches natural=peak nodes from the Overpass API, scores them for
|
||||||
|
// notability, assigns per-zoom visibility (minzoom), upgrades the built-in
|
||||||
|
// seed peaks to their OSM counterparts (keeping everyone's ascents), and
|
||||||
|
// recomputes visibility across the whole catalog. Idempotent - safe to
|
||||||
|
// re-run to refresh the data.
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
import { assignMinzoom, scorePeak } from '../src/lib/server/peak-score.js';
|
||||||
|
|
||||||
|
// honor HTTP(S)_PROXY/NO_PROXY env vars (no-op when unset)
|
||||||
|
setGlobalDispatcher(new EnvHttpProxyAgent());
|
||||||
|
|
||||||
|
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
||||||
|
// the Alps, generously: Nice to Vienna
|
||||||
|
const BBOX = '43.0,4.7,48.7,16.8';
|
||||||
|
const QUERY = `[out:json][timeout:600];node["natural"="peak"]["name"](${BBOX});out body;`;
|
||||||
|
|
||||||
|
const dbPath = process.argv.includes('--db')
|
||||||
|
? process.argv[process.argv.indexOf('--db') + 1]
|
||||||
|
: (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db';
|
||||||
|
|
||||||
|
function parseEle(raw) {
|
||||||
|
if (raw === undefined) return null;
|
||||||
|
const ele = parseFloat(String(raw).replace(',', '.'));
|
||||||
|
return Number.isFinite(ele) && ele > 0 && ele < 5000 ? ele : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetching peaks from Overpass (bbox ${BBOX}) - this can take a few minutes…`);
|
||||||
|
const response = await fetch(OVERPASS_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'data=' + encodeURIComponent(QUERY),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': 'streba-peak-import/1.0 (self-hosted GPX analyser)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Overpass request failed: ${response.status} ${response.statusText}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const osm = await response.json();
|
||||||
|
console.log(`Received ${osm.elements.length} peaks.`);
|
||||||
|
|
||||||
|
const peaks = osm.elements
|
||||||
|
.filter((el) => el.type === 'node' && el.tags?.name)
|
||||||
|
.map((el) => ({
|
||||||
|
osm_id: `node:${el.id}`,
|
||||||
|
name: el.tags.name,
|
||||||
|
elevation_m: parseEle(el.tags.ele),
|
||||||
|
lat: el.lat,
|
||||||
|
lon: el.lon,
|
||||||
|
wikipedia: el.tags.wikipedia ?? null,
|
||||||
|
wikidata: el.tags.wikidata ?? null,
|
||||||
|
prominence_m: parseEle(el.tags.prominence),
|
||||||
|
score: 0,
|
||||||
|
minzoom: 14
|
||||||
|
}));
|
||||||
|
for (const p of peaks) p.score = scorePeak(p);
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
const hasPeaksTable = db
|
||||||
|
.prepare(`SELECT count(*) n FROM sqlite_master WHERE type = 'table' AND name = 'peaks'`)
|
||||||
|
.get().n;
|
||||||
|
if (!hasPeaksTable) {
|
||||||
|
console.error(`No peaks table in ${dbPath} - start the app once first so it creates the schema.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO peaks (osm_id, name, elevation_m, lat, lon, wikipedia, wikidata, score, minzoom)
|
||||||
|
VALUES (@osm_id, @name, @elevation_m, @lat, @lon, @wikipedia, @wikidata, @score, @minzoom)
|
||||||
|
ON CONFLICT (osm_id) DO UPDATE SET
|
||||||
|
name = excluded.name, elevation_m = excluded.elevation_m,
|
||||||
|
lat = excluded.lat, lon = excluded.lon,
|
||||||
|
wikipedia = excluded.wikipedia, wikidata = excluded.wikidata,
|
||||||
|
score = excluded.score
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const p of peaks) upsert.run(p);
|
||||||
|
|
||||||
|
// upgrade curated seed rows to their OSM counterpart, keeping ascents.
|
||||||
|
// OSM names are often multilingual ("Mont Blanc / Monte Bianco"), so match
|
||||||
|
// by name containment first, then by plain summit proximity.
|
||||||
|
const seeds = db.prepare(`SELECT * FROM peaks WHERE osm_id LIKE 'seed:%'`).all();
|
||||||
|
const candidatesFor = db.prepare(
|
||||||
|
`SELECT id, name, lat, lon, elevation_m FROM peaks
|
||||||
|
WHERE osm_id LIKE 'node:%' AND abs(lat - ?) < 0.01 AND abs(lon - ?) < 0.015`
|
||||||
|
);
|
||||||
|
let upgraded = 0;
|
||||||
|
for (const seed of seeds) {
|
||||||
|
const candidates = candidatesFor.all(seed.lat, seed.lon);
|
||||||
|
const seedName = seed.name.toLowerCase();
|
||||||
|
const dist = (c) => Math.hypot(c.lat - seed.lat, (c.lon - seed.lon) * 0.7);
|
||||||
|
const byName = candidates
|
||||||
|
.filter((c) => {
|
||||||
|
const n = c.name.toLowerCase();
|
||||||
|
return n.includes(seedName) || seedName.includes(n);
|
||||||
|
})
|
||||||
|
.sort((a, b) => dist(a) - dist(b))[0];
|
||||||
|
const byProximity = candidates
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
dist(c) < 0.004 &&
|
||||||
|
(c.elevation_m == null || Math.abs(c.elevation_m - seed.elevation_m) < 200)
|
||||||
|
)
|
||||||
|
.sort((a, b) => dist(a) - dist(b))[0];
|
||||||
|
const match = byName ?? byProximity;
|
||||||
|
if (match) {
|
||||||
|
db.prepare('UPDATE OR IGNORE ascents SET peak_id = ? WHERE peak_id = ?').run(match.id, seed.id);
|
||||||
|
db.prepare('DELETE FROM peaks WHERE id = ?').run(seed.id);
|
||||||
|
upgraded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Upgraded ${upgraded}/${seeds.length} seed peaks to OSM nodes.`);
|
||||||
|
|
||||||
|
// recompute zoom visibility across the merged catalog
|
||||||
|
const all = db.prepare('SELECT id, lat, lon, score FROM peaks').all();
|
||||||
|
assignMinzoom(all);
|
||||||
|
const setZoom = db.prepare('UPDATE peaks SET minzoom = ? WHERE id = ?');
|
||||||
|
for (const p of all) setZoom.run(p.minzoom, p.id);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const total = db.prepare('SELECT count(*) n FROM peaks').get().n;
|
||||||
|
const byZoom = db
|
||||||
|
.prepare('SELECT minzoom, count(*) n FROM peaks GROUP BY minzoom ORDER BY minzoom')
|
||||||
|
.all();
|
||||||
|
console.log(`Catalog now holds ${total} peaks.`);
|
||||||
|
console.log('Visible from zoom:', byZoom.map((r) => `${r.minzoom}: ${r.n}`).join(', '));
|
||||||
Vendored
+7
-1
@@ -1,5 +1,11 @@
|
|||||||
|
import type { User } from '$lib/server/auth';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {}
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { error, redirect, type Handle } from '@sveltejs/kit';
|
||||||
|
import { validateSession } from '$lib/server/auth';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = new Set(['/login', '/signup']);
|
||||||
|
|
||||||
|
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 (path.startsWith('/api/')) error(401, 'Not signed in');
|
||||||
|
redirect(303, '/login');
|
||||||
|
}
|
||||||
|
if (event.locals.user && PUBLIC_PATHS.has(path)) {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
@@ -19,16 +19,26 @@
|
|||||||
const STYLE_URL = 'https://maptiler.servert.nl/styles/minimal-world-maps/style.json';
|
const STYLE_URL = 'https://maptiler.servert.nl/styles/minimal-world-maps/style.json';
|
||||||
const TRACK_COLOR = '#2a78d6';
|
const TRACK_COLOR = '#2a78d6';
|
||||||
|
|
||||||
|
export interface Viewport {
|
||||||
|
minLat: number;
|
||||||
|
minLon: number;
|
||||||
|
maxLat: number;
|
||||||
|
maxLon: number;
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tracks = [],
|
tracks = [],
|
||||||
peaks = [],
|
peaks = [],
|
||||||
height = '420px',
|
height = '420px',
|
||||||
onpeakclick
|
onpeakclick,
|
||||||
|
onviewport
|
||||||
}: {
|
}: {
|
||||||
tracks?: MapTrack[];
|
tracks?: MapTrack[];
|
||||||
peaks?: MapPeak[];
|
peaks?: MapPeak[];
|
||||||
height?: string;
|
height?: string;
|
||||||
onpeakclick?: (id: number) => void;
|
onpeakclick?: (id: number) => void;
|
||||||
|
onviewport?: (view: Viewport) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
@@ -108,6 +118,22 @@
|
|||||||
|
|
||||||
renderPeaks();
|
renderPeaks();
|
||||||
|
|
||||||
|
if (onviewport) {
|
||||||
|
const report = () => {
|
||||||
|
if (!map) return;
|
||||||
|
const b = map.getBounds();
|
||||||
|
onviewport({
|
||||||
|
minLat: b.getSouth(),
|
||||||
|
minLon: b.getWest(),
|
||||||
|
maxLat: b.getNorth(),
|
||||||
|
maxLon: b.getEast(),
|
||||||
|
zoom: map.getZoom()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
map.on('moveend', report);
|
||||||
|
map.once('load', report);
|
||||||
|
}
|
||||||
|
|
||||||
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]);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { db } from './db';
|
||||||
|
|
||||||
|
const SESSION_DAYS = 30;
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString('hex');
|
||||||
|
return `scrypt:${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [scheme, salt, hash] = stored.split(':');
|
||||||
|
if (scheme !== 'scrypt' || !salt || !hash) return false;
|
||||||
|
const candidate = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
||||||
|
return timingSafeEqual(candidate, Buffer.from(hash, 'hex'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(username: string, password: string): User {
|
||||||
|
const result = db
|
||||||
|
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||||
|
.run(username, hashPassword(password));
|
||||||
|
return { id: Number(result.lastInsertRowid), username };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUser(username: string): { id: number; username: string; password_hash: string } | undefined {
|
||||||
|
return db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get(username) as
|
||||||
|
| { id: number; username: string; password_hash: string }
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenId(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(userId: number): string {
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
const expires = Date.now() + SESSION_DAYS * 86400_000;
|
||||||
|
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
|
||||||
|
tokenId(token),
|
||||||
|
userId,
|
||||||
|
expires
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSession(token: string | undefined): User | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT s.id sid, s.expires_at, u.id, u.username
|
||||||
|
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ?`
|
||||||
|
)
|
||||||
|
.get(tokenId(token)) as { sid: string; expires_at: number; id: number; username: string } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expires_at < Date.now()) {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE id = ?').run(row.sid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// sliding renewal once past the halfway point
|
||||||
|
if (row.expires_at - Date.now() < (SESSION_DAYS / 2) * 86400_000) {
|
||||||
|
db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(
|
||||||
|
Date.now() + SESSION_DAYS * 86400_000,
|
||||||
|
row.sid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { id: row.id, username: row.username };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroySession(token: string | undefined): void {
|
||||||
|
if (token) db.prepare('DELETE FROM sessions WHERE id = ?').run(tokenId(token));
|
||||||
|
}
|
||||||
+192
-37
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { PEAKS } from './peaks-seed';
|
import { PEAKS } from './peaks-seed';
|
||||||
|
import { assignMinzoom, scorePeak } from './peak-score';
|
||||||
|
|
||||||
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
|
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
@@ -11,8 +12,22 @@ db.pragma('journal_mode = WAL');
|
|||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS activities (
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type TEXT NOT NULL DEFAULT 'outdoor',
|
type TEXT NOT NULL DEFAULT 'outdoor',
|
||||||
date TEXT,
|
date TEXT,
|
||||||
@@ -27,31 +42,75 @@ db.exec(`
|
|||||||
bounds TEXT NOT NULL,
|
bounds TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activities_user ON activities(user_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS peaks (
|
CREATE TABLE IF NOT EXISTS peaks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
osm_id TEXT NOT NULL UNIQUE,
|
||||||
elevation_m INTEGER NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
elevation_m REAL,
|
||||||
lat REAL NOT NULL,
|
lat REAL NOT NULL,
|
||||||
lon REAL NOT NULL,
|
lon REAL NOT NULL,
|
||||||
country TEXT NOT NULL,
|
country TEXT,
|
||||||
region TEXT NOT NULL,
|
region TEXT,
|
||||||
|
wikipedia TEXT,
|
||||||
|
wikidata TEXT,
|
||||||
|
score REAL NOT NULL DEFAULT 0,
|
||||||
|
minzoom INTEGER NOT NULL DEFAULT 14
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_peaks_lat_zoom ON peaks(minzoom, lat, lon);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ascents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
peak_id INTEGER NOT NULL REFERENCES peaks(id) ON DELETE CASCADE,
|
||||||
climbed_at TEXT,
|
climbed_at TEXT,
|
||||||
activity_id INTEGER REFERENCES activities(id) ON DELETE SET NULL,
|
activity_id INTEGER REFERENCES activities(id) ON DELETE SET NULL,
|
||||||
note TEXT
|
note TEXT,
|
||||||
|
UNIQUE (user_id, peak_id)
|
||||||
);
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ascents_user ON ascents(user_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const seedPeak = db.prepare(`
|
// Seed the catalog with the curated list while no OSM import has run.
|
||||||
INSERT OR IGNORE INTO peaks (name, elevation_m, lat, lon, country, region)
|
// Seed rows use osm_id "seed:<name>"; scripts/import-peaks.js upgrades them
|
||||||
VALUES (@name, @elevation_m, @lat, @lon, @country, @region)
|
// to real OSM nodes (preserving ascents) and fills in the rest of the Alps.
|
||||||
|
const peakCount = (db.prepare('SELECT count(*) n FROM peaks').get() as { n: number }).n;
|
||||||
|
if (peakCount === 0) {
|
||||||
|
const seeded = PEAKS.map((p) => ({
|
||||||
|
...p,
|
||||||
|
lat: p.lat,
|
||||||
|
lon: p.lon,
|
||||||
|
wikipedia: 'seed',
|
||||||
|
score: 0,
|
||||||
|
minzoom: 14
|
||||||
|
}));
|
||||||
|
for (const p of seeded) p.score = scorePeak(p);
|
||||||
|
assignMinzoom(seeded);
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT INTO peaks (osm_id, name, elevation_m, lat, lon, country, region, score, minzoom)
|
||||||
|
VALUES (@osm_id, @name, @elevation_m, @lat, @lon, @country, @region, @score, @minzoom)
|
||||||
`);
|
`);
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const peak of PEAKS) seedPeak.run(peak);
|
for (const p of seeded) {
|
||||||
|
insert.run({
|
||||||
|
osm_id: `seed:${p.name}`,
|
||||||
|
name: p.name,
|
||||||
|
elevation_m: p.elevation_m,
|
||||||
|
lat: p.lat,
|
||||||
|
lon: p.lon,
|
||||||
|
country: p.country,
|
||||||
|
region: p.region,
|
||||||
|
score: p.score,
|
||||||
|
minzoom: p.minzoom
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActivityRow {
|
export interface ActivityRow {
|
||||||
id: number;
|
id: number;
|
||||||
|
user_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
@@ -69,55 +128,151 @@ export interface ActivityRow {
|
|||||||
|
|
||||||
export interface PeakRow {
|
export interface PeakRow {
|
||||||
id: number;
|
id: number;
|
||||||
|
osm_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
elevation_m: number;
|
elevation_m: number | null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
country: string;
|
country: string | null;
|
||||||
region: string;
|
region: string | null;
|
||||||
|
wikipedia: string | null;
|
||||||
|
wikidata: string | null;
|
||||||
|
score: number;
|
||||||
|
minzoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AscentRow {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
peak_id: number;
|
||||||
climbed_at: string | null;
|
climbed_at: string | null;
|
||||||
activity_id: number | null;
|
activity_id: number | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listActivities(): Omit<ActivityRow, 'points'>[] {
|
export function listActivities(userId: number): Omit<ActivityRow, 'points'>[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, name, type, date, distance_m, duration_s, moving_s,
|
`SELECT id, user_id, name, type, date, distance_m, duration_s, moving_s,
|
||||||
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, bounds, created_at
|
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, bounds, created_at
|
||||||
FROM activities ORDER BY date DESC, id DESC`
|
FROM activities WHERE user_id = ? ORDER BY date DESC, id DESC`
|
||||||
)
|
)
|
||||||
.all() as Omit<ActivityRow, 'points'>[];
|
.all(userId) as Omit<ActivityRow, 'points'>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActivity(id: number): ActivityRow | undefined {
|
export function getActivity(id: number, userId: number): ActivityRow | undefined {
|
||||||
return db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow | undefined;
|
return db.prepare('SELECT * FROM activities WHERE id = ? AND user_id = ?').get(id, userId) as
|
||||||
|
| ActivityRow
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteActivity(id: number): void {
|
export function deleteActivity(id: number, userId: number): void {
|
||||||
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
|
db.prepare('DELETE FROM activities WHERE id = ? AND user_id = ?').run(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listPeaks(): PeakRow[] {
|
/** Notable peaks in a bounding box for a zoom level, with the user's climbed state. */
|
||||||
return db.prepare('SELECT * FROM peaks ORDER BY elevation_m DESC').all() as PeakRow[];
|
export function peaksInView(
|
||||||
|
userId: number,
|
||||||
|
bbox: { minLat: number; minLon: number; maxLat: number; maxLon: number },
|
||||||
|
zoom: number,
|
||||||
|
limit = 300
|
||||||
|
): (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
|
||||||
|
AND p.lat BETWEEN @minLat AND @maxLat
|
||||||
|
AND p.lon BETWEEN @minLon AND @maxLon
|
||||||
|
ORDER BY p.score DESC
|
||||||
|
LIMIT @limit`
|
||||||
|
)
|
||||||
|
.all({ userId, zoom: Math.floor(zoom), ...bbox, limit }) as (PeakRow & {
|
||||||
|
climbed_at: string | null;
|
||||||
|
ascent_activity_id: number | null;
|
||||||
|
})[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function togglePeak(id: number, date?: string): PeakRow | undefined {
|
/** Peaks near a track's bounding box, for summit detection on upload. */
|
||||||
const peak = db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow | undefined;
|
export function peaksNearBounds(
|
||||||
if (!peak) return undefined;
|
bounds: [[number, number], [number, number]],
|
||||||
if (peak.climbed_at) {
|
marginDeg = 0.01
|
||||||
db.prepare('UPDATE peaks SET climbed_at = NULL, activity_id = NULL WHERE id = ?').run(id);
|
): PeakRow[] {
|
||||||
} else {
|
return db
|
||||||
db.prepare('UPDATE peaks SET climbed_at = ? WHERE id = ?').run(
|
.prepare(
|
||||||
date ?? new Date().toISOString().slice(0, 10),
|
`SELECT * FROM peaks
|
||||||
id
|
WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?`
|
||||||
|
)
|
||||||
|
.all(
|
||||||
|
bounds[0][0] - marginDeg,
|
||||||
|
bounds[1][0] + marginDeg,
|
||||||
|
bounds[0][1] - marginDeg,
|
||||||
|
bounds[1][1] + marginDeg
|
||||||
|
) as PeakRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAscents(
|
||||||
|
userId: number
|
||||||
|
): (AscentRow & { name: string; elevation_m: number | null; lat: number; lon: number })[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.*, p.name, p.elevation_m, p.lat, p.lon
|
||||||
|
FROM ascents a JOIN peaks p ON p.id = a.peak_id
|
||||||
|
WHERE a.user_id = ?
|
||||||
|
ORDER BY p.elevation_m DESC`
|
||||||
|
)
|
||||||
|
.all(userId) as (AscentRow & {
|
||||||
|
name: string;
|
||||||
|
elevation_m: number | null;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
})[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countAscents(userId: number): number {
|
||||||
|
return (db.prepare('SELECT count(*) n FROM ascents WHERE user_id = ?').get(userId) as { n: number }).n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle an ascent; returns the new climbed_at (or null if now unclimbed). */
|
||||||
|
export function toggleAscent(userId: number, peakId: number): string | null {
|
||||||
|
const existing = db
|
||||||
|
.prepare('SELECT id FROM ascents WHERE user_id = ? AND peak_id = ?')
|
||||||
|
.get(userId, peakId) as { id: number } | undefined;
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('DELETE FROM ascents WHERE id = ?').run(existing.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
db.prepare('INSERT INTO ascents (user_id, peak_id, climbed_at) VALUES (?, ?, ?)').run(
|
||||||
|
userId,
|
||||||
|
peakId,
|
||||||
|
date
|
||||||
);
|
);
|
||||||
}
|
return date;
|
||||||
return db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markPeakClimbed(peakId: number, activityId: number, date: string | null): void {
|
export function recordAscent(
|
||||||
db.prepare(
|
userId: number,
|
||||||
'UPDATE peaks SET climbed_at = ?, activity_id = ? WHERE id = ? AND climbed_at IS NULL'
|
peakId: number,
|
||||||
).run(date ?? new Date().toISOString().slice(0, 10), activityId, peakId);
|
activityId: number,
|
||||||
|
date: string | null
|
||||||
|
): boolean {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO ascents (user_id, peak_id, climbed_at, activity_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (user_id, peak_id) DO NOTHING`
|
||||||
|
)
|
||||||
|
.run(userId, peakId, date ?? new Date().toISOString().slice(0, 10), activityId);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function baggedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.*, a.climbed_at FROM ascents a JOIN peaks p ON p.id = a.peak_id
|
||||||
|
WHERE a.activity_id = ? AND a.user_id = ?`
|
||||||
|
)
|
||||||
|
.all(activityId, userId) as (PeakRow & { climbed_at: string | null })[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Notability scoring and zoom-level thinning for the peaks catalog.
|
||||||
|
// Plain JS so both the SvelteKit server and scripts/import-peaks.js can use it.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher score = more notable. Elevation is the base; encyclopedic presence
|
||||||
|
* and topographic prominence (when tagged in OSM) push famous peaks up.
|
||||||
|
* @param {{ elevation_m: number | null, wikipedia?: string | null, wikidata?: string | null, prominence_m?: number | null }} peak
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function scorePeak(peak) {
|
||||||
|
let score = peak.elevation_m ?? 0;
|
||||||
|
if (peak.wikipedia) score += 800;
|
||||||
|
if (peak.wikidata) score += 400;
|
||||||
|
if (peak.prominence_m) score += Math.min(peak.prominence_m, 1500);
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
* 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
|
||||||
|
* @param {P[]} peaks
|
||||||
|
* @returns {P[]} the same array, each peak with `minzoom` set
|
||||||
|
*/
|
||||||
|
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 occupied = new Set();
|
||||||
|
for (const peak of sorted) {
|
||||||
|
const key = `${Math.floor(peak.lon / cell)}:${Math.floor(peak.lat / cell)}`;
|
||||||
|
if (occupied.has(key)) continue;
|
||||||
|
occupied.add(key);
|
||||||
|
if (peak.minzoom === MAX_MINZOOM) peak.minzoom = z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peaks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return { user: locals.user };
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: '/', label: 'Dashboard' },
|
{ href: '/', label: 'Dashboard' },
|
||||||
@@ -30,11 +30,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Streba
|
Streba
|
||||||
</a>
|
</a>
|
||||||
|
{#if data.user}
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
{#each links as link (link.href)}
|
{#each links as link (link.href)}
|
||||||
<a href={link.href} class:active={isActive(link.href)}>{link.label}</a>
|
<a href={link.href} class:active={isActive(link.href)}>{link.label}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<form class="user" method="POST" action="/logout">
|
||||||
|
<span class="username">{data.user.username}</span>
|
||||||
|
<button class="logout" type="submit" title="Sign out">Sign out</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -97,6 +103,31 @@
|
|||||||
background: var(--accent-wash);
|
background: var(--accent-wash);
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
.user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.username {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.logout {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.logout:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
main {
|
main {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
+12
-12
@@ -1,16 +1,14 @@
|
|||||||
import { db, listActivities, listPeaks } from '$lib/server/db';
|
import { countAscents, db, listActivities, listAscents } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = () => {
|
export const load: PageServerLoad = ({ locals }) => {
|
||||||
const activities = listActivities();
|
const userId = locals.user!.id;
|
||||||
const peaks = listPeaks();
|
const activities = listActivities(userId);
|
||||||
|
|
||||||
// all tracks, thinned for the overview map
|
// all of the user's tracks, thinned for the overview map
|
||||||
const rows = db.prepare('SELECT id, name, points FROM activities').all() as {
|
const rows = db
|
||||||
id: number;
|
.prepare('SELECT id, name, points FROM activities WHERE user_id = ?')
|
||||||
name: string;
|
.all(userId) as { id: number; name: string; points: string }[];
|
||||||
points: 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));
|
||||||
@@ -20,6 +18,8 @@ export const load: PageServerLoad = () => {
|
|||||||
return { id: row.id, name: row.name, latlngs };
|
return { id: row.id, name: row.name, latlngs };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ascents = listAscents(userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activities,
|
activities,
|
||||||
tracks,
|
tracks,
|
||||||
@@ -29,7 +29,7 @@ export const load: PageServerLoad = () => {
|
|||||||
elev_gain_m: activities.reduce((sum, a) => sum + a.elev_gain_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)
|
moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
|
||||||
},
|
},
|
||||||
peaksClimbed: peaks.filter((p) => p.climbed_at).length,
|
peaksClimbed: countAscents(userId),
|
||||||
peaksTotal: peaks.length
|
highestAscent: ascents[0] ?? null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-16
@@ -48,12 +48,15 @@
|
|||||||
<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.peaksClimbed}</span>
|
||||||
<span class="of">of {data.peaksTotal} peaks climbed</span>
|
<span class="of">peak{data.peaksClimbed === 1 ? '' : 's'} climbed</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={data.peaksTotal} aria-valuenow={data.peaksClimbed} aria-label="Peaks climbed">
|
{#if data.highestAscent}
|
||||||
<div class="fill" style:width="{(data.peaksClimbed / data.peaksTotal) * 100}%"></div>
|
<p class="highest">
|
||||||
</div>
|
Highest so far: <strong>{data.highestAscent.name}</strong>
|
||||||
<a class="btn ghost" href="/peaks">Open the checklist</a>
|
({Math.round(data.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m)
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<a class="btn ghost" href="/peaks">Open the peak map</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,16 +118,9 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-left: 0.4rem;
|
margin-left: 0.4rem;
|
||||||
}
|
}
|
||||||
.meter {
|
.highest {
|
||||||
width: 100%;
|
margin: 0;
|
||||||
height: 8px;
|
font-size: 0.88rem;
|
||||||
border-radius: 999px;
|
color: var(--text-secondary);
|
||||||
background: var(--accent-track);
|
|
||||||
}
|
|
||||||
.fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--accent);
|
|
||||||
min-width: 2px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { listActivities } from '$lib/server/db';
|
import { listActivities } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = () => {
|
export const load: PageServerLoad = ({ locals }) => {
|
||||||
return { activities: listActivities() };
|
return { activities: listActivities(locals.user!.id) };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { db, deleteActivity, getActivity } from '$lib/server/db';
|
import { baggedPeaks, deleteActivity, getActivity } from '$lib/server/db';
|
||||||
import { haversine } from '$lib/server/gpx';
|
import { haversine } from '$lib/server/gpx';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import type { PeakRow } from '$lib/server/db';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ params }) => {
|
export const load: PageServerLoad = ({ params, locals }) => {
|
||||||
const activity = getActivity(Number(params.id));
|
const activity = getActivity(Number(params.id), locals.user!.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 {
|
||||||
@@ -27,9 +26,7 @@ export const load: PageServerLoad = ({ params }) => {
|
|||||||
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 = db
|
const bagged = baggedPeaks(activity.id, locals.user!.id);
|
||||||
.prepare('SELECT * FROM peaks WHERE activity_id = ?')
|
|
||||||
.all(activity.id) as PeakRow[];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activity: { ...activity, points: undefined },
|
activity: { ...activity, points: undefined },
|
||||||
@@ -38,7 +35,7 @@ export const load: PageServerLoad = ({ params }) => {
|
|||||||
bagged: bagged.map((p) => ({
|
bagged: bagged.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
elevation_m: p.elevation_m,
|
elevation_m: Math.round(p.elevation_m ?? 0),
|
||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
lon: p.lon,
|
lon: p.lon,
|
||||||
climbed: true
|
climbed: true
|
||||||
@@ -47,8 +44,8 @@ export const load: PageServerLoad = ({ params }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
delete: async ({ params }) => {
|
delete: async ({ params, locals }) => {
|
||||||
deleteActivity(Number(params.id));
|
deleteActivity(Number(params.id), locals.user!.id);
|
||||||
redirect(303, '/activities');
|
redirect(303, '/activities');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { peaksInView } from '$lib/server/db';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ url, locals }) => {
|
||||||
|
const num = (name: string) => {
|
||||||
|
const value = parseFloat(url.searchParams.get(name) ?? '');
|
||||||
|
if (!Number.isFinite(value)) error(400, `Missing or invalid "${name}"`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const bbox = {
|
||||||
|
minLat: num('minLat'),
|
||||||
|
minLon: num('minLon'),
|
||||||
|
maxLat: num('maxLat'),
|
||||||
|
maxLon: num('maxLon')
|
||||||
|
};
|
||||||
|
const zoom = num('zoom');
|
||||||
|
|
||||||
|
const peaks = peaksInView(locals.user!.id, bbox, zoom).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
elevation_m: p.elevation_m,
|
||||||
|
lat: p.lat,
|
||||||
|
lon: p.lon,
|
||||||
|
climbed_at: p.climbed_at
|
||||||
|
}));
|
||||||
|
return json({ peaks });
|
||||||
|
};
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { error, json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { togglePeak } from '$lib/server/db';
|
import { toggleAscent } from '$lib/server/db';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = ({ params }) => {
|
export const POST: RequestHandler = ({ params, locals }) => {
|
||||||
const peak = togglePeak(Number(params.id));
|
const climbed_at = toggleAscent(locals.user!.id, Number(params.id));
|
||||||
if (!peak) error(404, 'Peak not found');
|
return json({ id: Number(params.id), climbed_at });
|
||||||
return json(peak);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createSession, findUser, verifyPassword } from '$lib/server/auth';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, cookies }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const username = String(form.get('username') ?? '').trim();
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
|
||||||
|
const user = username ? findUser(username) : undefined;
|
||||||
|
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||||
|
return fail(400, { username, error: 'Wrong username or password.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.set('session', createSession(user.id), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 30 * 86400
|
||||||
|
});
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { form } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Sign in · Streba</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="auth card">
|
||||||
|
<h1>Welcome back</h1>
|
||||||
|
<p class="page-sub">Sign in to your Streba account.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input name="username" required autocomplete="username" value={form?.username ?? ''} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input name="password" type="password" required autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
||||||
|
<button class="btn" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
<p class="alt">No account yet? <a href="/signup">Sign up</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth {
|
||||||
|
max-width: 380px;
|
||||||
|
margin: 3rem auto 0;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--critical);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.alt {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.alt a {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { destroySession } from '$lib/server/auth';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = ({ cookies }) => {
|
||||||
|
destroySession(cookies.get('session'));
|
||||||
|
cookies.delete('session', { path: '/' });
|
||||||
|
redirect(303, '/login');
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { listPeaks } from '$lib/server/db';
|
import { listAscents } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = () => {
|
export const load: PageServerLoad = ({ locals }) => {
|
||||||
return { peaks: listPeaks() };
|
return { ascents: listAscents(locals.user!.id) };
|
||||||
};
|
};
|
||||||
|
|||||||
+122
-62
@@ -1,49 +1,92 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Map from '$lib/components/Map.svelte';
|
import Map, { type Viewport } from '$lib/components/Map.svelte';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally -- local copy so toggles update instantly; resynced below
|
interface ViewPeak {
|
||||||
let peaks = $state(data.peaks);
|
id: number;
|
||||||
$effect(() => {
|
name: string;
|
||||||
peaks = data.peaks;
|
elevation_m: number | null;
|
||||||
});
|
lat: number;
|
||||||
let filter: 'all' | 'climbed' | 'remaining' = $state('all');
|
lon: number;
|
||||||
|
climbed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const climbed = $derived(peaks.filter((p) => p.climbed_at).length);
|
// svelte-ignore state_referenced_locally -- local copy so toggles update instantly; resynced below
|
||||||
const highest = $derived(peaks.filter((p) => p.climbed_at).sort((a, b) => b.elevation_m - a.elevation_m)[0]);
|
let ascents = $state(data.ascents);
|
||||||
|
$effect(() => {
|
||||||
|
ascents = data.ascents;
|
||||||
|
});
|
||||||
|
|
||||||
|
let viewPeaks: ViewPeak[] = $state([]);
|
||||||
|
let tab: 'view' | 'mine' = $state('view');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
const highest = $derived(ascents[0] ?? null);
|
||||||
|
|
||||||
const mapPeaks = $derived(
|
const mapPeaks = $derived(
|
||||||
peaks.map((p) => ({
|
viewPeaks.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
elevation_m: p.elevation_m,
|
elevation_m: Math.round(p.elevation_m ?? 0),
|
||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
lon: p.lon,
|
lon: p.lon,
|
||||||
climbed: !!p.climbed_at
|
climbed: !!p.climbed_at
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const regions = $derived.by(() => {
|
let fetchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const visible = peaks.filter((p) =>
|
let lastView: Viewport | undefined;
|
||||||
filter === 'all' ? true : filter === 'climbed' ? !!p.climbed_at : !p.climbed_at
|
|
||||||
);
|
function onviewport(view: Viewport) {
|
||||||
const byRegion = new globalThis.Map<string, typeof visible>();
|
lastView = view;
|
||||||
for (const p of visible) {
|
clearTimeout(fetchTimer);
|
||||||
if (!byRegion.has(p.region)) byRegion.set(p.region, []);
|
fetchTimer = setTimeout(loadPeaks, 250);
|
||||||
byRegion.get(p.region)!.push(p);
|
|
||||||
}
|
}
|
||||||
return [...byRegion.entries()].sort(
|
|
||||||
(a, b) => Math.max(...b[1].map((p) => p.elevation_m)) - Math.max(...a[1].map((p) => p.elevation_m))
|
async function loadPeaks() {
|
||||||
);
|
if (!lastView) return;
|
||||||
|
loading = true;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
minLat: String(lastView.minLat),
|
||||||
|
minLon: String(lastView.minLon),
|
||||||
|
maxLat: String(lastView.maxLat),
|
||||||
|
maxLon: String(lastView.maxLon),
|
||||||
|
zoom: String(Math.round(lastView.zoom))
|
||||||
});
|
});
|
||||||
|
const res = await fetch(`/api/peaks?${params}`);
|
||||||
|
if (res.ok) viewPeaks = (await res.json()).peaks;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function toggle(id: number) {
|
async function toggle(id: number) {
|
||||||
const res = await fetch(`/api/peaks/${id}/toggle`, { method: 'POST' });
|
const res = await fetch(`/api/peaks/${id}/toggle`, { method: 'POST' });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const updated = await res.json();
|
const { climbed_at } = await res.json();
|
||||||
peaks = peaks.map((p) => (p.id === id ? updated : p));
|
viewPeaks = viewPeaks.map((p) => (p.id === id ? { ...p, climbed_at } : p));
|
||||||
|
if (climbed_at === null) {
|
||||||
|
ascents = ascents.filter((a) => a.peak_id !== id);
|
||||||
|
} else {
|
||||||
|
const peak = viewPeaks.find((p) => p.id === id);
|
||||||
|
if (peak) {
|
||||||
|
ascents = [
|
||||||
|
...ascents,
|
||||||
|
{
|
||||||
|
id: -id,
|
||||||
|
user_id: 0,
|
||||||
|
peak_id: id,
|
||||||
|
climbed_at,
|
||||||
|
activity_id: null,
|
||||||
|
note: null,
|
||||||
|
name: peak.name,
|
||||||
|
elevation_m: peak.elevation_m,
|
||||||
|
lat: peak.lat,
|
||||||
|
lon: peak.lon
|
||||||
|
}
|
||||||
|
].sort((a, b) => (b.elevation_m ?? 0) - (a.elevation_m ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -53,38 +96,34 @@
|
|||||||
|
|
||||||
<h1>Alpine peaks</h1>
|
<h1>Alpine peaks</h1>
|
||||||
<p class="page-sub">
|
<p class="page-sub">
|
||||||
{climbed} of {peaks.length} peaks climbed{#if highest} · highest so far: {highest.name} ({highest.elevation_m.toLocaleString('en-US')} m){/if}.
|
{ascents.length} peak{ascents.length === 1 ? '' : 's'} climbed{#if highest} · highest so far: {highest.name}
|
||||||
Click a peak on the map or in the list to cross it off.
|
({Math.round(highest.elevation_m ?? 0).toLocaleString('en-US')} m){/if}.
|
||||||
|
Zoom in to reveal less prominent summits; click a peak to cross it off.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={peaks.length} aria-valuenow={climbed} aria-label="Peaks climbed">
|
<Map peaks={mapPeaks} height="480px" onpeakclick={toggle} {onviewport} />
|
||||||
<div class="fill" style:width="{(climbed / peaks.length) * 100}%"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Map peaks={mapPeaks} height="480px" onpeakclick={toggle} />
|
<div class="tabs" role="group" aria-label="Peak lists">
|
||||||
|
<button class="filter-btn" class:on={tab === 'view'} onclick={() => (tab = 'view')}>
|
||||||
<div class="filters" role="group" aria-label="Filter peaks">
|
In view {loading ? '…' : `(${viewPeaks.length})`}
|
||||||
{#each [['all', 'All'], ['remaining', 'To climb'], ['climbed', 'Climbed']] as [key, label] (key)}
|
</button>
|
||||||
<button
|
<button class="filter-btn" class:on={tab === 'mine'} onclick={() => (tab = 'mine')}>
|
||||||
class="filter-btn"
|
My ascents ({ascents.length})
|
||||||
class:on={filter === key}
|
|
||||||
onclick={() => (filter = key as typeof filter)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each regions as [region, list] (region)}
|
{#if tab === 'view'}
|
||||||
<h2>{region}</h2>
|
{#if viewPeaks.length === 0}
|
||||||
|
<div class="card empty">No notable peaks in this view - try panning to the Alps or zooming in.</div>
|
||||||
|
{:else}
|
||||||
<div class="card grid">
|
<div class="card grid">
|
||||||
{#each list as peak (peak.id)}
|
{#each viewPeaks as peak (peak.id)}
|
||||||
<button class="peak" class:done={peak.climbed_at} onclick={() => toggle(peak.id)}>
|
<button class="peak" class:done={peak.climbed_at} onclick={() => toggle(peak.id)}>
|
||||||
<span class="check" aria-hidden="true">{peak.climbed_at ? '✓' : ''}</span>
|
<span class="check" aria-hidden="true">{peak.climbed_at ? '✓' : ''}</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.toLocaleString('en-US')} m · {peak.country}
|
{peak.elevation_m ? `${Math.round(peak.elevation_m).toLocaleString('en-US')} m` : 'elevation unknown'}
|
||||||
{#if peak.climbed_at}
|
{#if peak.climbed_at}
|
||||||
· climbed {fmtDate(peak.climbed_at)}
|
· climbed {fmtDate(peak.climbed_at)}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -93,28 +132,41 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if ascents.length === 0}
|
||||||
|
<div class="card empty">
|
||||||
|
Nothing climbed yet - click a peak on the map, or upload a GPX track that crosses a summit.
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card empty-filter">Nothing here - try another filter.</div>
|
<div class="card grid">
|
||||||
|
{#each ascents as ascent (ascent.peak_id)}
|
||||||
|
<div class="peak done">
|
||||||
|
<button
|
||||||
|
class="check"
|
||||||
|
title="Remove ascent"
|
||||||
|
aria-label="Remove ascent of {ascent.name}"
|
||||||
|
onclick={() => toggle(ascent.peak_id)}>✓</button
|
||||||
|
>
|
||||||
|
<span class="info">
|
||||||
|
<span class="name">{ascent.name}</span>
|
||||||
|
<span class="sub">
|
||||||
|
{ascent.elevation_m ? `${Math.round(ascent.elevation_m).toLocaleString('en-US')} m` : ''}
|
||||||
|
· climbed {fmtDate(ascent.climbed_at)}
|
||||||
|
{#if ascent.activity_id}
|
||||||
|
· <a href="/activities/{ascent.activity_id}">view activity</a>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.meter {
|
.tabs {
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--accent-track);
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
.fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--accent);
|
|
||||||
min-width: 2px;
|
|
||||||
}
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.25rem;
|
||||||
}
|
}
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
padding: 0.35rem 0.9rem;
|
padding: 0.35rem 0.9rem;
|
||||||
@@ -138,6 +190,7 @@
|
|||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
.peak {
|
.peak {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -159,14 +212,18 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1.5px solid var(--baseline);
|
border: 1.5px solid var(--baseline);
|
||||||
|
background: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
transition: background 120ms, border-color 120ms;
|
transition: background 120ms, border-color 120ms;
|
||||||
}
|
}
|
||||||
.peak.done .check {
|
.peak.done .check {
|
||||||
@@ -192,8 +249,11 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.empty-filter {
|
.sub a {
|
||||||
margin-top: 1rem;
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
margin-top: 0.75rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createSession, createUser, findUser } from '$lib/server/auth';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, cookies }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const username = String(form.get('username') ?? '').trim();
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9_.-]{3,30}$/.test(username)) {
|
||||||
|
return fail(400, {
|
||||||
|
username,
|
||||||
|
error: 'Username must be 3-30 characters (letters, digits, _ . -).'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
return fail(400, { username, error: 'Password must be at least 8 characters.' });
|
||||||
|
}
|
||||||
|
if (findUser(username)) {
|
||||||
|
return fail(400, { username, error: 'That username is taken.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = createUser(username, password);
|
||||||
|
cookies.set('session', createSession(user.id), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 30 * 86400
|
||||||
|
});
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { form } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Sign up · Streba</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="auth card">
|
||||||
|
<h1>Create account</h1>
|
||||||
|
<p class="page-sub">Track your activities and bag Alpine peaks.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="30"
|
||||||
|
pattern="[a-zA-Z0-9_.\-]+"
|
||||||
|
autocomplete="username"
|
||||||
|
value={form?.username ?? ''}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input name="password" type="password" required minlength="8" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
||||||
|
<button class="btn" type="submit">Sign up</button>
|
||||||
|
</form>
|
||||||
|
<p class="alt">Already have an account? <a href="/login">Sign in</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth {
|
||||||
|
max-width: 380px;
|
||||||
|
margin: 3rem auto 0;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--critical);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.alt {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.alt a {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { db, listPeaks, markPeakClimbed } from '$lib/server/db';
|
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';
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request }) => {
|
default: async ({ request, locals }) => {
|
||||||
|
const userId = locals.user!.id;
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const files = form.getAll('gpx').filter((f): f is File => f instanceof File && f.size > 0);
|
const files = form.getAll('gpx').filter((f): f is File => f instanceof File && f.size > 0);
|
||||||
if (files.length === 0) return fail(400, { error: 'No GPX files selected.' });
|
if (files.length === 0) return fail(400, { error: 'No GPX files selected.' });
|
||||||
@@ -14,10 +15,10 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const insert = db.prepare(`
|
const insert = db.prepare(`
|
||||||
INSERT INTO activities
|
INSERT INTO activities
|
||||||
(name, type, date, distance_m, duration_s, moving_s,
|
(user_id, name, type, date, distance_m, duration_s, moving_s,
|
||||||
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, points, bounds)
|
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, points, bounds)
|
||||||
VALUES
|
VALUES
|
||||||
(@name, @type, @date, @distance_m, @duration_s, @moving_s,
|
(@user_id, @name, @type, @date, @distance_m, @duration_s, @moving_s,
|
||||||
@elev_gain_m, @elev_loss_m, @elev_min_m, @elev_max_m, @points, @bounds)
|
@elev_gain_m, @elev_loss_m, @elev_min_m, @elev_max_m, @points, @bounds)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export const actions: Actions = {
|
|||||||
try {
|
try {
|
||||||
const gpx = parseGpx(await file.text());
|
const gpx = parseGpx(await file.text());
|
||||||
const result = insert.run({
|
const result = insert.run({
|
||||||
|
user_id: userId,
|
||||||
name: gpx.name ?? file.name.replace(/\.gpx$/i, ''),
|
name: gpx.name ?? file.name.replace(/\.gpx$/i, ''),
|
||||||
type: gpx.type ?? 'outdoor',
|
type: gpx.type ?? 'outdoor',
|
||||||
date: gpx.date,
|
date: gpx.date,
|
||||||
@@ -40,15 +42,14 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
const activityId = Number(result.lastInsertRowid);
|
const activityId = Number(result.lastInsertRowid);
|
||||||
|
|
||||||
const unclimbed = listPeaks().filter((p) => !p.climbed_at);
|
const nearby = peaksNearBounds(gpx.bounds);
|
||||||
const bagged = matchPeaks(gpx.points, unclimbed);
|
const summited = matchPeaks(gpx.points, nearby);
|
||||||
for (const peak of bagged) markPeakClimbed(peak.id, activityId, gpx.date);
|
const newPeaks: string[] = [];
|
||||||
|
for (const peak of summited) {
|
||||||
|
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
|
||||||
|
}
|
||||||
|
|
||||||
uploaded.push({
|
uploaded.push({ id: activityId, name: gpx.name ?? file.name, newPeaks });
|
||||||
id: activityId,
|
|
||||||
name: gpx.name ?? file.name,
|
|
||||||
newPeaks: bagged.map((p) => p.name)
|
|
||||||
});
|
|
||||||
} 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'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user