diff --git a/backend/version.py b/backend/version.py index bba4d7e..6a2500b 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.134" +APP_VERSION = "0.8.136" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,20 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.136", + "date": "2026-05-13", + "changes": [ + "Fix: api/exercises.js — Mandanten-Header für Raw-fetch (PUT Übung, Medien-Upload, Bulk-Archiv) über lokale withActiveClubHeaders statt mergeActiveClubHeader-Import (ReferenceError beim Speichern).", + ], + }, + { + "version": "0.8.135", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 4 Welle 3: frontend/src/api/exercises.js (Übungen, Medien/Archiv, Progressionsgraphen, KI); client.js exportiert API_URL und mergeActiveClubHeader; utils/api.js re-exportiert Modul, api-Objekt spread exercises.", + ], + }, { "version": "0.8.134", "date": "2026-05-14", diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 65dae3b..91bed80 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -9,7 +9,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP | [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md) | Zielarchitektur (Frontend, API, Daten), Qualitätsziele, Einbindung neuer Features | | [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md) | Erfasste Architekturschuld, Reihenfolge und Massnahmen zur Behebung | | [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md) | Phasen, Meilensteine, Abnahmekriterien, Aufwandsschwerpunkte | -| [`frontend/src/api/client.js`](../../frontend/src/api/client.js) | Phase 4: zentraler HTTP-Client (`request`, `ACTIVE_CLUB_STORAGE_KEY`) | +| [`frontend/src/api/client.js`](../../frontend/src/api/client.js) | Phase 4: zentraler HTTP-Client (`request`, `ACTIVE_CLUB_STORAGE_KEY`, `API_URL`, `mergeActiveClubHeader`) | +| [`frontend/src/api/exercises.js`](../../frontend/src/api/exercises.js) | Phase 4: Übungen, Medien/Archiv, Progressionsgraphen, KI-Hilfen | | [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) | | [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) | | [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) | diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index 7d2f2b1..558d42c 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -8,7 +8,7 @@ - **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**. - **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget. - **Phase 3 (abgeschlossen 2026-05-14):** Übungsliste modularisiert; Trainingsplanung/Übungsformular: **Page-Dateien unter Soft-Limit** — Implementierung in `TrainingPlanningPageRoot.jsx`, `ExerciseFormPageRoot.jsx`, `ExercisesListPageRoot.jsx`; `pages/*.jsx` nur Re-Export. Playwright **Tests 9–10**. -- **Phase 4 (fortlaufend 2026-05-14):** API **Welle 1** `client.js`; **Welle 2** `planning.js`; `utils/api.js` bleibt Facade (`export *`, `api`-Objekt `...planning`). +- **Phase 4 (fortlaufend 2026-05-14):** API **Welle 1** `client.js`; **Welle 2** `planning.js`; **Welle 3** `exercises.js`; `utils/api.js` bleibt Facade (`export *`, `api`-Objekt `...exercises`, `...planning`). **Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**. **Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md). @@ -93,7 +93,7 @@ ## Phase 4 – API-Client Modularisierung -**Status:** **fortlaufend** (2026-05-14) — Welle 1: **`client.js`**; Welle 2: **`planning.js`**; **`utils/api.js`** bleibt vollständige Facade. +**Status:** **fortlaufend** (2026-05-14) — Welle 1: **`client.js`**; Welle 2: **`planning.js`**; Welle 3: **`exercises.js`**; **`utils/api.js`** bleibt vollständige Facade. **Fokus:** Wartbarkeit für viele neue Features. @@ -101,7 +101,8 @@ |------|-------|--------| | `frontend/src/api/client.js` — zentraler HTTP-Client | A2 | erledigt (Welle 1) | | `frontend/src/api/planning.js` — Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, Dashboard-KPIs) | A2 | erledigt (Welle 2) | -| Domänen-Module unter `frontend/src/api/` + schrittweise Entlastung von `api.js` | A2 | offen | +| `frontend/src/api/exercises.js` — Übungen, Medien/Archiv, Varianten, Progressionsgraphen, KI | A2 | erledigt (Welle 3) | +| Weitere Domänen-Module unter `frontend/src/api/` + Entlastung von `utils/api.js` | A2 | offen | | Neue Endpoints primär in Domänen-Dateien | S3 | offen | **Abnahme:** Anteil neuer Module > X% der neuen Zeilen (Team-Ziel); Monolith wächst nicht weiter. diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 6d776e3..e4a0806 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,14 +1,14 @@ /** * HTTP-Client: Token, Mandanten-Header, Fehler-Mapping. - * Alle API-Aufrufe laufen über request() — siehe utils/api.js (Facade) und künftige Domänenmodule. + * Alle API-Aufrufe laufen über request() — siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js). */ -const API_URL = import.meta.env.VITE_API_URL || '' +export const API_URL = import.meta.env.VITE_API_URL || '' /** LocalStorage + Request-Header für Mandanten-Kontext */ export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id' -function mergeActiveClubHeader(headers = {}) { +export function mergeActiveClubHeader(headers = {}) { const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) if (cid && /^\d+$/.test(String(cid).trim())) { return { ...headers, 'X-Active-Club-Id': String(cid).trim() } diff --git a/frontend/src/api/exercises.js b/frontend/src/api/exercises.js new file mode 100644 index 0000000..3c3f387 --- /dev/null +++ b/frontend/src/api/exercises.js @@ -0,0 +1,593 @@ +/** + * Übungen: Liste/CRUD, Medien & Archiv-Anbindung, Progressionsgraphen, KI-Hilfen. + */ + +import { stripHtmlToText } from '../utils/htmlUtils' +import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi' +import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js' + +/** Wie `mergeActiveClubHeader` in client.js — lokal, damit Raw-`fetch`-Pfade nicht von einem Namensimport abhängen. */ +function withActiveClubHeaders(headers = {}) { + const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) + if (cid && /^\d+$/.test(String(cid).trim())) { + return { ...headers, 'X-Active-Club-Id': String(cid).trim() } + } + return { ...headers } +} + +// ============================================================================ +// Exercises +// ============================================================================ + +export async function listExercises(filters = {}) { + const q = new URLSearchParams() + Object.entries(filters).forEach(([k, v]) => { + if (v === undefined || v === null) return + if (typeof v === 'boolean') { + q.set(k, v ? 'true' : 'false') + return + } + if (Array.isArray(v)) { + if (v.length === 0) return + v.forEach((item) => { + if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') { + q.append(k, String(item)) + } + }) + return + } + if (String(v).trim() !== '') q.set(k, String(v)) + }) + const query = q.toString() + return request(`/api/exercises${query ? '?' + query : ''}`) +} + +/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */ +export function buildExerciseApiPayload(formData, extras = {}) { + const num = (v) => (v === '' || v == null ? null : Number(v)) + + const goalHtml = formData.goal || '' + const execHtml = formData.execution || '' + const goalText = stripHtmlToText(goalHtml) + const execText = stripHtmlToText(execHtml) + + if (!goalText && !execText) { + throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).') + } + + const mapFocus = (formData.focus_areas_multi || []) + .filter((x) => x && x.focus_area_id) + .map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary })) + const mapStyles = (formData.training_styles_multi || []) + .filter((x) => x && x.training_style_id) + .map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary })) + const mapTTypes = (formData.training_types_multi || []) + .filter((x) => x && x.training_type_id) + .map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary })) + const mapTg = (formData.target_groups_multi || []) + .filter((x) => x && x.target_group_id) + .map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary })) + + const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase() + + const payload = { + title: (formData.title || '').trim(), + summary: formData.summary || null, + goal: goalHtml.trim() ? goalHtml : null, + execution: execHtml.trim() ? execHtml : null, + preparation: formData.preparation || null, + trainer_notes: formData.trainer_notes || null, + duration_min: num(formData.duration_min), + duration_max: num(formData.duration_max), + group_size_min: num(formData.group_size_min), + group_size_max: num(formData.group_size_max), + equipment: Array.isArray(formData.equipment) ? formData.equipment : [], + focus_areas_multi: mapFocus, + training_styles_multi: mapStyles, + training_types_multi: mapTTypes, + target_groups_multi: mapTg, + age_groups: [], + skills: (formData.skills || []).map((s) => ({ + skill_id: s.skill_id, + is_primary: !!s.is_primary, + intensity: s.intensity || null, + required_level: s.required_level || null, + target_level: s.target_level || null, + })), + visibility: visibilityNorm, + status: formData.status || 'draft', + club_id: visibilityNorm === 'club' ? num(formData.club_id) : null, + exercise_kind: + String(formData.exercise_kind || 'simple').toLowerCase() === 'combination' + ? 'combination' + : 'simple', + ...extras, + } + + const isCombo = payload.exercise_kind === 'combination' + + if (isCombo) { + let mpObj = {} + const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : '' + if (mpRaw) { + try { + const parsed = JSON.parse(mpRaw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Ablaufprofil muss ein JSON-Objekt sein.') + } + mpObj = parsed + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error('Ablaufprofil (JSON): Syntax ungültig.') + } + throw e + } + } + + const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : [] + const combination_slots = [] + + function parseTimingField(raw) { + if (raw === '' || raw == null || raw === undefined) return undefined + const n = parseInt(String(raw), 10) + return Number.isFinite(n) ? n : undefined + } + + for (let i = 0; i < slotRows.length; i += 1) { + const row = slotRows[i] || {} + let ids = Array.isArray(row.candidate_exercise_ids) + ? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n)) + : [] + + /** Legacy: noch idsText Unterstützung für Import von älteren FormStand */ + if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) { + ids = row.idsText + .split(/[\s,;]+/) + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => parseInt(s, 10)) + .filter((n) => Number.isFinite(n)) + } + + combination_slots.push({ + slot_index: i, + title: (typeof row.title === 'string' && row.title.trim()) || null, + candidate_exercise_ids: ids, + }) + } + + const slot_profiles_v1_next = [] + for (let i = 0; i < slotRows.length; i += 1) { + const row = slotRows[i] || {} + const o = { slot_index: i } + const advanceMode = normalizeAdvanceMode(row.advance_mode) + if (advanceMode !== 'timed') o.advance_mode = advanceMode + const load = parseTimingField(row.load_sec) + const crs = parseTimingField(row.consecutive_reps) + const rsc = parseTimingField(row.rep_series_count) + const intra = parseTimingField(row.intra_rep_rest_sec) + const tran = parseTimingField(row.transition_after_sec) + const serienUi = parseComboRepSeriesCountUi(row.rep_series_count) + const allowInterSeriesPause = + advanceMode === 'timed' || + ((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2) + + if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load) + if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs) + if ( + rsc !== undefined && + rsc >= 1 && + (advanceMode === 'rep' || advanceMode === 'manual') + ) { + o.rep_series_count = Math.round(rsc) + } + if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra) + if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran) + if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o) + } + + payload.method_archetype = (formData.method_archetype || '').trim() || null + if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next + else delete mpObj.slot_profiles_v1 + payload.method_profile = mpObj + payload.combination_slots = combination_slots + } else { + payload.method_archetype = null + payload.method_profile = {} + } + + return payload +} + +export async function uploadExerciseMedia(exerciseId, formData) { + const token = localStorage.getItem('authToken') + const headers = withActiveClubHeaders({}) + if (token) headers['X-Auth-Token'] = token + const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, { + method: 'POST', + headers, + body: formData, + }) + if (!response.ok) { + const text = await response.text() + let parsed = null + try { + parsed = text ? JSON.parse(text) : null + } catch { + parsed = null + } + const d = parsed?.detail + if ( + response.status === 409 && + d && + typeof d === 'object' && + !Array.isArray(d) && + typeof d.code === 'string' + ) { + const e = new Error( + typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden', + ) + e.code = d.code + e.status = 409 + e.payload = d + throw e + } + if (response.status === 413) { + const nginx = (text || '').toLowerCase().includes('nginx') + throw new Error( + nginx + ? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).' + : 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.', + ) + } + const msg = + typeof d === 'string' + ? d + : d != null && typeof d === 'object' && typeof d.message === 'string' + ? d.message + : d != null + ? JSON.stringify(d) + : text && text.length < 400 && !/^\s* { - if (v === undefined || v === null) return - if (typeof v === 'boolean') { - q.set(k, v ? 'true' : 'false') - return - } - if (Array.isArray(v)) { - if (v.length === 0) return - v.forEach((item) => { - if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') { - q.append(k, String(item)) - } - }) - return - } - if (String(v).trim() !== '') q.set(k, String(v)) - }) - const query = q.toString() - return request(`/api/exercises${query ? '?' + query : ''}`) -} - -/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */ -export function buildExerciseApiPayload(formData, extras = {}) { - const num = (v) => (v === '' || v == null ? null : Number(v)) - - const goalHtml = formData.goal || '' - const execHtml = formData.execution || '' - const goalText = stripHtmlToText(goalHtml) - const execText = stripHtmlToText(execHtml) - - if (!goalText && !execText) { - throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).') - } - - const mapFocus = (formData.focus_areas_multi || []) - .filter((x) => x && x.focus_area_id) - .map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary })) - const mapStyles = (formData.training_styles_multi || []) - .filter((x) => x && x.training_style_id) - .map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary })) - const mapTTypes = (formData.training_types_multi || []) - .filter((x) => x && x.training_type_id) - .map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary })) - const mapTg = (formData.target_groups_multi || []) - .filter((x) => x && x.target_group_id) - .map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary })) - - const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase() - - const payload = { - title: (formData.title || '').trim(), - summary: formData.summary || null, - goal: goalHtml.trim() ? goalHtml : null, - execution: execHtml.trim() ? execHtml : null, - preparation: formData.preparation || null, - trainer_notes: formData.trainer_notes || null, - duration_min: num(formData.duration_min), - duration_max: num(formData.duration_max), - group_size_min: num(formData.group_size_min), - group_size_max: num(formData.group_size_max), - equipment: Array.isArray(formData.equipment) ? formData.equipment : [], - focus_areas_multi: mapFocus, - training_styles_multi: mapStyles, - training_types_multi: mapTTypes, - target_groups_multi: mapTg, - age_groups: [], - skills: (formData.skills || []).map((s) => ({ - skill_id: s.skill_id, - is_primary: !!s.is_primary, - intensity: s.intensity || null, - required_level: s.required_level || null, - target_level: s.target_level || null, - })), - visibility: visibilityNorm, - status: formData.status || 'draft', - club_id: visibilityNorm === 'club' ? num(formData.club_id) : null, - exercise_kind: - String(formData.exercise_kind || 'simple').toLowerCase() === 'combination' - ? 'combination' - : 'simple', - ...extras, - } - - const isCombo = payload.exercise_kind === 'combination' - - if (isCombo) { - let mpObj = {} - const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : '' - if (mpRaw) { - try { - const parsed = JSON.parse(mpRaw) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Ablaufprofil muss ein JSON-Objekt sein.') - } - mpObj = parsed - } catch (e) { - if (e instanceof SyntaxError) { - throw new Error('Ablaufprofil (JSON): Syntax ungültig.') - } - throw e - } - } - - const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : [] - const combination_slots = [] - - function parseTimingField(raw) { - if (raw === '' || raw == null || raw === undefined) return undefined - const n = parseInt(String(raw), 10) - return Number.isFinite(n) ? n : undefined - } - - for (let i = 0; i < slotRows.length; i += 1) { - const row = slotRows[i] || {} - let ids = Array.isArray(row.candidate_exercise_ids) - ? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n)) - : [] - - /** Legacy: noch idsText Unterstützung für Import von älteren FormStand */ - if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) { - ids = row.idsText - .split(/[\s,;]+/) - .map((s) => s.trim()) - .filter(Boolean) - .map((s) => parseInt(s, 10)) - .filter((n) => Number.isFinite(n)) - } - - combination_slots.push({ - slot_index: i, - title: (typeof row.title === 'string' && row.title.trim()) || null, - candidate_exercise_ids: ids, - }) - } - - const slot_profiles_v1_next = [] - for (let i = 0; i < slotRows.length; i += 1) { - const row = slotRows[i] || {} - const o = { slot_index: i } - const advanceMode = normalizeAdvanceMode(row.advance_mode) - if (advanceMode !== 'timed') o.advance_mode = advanceMode - const load = parseTimingField(row.load_sec) - const crs = parseTimingField(row.consecutive_reps) - const rsc = parseTimingField(row.rep_series_count) - const intra = parseTimingField(row.intra_rep_rest_sec) - const tran = parseTimingField(row.transition_after_sec) - const serienUi = parseComboRepSeriesCountUi(row.rep_series_count) - const allowInterSeriesPause = - advanceMode === 'timed' || - ((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2) - - if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load) - if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs) - if ( - rsc !== undefined && - rsc >= 1 && - (advanceMode === 'rep' || advanceMode === 'manual') - ) { - o.rep_series_count = Math.round(rsc) - } - if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra) - if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran) - if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o) - } - - payload.method_archetype = (formData.method_archetype || '').trim() || null - if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next - else delete mpObj.slot_profiles_v1 - payload.method_profile = mpObj - payload.combination_slots = combination_slots - } else { - payload.method_archetype = null - payload.method_profile = {} - } - - return payload -} - -export async function uploadExerciseMedia(exerciseId, formData) { - const token = localStorage.getItem('authToken') - const headers = mergeActiveClubHeader({}) - if (token) headers['X-Auth-Token'] = token - const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, { - method: 'POST', - headers, - body: formData, - }) - if (!response.ok) { - const text = await response.text() - let parsed = null - try { - parsed = text ? JSON.parse(text) : null - } catch { - parsed = null - } - const d = parsed?.detail - if ( - response.status === 409 && - d && - typeof d === 'object' && - !Array.isArray(d) && - typeof d.code === 'string' - ) { - const e = new Error( - typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden', - ) - e.code = d.code - e.status = 409 - e.payload = d - throw e - } - if (response.status === 413) { - const nginx = (text || '').toLowerCase().includes('nginx') - throw new Error( - nginx - ? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).' - : 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.', - ) - } - const msg = - typeof d === 'string' - ? d - : d != null && typeof d === 'object' && typeof d.message === 'string' - ? d.message - : d != null - ? JSON.stringify(d) - : text && text.length < 400 && !/^\s*