feat: enhance Exercises and Clubs pages with improved UI and functionality
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 28s

- Added new utility functions for handling exercise focus areas, style directions, and training types, improving data presentation.
- Refactored ExercisesListPage to utilize new card layouts and improved visibility labels for exercises.
- Updated ClubsPage and SkillsPage to implement a consistent tab navigation style, enhancing user experience.
- Enhanced CSS styles for better responsiveness and visual consistency across various components.
- Improved loading states and accessibility features for better user feedback and interaction.
This commit is contained in:
Lars 2026-05-06 12:20:22 +02:00
parent 68923b0364
commit 5096eec16b
7 changed files with 425 additions and 154 deletions

View File

@ -26,6 +26,24 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"])
def _coerce_json_str_list(val: Any) -> List[str]:
"""JSON-Aggregat oder JSON-String aus PG in eine saubere str-Liste für die Listen-API."""
if val is None:
return []
if isinstance(val, list):
return [str(x) for x in val if x is not None and str(x).strip()]
if isinstance(val, str):
try:
parsed = json.loads(val)
if isinstance(parsed, list):
return [str(x) for x in parsed if x is not None and str(x).strip()]
except Exception:
return []
return []
return []
# Kanonische Fähigkeitsstufen 15 (Übung ↔ Skill-Zeile), siehe Migration 029
_CANONICAL_SKILL_LEVELS = frozenset(
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
@ -971,7 +989,34 @@ def list_exercises(
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS primary_focus_name
) AS primary_focus_name,
(
SELECT COALESCE(
json_agg(fa.name ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC),
'[]'::json
)
FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
) AS focus_area_names,
(
SELECT COALESCE(
json_agg(sd.name ORDER BY esd.is_primary DESC NULLS LAST, sd.name ASC),
'[]'::json
)
FROM exercise_style_directions esd
JOIN style_directions sd ON sd.id = esd.style_direction_id
WHERE esd.exercise_id = e.id
) AS style_direction_names,
(
SELECT COALESCE(
json_agg(tt.name ORDER BY ett.is_primary DESC NULLS LAST, tt.sort_order NULLS LAST, tt.name ASC),
'[]'::json
)
FROM exercise_training_types ett
JOIN training_types tt ON tt.id = ett.training_type_id
WHERE ett.exercise_id = e.id
) AS training_type_names
{variants_sql}
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
@ -990,6 +1035,9 @@ def list_exercises(
d = r2d(r)
pfn = d.get("primary_focus_name")
d["focus_area"] = pfn
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
d["style_direction_names"] = _coerce_json_str_list(d.get("style_direction_names"))
d["training_type_names"] = _coerce_json_str_list(d.get("training_type_names"))
if include_variants:
v = d.get("variants")
if isinstance(v, str):

View File

@ -1442,12 +1442,6 @@ button.capture-shell__nav-item {
.skills-page__tabs-scroll::-webkit-scrollbar {
display: none;
}
.exercises-page-mode-switch,
.skills-page-mode-switch {
width: max(100%, min(20rem, 100vw - 24px));
max-width: none;
}
}
/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
@ -2463,6 +2457,12 @@ button.capture-shell__nav-item {
flex: 1;
}
.clubs-page__intro {
margin: 0 0 1.25rem;
max-width: 46rem;
line-height: 1.55;
}
/* Übungsliste: Kopf, Modus-Segmente, Hinweise */
.exercises-page__header {
display: flex;
@ -2478,11 +2478,6 @@ button.capture-shell__nav-item {
.exercises-page-toolbar-tabs {
margin-bottom: 14px;
}
.exercises-page-mode-switch,
.skills-page-mode-switch {
width: 100%;
max-width: min(100%, 28rem);
}
.exercise-search-hint {
font-size: 12px;
color: var(--text3);
@ -2515,14 +2510,24 @@ button.capture-shell__nav-item {
}
.exercises-list-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
gap: 14px;
align-items: stretch;
}
.exercises-list-grid > .exercise-card {
height: 100%;
min-height: 0;
}
.exercise-card-layout {
display: flex;
gap: 10px;
align-items: flex-start;
}
.exercise-card-layout--grow {
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
.exercise-card-layout__check {
margin-top: 4px;
flex-shrink: 0;
@ -2562,6 +2567,28 @@ button.capture-shell__nav-item {
line-height: 1.4;
margin: 0;
}
.exercise-card-summary--rich {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
word-break: break-word;
}
.exercise-card-summary--rich b,
.exercise-card-summary--rich strong {
font-weight: 700;
color: var(--text1);
}
.exercise-card-summary--rich i,
.exercise-card-summary--rich em {
font-style: italic;
}
.exercise-card-summary--rich p {
margin: 0 0 0.35em;
}
.exercise-card-summary--rich p:last-child {
margin-bottom: 0;
}
.exercises-meta-line {
font-size: 13px;
color: var(--text2);
@ -3665,7 +3692,31 @@ button.capture-shell__nav-item {
.exercise-card {
display: flex;
flex-direction: column;
min-height: 200px;
min-height: 0;
border-left: 4px solid var(--border2);
transition: border-color 0.15s, box-shadow 0.15s;
}
.exercise-card--scope-official {
border-left-color: var(--accent);
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--surface)) 0%, var(--surface) 64%);
}
.exercise-card--scope-club {
border-left-color: var(--warn);
background: linear-gradient(180deg, color-mix(in srgb, var(--warn) 10%, var(--surface)) 0%, var(--surface) 64%);
}
.exercise-card--scope-private {
border-left-color: var(--text3);
}
.exercise-card--mine {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, var(--border));
}
@media (prefers-color-scheme: dark) {
.exercise-card--scope-official {
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 12%, var(--surface)) 0%, var(--surface) 64%);
}
.exercise-card--scope-club {
background: linear-gradient(180deg, color-mix(in srgb, var(--warn) 12%, var(--surface)) 0%, var(--surface) 64%);
}
}
.exercise-card__body {
flex: 1 1 auto;
@ -3714,10 +3765,51 @@ button.capture-shell__nav-item {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 12px;
margin-top: auto;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.exercise-card__actions--icons {
justify-content: flex-end;
gap: 8px;
flex-wrap: nowrap;
}
.exercise-card__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border-radius: 8px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text1);
text-decoration: none;
cursor: pointer;
flex-shrink: 0;
transition: background 0.12s, border-color 0.12s, color 0.12s;
-webkit-tap-highlight-color: transparent;
}
.exercise-card__icon-btn:hover {
background: var(--surface);
border-color: var(--accent);
color: var(--accent-dark);
}
@media (prefers-color-scheme: dark) {
.exercise-card__icon-btn:hover {
color: var(--accent);
}
}
.exercise-card__icon-btn--danger {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 35%, var(--border2));
}
.exercise-card__icon-btn--danger:hover {
background: color-mix(in srgb, var(--danger) 10%, var(--surface2));
border-color: var(--danger);
color: var(--danger);
}
.exercise-card__actions .btn,
.exercise-card__actions a.btn {
flex: 1 1 auto;
@ -3747,6 +3839,28 @@ button.capture-shell__nav-item {
color: var(--accent-dark);
border-color: transparent;
}
.exercise-tag--style {
background: color-mix(in srgb, var(--accent) 12%, var(--surface2));
color: var(--accent-dark);
border-color: color-mix(in srgb, var(--accent) 22%, var(--border));
}
.exercise-tag--training {
background: var(--surface2);
color: var(--text1);
border-color: var(--border2);
}
.exercise-tag--scope {
font-weight: 700;
background: var(--surface);
color: var(--text2);
}
.exercise-tag--meta {
font-weight: 600;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text3);
}
.exercise-detail-shell {
max-width: none;

View File

@ -27,12 +27,14 @@ export default function AdminMaturityModelsPage() {
</p>
</header>
<div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten">
<div className="admin-page-subtabs" role="tablist" aria-label="Bereiche Fähigkeiten">
<button
type="button"
role="tab"
aria-selected={tab === 'catalog'}
className={'admin-tabs__tab' + (tab === 'catalog' ? ' admin-tabs__tab--active' : '')}
className={
'admin-page-subtabs__btn' + (tab === 'catalog' ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setTab('catalog')}
>
Katalog und Hierarchie
@ -41,7 +43,9 @@ export default function AdminMaturityModelsPage() {
type="button"
role="tab"
aria-selected={tab === 'models'}
className={'admin-tabs__tab' + (tab === 'models' ? ' admin-tabs__tab--active' : '')}
className={
'admin-page-subtabs__btn' + (tab === 'models' ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setTab('models')}
>
Reifegradmodelle
@ -50,7 +54,9 @@ export default function AdminMaturityModelsPage() {
type="button"
role="tab"
aria-selected={tab === 'bindings'}
className={'admin-tabs__tab' + (tab === 'bindings' ? ' admin-tabs__tab--active' : '')}
className={
'admin-page-subtabs__btn' + (tab === 'bindings' ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setTab('bindings')}
>
Kontext-Zuordnung
@ -59,7 +65,9 @@ export default function AdminMaturityModelsPage() {
type="button"
role="tab"
aria-selected={tab === 'matrixviz'}
className={'admin-tabs__tab' + (tab === 'matrixviz' ? ' admin-tabs__tab--active' : '')}
className={
'admin-page-subtabs__btn' + (tab === 'matrixviz' ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setTab('matrixviz')}
>
Matrix-Ansicht und Export

View File

@ -287,52 +287,44 @@ function ClubsPage() {
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="skills-page__loading">
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
return (
<div className="app-page">
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
Sparten sind optional typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
</p>
const clubTabIds = canManageOrgSomewhere
? ['clubs', 'divisions', 'groups', 'members']
: ['clubs', 'divisions', 'groups']
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
borderBottom: '2px solid var(--border)'
}}>
{(canManageOrgSomewhere
? ['clubs', 'divisions', 'groups', 'members']
: ['clubs', 'divisions', 'groups']
).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 === 'clubs' && 'Vereine'}
{tab === 'divisions' && 'Sparten'}
{tab === 'groups' && 'Trainingsgruppen'}
{tab === 'members' && 'Mitglieder'}
</button>
))}
</div>
return (
<div className="app-page clubs-page">
<h1 className="page-title">Vereinsverwaltung</h1>
<p className="clubs-page__intro muted">
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
Sparten sind optional typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
</p>
<div className="admin-page-subtabs" role="tablist" aria-label="Vereinsverwaltung">
{clubTabIds.map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
className={
'admin-page-subtabs__btn' + (activeTab === tab ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setActiveTab(tab)}
>
{tab === 'clubs' && 'Vereine'}
{tab === 'divisions' && 'Sparten'}
{tab === 'groups' && 'Trainingsgruppen'}
{tab === 'members' && 'Mitglieder'}
</button>
))}
</div>
{/* Clubs Tab */}
{activeTab === 'clubs' && (

View File

@ -1,10 +1,12 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Eye, Pencil, Trash2 } from 'lucide-react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
@ -22,6 +24,38 @@ const INITIAL_FILTERS = {
status_any: [],
}
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
const STATUS_LABELS = {
draft: 'Entwurf',
in_review: 'In Prüfung',
approved: 'Freigegeben',
archived: 'Archiv',
}
function visibilityLabel(v) {
return VIS_LABELS[v] || v || '—'
}
function statusLabel(s) {
return STATUS_LABELS[s] || s || '—'
}
function exerciseFocusNames(ex) {
const fromApi = coerceApiNameList(ex.focus_area_names)
if (fromApi.length) return fromApi
if (ex.focus_area) return [ex.focus_area]
return []
}
function exerciseCardClassName(exercise, userId) {
const vis = exercise.visibility || 'private'
const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
const mine = userId != null && Number(exercise.created_by) === Number(userId)
return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
.filter(Boolean)
.join(' ')
}
function levelOptionShort(levelStr) {
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
return o ? String(o.level) : String(levelStr)
@ -569,33 +603,30 @@ function ExercisesListPage() {
)}
</div>
<div className="exercises-page-toolbar-tabs" role="tablist" aria-label="Übungen Bereiche">
<div className="planning-segment-group planning-segment-group--equal exercises-page-mode-switch">
<button
type="button"
role="tab"
aria-selected={pageTab === 'list'}
className={
'planning-segment-group__btn' +
(pageTab === 'list' ? ' planning-segment-group__btn--active' : '')
}
onClick={() => setPageTab('list')}
>
Liste
</button>
<button
type="button"
role="tab"
aria-selected={pageTab === 'progression'}
className={
'planning-segment-group__btn' +
(pageTab === 'progression' ? ' planning-segment-group__btn--active' : '')
}
onClick={() => setPageTab('progression')}
>
Progressionsgraphen
</button>
</div>
<div className="exercises-page-toolbar-tabs admin-page-subtabs" role="tablist" aria-label="Übungen Bereiche">
<button
type="button"
role="tab"
aria-selected={pageTab === 'list'}
className={
'admin-page-subtabs__btn' + (pageTab === 'list' ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setPageTab('list')}
>
Liste
</button>
<button
type="button"
role="tab"
aria-selected={pageTab === 'progression'}
className={
'admin-page-subtabs__btn' +
(pageTab === 'progression' ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setPageTab('progression')}
>
Progressionsgraphen
</button>
</div>
{pageTab === 'progression' ? (
@ -1093,55 +1124,80 @@ function ExercisesListPage() {
{hasMore ? ' · es gibt weitere Einträge' : ''}
</p>
<div className="exercises-list-grid">
{exercises.map((exercise) => (
<div key={exercise.id} className="card exercise-card">
<div className="exercise-card-layout">
<input
type="checkbox"
checked={selectedIds.has(Number(exercise.id))}
onChange={() => toggleSelect(exercise.id)}
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
className="exercise-card-layout__check"
/>
<div className="exercise-card__body exercise-card-body-flex">
<h3 className="exercise-card-title">
<Link to={`/exercises/${exercise.id}`}>
{exercise.title}
{exercises.map((exercise) => {
const focusNames = exerciseFocusNames(exercise)
const styleNames = coerceApiNameList(exercise.style_direction_names)
const typeNames = coerceApiNameList(exercise.training_type_names)
const summaryHtml = exercise.summary
? sanitizeExerciseRichText(exercise.summary)
: ''
return (
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
<div className="exercise-card-layout exercise-card-layout--grow">
<input
type="checkbox"
checked={selectedIds.has(Number(exercise.id))}
onChange={() => toggleSelect(exercise.id)}
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
className="exercise-card-layout__check"
/>
<div className="exercise-card__body exercise-card-body-flex">
<h3 className="exercise-card-title">
<Link to={`/exercises/${exercise.id}`}>
{exercise.title}
</Link>
</h3>
<div className="exercise-card-tags">
{focusNames.map((name) => (
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">{name}</span>
))}
{styleNames.map((name) => (
<span key={`sd:${name}`} className="exercise-tag exercise-tag--style">{name}</span>
))}
{typeNames.map((name) => (
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
))}
<span className="exercise-tag exercise-tag--scope">{visibilityLabel(exercise.visibility)}</span>
<span className="exercise-tag exercise-tag--meta">{statusLabel(exercise.status)}</span>
</div>
{summaryHtml ? (
<div
className="exercise-card-summary exercise-card-summary--rich"
dangerouslySetInnerHTML={{ __html: summaryHtml }}
/>
) : null}
</div>
</div>
<div className="exercise-card__actions exercise-card__actions--icons">
<Link
to={`/exercises/${exercise.id}`}
className="exercise-card__icon-btn"
title="Ansehen"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
>
<Eye size={18} strokeWidth={2} aria-hidden />
</Link>
</h3>
<div className="exercise-card-tags">
{exercise.focus_area && (
<span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
)}
<span className="exercise-tag">{exercise.visibility}</span>
<span className="exercise-tag">{exercise.status}</span>
</div>
{exercise.summary && (
<p className="exercise-card-summary">
{exercise.summary.length > 160
? `${exercise.summary.slice(0, 160)}`
: exercise.summary}
</p>
)}
<Link
to={`/exercises/${exercise.id}/edit`}
className="exercise-card__icon-btn"
title="Bearbeiten"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`}
>
<Pencil size={18} strokeWidth={2} aria-hidden />
</Link>
<button
type="button"
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
title="Löschen"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ löschen`}
onClick={() => handleDelete(exercise)}
>
<Trash2 size={18} strokeWidth={2} aria-hidden />
</button>
</div>
</div>
<div className="exercise-card__actions">
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
Ansehen
</Link>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
Bearbeiten
</Link>
<button
type="button"
className="btn btn-danger btn-small"
onClick={() => handleDelete(exercise)}
>
Löschen
</button>
</div>
</div>
))}
)
})}
</div>
{hasMore && (
<div className="exercises-load-more">

View File

@ -146,28 +146,29 @@ function SkillsPage() {
<div className="app-page skills-page">
<h1 className="page-title">Fähigkeiten & Methoden</h1>
<div className="skills-page__tabs-scroll">
<div
className="planning-segment-group planning-segment-group--equal skills-page-mode-switch"
role="tablist"
aria-label="Bereich wählen"
<div className="skills-page__tabs-scroll admin-page-subtabs" role="tablist" aria-label="Bereich wählen">
<button
type="button"
role="tab"
aria-selected={activeTab === 'skills'}
className={
'admin-page-subtabs__btn' + (activeTab === 'skills' ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setActiveTab('skills')}
>
{['skills', 'methods'].map(tab => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
className={
'planning-segment-group__btn' +
(activeTab === tab ? ' planning-segment-group__btn--active' : '')
}
onClick={() => setActiveTab(tab)}
>
{tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
</button>
))}
</div>
Fähigkeiten
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'methods'}
className={
'admin-page-subtabs__btn' + (activeTab === 'methods' ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => setActiveTab('methods')}
>
Trainingsmethoden
</button>
</div>
{/* Skills Tab */}

View File

@ -0,0 +1,52 @@
/**
* Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute).
* Für Anzeige mit dangerouslySetInnerHTML.
*/
const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li'])
function cleanTree(parent) {
const nodes = Array.from(parent.childNodes)
for (const node of nodes) {
if (node.nodeType === Node.TEXT_NODE) continue
if (node.nodeType !== Node.ELEMENT_NODE) {
parent.removeChild(node)
continue
}
const tag = node.tagName.toLowerCase()
if (!ALLOWED_TAGS.has(tag)) {
while (node.firstChild) {
parent.insertBefore(node.firstChild, node)
}
parent.removeChild(node)
continue
}
while (node.attributes.length > 0) {
node.removeAttribute(node.attributes[0].name)
}
cleanTree(node)
}
}
export function sanitizeExerciseRichText(html) {
if (html == null || typeof html !== 'string') return ''
const trimmed = html.trim()
if (!trimmed) return ''
const tpl = document.createElement('template')
tpl.innerHTML = trimmed
cleanTree(tpl.content)
return tpl.innerHTML
}
export function coerceApiNameList(value) {
if (Array.isArray(value)) return value.map(String).filter((s) => s.trim())
if (typeof value === 'string') {
try {
const p = JSON.parse(value)
if (Array.isArray(p)) return p.map(String).filter((s) => s.trim())
} catch {
return []
}
}
return []
}