add user accounts and OSM peak catalog with zoom-based notability
This commit is contained in:
Vendored
+7
-1
@@ -1,5 +1,11 @@
|
||||
import type { User } from '$lib/server/auth';
|
||||
|
||||
declare global {
|
||||
namespace App {}
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: User | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 TRACK_COLOR = '#2a78d6';
|
||||
|
||||
export interface Viewport {
|
||||
minLat: number;
|
||||
minLon: number;
|
||||
maxLat: number;
|
||||
maxLon: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
let {
|
||||
tracks = [],
|
||||
peaks = [],
|
||||
height = '420px',
|
||||
onpeakclick
|
||||
onpeakclick,
|
||||
onviewport
|
||||
}: {
|
||||
tracks?: MapTrack[];
|
||||
peaks?: MapPeak[];
|
||||
height?: string;
|
||||
onpeakclick?: (id: number) => void;
|
||||
onviewport?: (view: Viewport) => void;
|
||||
} = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
@@ -108,6 +118,22 @@
|
||||
|
||||
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();
|
||||
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]);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+195
-40
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { PEAKS } from './peaks-seed';
|
||||
import { assignMinzoom, scorePeak } from './peak-score';
|
||||
|
||||
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -11,8 +12,22 @@ db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'outdoor',
|
||||
date TEXT,
|
||||
@@ -27,31 +42,75 @@ db.exec(`
|
||||
bounds TEXT NOT NULL,
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
elevation_m INTEGER NOT NULL,
|
||||
osm_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
elevation_m REAL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
country TEXT,
|
||||
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,
|
||||
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(`
|
||||
INSERT OR IGNORE INTO peaks (name, elevation_m, lat, lon, country, region)
|
||||
VALUES (@name, @elevation_m, @lat, @lon, @country, @region)
|
||||
`);
|
||||
db.transaction(() => {
|
||||
for (const peak of PEAKS) seedPeak.run(peak);
|
||||
})();
|
||||
// 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.
|
||||
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(() => {
|
||||
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 {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
date: string | null;
|
||||
@@ -69,55 +128,151 @@ export interface ActivityRow {
|
||||
|
||||
export interface PeakRow {
|
||||
id: number;
|
||||
osm_id: string;
|
||||
name: string;
|
||||
elevation_m: number;
|
||||
elevation_m: number | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
country: string;
|
||||
region: string;
|
||||
country: string | null;
|
||||
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;
|
||||
activity_id: number | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export function listActivities(): Omit<ActivityRow, 'points'>[] {
|
||||
export function listActivities(userId: number): Omit<ActivityRow, 'points'>[] {
|
||||
return db
|
||||
.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
|
||||
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 {
|
||||
return db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow | undefined;
|
||||
export function getActivity(id: number, userId: number): ActivityRow | undefined {
|
||||
return db.prepare('SELECT * FROM activities WHERE id = ? AND user_id = ?').get(id, userId) as
|
||||
| ActivityRow
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function deleteActivity(id: number): void {
|
||||
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
|
||||
export function deleteActivity(id: number, userId: number): void {
|
||||
db.prepare('DELETE FROM activities WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
export function listPeaks(): PeakRow[] {
|
||||
return db.prepare('SELECT * FROM peaks ORDER BY elevation_m DESC').all() as PeakRow[];
|
||||
/** Notable peaks in a bounding box for a zoom level, with the user's climbed state. */
|
||||
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 {
|
||||
const peak = db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow | undefined;
|
||||
if (!peak) return undefined;
|
||||
if (peak.climbed_at) {
|
||||
db.prepare('UPDATE peaks SET climbed_at = NULL, activity_id = NULL WHERE id = ?').run(id);
|
||||
} else {
|
||||
db.prepare('UPDATE peaks SET climbed_at = ? WHERE id = ?').run(
|
||||
date ?? new Date().toISOString().slice(0, 10),
|
||||
id
|
||||
);
|
||||
/** Peaks near a track's bounding box, for summit detection on upload. */
|
||||
export function peaksNearBounds(
|
||||
bounds: [[number, number], [number, number]],
|
||||
marginDeg = 0.01
|
||||
): PeakRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM peaks
|
||||
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;
|
||||
}
|
||||
return db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow;
|
||||
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;
|
||||
}
|
||||
|
||||
export function markPeakClimbed(peakId: number, activityId: number, date: string | null): void {
|
||||
db.prepare(
|
||||
'UPDATE peaks SET climbed_at = ?, activity_id = ? WHERE id = ? AND climbed_at IS NULL'
|
||||
).run(date ?? new Date().toISOString().slice(0, 10), activityId, peakId);
|
||||
export function recordAscent(
|
||||
userId: number,
|
||||
peakId: number,
|
||||
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 { page } from '$app/state';
|
||||
|
||||
let { children } = $props();
|
||||
let { children, data } = $props();
|
||||
|
||||
const links = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
@@ -30,11 +30,17 @@
|
||||
</svg>
|
||||
Streba
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
{#each links as link (link.href)}
|
||||
<a href={link.href} class:active={isActive(link.href)}>{link.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if data.user}
|
||||
<div class="nav-links">
|
||||
{#each links as link (link.href)}
|
||||
<a href={link.href} class:active={isActive(link.href)}>{link.label}</a>
|
||||
{/each}
|
||||
</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>
|
||||
</header>
|
||||
|
||||
@@ -97,6 +103,31 @@
|
||||
background: var(--accent-wash);
|
||||
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 {
|
||||
max-width: 1080px;
|
||||
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';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
const activities = listActivities();
|
||||
const peaks = listPeaks();
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
const userId = locals.user!.id;
|
||||
const activities = listActivities(userId);
|
||||
|
||||
// all tracks, thinned for the overview map
|
||||
const rows = db.prepare('SELECT id, name, points FROM activities').all() as {
|
||||
id: number;
|
||||
name: string;
|
||||
points: string;
|
||||
}[];
|
||||
// all of the user's tracks, thinned for the overview map
|
||||
const rows = db
|
||||
.prepare('SELECT id, name, points FROM activities WHERE user_id = ?')
|
||||
.all(userId) as { id: number; name: string; points: string }[];
|
||||
const tracks = rows.map((row) => {
|
||||
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
|
||||
const step = Math.max(1, Math.floor(points.length / 300));
|
||||
@@ -20,6 +18,8 @@ export const load: PageServerLoad = () => {
|
||||
return { id: row.id, name: row.name, latlngs };
|
||||
});
|
||||
|
||||
const ascents = listAscents(userId);
|
||||
|
||||
return {
|
||||
activities,
|
||||
tracks,
|
||||
@@ -29,7 +29,7 @@ export const load: PageServerLoad = () => {
|
||||
elev_gain_m: activities.reduce((sum, a) => sum + a.elev_gain_m, 0),
|
||||
moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
|
||||
},
|
||||
peaksClimbed: peaks.filter((p) => p.climbed_at).length,
|
||||
peaksTotal: peaks.length
|
||||
peaksClimbed: countAscents(userId),
|
||||
highestAscent: ascents[0] ?? null
|
||||
};
|
||||
};
|
||||
|
||||
+12
-16
@@ -48,12 +48,15 @@
|
||||
<div class="card peak-progress">
|
||||
<div class="meter-nums">
|
||||
<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 class="meter" role="meter" aria-valuemin="0" aria-valuemax={data.peaksTotal} aria-valuenow={data.peaksClimbed} aria-label="Peaks climbed">
|
||||
<div class="fill" style:width="{(data.peaksClimbed / data.peaksTotal) * 100}%"></div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/peaks">Open the checklist</a>
|
||||
{#if data.highestAscent}
|
||||
<p class="highest">
|
||||
Highest so far: <strong>{data.highestAscent.name}</strong>
|
||||
({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>
|
||||
</section>
|
||||
</div>
|
||||
@@ -115,16 +118,9 @@
|
||||
font-size: 0.9rem;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
.meter {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-track);
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
min-width: 2px;
|
||||
.highest {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { listActivities } from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { activities: listActivities() };
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
return { activities: listActivities(locals.user!.id) };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 type { Actions, PageServerLoad } from './$types';
|
||||
import type { PeakRow } from '$lib/server/db';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
const activity = getActivity(Number(params.id));
|
||||
export const load: PageServerLoad = ({ params, locals }) => {
|
||||
const activity = getActivity(Number(params.id), locals.user!.id);
|
||||
if (!activity) error(404, 'Activity not found');
|
||||
|
||||
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! });
|
||||
}
|
||||
|
||||
const bagged = db
|
||||
.prepare('SELECT * FROM peaks WHERE activity_id = ?')
|
||||
.all(activity.id) as PeakRow[];
|
||||
const bagged = baggedPeaks(activity.id, locals.user!.id);
|
||||
|
||||
return {
|
||||
activity: { ...activity, points: undefined },
|
||||
@@ -38,7 +35,7 @@ export const load: PageServerLoad = ({ params }) => {
|
||||
bagged: bagged.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
elevation_m: p.elevation_m,
|
||||
elevation_m: Math.round(p.elevation_m ?? 0),
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
climbed: true
|
||||
@@ -47,8 +44,8 @@ export const load: PageServerLoad = ({ params }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
delete: async ({ params }) => {
|
||||
deleteActivity(Number(params.id));
|
||||
delete: async ({ params, locals }) => {
|
||||
deleteActivity(Number(params.id), locals.user!.id);
|
||||
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 { togglePeak } from '$lib/server/db';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { toggleAscent } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = ({ params }) => {
|
||||
const peak = togglePeak(Number(params.id));
|
||||
if (!peak) error(404, 'Peak not found');
|
||||
return json(peak);
|
||||
export const POST: RequestHandler = ({ params, locals }) => {
|
||||
const climbed_at = toggleAscent(locals.user!.id, Number(params.id));
|
||||
return json({ id: Number(params.id), climbed_at });
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { peaks: listPeaks() };
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
return { ascents: listAscents(locals.user!.id) };
|
||||
};
|
||||
|
||||
+134
-74
@@ -1,49 +1,92 @@
|
||||
<script lang="ts">
|
||||
import Map from '$lib/components/Map.svelte';
|
||||
import Map, { type Viewport } from '$lib/components/Map.svelte';
|
||||
import { fmtDate } from '$lib/format';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally -- local copy so toggles update instantly; resynced below
|
||||
let peaks = $state(data.peaks);
|
||||
$effect(() => {
|
||||
peaks = data.peaks;
|
||||
});
|
||||
let filter: 'all' | 'climbed' | 'remaining' = $state('all');
|
||||
interface ViewPeak {
|
||||
id: number;
|
||||
name: string;
|
||||
elevation_m: number | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
climbed_at: string | null;
|
||||
}
|
||||
|
||||
const climbed = $derived(peaks.filter((p) => p.climbed_at).length);
|
||||
const highest = $derived(peaks.filter((p) => p.climbed_at).sort((a, b) => b.elevation_m - a.elevation_m)[0]);
|
||||
// svelte-ignore state_referenced_locally -- local copy so toggles update instantly; resynced below
|
||||
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(
|
||||
peaks.map((p) => ({
|
||||
viewPeaks.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
elevation_m: p.elevation_m,
|
||||
elevation_m: Math.round(p.elevation_m ?? 0),
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
climbed: !!p.climbed_at
|
||||
}))
|
||||
);
|
||||
|
||||
const regions = $derived.by(() => {
|
||||
const visible = peaks.filter((p) =>
|
||||
filter === 'all' ? true : filter === 'climbed' ? !!p.climbed_at : !p.climbed_at
|
||||
);
|
||||
const byRegion = new globalThis.Map<string, typeof visible>();
|
||||
for (const p of visible) {
|
||||
if (!byRegion.has(p.region)) byRegion.set(p.region, []);
|
||||
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))
|
||||
);
|
||||
});
|
||||
let fetchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastView: Viewport | undefined;
|
||||
|
||||
function onviewport(view: Viewport) {
|
||||
lastView = view;
|
||||
clearTimeout(fetchTimer);
|
||||
fetchTimer = setTimeout(loadPeaks, 250);
|
||||
}
|
||||
|
||||
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) {
|
||||
const res = await fetch(`/api/peaks/${id}/toggle`, { method: 'POST' });
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
peaks = peaks.map((p) => (p.id === id ? updated : p));
|
||||
const { climbed_at } = await res.json();
|
||||
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>
|
||||
|
||||
@@ -53,68 +96,77 @@
|
||||
|
||||
<h1>Alpine peaks</h1>
|
||||
<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}.
|
||||
Click a peak on the map or in the list to cross it off.
|
||||
{ascents.length} peak{ascents.length === 1 ? '' : 's'} climbed{#if highest} · highest so far: {highest.name}
|
||||
({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>
|
||||
|
||||
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={peaks.length} aria-valuenow={climbed} aria-label="Peaks climbed">
|
||||
<div class="fill" style:width="{(climbed / peaks.length) * 100}%"></div>
|
||||
<Map 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})`}
|
||||
</button>
|
||||
<button class="filter-btn" class:on={tab === 'mine'} onclick={() => (tab = 'mine')}>
|
||||
My ascents ({ascents.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Map peaks={mapPeaks} height="480px" onpeakclick={toggle} />
|
||||
|
||||
<div class="filters" role="group" aria-label="Filter peaks">
|
||||
{#each [['all', 'All'], ['remaining', 'To climb'], ['climbed', 'Climbed']] as [key, label] (key)}
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:on={filter === key}
|
||||
onclick={() => (filter = key as typeof filter)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each regions as [region, list] (region)}
|
||||
<h2>{region}</h2>
|
||||
{#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>
|
||||
{: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>
|
||||
<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}
|
||||
{: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}
|
||||
<div class="card grid">
|
||||
{#each list 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 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">{peak.name}</span>
|
||||
<span class="name">{ascent.name}</span>
|
||||
<span class="sub">
|
||||
{peak.elevation_m.toLocaleString('en-US')} m · {peak.country}
|
||||
{#if peak.climbed_at}
|
||||
· climbed {fmtDate(peak.climbed_at)}
|
||||
{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>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card empty-filter">Nothing here - try another filter.</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.meter {
|
||||
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 {
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 0.35rem 0.9rem;
|
||||
@@ -138,6 +190,7 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.peak {
|
||||
display: flex;
|
||||
@@ -159,14 +212,18 @@
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid var(--baseline);
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.peak.done .check {
|
||||
@@ -192,8 +249,11 @@
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty-filter {
|
||||
margin-top: 1rem;
|
||||
.sub a {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.empty {
|
||||
margin-top: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
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 { db, listPeaks, markPeakClimbed } from '$lib/server/db';
|
||||
import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
|
||||
import { matchPeaks, parseGpx } from '$lib/server/gpx';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
default: async ({ request, locals }) => {
|
||||
const userId = locals.user!.id;
|
||||
const form = await request.formData();
|
||||
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.' });
|
||||
@@ -14,10 +15,10 @@ export const actions: Actions = {
|
||||
|
||||
const insert = db.prepare(`
|
||||
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)
|
||||
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)
|
||||
`);
|
||||
|
||||
@@ -25,6 +26,7 @@ export const actions: Actions = {
|
||||
try {
|
||||
const gpx = parseGpx(await file.text());
|
||||
const result = insert.run({
|
||||
user_id: userId,
|
||||
name: gpx.name ?? file.name.replace(/\.gpx$/i, ''),
|
||||
type: gpx.type ?? 'outdoor',
|
||||
date: gpx.date,
|
||||
@@ -40,15 +42,14 @@ export const actions: Actions = {
|
||||
});
|
||||
const activityId = Number(result.lastInsertRowid);
|
||||
|
||||
const unclimbed = listPeaks().filter((p) => !p.climbed_at);
|
||||
const bagged = matchPeaks(gpx.points, unclimbed);
|
||||
for (const peak of bagged) markPeakClimbed(peak.id, activityId, gpx.date);
|
||||
const nearby = peaksNearBounds(gpx.bounds);
|
||||
const summited = matchPeaks(gpx.points, nearby);
|
||||
const newPeaks: string[] = [];
|
||||
for (const peak of summited) {
|
||||
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
|
||||
}
|
||||
|
||||
uploaded.push({
|
||||
id: activityId,
|
||||
name: gpx.name ?? file.name,
|
||||
newPeaks: bagged.map((p) => p.name)
|
||||
});
|
||||
uploaded.push({ id: activityId, name: gpx.name ?? file.name, newPeaks });
|
||||
} catch (err) {
|
||||
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user