thats-me/frontend/src/services/apiClient.js
Kevin Adametz 83c6c5813d Frontend: Offline-Warteschlange, PWA, Event-Panel und Markenauftritt
Offline
- Alle Schreibzugriffe von API-Nutzern laufen über eine Warteschlange in
  IndexedDB (services/syncQueue.js): FIFO, zusammengefasste Updates,
  Wiederholung bei Netz/Sichtbarkeit/Backoff, 4xx wird als Fehler gemeldet.
- Medien-Uploads liegen mit Bytes in der Queue, erscheinen sofort mit
  Marker und werden nach dem Upload durch das Server-Objekt ersetzt.
- PWA-Modus mit Service Worker für die App-Hülle; API nie gecacht.
  npm run build erzeugt dist/pwa.
- Speicherzustand im Panel: ausstehend / Fehler mit Wiederholen-Knopf,
  Verbindungsanzeige im Header.

Event
- Event entsteht erst mit dem ersten Eintrag; jede Löschung fragt nach.
- Key Image als 4:3-Rahmen, Galerie mit Video, mehrere Sprachnotizen mit
  Notiz, Live-Pegel beim Aufnehmen, WAV-Umwandlung für lokale Aufnahmen.
- Neue Felder: Zeitraum, Kategorie, Personen, Meilenstein; Stimmung als
  kompakte Zeile mit ausklappbarer Punktfarbe.

Oberfläche
- Untere Leiste: Darstellung und Zoom links, Jahres-Pille mittig, Plus rechts.
- Logos und Goldpalette, Login neu, zehn Wellen-Presets mit Vorschau,
  Hintergrundbilder als Kacheln, Kontomenü und Einstellungen bereinigt,
  Escape schließt Ebenen, größere Touch-Ziele.
- Alten Sync-Service und ungenutzte Legacy-Seiten entfernt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 07:00:35 +00:00

182 lines
4.6 KiB
JavaScript

import { db } from 'src/db'
function normalizeApiBase(base) {
const trimmedBase = String(base).replace(/\/+$/, '')
if (/^https?:\/\/api\.thats-me\.(app|test)$/i.test(trimmedBase)) {
return `${trimmedBase}/api`
}
return trimmedBase
}
function resolveApiBase() {
if (import.meta.env.VITE_API_BASE) {
return normalizeApiBase(import.meta.env.VITE_API_BASE)
}
if (typeof window !== 'undefined' && window.location.hostname === 'app.thats-me.test') {
return normalizeApiBase('https://api.thats-me.test')
}
if (typeof window !== 'undefined' && window.location.hostname.endsWith('thats-me.app')) {
return normalizeApiBase('https://api.thats-me.app')
}
if (import.meta.env.PROD) {
return normalizeApiBase('https://api.thats-me.app')
}
return normalizeApiBase('/api')
}
export const API_BASE = resolveApiBase()
export class ApiError extends Error {
constructor(message, { status = 0, errors = null } = {}) {
super(message)
this.name = 'ApiError'
this.status = status
this.errors = errors
}
}
export async function getToken() {
try {
const meta = await db.meta.get('accessToken')
return meta?.value || null
} catch {
return null
}
}
export async function setToken(token) {
if (!token) {
await clearToken()
return
}
await db.meta.put({ key: 'accessToken', value: token })
}
export async function clearToken() {
await db.meta.delete('accessToken')
}
// Listeners notified when the server rejects the stored token, so the auth
// store can end the session instead of leaving the UI logged in with a
// token that no longer works.
const unauthorizedHandlers = new Set()
export function onUnauthorized(handler) {
unauthorizedHandlers.add(handler)
return () => unauthorizedHandlers.delete(handler)
}
async function handleUnauthorized(usedToken) {
// A 401 from a request that belongs to an earlier session must not tear
// down the session that replaced it.
const storedToken = await getToken()
if (storedToken && storedToken !== usedToken) return
await clearToken()
for (const handler of unauthorizedHandlers) {
try {
handler()
} catch (e) {
console.warn('Unauthorized handler failed:', e)
}
}
}
async function readResponsePayload(response) {
if (response.status === 204) return null
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
export async function apiFetch(path, options = {}) {
const { auth = true, body, headers = {}, ...fetchOptions } = options
const token = auth ? await getToken() : null
if (auth && !token) {
// The token is gone (logged out in another tab, storage cleared): end the
// session here too instead of leaving a logged-in UI whose requests all
// fail.
await handleUnauthorized(null)
throw new ApiError('Nicht angemeldet.', { status: 401 })
}
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData
const requestUrl = `${API_BASE}${path}`
if (import.meta.env.DEV) {
console.debug('API request:', requestUrl)
}
const response = await fetch(requestUrl, {
...fetchOptions,
body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body,
headers: {
Accept: 'application/json',
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers
}
})
if (response.status === 401 && auth) {
await handleUnauthorized(token)
}
return response
}
export async function apiJson(path, options = {}) {
const response = await apiFetch(path, options)
const payload = await readResponsePayload(response)
if (!response.ok) {
throw new ApiError(payload?.message || 'Die API-Anfrage ist fehlgeschlagen.', {
status: response.status,
errors: payload?.errors ?? null
})
}
return payload
}
export async function loginRemote(email, password) {
return apiJson('/login', {
method: 'POST',
auth: false,
body: { email, password }
})
}
/**
* Revoke a specific token. Used on logout so the revoke request carries the
* token being discarded, even if local state is cleared in parallel.
*/
export async function revokeToken(token) {
const response = await fetch(`${API_BASE}/logout`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
if (!response.ok && response.status !== 401) {
throw new ApiError('Der Token konnte nicht widerrufen werden.', { status: response.status })
}
}