Add webapp subproject (Vite + TypeScript + Web Components)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
webapp/.gitignore
vendored
Normal file
2
webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
49
webapp/CLAUDE.md
Normal file
49
webapp/CLAUDE.md
Normal file
@@ -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 `<track-map>` konfigurierbar (REQ-MAP-02)
|
||||||
|
- Neue Routen in `main.ts` registrieren (via `route()`), neue Komponenten in `components/` anlegen und in `main.ts` importieren
|
||||||
42
webapp/README.md
Normal file
42
webapp/README.md
Normal file
@@ -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 `<track-map>`
|
||||||
13
webapp/index.html
Normal file
13
webapp/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Pamietnik</title>
|
||||||
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
webapp/package.json
Normal file
18
webapp/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
webapp/src/api.ts
Normal file
67
webapp/src/api.ts
Normal file
@@ -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<T>(path: string): Promise<T> {
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getDays(from?: string, to?: string): Promise<DaySummary[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (from) params.set('from', from)
|
||||||
|
if (to) params.set('to', to)
|
||||||
|
return get<DaySummary[]>(`/v1/days?${params}`)
|
||||||
|
},
|
||||||
|
getTrackpoints(date: string): Promise<Trackpoint[]> {
|
||||||
|
return get<Trackpoint[]>(`/v1/trackpoints?date=${date}`)
|
||||||
|
},
|
||||||
|
getStops(date: string): Promise<Stop[]> {
|
||||||
|
return get<Stop[]>(`/v1/stops?date=${date}`)
|
||||||
|
},
|
||||||
|
getSuggestions(date: string): Promise<Suggestion[]> {
|
||||||
|
return get<Suggestion[]>(`/v1/suggestions?date=${date}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
14
webapp/src/auth.ts
Normal file
14
webapp/src/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export async function login(username: string, password: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await fetch('/logout', { method: 'POST', credentials: 'include' })
|
||||||
|
}
|
||||||
31
webapp/src/components/app-shell.ts
Normal file
31
webapp/src/components/app-shell.ts
Normal 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)
|
||||||
58
webapp/src/components/day-detail-page.ts
Normal file
58
webapp/src/components/day-detail-page.ts
Normal 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)
|
||||||
30
webapp/src/components/days-page.ts
Normal file
30
webapp/src/components/days-page.ts
Normal 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)
|
||||||
32
webapp/src/components/login-page.ts
Normal file
32
webapp/src/components/login-page.ts
Normal 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)
|
||||||
74
webapp/src/components/track-map.ts
Normal file
74
webapp/src/components/track-map.ts
Normal 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)
|
||||||
27
webapp/src/main.ts
Normal file
27
webapp/src/main.ts
Normal file
@@ -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 = '<app-shell></app-shell>'
|
||||||
|
|
||||||
|
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()
|
||||||
39
webapp/src/router.ts
Normal file
39
webapp/src/router.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
type RouteHandler = (params: Record<string, string>) => 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<string, string> = {}
|
||||||
|
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)
|
||||||
|
}
|
||||||
17
webapp/src/style.css
Normal file
17
webapp/src/style.css
Normal file
@@ -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%;
|
||||||
|
}
|
||||||
15
webapp/tsconfig.json
Normal file
15
webapp/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
14
webapp/vite.config.ts
Normal file
14
webapp/vite.config.ts
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user