banyaro/backend/static/js/api.js
rene b9df636535 Sprint 6: Karte / Orte / Routen mit GPS-Aufzeichnung
- backend/routes/places.py: CRUD für hundefreundliche Orte (6 Typen)
- backend/routes/routen.py: CRUD für Gassi-Routen mit GPS-Track (JSON)
- main.py: beide Router eingehängt (/api/places, /api/routes)
- api.js: places + routes erweitert (list, update, delete)
- pages/places.js: Karte + Liste, Typ-Filter, Ort anlegen/bearbeiten
- pages/routes.js: Routen entdecken + GPS-Aufzeichnung mit Stoppuhr
- pages/map.js: zentrale Übersichtskarte (Orte + Giftköder, Layer-Toggle)
- components.css: Styles für alle drei neuen Seiten
- sw.js: by-v19 → by-v20
2026-04-14 06:03:37 +02:00

275 lines
9.7 KiB
JavaScript

/* ============================================================
BAN YARO — API Client
Zentraler Eingang für ALLE Backend-Kommunikation.
Kein fetch() wird außerhalb dieser Datei aufgerufen.
============================================================ */
const API = (() => {
// ----------------------------------------------------------
// Interner HTTP-Kern
// ----------------------------------------------------------
async function _request(method, path, body = null, options = {}) {
const config = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
};
if (body && !(body instanceof FormData)) {
config.body = JSON.stringify(body);
} else if (body instanceof FormData) {
delete config.headers['Content-Type']; // Browser setzt multipart/form-data
config.body = body;
}
// JWT aus localStorage als Bearer (für API-Calls die das brauchen)
const token = localStorage.getItem('by_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
let response;
try {
response = await fetch(`/api${path}`, config);
} catch (err) {
throw new APIError('Keine Verbindung zum Server.', 0, 'network');
}
// 204 No Content
if (response.status === 204) return null;
let data;
try {
data = await response.json();
} catch {
data = null;
}
if (!response.ok) {
const message = data?.detail || data?.message || `Fehler ${response.status}`;
throw new APIError(message, response.status, data?.code);
}
return data;
}
// ----------------------------------------------------------
// Öffentliche HTTP-Methoden
// ----------------------------------------------------------
const get = (path) => _request('GET', path);
const post = (path, body) => _request('POST', path, body);
const put = (path, body) => _request('PUT', path, body);
const patch = (path, body) => _request('PATCH', path, body);
const del = (path) => _request('DELETE', path);
const upload = (path, form) => _request('POST', path, form); // FormData
// ----------------------------------------------------------
// AUTH
// ----------------------------------------------------------
const auth = {
login(email, password) {
return post('/auth/login', { email, password });
},
register(email, password, name) {
return post('/auth/register', { email, password, name });
},
logout() {
localStorage.removeItem('by_token');
return post('/auth/logout');
},
me() {
return get('/auth/me');
},
};
// ----------------------------------------------------------
// HUNDE-PROFIL
// ----------------------------------------------------------
const dogs = {
list() { return get('/dogs'); },
get(id) { return get(`/dogs/${id}`); },
create(data) { return post('/dogs', data); },
update(id, data) { return patch(`/dogs/${id}`, data); },
delete(id) { return del(`/dogs/${id}`); },
uploadPhoto(id, formData) { return upload(`/dogs/${id}/photo`, formData); },
};
// ----------------------------------------------------------
// TAGEBUCH
// ----------------------------------------------------------
const diary = {
list(dogId, params = {}) {
const q = new URLSearchParams(params).toString();
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
},
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
delete(dogId, id) { return del(`/dogs/${dogId}/diary/${id}`); },
uploadMedia(dogId, id, formData) {
return upload(`/dogs/${dogId}/diary/${id}/media`, formData);
},
};
// ----------------------------------------------------------
// GESUNDHEIT
// ----------------------------------------------------------
const health = {
list(dogId, typ = null) {
const q = typ ? `?typ=${encodeURIComponent(typ)}` : '';
return get(`/dogs/${dogId}/health${q}`);
},
create(dogId, data) { return post(`/dogs/${dogId}/health`, data); },
update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); },
delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); },
uploadDokument(dogId, id, formData) {
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);
},
kiZusammenfassung(dogId) {
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
},
symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
},
};
// ----------------------------------------------------------
// TIERÄRZTE
// ----------------------------------------------------------
const tieraerzte = {
list() { return get('/tieraerzte'); },
create(data) { return post('/tieraerzte', data); },
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
};
// ----------------------------------------------------------
// GIFTKÖDER-ALARM
// ----------------------------------------------------------
const poison = {
listNearby(lat, lon, radius = 5000) {
return get(`/poison?lat=${lat}&lon=${lon}&radius=${radius}`);
},
report(data) { return post('/poison', data); },
confirm(id) { return post(`/poison/${id}/confirm`); },
resolve(id, data={}) { return post(`/poison/${id}/resolve`, data); },
uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); },
};
// ----------------------------------------------------------
// KARTE & ORTE
// ----------------------------------------------------------
const places = {
list(typ = null) {
return get(`/places${typ ? '?typ=' + encodeURIComponent(typ) : ''}`);
},
listNearby(lat, lon, typ = null, radius = 5000) {
const params = new URLSearchParams({ lat, lon, radius });
if (typ) params.set('typ', typ);
return get(`/places?${params}`);
},
get(id) { return get(`/places/${id}`); },
create(data) { return post('/places', data); },
update(id, data) { return patch(`/places/${id}`, data); },
delete(id) { return del(`/places/${id}`); },
};
// ----------------------------------------------------------
// GASSI-ROUTEN
// ----------------------------------------------------------
const routes = {
list() {
return get('/routes');
},
listNearby(lat, lon, radius = 10000) {
return get(`/routes?lat=${lat}&lon=${lon}&radius=${radius}`);
},
get(id) { return get(`/routes/${id}`); },
create(data) { return post('/routes', data); },
update(id, data) { return patch(`/routes/${id}`, data); },
delete(id) { return del(`/routes/${id}`); },
};
// ----------------------------------------------------------
// WETTER
// ----------------------------------------------------------
const weather = {
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
// PUSH NOTIFICATIONS
// ----------------------------------------------------------
const push = {
getVapidKey() { return get('/push/vapid-key'); },
subscribe(subscription) { return post('/push/subscribe', subscription); },
unsubscribe() { return del('/push/subscribe'); },
};
// ----------------------------------------------------------
// PUSH-SUBSCRIPTION HELPER
// ----------------------------------------------------------
async function subscribeToPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return null;
const { vapid_public_key } = await push.getVapidKey();
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapid_public_key),
});
await push.subscribe(subscription.toJSON());
return subscription;
}
function _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return new Uint8Array([...raw].map(c => c.charCodeAt(0)));
}
// ----------------------------------------------------------
// GEOLOCATION HELPER
// ----------------------------------------------------------
function getLocation(options = {}) {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation wird nicht unterstützt.'));
return;
}
navigator.geolocation.getCurrentPosition(
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
err => reject(err),
{ timeout: 8000, maximumAge: 60000, ...options }
);
});
}
// ----------------------------------------------------------
// ERROR-KLASSE
// ----------------------------------------------------------
class APIError extends Error {
constructor(message, status, code) {
super(message);
this.name = 'APIError';
this.status = status;
this.code = code;
}
}
// Öffentliche API
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, weather, push,
subscribeToPush, getLocation,
APIError,
};
})();