Add webapp subproject (Vite + TypeScript + Web Components)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-04-05 20:25:50 +02:00
parent 37c56e7e3e
commit 07fdd3de31
17 changed files with 542 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import { logout } from '../auth'
import { navigate } from '../router'
export class AppShell extends HTMLElement {
private outlet!: HTMLElement
connectedCallback(): void {
this.innerHTML = `
<nav>
<a href="/days" id="nav-days">Tage</a>
<button id="nav-logout">Abmelden</button>
</nav>
<main id="outlet"></main>
`
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)

View File

@@ -0,0 +1,58 @@
import { api, Trackpoint, Stop, Suggestion } from '../api'
import './track-map'
export class DayDetailPage extends HTMLElement {
date = ''
async connectedCallback(): Promise<void> {
this.innerHTML = `<h1>${this.date}</h1><p>Lade...</p>`
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 = `<h1>${this.date}</h1><p>Fehler: ${err}</p>`
}
}
private render(trackpoints: Trackpoint[], stops: Stop[], suggestions: Suggestion[]): void {
this.innerHTML = `<h1>${this.date}</h1>`
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)

View File

@@ -0,0 +1,30 @@
import { api, DaySummary } from '../api'
import { navigate } from '../router'
export class DaysPage extends HTMLElement {
async connectedCallback(): Promise<void> {
this.innerHTML = '<p>Lade...</p>'
try {
const days = await api.getDays()
if (days.length === 0) {
this.innerHTML = '<p>Noch keine Einträge.</p>'
return
}
this.innerHTML = '<h1>Tage</h1><ul id="days-list"></ul>'
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 = `<p>Fehler: ${err}</p>`
}
}
}
customElements.define('days-page', DaysPage)

View File

@@ -0,0 +1,32 @@
import { login } from '../auth'
import { navigate } from '../router'
export class LoginPage extends HTMLElement {
connectedCallback(): void {
this.innerHTML = `
<form id="login-form">
<h1>Anmelden</h1>
<label>Benutzername <input name="username" type="text" required /></label>
<label>Passwort <input name="password" type="password" required /></label>
<p id="login-error" hidden></p>
<button type="submit">Anmelden</button>
</form>
`
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)

View File

@@ -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)