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>
182 lines
4.6 KiB
JavaScript
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 })
|
|
}
|
|
}
|