diff --git a/webapp/.gitignore b/webapp/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/webapp/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/webapp/CLAUDE.md b/webapp/CLAUDE.md new file mode 100644 index 0000000..6c1e723 --- /dev/null +++ b/webapp/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md — Pamietnik Webapp + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Stack + +- **Sprache:** TypeScript (strict) +- **Build:** Vite +- **UI:** Vanilla Web Components (kein Framework) +- **Karte:** MapLibre GL JS + OpenStreetMap Tiles (konfigurierbare Tile-URL) +- **Kein CSS-Framework** — minimales handgeschriebenes CSS + +## Befehle + +```bash +npm install # Dependencies installieren +npm run dev # Dev-Server auf :5173 (proxied /v1, /login, /logout → :8080) +npm run build # TypeScript-Check + Vite-Build nach dist/ +npm run typecheck # Nur TypeScript-Prüfung ohne Build +npm run preview # Build-Ergebnis lokal vorschauen +``` + +Das Backend muss lokal auf `:8080` laufen (siehe `../backend/`). + +## Architektur + +``` +src/ + main.ts App-Bootstrap, Routen registrieren + router.ts History-API-Router (keine externe Library) + api.ts Alle fetch()-Wrapper + TypeScript-Typen für API-Responses + auth.ts Login/Logout (POST /login, POST /logout) + style.css Globaler Reset + Basis-Layout + components/ + app-shell.ts Hauptkomponente: Navigation + Router-Outlet + login-page.ts Login-Formular + days-page.ts Tagesübersicht (GET /v1/days) + day-detail-page.ts Tagesdetail + Karte (GET /v1/trackpoints|stops|suggestions) + track-map.ts MapLibre-Wrapper als isolierte Web-Component +``` + +## Konventionen + +- Alle API-Typen (Trackpoint, Stop, Suggestion, DaySummary) sind in `api.ts` definiert +- Alle `fetch()`-Aufrufe laufen über `api.ts` — kein direktes `fetch()` in Komponenten +- Auth-State wird über Session-Cookie gehalten (`credentials: 'include'` bei jedem Request) +- 401-Response in `api.ts` → automatischer Redirect auf `/login` +- Tile-URL der Karte ist als Property auf `` konfigurierbar (REQ-MAP-02) +- Neue Routen in `main.ts` registrieren (via `route()`), neue Komponenten in `components/` anlegen und in `main.ts` importieren diff --git a/webapp/README.md b/webapp/README.md new file mode 100644 index 0000000..891dc15 --- /dev/null +++ b/webapp/README.md @@ -0,0 +1,42 @@ +# Pamietnik Webapp + +Eigenständige Single-Page-Application für das Pamietnik-Reisejournal. Kommuniziert über REST mit dem Go-Backend. + +## Technologie + +| Bereich | Technologie | Begründung | +|---------|-------------|------------| +| Sprache | TypeScript 5 (strict) | Typsicherheit, kein Laufzeit-Overhead | +| Build | Vite 6 | Schnelles HMR, ESM-native, minimale Konfiguration | +| UI | Vanilla Web Components | Kein Framework-Overhead; Standard-Browser-API | +| Karte | MapLibre GL JS 4 | Open-Source, OpenStreetMap-kompatibel, konfigurierbare Tile-Quelle (DEC-MAP-01) | +| CSS | Handgeschriebenes CSS | Kein Framework nötig bei dieser Projektgröße | + +## Abhängigkeiten + +``` +maplibre-gl Kartenrendering (OSM-Tiles, Track-Anzeige, Stop-Marker) +vite Build-Tool + Dev-Server mit Proxy +typescript Compiler + Typprüfung +``` + +Bewusst keine weiteren Abhängigkeiten (kein React, kein Vue, kein CSS-Framework). + +## Schnellstart + +```bash +# Backend starten (Voraussetzung) +cd ../backend && docker-compose up -d + +# Webapp +npm install +npm run dev # http://localhost:5173 +``` + +## Features + +- Login/Logout via Session Cookie +- Tagesübersicht: Liste aller Tage mit Trackpoint-Anzahl +- Tagesdetail: Trackpoints, Stops, Vorschläge + interaktive Karte +- Karte zeigt GPS-Track als Linie und Stops als Marker mit Popup (Place-Label) +- Tile-Quelle der Karte konfigurierbar über `tileUrl`-Property auf `` diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..b5aa04f --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,13 @@ + + + + + + Pamietnik + + + +
+ + + diff --git a/webapp/package.json b/webapp/package.json new file mode 100644 index 0000000..70692bb --- /dev/null +++ b/webapp/package.json @@ -0,0 +1,18 @@ +{ + "name": "pamietnik-webapp", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "maplibre-gl": "^4.7.1" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.0.6" + } +} diff --git a/webapp/src/api.ts b/webapp/src/api.ts new file mode 100644 index 0000000..4e17065 --- /dev/null +++ b/webapp/src/api.ts @@ -0,0 +1,67 @@ +export interface DaySummary { + date: string + count: number + first_ts?: string + last_ts?: string +} + +export interface Trackpoint { + event_id: string + device_id: string + trip_id: string + timestamp: string + lat: number + lon: number + source: 'gps' | 'manual' + note?: string +} + +export interface Stop { + stop_id: string + device_id: string + trip_id: string + start_ts: string + end_ts: string + center_lat: number + center_lon: number + duration_s: number + place_label?: string +} + +export interface Suggestion { + suggestion_id: string + stop_id: string + type: 'highlight' | 'name_place' | 'add_note' + title: string + text: string + created_at: string + dismissed_at?: string +} + +async function get(path: string): Promise { + const res = await fetch(path, { credentials: 'include' }) + if (res.status === 401) { + window.location.href = '/login' + throw new Error('unauthenticated') + } + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return res.json() as Promise +} + +export const api = { + getDays(from?: string, to?: string): Promise { + const params = new URLSearchParams() + if (from) params.set('from', from) + if (to) params.set('to', to) + return get(`/v1/days?${params}`) + }, + getTrackpoints(date: string): Promise { + return get(`/v1/trackpoints?date=${date}`) + }, + getStops(date: string): Promise { + return get(`/v1/stops?date=${date}`) + }, + getSuggestions(date: string): Promise { + return get(`/v1/suggestions?date=${date}`) + }, +} diff --git a/webapp/src/auth.ts b/webapp/src/auth.ts new file mode 100644 index 0000000..c9d150e --- /dev/null +++ b/webapp/src/auth.ts @@ -0,0 +1,14 @@ +export async function login(username: string, password: string): Promise { + const body = new URLSearchParams({ username, password }) + const res = await fetch('/login', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + if (!res.ok) throw new Error('Login fehlgeschlagen') +} + +export async function logout(): Promise { + await fetch('/logout', { method: 'POST', credentials: 'include' }) +} diff --git a/webapp/src/components/app-shell.ts b/webapp/src/components/app-shell.ts new file mode 100644 index 0000000..aab75f8 --- /dev/null +++ b/webapp/src/components/app-shell.ts @@ -0,0 +1,31 @@ +import { logout } from '../auth' +import { navigate } from '../router' + +export class AppShell extends HTMLElement { + private outlet!: HTMLElement + + connectedCallback(): void { + this.innerHTML = ` + +
+ ` + this.outlet = this.querySelector('#outlet')! + this.querySelector('#nav-days')!.addEventListener('click', (e) => { + e.preventDefault() + navigate('/days') + }) + this.querySelector('#nav-logout')!.addEventListener('click', async () => { + await logout() + navigate('/login') + }) + } + + setPage(el: HTMLElement): void { + this.outlet.replaceChildren(el) + } +} + +customElements.define('app-shell', AppShell) diff --git a/webapp/src/components/day-detail-page.ts b/webapp/src/components/day-detail-page.ts new file mode 100644 index 0000000..383b676 --- /dev/null +++ b/webapp/src/components/day-detail-page.ts @@ -0,0 +1,58 @@ +import { api, Trackpoint, Stop, Suggestion } from '../api' +import './track-map' + +export class DayDetailPage extends HTMLElement { + date = '' + + async connectedCallback(): Promise { + this.innerHTML = `

${this.date}

Lade...

` + try { + const [trackpoints, stops, suggestions] = await Promise.all([ + api.getTrackpoints(this.date), + api.getStops(this.date), + api.getSuggestions(this.date), + ]) + this.render(trackpoints, stops, suggestions) + } catch (err) { + this.innerHTML = `

${this.date}

Fehler: ${err}

` + } + } + + private render(trackpoints: Trackpoint[], stops: Stop[], suggestions: Suggestion[]): void { + this.innerHTML = `

${this.date}

` + + const map = document.createElement('track-map') as HTMLElement & { + trackpoints: Trackpoint[] + stops: Stop[] + } + map.trackpoints = trackpoints + map.stops = stops + this.appendChild(map) + + if (suggestions.length > 0) { + const h2 = document.createElement('h2') + h2.textContent = 'Vorschläge' + this.appendChild(h2) + const ul = document.createElement('ul') + for (const s of suggestions) { + const li = document.createElement('li') + li.textContent = `${s.title}: ${s.text}` + ul.appendChild(li) + } + this.appendChild(ul) + } + + const h2 = document.createElement('h2') + h2.textContent = `Trackpoints (${trackpoints.length})` + this.appendChild(h2) + const ul = document.createElement('ul') + for (const tp of trackpoints) { + const li = document.createElement('li') + li.textContent = `${tp.timestamp} — ${tp.lat.toFixed(5)}, ${tp.lon.toFixed(5)}${tp.note ? ' · ' + tp.note : ''}` + ul.appendChild(li) + } + this.appendChild(ul) + } +} + +customElements.define('day-detail-page', DayDetailPage) diff --git a/webapp/src/components/days-page.ts b/webapp/src/components/days-page.ts new file mode 100644 index 0000000..80f9cda --- /dev/null +++ b/webapp/src/components/days-page.ts @@ -0,0 +1,30 @@ +import { api, DaySummary } from '../api' +import { navigate } from '../router' + +export class DaysPage extends HTMLElement { + async connectedCallback(): Promise { + this.innerHTML = '

Lade...

' + try { + const days = await api.getDays() + if (days.length === 0) { + this.innerHTML = '

Noch keine Einträge.

' + return + } + this.innerHTML = '

Tage

    ' + const list = this.querySelector('#days-list')! + for (const day of days) { + const li = document.createElement('li') + const a = document.createElement('a') + a.href = `/days/${day.date}` + a.textContent = `${day.date} (${day.count} Punkte)` + a.addEventListener('click', (e) => { e.preventDefault(); navigate(`/days/${day.date}`) }) + li.appendChild(a) + list.appendChild(li) + } + } catch (err) { + this.innerHTML = `

    Fehler: ${err}

    ` + } + } +} + +customElements.define('days-page', DaysPage) diff --git a/webapp/src/components/login-page.ts b/webapp/src/components/login-page.ts new file mode 100644 index 0000000..e65f8d3 --- /dev/null +++ b/webapp/src/components/login-page.ts @@ -0,0 +1,32 @@ +import { login } from '../auth' +import { navigate } from '../router' + +export class LoginPage extends HTMLElement { + connectedCallback(): void { + this.innerHTML = ` +
    +

    Anmelden

    + + + + +
    + ` + this.querySelector('#login-form')!.addEventListener('submit', async (e) => { + e.preventDefault() + const form = e.target as HTMLFormElement + const username = (form.elements.namedItem('username') as HTMLInputElement).value + const password = (form.elements.namedItem('password') as HTMLInputElement).value + const errEl = this.querySelector('#login-error') as HTMLElement + try { + await login(username, password) + navigate('/days') + } catch { + errEl.textContent = 'Benutzername oder Passwort falsch.' + errEl.hidden = false + } + }) + } +} + +customElements.define('login-page', LoginPage) diff --git a/webapp/src/components/track-map.ts b/webapp/src/components/track-map.ts new file mode 100644 index 0000000..5f13fa5 --- /dev/null +++ b/webapp/src/components/track-map.ts @@ -0,0 +1,74 @@ +import maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' +import { Trackpoint, Stop } from '../api' + +const DEFAULT_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + +export class TrackMap extends HTMLElement { + trackpoints: Trackpoint[] = [] + stops: Stop[] = [] + tileUrl: string = DEFAULT_TILE_URL + + connectedCallback(): void { + const container = document.createElement('div') + container.style.cssText = 'width:100%;height:400px;' + this.appendChild(container) + + if (this.trackpoints.length === 0) return + + const lngs = this.trackpoints.map(tp => tp.lon) + const lats = this.trackpoints.map(tp => tp.lat) + const bounds: maplibregl.LngLatBoundsLike = [ + [Math.min(...lngs), Math.min(...lats)], + [Math.max(...lngs), Math.max(...lats)], + ] + + const map = new maplibregl.Map({ + container, + style: { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: [this.tileUrl], + tileSize: 256, + attribution: '© OpenStreetMap contributors', + }, + }, + layers: [{ id: 'osm', type: 'raster', source: 'osm' }], + }, + fitBoundsOptions: { padding: 40 }, + }) + + map.fitBounds(bounds, { padding: 40 }) + + map.on('load', () => { + map.addSource('track', { + type: 'geojson', + data: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: this.trackpoints.map(tp => [tp.lon, tp.lat]), + }, + properties: {}, + }, + }) + map.addLayer({ + id: 'track-line', + type: 'line', + source: 'track', + paint: { 'line-color': '#0066cc', 'line-width': 2 }, + }) + + for (const stop of this.stops) { + new maplibregl.Marker({ color: '#e63946' }) + .setLngLat([stop.center_lon, stop.center_lat]) + .setPopup(new maplibregl.Popup().setText(stop.place_label ?? `Stop ${stop.duration_s}s`)) + .addTo(map) + } + }) + } +} + +customElements.define('track-map', TrackMap) diff --git a/webapp/src/main.ts b/webapp/src/main.ts new file mode 100644 index 0000000..8a9030d --- /dev/null +++ b/webapp/src/main.ts @@ -0,0 +1,27 @@ +import './components/app-shell' +import './components/login-page' +import './components/days-page' +import './components/day-detail-page' +import './components/track-map' +import { route, startRouter } from './router' + +const app = document.getElementById('app')! +app.innerHTML = '' + +const shell = app.querySelector('app-shell') as HTMLElement & { setPage: (el: HTMLElement) => void } + +route('/login', () => { + shell.setPage(document.createElement('login-page')) +}) + +route('/days', () => { + shell.setPage(document.createElement('days-page')) +}) + +route('/days/:date', ({ date }) => { + const el = document.createElement('day-detail-page') as HTMLElement & { date: string } + el.date = date + shell.setPage(el) +}) + +startRouter() diff --git a/webapp/src/router.ts b/webapp/src/router.ts new file mode 100644 index 0000000..b6ddf17 --- /dev/null +++ b/webapp/src/router.ts @@ -0,0 +1,39 @@ +type RouteHandler = (params: Record) => void + +interface Route { + pattern: RegExp + keys: string[] + handler: RouteHandler +} + +const routes: Route[] = [] + +export function route(path: string, handler: RouteHandler): void { + const keys: string[] = [] + const pattern = new RegExp( + '^' + path.replace(/:([^/]+)/g, (_: string, k: string) => { keys.push(k); return '([^/]+)' }) + '$' + ) + routes.push({ pattern, keys, handler }) +} + +export function navigate(path: string): void { + history.pushState(null, '', path) + dispatch(path) +} + +function dispatch(path: string): void { + for (const r of routes) { + const m = path.match(r.pattern) + if (m) { + const params: Record = {} + r.keys.forEach((k, i) => { params[k] = m[i + 1] }) + r.handler(params) + return + } + } +} + +export function startRouter(): void { + window.addEventListener('popstate', () => dispatch(location.pathname)) + dispatch(location.pathname) +} diff --git a/webapp/src/style.css b/webapp/src/style.css new file mode 100644 index 0000000..72a211d --- /dev/null +++ b/webapp/src/style.css @@ -0,0 +1,17 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + font-family: system-ui, sans-serif; + font-size: 16px; + color: #1a1a1a; + background: #f5f5f5; +} + +#app { + height: 100%; +} diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json new file mode 100644 index 0000000..56f1771 --- /dev/null +++ b/webapp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useDefineForClassFields": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts new file mode 100644 index 0000000..ccf8225 --- /dev/null +++ b/webapp/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + proxy: { + '/v1': 'http://localhost:8080', + '/login': 'http://localhost:8080', + '/logout': 'http://localhost:8080', + }, + }, + build: { + outDir: 'dist', + }, +})