feat: Skills & Methods catalog complete
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 14s

Backend:
- Created routers/skills.py with full CRUD
  - Skills: list (with category filter), get, create, update, delete
  - Methods: list (with category filter), get, create, update, delete
  - Default: only show active items
  - Read access: all authenticated users
  - Write access: admin only
- Registered skills router in main.py

Frontend:
- Complete SkillsPage with 2 tabs (Fähigkeiten, Trainingsmethoden)
- Browse by category with cards layout
- Admin CRUD forms (importance rating for skills, duration/group size for methods)
- Mobile-responsive grid layout
- Updated api.js with all skill/method functions
- Added /skills route to App.jsx

Migration already exists: 003_catalogs.sql (skills, training_methods + seed data)

Next: Training Planning (core feature)
This commit is contained in:
Lars 2026-04-22 16:50:31 +02:00
parent 8e027e02bb
commit 505a8e5e38
5 changed files with 912 additions and 7 deletions

View File

@ -70,16 +70,17 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, clubs
from routers import auth, profiles, exercises, clubs, skills
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
app.include_router(clubs.router)
app.include_router(skills.router)
# TODO: Add more routers as they are created
# from routers import skills, methods
# app.include_router(skills.router, prefix="/api")
# from routers import training_planning
# app.include_router(training_planning.router, prefix="/api")
# ... etc
if __name__ == "__main__":

340
backend/routers/skills.py Normal file
View File

@ -0,0 +1,340 @@
"""
Skills & Methods Catalog Endpoints for Shinkan Jinkendo
Handles CRUD operations for skills and training methods.
Read access for all authenticated users.
Write access for admins only.
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from db import get_db, get_cursor, r2d
from auth import require_auth
router = APIRouter(prefix="/api", tags=["skills"])
# ── List Skills ───────────────────────────────────────────────────────
@router.get("/skills")
def list_skills(
category: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
session=Depends(require_auth)
):
"""
List all skills (public for authenticated users).
Filters:
- category: kihon, kumite, kata, selbstverteidigung, fitness
- status: active, inactive
"""
with get_db() as conn:
cur = get_cursor(conn)
query = "SELECT * FROM skills"
params = []
where = []
if category:
where.append("category = %s")
params.append(category)
if status:
where.append("status = %s")
params.append(status)
else:
# Default: only active skills
where.append("status = 'active'")
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY importance DESC, name"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Get Skill ─────────────────────────────────────────────────────────
@router.get("/skills/{skill_id}")
def get_skill(skill_id: int, session=Depends(require_auth)):
"""Get skill by ID."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM skills WHERE id = %s", (skill_id,))
skill = cur.fetchone()
if not skill:
raise HTTPException(404, "Fähigkeit nicht gefunden")
return r2d(skill)
# ── Create Skill ──────────────────────────────────────────────────────
@router.post("/skills")
def create_skill(data: dict, session=Depends(require_auth)):
"""Create new skill (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Fähigkeiten erstellen")
name = data.get('name')
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
INSERT INTO skills (name, category, description, importance, keywords, status)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (
name,
data.get('category'),
data.get('description'),
data.get('importance'),
data.get('keywords'),
data.get('status', 'active')
))
skill_id = cur.fetchone()['id']
conn.commit()
return get_skill(skill_id, session)
# ── Update Skill ──────────────────────────────────────────────────────
@router.put("/skills/{skill_id}")
def update_skill(skill_id: int, data: dict, session=Depends(require_auth)):
"""Update skill (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Fähigkeiten bearbeiten")
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,))
if not cur.fetchone():
raise HTTPException(404, "Fähigkeit nicht gefunden")
# Update
cur.execute("""
UPDATE skills SET
name = %s,
category = %s,
description = %s,
importance = %s,
keywords = %s,
status = %s,
updated_at = NOW()
WHERE id = %s
""", (
data.get('name'),
data.get('category'),
data.get('description'),
data.get('importance'),
data.get('keywords'),
data.get('status'),
skill_id
))
conn.commit()
return get_skill(skill_id, session)
# ── Delete Skill ──────────────────────────────────────────────────────
@router.delete("/skills/{skill_id}")
def delete_skill(skill_id: int, session=Depends(require_auth)):
"""Delete skill (superadmin only)."""
role = session.get('role')
if role != 'superadmin':
raise HTTPException(403, "Nur Superadmins dürfen Fähigkeiten löschen")
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,))
if not cur.fetchone():
raise HTTPException(404, "Fähigkeit nicht gefunden")
# Delete
cur.execute("DELETE FROM skills WHERE id = %s", (skill_id,))
conn.commit()
return {"ok": True}
# ── List Training Methods ─────────────────────────────────────────────
@router.get("/methods")
def list_methods(
category: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
session=Depends(require_auth)
):
"""
List all training methods (public for authenticated users).
Filters:
- category: kondition, didaktik, koordination, kraft, etc.
- status: active, inactive
"""
with get_db() as conn:
cur = get_cursor(conn)
query = "SELECT * FROM training_methods"
params = []
where = []
if category:
where.append("category = %s")
params.append(category)
if status:
where.append("status = %s")
params.append(status)
else:
# Default: only active methods
where.append("status = 'active'")
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY name"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Get Training Method ───────────────────────────────────────────────
@router.get("/methods/{method_id}")
def get_method(method_id: int, session=Depends(require_auth)):
"""Get training method by ID."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM training_methods WHERE id = %s", (method_id,))
method = cur.fetchone()
if not method:
raise HTTPException(404, "Trainingsmethode nicht gefunden")
return r2d(method)
# ── Create Training Method ────────────────────────────────────────────
@router.post("/methods")
def create_method(data: dict, session=Depends(require_auth)):
"""Create new training method (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Trainingsmethoden erstellen")
name = data.get('name')
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
INSERT INTO training_methods (
name, abbreviation, category, description,
typical_duration, typical_group_size,
related_skills, keywords, status
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
name,
data.get('abbreviation'),
data.get('category'),
data.get('description'),
data.get('typical_duration'),
data.get('typical_group_size'),
data.get('related_skills'),
data.get('keywords'),
data.get('status', 'active')
))
method_id = cur.fetchone()['id']
conn.commit()
return get_method(method_id, session)
# ── Update Training Method ────────────────────────────────────────────
@router.put("/methods/{method_id}")
def update_method(method_id: int, data: dict, session=Depends(require_auth)):
"""Update training method (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Trainingsmethoden bearbeiten")
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM training_methods WHERE id = %s", (method_id,))
if not cur.fetchone():
raise HTTPException(404, "Trainingsmethode nicht gefunden")
# Update
cur.execute("""
UPDATE training_methods SET
name = %s,
abbreviation = %s,
category = %s,
description = %s,
typical_duration = %s,
typical_group_size = %s,
related_skills = %s,
keywords = %s,
status = %s,
updated_at = NOW()
WHERE id = %s
""", (
data.get('name'),
data.get('abbreviation'),
data.get('category'),
data.get('description'),
data.get('typical_duration'),
data.get('typical_group_size'),
data.get('related_skills'),
data.get('keywords'),
data.get('status'),
method_id
))
conn.commit()
return get_method(method_id, session)
# ── Delete Training Method ────────────────────────────────────────────
@router.delete("/methods/{method_id}")
def delete_method(method_id: int, session=Depends(require_auth)):
"""Delete training method (superadmin only)."""
role = session.get('role')
if role != 'superadmin':
raise HTTPException(403, "Nur Superadmins dürfen Trainingsmethoden löschen")
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM training_methods WHERE id = %s", (method_id,))
if not cur.fetchone():
raise HTTPException(404, "Trainingsmethode nicht gefunden")
# Delete
cur.execute("DELETE FROM training_methods WHERE id = %s", (method_id,))
conn.commit()
return {"ok": True}

View File

@ -8,6 +8,7 @@ import Dashboard from './pages/Dashboard'
import ProfilePage from './pages/ProfilePage'
import ExercisesPage from './pages/ExercisesPage'
import ClubsPage from './pages/ClubsPage'
import SkillsPage from './pages/SkillsPage'
import './app.css'
// Bottom Navigation (Mobile)
@ -158,6 +159,14 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/skills"
element={
<ProtectedRoute>
<SkillsPage />
</ProtectedRoute>
}
/>
{/* Catch all - redirect to dashboard or login */}
<Route path="*" element={<Navigate to="/" replace />} />

View File

@ -0,0 +1,517 @@
import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
function SkillsPage() {
const { user } = useAuth()
const [activeTab, setActiveTab] = useState('skills')
const [skills, setSkills] = useState([])
const [methods, setMethods] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState(null)
const [modalType, setModalType] = useState('skill')
const [formData, setFormData] = useState({})
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const [skillsData, methodsData] = await Promise.all([
api.listSkills(),
api.listMethods()
])
setSkills(skillsData)
setMethods(methodsData)
} catch (err) {
console.error('Failed to load data:', err)
alert('Fehler beim Laden: ' + err.message)
} finally {
setLoading(false)
}
}
const handleCreate = (type) => {
setEditing(null)
setModalType(type)
if (type === 'skill') {
setFormData({
name: '',
category: '',
description: '',
importance: 3,
keywords: [],
status: 'active'
})
} else {
setFormData({
name: '',
abbreviation: '',
category: '',
description: '',
typical_duration: '',
typical_group_size: '',
related_skills: [],
keywords: [],
status: 'active'
})
}
setShowModal(true)
}
const handleEdit = (item, type) => {
setEditing(item)
setModalType(type)
setFormData({ ...item })
setShowModal(true)
}
const handleDelete = async (item, type) => {
const confirmMsg = type === 'skill'
? `Fähigkeit "${item.name}" wirklich löschen?`
: `Trainingsmethode "${item.name}" wirklich löschen?`
if (!confirm(confirmMsg)) return
try {
if (type === 'skill') {
await api.deleteSkill(item.id)
} else {
await api.deleteMethod(item.id)
}
await loadData()
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
try {
if (modalType === 'skill') {
if (editing) {
await api.updateSkill(editing.id, formData)
} else {
await api.createSkill(formData)
}
} else {
if (editing) {
await api.updateMethod(editing.id, formData)
} else {
await api.createMethod(formData)
}
}
setShowModal(false)
await loadData()
} catch (err) {
alert('Fehler beim Speichern: ' + err.message)
}
}
const updateFormField = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const groupByCategory = (items) => {
const grouped = {}
items.forEach(item => {
const cat = item.category || 'Ohne Kategorie'
if (!grouped[cat]) grouped[cat] = []
grouped[cat].push(item)
})
return grouped
}
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
const skillsByCategory = groupByCategory(skills)
const methodsByCategory = groupByCategory(methods)
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Fähigkeiten & Methoden</h1>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
borderBottom: '2px solid var(--border)'
}}>
{['skills', 'methods'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '0.75rem 1.5rem',
background: activeTab === tab ? 'var(--accent)' : 'transparent',
color: activeTab === tab ? 'white' : 'var(--text1)',
border: 'none',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: activeTab === tab ? 'bold' : 'normal'
}}
>
{tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
</button>
))}
</div>
{/* Skills Tab */}
{activeTab === 'skills' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<p style={{ color: 'var(--text2)' }}>
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
</p>
{isAdmin && (
<button className="btn btn-primary" onClick={() => handleCreate('skill')}>
+ Neue Fähigkeit
</button>
)}
</div>
{Object.keys(skillsByCategory).length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Fähigkeiten gefunden
</p>
</div>
) : (
Object.keys(skillsByCategory).sort().map(category => (
<div key={category} style={{ marginBottom: '2rem' }}>
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
{category}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1rem'
}}>
{skillsByCategory[category].map(skill => (
<div key={skill.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
<h3 style={{ fontSize: '1rem' }}>{skill.name}</h3>
{skill.importance && (
<span style={{
fontSize: '0.875rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--accent)',
color: 'white'
}}>
{skill.importance}/5
</span>
)}
</div>
{skill.description && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
{skill.description}
</p>
)}
{isAdmin && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => handleEdit(skill, 'skill')}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
onClick={() => handleDelete(skill, 'skill')}
>
Löschen
</button>
</div>
)}
</div>
))}
</div>
</div>
))
)}
</>
)}
{/* Methods Tab */}
{activeTab === 'methods' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<p style={{ color: 'var(--text2)' }}>
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
</p>
{isAdmin && (
<button className="btn btn-primary" onClick={() => handleCreate('method')}>
+ Neue Methode
</button>
)}
</div>
{Object.keys(methodsByCategory).length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Trainingsmethoden gefunden
</p>
</div>
) : (
Object.keys(methodsByCategory).sort().map(category => (
<div key={category} style={{ marginBottom: '2rem' }}>
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
{category}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '1rem'
}}>
{methodsByCategory[category].map(method => (
<div key={method.id} className="card">
<div style={{ marginBottom: '0.5rem' }}>
<h3 style={{ fontSize: '1rem', marginBottom: '0.25rem' }}>
{method.name}
{method.abbreviation && (
<span style={{ color: 'var(--text2)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
({method.abbreviation})
</span>
)}
</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{method.typical_duration && (
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
{method.typical_duration} min
</span>
)}
{method.typical_group_size && (
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
👥 {method.typical_group_size}
</span>
)}
</div>
</div>
{method.description && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
{method.description}
</p>
)}
{isAdmin && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => handleEdit(method, 'method')}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
onClick={() => handleDelete(method, 'method')}
>
Löschen
</button>
</div>
)}
</div>
))}
</div>
</div>
))
)}
</>
)}
{/* Modal */}
{showModal && isAdmin && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem'
}}>
<div style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '2rem',
maxWidth: '600px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ marginBottom: '1.5rem' }}>
{editing
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
}
</h2>
<form onSubmit={handleSubmit}>
<div className="form-row">
<label className="form-label">Name *</label>
<input
type="text"
className="form-input"
value={formData.name || ''}
onChange={(e) => updateFormField('name', e.target.value)}
required
/>
</div>
{modalType === 'method' && (
<div className="form-row">
<label className="form-label">Kürzel</label>
<input
type="text"
className="form-input"
value={formData.abbreviation || ''}
onChange={(e) => updateFormField('abbreviation', e.target.value)}
maxLength={20}
/>
</div>
)}
<div className="form-row">
<label className="form-label">Kategorie</label>
<input
type="text"
className="form-input"
value={formData.category || ''}
onChange={(e) => updateFormField('category', e.target.value)}
placeholder={modalType === 'skill' ? 'z.B. kihon, kumite, kata' : 'z.B. kondition, didaktik'}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={formData.description || ''}
onChange={(e) => updateFormField('description', e.target.value)}
/>
</div>
{modalType === 'skill' && (
<div className="form-row">
<label className="form-label">Wichtigkeit (1-5)</label>
<input
type="number"
className="form-input"
min={1}
max={5}
value={formData.importance || 3}
onChange={(e) => updateFormField('importance', parseInt(e.target.value))}
/>
</div>
)}
{modalType === 'method' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="form-row">
<label className="form-label">Typische Dauer (min)</label>
<input
type="number"
className="form-input"
value={formData.typical_duration || ''}
onChange={(e) => updateFormField('typical_duration', e.target.value ? parseInt(e.target.value) : '')}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppengröße</label>
<input
type="text"
className="form-input"
value={formData.typical_group_size || ''}
onChange={(e) => updateFormField('typical_group_size', e.target.value)}
placeholder="z.B. 10-20"
/>
</div>
</div>
</>
)}
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status || 'active'}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
{editing ? 'Speichern' : 'Erstellen'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowModal(false)}
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
)
}
export default SkillsPage

View File

@ -144,8 +144,13 @@ export async function deleteTrainingGroup(id) {
// Skills & Methods
// ============================================================================
export async function listSkills() {
return request('/api/skills')
export async function listSkills(filters = {}) {
const query = new URLSearchParams(filters).toString()
return request(`/api/skills${query ? '?' + query : ''}`)
}
export async function getSkill(id) {
return request(`/api/skills/${id}`)
}
export async function createSkill(data) {
@ -155,8 +160,24 @@ export async function createSkill(data) {
})
}
export async function listMethods() {
return request('/api/methods')
export async function updateSkill(id, data) {
return request(`/api/skills/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteSkill(id) {
return request(`/api/skills/${id}`, { method: 'DELETE' })
}
export async function listMethods(filters = {}) {
const query = new URLSearchParams(filters).toString()
return request(`/api/methods${query ? '?' + query : ''}`)
}
export async function getMethod(id) {
return request(`/api/methods/${id}`)
}
export async function createMethod(data) {
@ -166,6 +187,17 @@ export async function createMethod(data) {
})
}
export async function updateMethod(id, data) {
return request(`/api/methods/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteMethod(id) {
return request(`/api/methods/${id}`, { method: 'DELETE' })
}
// ============================================================================
// Exercises
// ============================================================================
@ -261,9 +293,15 @@ export const api = {
// Skills & Methods
listSkills,
getSkill,
createSkill,
updateSkill,
deleteSkill,
listMethods,
getMethod,
createMethod,
updateMethod,
deleteMethod,
// Exercises
listExercises,