chore(version): update version and changelog for release 0.8.136
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
- Bumped APP_VERSION to 0.8.136 and updated the changelog to reflect recent changes. - Fixed the Mandanten-Header handling in `api/exercises.js` for improved API requests. - Continued Frontend Phase 4 with the addition of the `exercises.js` module, enhancing the API structure. - Updated architecture documentation to include details on the new `exercises.js` API and its integration with the existing client structure. - Enhanced `utils/api.js` to re-export the new exercises module, streamlining API access.
This commit is contained in:
parent
8175e239b4
commit
e759076a6c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
593
frontend/src/api/exercises.js
Normal file
593
frontend/src/api/exercises.js
Normal file
|
|
@ -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*</.test(text)
|
||||
? `HTTP ${response.status}: ${text.trim()}`
|
||||
: `HTTP ${response.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function updateExerciseMedia(exerciseId, mediaId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseMedia(exerciseId, mediaId) {
|
||||
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function reorderExerciseMedia(exerciseId, mediaIds) {
|
||||
return request(`/api/exercises/${exerciseId}/media/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ media_ids: mediaIds }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
|
||||
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
|
||||
return request(`/api/media-assets/${assetId}/lifecycle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, ...extra }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
|
||||
export async function listMediaAssets(params = {}) {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.q) sp.set('q', params.q)
|
||||
if (params.limit != null) sp.set('limit', String(params.limit))
|
||||
if (params.offset != null) sp.set('offset', String(params.offset))
|
||||
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
|
||||
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
|
||||
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
|
||||
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
|
||||
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
|
||||
const qs = sp.toString()
|
||||
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function patchMediaAsset(assetId, data) {
|
||||
return request(`/api/media-assets/${assetId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function bulkMediaLifecycle(data) {
|
||||
return request('/api/media-assets/bulk-lifecycle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function bulkPatchMediaAssets(data) {
|
||||
return request('/api/media-assets/bulk-patch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
|
||||
* @param {File[]} files
|
||||
* @param {{ visibility?: string, club_id?: number }} [options]
|
||||
*/
|
||||
export async function bulkUploadMediaAssets(files, options = {}) {
|
||||
const visibility = options.visibility || 'private'
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = withActiveClubHeaders({})
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const formData = new FormData()
|
||||
formData.append('visibility', String(visibility))
|
||||
if (options.club_id != null && options.club_id !== '') {
|
||||
formData.append('club_id', String(options.club_id))
|
||||
}
|
||||
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
|
||||
if (options.copyright_notice != null && String(options.copyright_notice).trim())
|
||||
formData.append('copyright_notice', String(options.copyright_notice).trim())
|
||||
const p06Fields = [
|
||||
'rights_holder_confirmed',
|
||||
'contains_identifiable_persons',
|
||||
'person_consent_confirmed',
|
||||
'person_consent_context',
|
||||
'contains_minors',
|
||||
'parental_consent_confirmed',
|
||||
'parental_consent_context',
|
||||
'contains_music',
|
||||
'music_rights_confirmed',
|
||||
'music_rights_context',
|
||||
'contains_third_party_content',
|
||||
'third_party_rights_confirmed',
|
||||
'third_party_rights_context',
|
||||
]
|
||||
for (const f of p06Fields) {
|
||||
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
|
||||
}
|
||||
const arr = Array.isArray(files) ? files : [files]
|
||||
for (const f of arr) {
|
||||
if (f) formData.append('files', f)
|
||||
}
|
||||
const url = `${API_URL}/api/media-assets/bulk-upload`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const d = parsed.detail
|
||||
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
|
||||
export async function getMediaAssetJournal(assetId) {
|
||||
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
|
||||
}
|
||||
|
||||
export async function addMediaAssetDeclarationCorrection(assetId, body) {
|
||||
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function attachExerciseMediaFromAsset(exerciseId, body) {
|
||||
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// P-11: Legal-Hold-Endpunkte
|
||||
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
|
||||
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
|
||||
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ release_note: releaseNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
|
||||
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
|
||||
}
|
||||
|
||||
export async function getExercise(id) {
|
||||
return request(`/api/exercises/${id}`)
|
||||
}
|
||||
|
||||
export async function createExercise(data) {
|
||||
return request('/api/exercises', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExercise(id, data) {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = withActiveClubHeaders({ 'Content-Type': 'application/json' })
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const url = `${API_URL}/api/exercises/${id}`
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
const d = parsed?.detail
|
||||
if (
|
||||
response.status === 422 &&
|
||||
d &&
|
||||
typeof d === 'object' &&
|
||||
!Array.isArray(d) &&
|
||||
typeof d.code === 'string'
|
||||
) {
|
||||
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
|
||||
e.status = 422
|
||||
e.code = d.code
|
||||
e.payload = d
|
||||
throw e
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
|
||||
throw new Error(msg)
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
|
||||
export async function bulkPatchExercisesMetadata(data) {
|
||||
return request('/api/exercises/bulk-metadata', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExercise(id) {
|
||||
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function createExerciseVariant(exerciseId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/variants`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseVariant(exerciseId, variantId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseVariant(exerciseId, variantId) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function reorderExerciseVariants(exerciseId, variantIds) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ variant_ids: variantIds }),
|
||||
})
|
||||
}
|
||||
|
||||
// Progressionsgraphen (Übung → Übung), Migration 032/033
|
||||
export async function listExerciseProgressionGraphs() {
|
||||
return request('/api/exercise-progression-graphs')
|
||||
}
|
||||
|
||||
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
|
||||
const q = includeEdges ? '?include_edges=true' : ''
|
||||
return request(`/api/exercise-progression-graphs/${id}${q}`)
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionGraph(data) {
|
||||
return request('/api/exercise-progression-graphs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseProgressionGraph(id, data) {
|
||||
return request(`/api/exercise-progression-graphs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionGraph(id) {
|
||||
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function listExerciseProgressionEdges(graphId, query = {}) {
|
||||
const q = new URLSearchParams()
|
||||
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
|
||||
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
|
||||
const qs = q.toString()
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionEdge(graphId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionSequence(graphId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ edge_ids: edgeIds }),
|
||||
})
|
||||
}
|
||||
|
||||
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
|
||||
export async function suggestExerciseAi(payload) {
|
||||
return request('/api/exercises/ai/suggest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function regenerateExerciseAi(exerciseId, payload) {
|
||||
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
|
@ -4,12 +4,12 @@
|
|||
* Zentrale API-Kommunikation mit automatischer Token-Injektion
|
||||
*/
|
||||
|
||||
import { stripHtmlToText } from './htmlUtils'
|
||||
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from './combinationMethodProfileUi'
|
||||
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
|
||||
import * as exercises from '../api/exercises.js'
|
||||
import * as planning from '../api/planning.js'
|
||||
|
||||
export { ACTIVE_CLUB_STORAGE_KEY }
|
||||
export * from '../api/exercises.js'
|
||||
export * from '../api/planning.js'
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -326,583 +326,6 @@ export async function deleteMethod(id) {
|
|||
return request(`/api/methods/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 = 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*</.test(text)
|
||||
? `HTTP ${response.status}: ${text.trim()}`
|
||||
: `HTTP ${response.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function updateExerciseMedia(exerciseId, mediaId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseMedia(exerciseId, mediaId) {
|
||||
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function reorderExerciseMedia(exerciseId, mediaIds) {
|
||||
return request(`/api/exercises/${exerciseId}/media/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ media_ids: mediaIds }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
|
||||
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
|
||||
return request(`/api/media-assets/${assetId}/lifecycle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, ...extra }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
|
||||
export async function listMediaAssets(params = {}) {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.q) sp.set('q', params.q)
|
||||
if (params.limit != null) sp.set('limit', String(params.limit))
|
||||
if (params.offset != null) sp.set('offset', String(params.offset))
|
||||
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
|
||||
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
|
||||
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
|
||||
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
|
||||
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
|
||||
const qs = sp.toString()
|
||||
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function patchMediaAsset(assetId, data) {
|
||||
return request(`/api/media-assets/${assetId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function bulkMediaLifecycle(data) {
|
||||
return request('/api/media-assets/bulk-lifecycle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function bulkPatchMediaAssets(data) {
|
||||
return request('/api/media-assets/bulk-patch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
|
||||
* @param {File[]} files
|
||||
* @param {{ visibility?: string, club_id?: number }} [options]
|
||||
*/
|
||||
export async function bulkUploadMediaAssets(files, options = {}) {
|
||||
const visibility = options.visibility || 'private'
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = mergeActiveClubHeader({})
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const formData = new FormData()
|
||||
formData.append('visibility', String(visibility))
|
||||
if (options.club_id != null && options.club_id !== '') {
|
||||
formData.append('club_id', String(options.club_id))
|
||||
}
|
||||
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
|
||||
if (options.copyright_notice != null && String(options.copyright_notice).trim())
|
||||
formData.append('copyright_notice', String(options.copyright_notice).trim())
|
||||
const p06Fields = [
|
||||
'rights_holder_confirmed',
|
||||
'contains_identifiable_persons',
|
||||
'person_consent_confirmed',
|
||||
'person_consent_context',
|
||||
'contains_minors',
|
||||
'parental_consent_confirmed',
|
||||
'parental_consent_context',
|
||||
'contains_music',
|
||||
'music_rights_confirmed',
|
||||
'music_rights_context',
|
||||
'contains_third_party_content',
|
||||
'third_party_rights_confirmed',
|
||||
'third_party_rights_context',
|
||||
]
|
||||
for (const f of p06Fields) {
|
||||
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
|
||||
}
|
||||
const arr = Array.isArray(files) ? files : [files]
|
||||
for (const f of arr) {
|
||||
if (f) formData.append('files', f)
|
||||
}
|
||||
const url = `${API_URL}/api/media-assets/bulk-upload`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const d = parsed.detail
|
||||
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
|
||||
export async function getMediaAssetJournal(assetId) {
|
||||
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
|
||||
}
|
||||
|
||||
export async function addMediaAssetDeclarationCorrection(assetId, body) {
|
||||
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function attachExerciseMediaFromAsset(exerciseId, body) {
|
||||
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// P-11: Legal-Hold-Endpunkte
|
||||
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
|
||||
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
|
||||
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ release_note: releaseNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
|
||||
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
|
||||
}
|
||||
|
||||
export async function getExercise(id) {
|
||||
return request(`/api/exercises/${id}`)
|
||||
}
|
||||
|
||||
export async function createExercise(data) {
|
||||
return request('/api/exercises', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExercise(id, data) {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = mergeActiveClubHeader({ 'Content-Type': 'application/json' })
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const url = `${API_URL}/api/exercises/${id}`
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
const d = parsed?.detail
|
||||
if (
|
||||
response.status === 422 &&
|
||||
d &&
|
||||
typeof d === 'object' &&
|
||||
!Array.isArray(d) &&
|
||||
typeof d.code === 'string'
|
||||
) {
|
||||
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
|
||||
e.status = 422
|
||||
e.code = d.code
|
||||
e.payload = d
|
||||
throw e
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
|
||||
throw new Error(msg)
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
|
||||
export async function bulkPatchExercisesMetadata(data) {
|
||||
return request('/api/exercises/bulk-metadata', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExercise(id) {
|
||||
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function createExerciseVariant(exerciseId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/variants`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseVariant(exerciseId, variantId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseVariant(exerciseId, variantId) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function reorderExerciseVariants(exerciseId, variantIds) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ variant_ids: variantIds }),
|
||||
})
|
||||
}
|
||||
|
||||
// Progressionsgraphen (Übung → Übung), Migration 032/033
|
||||
export async function listExerciseProgressionGraphs() {
|
||||
return request('/api/exercise-progression-graphs')
|
||||
}
|
||||
|
||||
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
|
||||
const q = includeEdges ? '?include_edges=true' : ''
|
||||
return request(`/api/exercise-progression-graphs/${id}${q}`)
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionGraph(data) {
|
||||
return request('/api/exercise-progression-graphs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseProgressionGraph(id, data) {
|
||||
return request(`/api/exercise-progression-graphs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionGraph(id) {
|
||||
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function listExerciseProgressionEdges(graphId, query = {}) {
|
||||
const q = new URLSearchParams()
|
||||
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
|
||||
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
|
||||
const qs = q.toString()
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionEdge(graphId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionSequence(graphId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ edge_ids: edgeIds }),
|
||||
})
|
||||
}
|
||||
|
||||
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
|
||||
export async function suggestExerciseAi(payload) {
|
||||
return request('/api/exercises/ai/suggest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function regenerateExerciseAi(exerciseId, payload) {
|
||||
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Catalogs (Admin-verwaltbare Stammdaten)
|
||||
// ============================================================================
|
||||
|
|
@ -1324,47 +747,8 @@ export const api = {
|
|||
updateMethod,
|
||||
deleteMethod,
|
||||
|
||||
// Exercises
|
||||
listExercises,
|
||||
getExercise,
|
||||
createExercise,
|
||||
updateExercise,
|
||||
bulkPatchExercisesMetadata,
|
||||
deleteExercise,
|
||||
createExerciseVariant,
|
||||
updateExerciseVariant,
|
||||
deleteExerciseVariant,
|
||||
reorderExerciseVariants,
|
||||
buildExerciseApiPayload,
|
||||
suggestExerciseAi,
|
||||
regenerateExerciseAi,
|
||||
uploadExerciseMedia,
|
||||
updateExerciseMedia,
|
||||
deleteExerciseMedia,
|
||||
reorderExerciseMedia,
|
||||
postMediaAssetLifecycle,
|
||||
listMediaAssets,
|
||||
patchMediaAsset,
|
||||
bulkMediaLifecycle,
|
||||
bulkPatchMediaAssets,
|
||||
bulkUploadMediaAssets,
|
||||
getMediaAssetJournal,
|
||||
addMediaAssetDeclarationCorrection,
|
||||
attachExerciseMediaFromAsset,
|
||||
setMediaAssetLegalHold,
|
||||
releaseMediaAssetLegalHold,
|
||||
listMediaAssetsWithLegalHold,
|
||||
listExerciseProgressionGraphs,
|
||||
getExerciseProgressionGraph,
|
||||
createExerciseProgressionGraph,
|
||||
updateExerciseProgressionGraph,
|
||||
deleteExerciseProgressionGraph,
|
||||
listExerciseProgressionEdges,
|
||||
createExerciseProgressionEdge,
|
||||
updateExerciseProgressionEdge,
|
||||
deleteExerciseProgressionEdge,
|
||||
createExerciseProgressionSequence,
|
||||
deleteExerciseProgressionEdgesBatch,
|
||||
// Exercises + Medien/Archiv (Progression, KI) → frontend/src/api/exercises.js
|
||||
...exercises,
|
||||
|
||||
// Training Planning → frontend/src/api/planning.js
|
||||
...planning,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user