Enhance Exercise Progression Graph Panel and Path Builder with New Features
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m31s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m31s
- Introduced a primary chain selection in the Exercise Progression Graph Panel to streamline exercise path management. - Updated the ProgressionChainEditor to support single path mode, allowing users to manage a single progression path more effectively. - Enhanced the ExerciseProgressionPathBuilder with improved logic for merging graph nodes into path steps and filtering gap offers. - Updated UI elements for better clarity and user experience, including new notifications and styling adjustments. - Incremented application version to reflect these updates.
This commit is contained in:
parent
800189ff8f
commit
8d5f0b533c
|
|
@ -277,6 +277,13 @@ export default function ExerciseProgressionGraphPanel({
|
|||
|
||||
const flowChains = useMemo(() => maximalLinearChains(nextEdgesFiltered), [nextEdgesFiltered])
|
||||
|
||||
const primaryChain = useMemo(() => {
|
||||
if (!flowChains.length) return null
|
||||
return flowChains.reduce((best, chain) =>
|
||||
chain.nodes.length >= best.nodes.length ? chain : best,
|
||||
)
|
||||
}, [flowChains])
|
||||
|
||||
const handleCreateGraph = async (e) => {
|
||||
e.preventDefault()
|
||||
const name = newGraphName.trim()
|
||||
|
|
@ -566,18 +573,43 @@ export default function ExerciseProgressionGraphPanel({
|
|||
|
||||
{selectedGraphId && (
|
||||
<>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
borderColor: 'color-mix(in srgb, var(--accent) 30%, var(--border))',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Progressionspfad</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||
Der Graph enthält typischerweise <strong>einen linearen Pfad</strong> (Übung → Übung). Unten
|
||||
manuell bearbeiten oder mit dem KI-Planer erweitern — Speichern im KI-Wizard ersetzt den Pfad.
|
||||
</p>
|
||||
|
||||
<ProgressionChainEditor
|
||||
ref={chainEditorRef}
|
||||
graphId={selectedGraphId}
|
||||
chains={flowChains}
|
||||
chains={primaryChain ? [primaryChain] : []}
|
||||
busy={busy}
|
||||
anchorExerciseId={anchorExerciseId}
|
||||
anchorTitle={anchorTitle}
|
||||
onRefresh={async () => refreshEdges(selectedGraphId)}
|
||||
onPickExercise={setPickContext}
|
||||
loadVariantsForExercise={loadVariantsForExercise}
|
||||
singlePathMode
|
||||
/>
|
||||
|
||||
<ExerciseProgressionPathBuilder
|
||||
graphId={selectedGraphId}
|
||||
disabled={busy}
|
||||
graphChainNodes={primaryChain?.nodes ?? null}
|
||||
graphChainEdgeIds={primaryChain?.edges?.map((e) => e.id) ?? null}
|
||||
onSaved={async () => {
|
||||
await refreshEdges(selectedGraphId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '12px' }}>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Schwestern & Alternativen</h3>
|
||||
{siblingEdgesFiltered.length === 0 ? (
|
||||
|
|
@ -671,19 +703,6 @@ export default function ExerciseProgressionGraphPanel({
|
|||
</div>
|
||||
</details>
|
||||
|
||||
<details className="card" style={{ marginBottom: '12px' }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>KI: Neuen Pfad planen (4 Schritte)</summary>
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
<ExerciseProgressionPathBuilder
|
||||
graphId={selectedGraphId}
|
||||
disabled={busy}
|
||||
onSaved={async () => {
|
||||
await refreshEdges(selectedGraphId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<SkillProfilePanel
|
||||
title="Fähigkeiten entlang des Pfads"
|
||||
hint="Alle Übungen als Knoten im Graph (Standardgewicht pro Übung; Intensität und Stufen aus der Übungsverknüpfung)."
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
|
||||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||
|
|
@ -134,6 +135,122 @@ const OFFER_SOURCE_LABELS = {
|
|||
roadmap_unfilled: 'Roadmap-Stufe',
|
||||
}
|
||||
|
||||
function normalizeTitleKey(text) {
|
||||
return String(text || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function mergeGraphIntoPathSteps(pathRows, graphNodes) {
|
||||
if (!Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) return pathRows
|
||||
return pathRows.map((row, i) => {
|
||||
const node = graphNodes[i]
|
||||
if (!node?.exercise_id) return row
|
||||
if (row.exerciseId != null) return row
|
||||
return {
|
||||
...row,
|
||||
exerciseId: Number(node.exercise_id),
|
||||
exerciseTitle: node.title || `Übung #${node.exercise_id}`,
|
||||
variantId: node.variant_id != null ? Number(node.variant_id) : null,
|
||||
variants: row.variants || [],
|
||||
isFromGraph: true,
|
||||
reasons: [...(row.reasons || []), 'Aus bestehendem Graph übernommen'],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function filterGapOffersForGraph(offers, pathRows, graphNodes) {
|
||||
if (!Array.isArray(offers) || !offers.length) return offers
|
||||
const graphIds = new Set(
|
||||
(graphNodes || []).map((n) => Number(n.exercise_id)).filter(Number.isFinite),
|
||||
)
|
||||
const graphTitles = new Set(
|
||||
(graphNodes || []).map((n) => normalizeTitleKey(n.title)).filter(Boolean),
|
||||
)
|
||||
const pathIds = new Set(
|
||||
(pathRows || []).map((r) => r.exerciseId).filter((id) => id != null),
|
||||
)
|
||||
|
||||
return offers.filter((offer) => {
|
||||
const hint = normalizeTitleKey(offer?.title_hint)
|
||||
const majorIdx =
|
||||
offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null
|
||||
|
||||
if (majorIdx != null && Number.isFinite(majorIdx) && graphNodes?.[majorIdx]) {
|
||||
const gid = Number(graphNodes[majorIdx].exercise_id)
|
||||
if (graphIds.has(gid) && pathIds.has(gid)) return false
|
||||
}
|
||||
|
||||
for (const gid of graphIds) {
|
||||
if (pathIds.has(gid) && hint) {
|
||||
const node = graphNodes.find((n) => Number(n.exercise_id) === gid)
|
||||
const nodeTitle = normalizeTitleKey(node?.title)
|
||||
if (nodeTitle && (nodeTitle === hint || nodeTitle.includes(hint) || hint.includes(nodeTitle))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of graphTitles) {
|
||||
if (hint && (t === hint || t.includes(hint) || hint.includes(t))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function SavedGraphPathStrip({ nodes, hasDraft }) {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '14px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px dashed var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--text1)' }}>Im Graph gespeichert:</strong> noch kein Pfad — der erste
|
||||
Speichervorgang legt die Übungsfolge an.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '14px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', marginBottom: '8px', color: 'var(--accent-dark)' }}>
|
||||
Im Graph gespeichert ({nodes.length} Schritte)
|
||||
{hasDraft ? (
|
||||
<span style={{ fontWeight: 400, color: 'var(--text3)', marginLeft: '8px' }}>
|
||||
— KI-Entwurf unten; Speichern ersetzt diesen Pfad
|
||||
</span>
|
||||
) : null}
|
||||
</strong>
|
||||
<ol style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.55 }}>
|
||||
{nodes.map((node, idx) => (
|
||||
<li key={`saved-${node.exercise_id}-${node.variant_id}-${idx}`}>
|
||||
<Link to={`/exercises/${node.exercise_id}`}>{node.title || `Übung #${node.exercise_id}`}</Link>
|
||||
{node.variant_name ? (
|
||||
<span style={{ color: 'var(--text3)' }}>{` · ${node.variant_name}`}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PATH_STEPS_HARD_MAX = 10
|
||||
|
||||
const WIZARD_STEPS = [
|
||||
|
|
@ -399,6 +516,8 @@ export default function ExerciseProgressionPathBuilder({
|
|||
graphId,
|
||||
disabled = false,
|
||||
onSaved,
|
||||
graphChainNodes = null,
|
||||
graphChainEdgeIds = null,
|
||||
}) {
|
||||
const [goalQuery, setGoalQuery] = useState('')
|
||||
const [startSituation, setStartSituation] = useState('')
|
||||
|
|
@ -887,7 +1006,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setActiveOffer(null)
|
||||
const title = (created.title || quickTitle || 'Übung').trim()
|
||||
setPathInsertNotice(
|
||||
`„${title}" wurde in den KI-Pfad eingefügt. Speichern Sie jetzt mit «Pfad in Graph speichern» — die Reihe erscheint dann oben unter «Reihen im Graph».`,
|
||||
`„${title}" wurde in den KI-Entwurf eingefügt. Mit «Pfad im Graph speichern» wird der gesamte Pfad übernommen.`,
|
||||
)
|
||||
setWizardStep(4)
|
||||
} catch (e) {
|
||||
|
|
@ -910,17 +1029,18 @@ export default function ExerciseProgressionPathBuilder({
|
|||
if (rows.length < 2) {
|
||||
throw new Error('Zu wenig Schritte im Vorschlag.')
|
||||
}
|
||||
setPathSteps(rows)
|
||||
setTargetSummary(res?.target_profile_summary || null)
|
||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||
setPathQa(qa)
|
||||
setGapFillOffers(
|
||||
Array.isArray(res?.gap_fill_offers)
|
||||
const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes)
|
||||
const rawGaps = Array.isArray(res?.gap_fill_offers)
|
||||
? res.gap_fill_offers
|
||||
: Array.isArray(qa?.gap_fill_offers)
|
||||
? qa.gap_fill_offers
|
||||
: [],
|
||||
)
|
||||
: []
|
||||
const gaps = filterGapOffersForGraph(rawGaps, mergedRows, graphChainNodes)
|
||||
setPathSteps(mergedRows)
|
||||
setTargetSummary(res?.target_profile_summary || null)
|
||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||
setPathQa(qa)
|
||||
setGapFillOffers(gaps)
|
||||
setProgressionRoadmap(res?.progression_roadmap || null)
|
||||
setPathSkillExpectations(res?.path_skill_expectations || null)
|
||||
setRoadmapDirty(false)
|
||||
|
|
@ -1125,6 +1245,12 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const edgeIds = Array.isArray(graphChainEdgeIds)
|
||||
? graphChainEdgeIds.filter((id) => Number.isFinite(Number(id)))
|
||||
: []
|
||||
if (edgeIds.length > 0) {
|
||||
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
|
||||
}
|
||||
const planningArtifact = buildPlanningArtifact()
|
||||
await api.createExerciseProgressionSequence(Number(graphId), {
|
||||
steps: steps.map((s) => ({
|
||||
|
|
@ -1148,12 +1274,19 @@ export default function ExerciseProgressionPathBuilder({
|
|||
if (typeof onSaved === 'function') await onSaved()
|
||||
const msg =
|
||||
skippedAi > 0
|
||||
? `${n} Kante(n) gespeichert. ${skippedAi} KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt).`
|
||||
: `${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`
|
||||
? `Pfad gespeichert (${n} Kante(n)). ${skippedAi} KI-Vorschlag/Vorschläge noch nicht angelegt.`
|
||||
: edgeIds.length > 0
|
||||
? `Progressionspfad aktualisiert (${n} Kante(n)).`
|
||||
: `Progressionspfad angelegt (${n} Kante(n)).`
|
||||
alert(msg)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError(e.message || 'Speichern fehlgeschlagen')
|
||||
const detail = e?.message || String(e)
|
||||
setError(
|
||||
detail.includes('409') || detail.toLowerCase().includes('duplikat')
|
||||
? 'Speichern fehlgeschlagen: Pfad-Konflikt. Bitte erneut versuchen — bestehende Kanten werden beim Speichern ersetzt.'
|
||||
: detail || 'Speichern fehlgeschlagen',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
|
@ -1161,17 +1294,20 @@ export default function ExerciseProgressionPathBuilder({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
borderColor: 'color-mix(in srgb, var(--accent) 35%, var(--border))',
|
||||
marginTop: '16px',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Progressionspfad planen (KI)</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||
In vier Schritten: Ziel festlegen → Roadmap bearbeiten → Übungen matchen → Lücken schließen und speichern.
|
||||
Ein Graph hat einen linearen Pfad. Oben der gespeicherte Stand, darunter der KI-Entwurf in vier Schritten.
|
||||
Speichern übernimmt den Entwurf und ersetzt den bisherigen Pfad.
|
||||
</p>
|
||||
|
||||
<SavedGraphPathStrip nodes={graphChainNodes} hasDraft={pathSteps.length > 0} />
|
||||
|
||||
<PlanningWizardStepper
|
||||
currentStep={wizardStep}
|
||||
maxReachable={maxReachableStep}
|
||||
|
|
@ -1759,6 +1895,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
: ''}
|
||||
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
|
||||
{step.isOffTopic ? ' (themenfremd)' : ''}
|
||||
{step.isFromGraph ? ' (im Graph)' : ''}
|
||||
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
|
||||
</label>
|
||||
{step.roadmapLearningGoal ? (
|
||||
|
|
@ -1922,8 +2059,8 @@ export default function ExerciseProgressionPathBuilder({
|
|||
color: 'var(--accent-dark)',
|
||||
}}
|
||||
>
|
||||
<strong>Wichtig:</strong> Der KI-Pfad ist noch nicht im Graph gespeichert. KI-Übungen werden erst nach
|
||||
«Pfad in Graph speichern» als neue Reihe oben sichtbar.
|
||||
<strong>Wichtig:</strong> Der KI-Entwurf ist noch nicht gespeichert. «Pfad im Graph speichern» übernimmt
|
||||
ihn und ersetzt den oben gezeigten Pfad.
|
||||
{pathInsertNotice ? <p style={{ margin: '8px 0 0' }}>{pathInsertNotice}</p> : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -2042,6 +2179,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
<span style={{ color: 'var(--danger)' }}> — noch nicht angelegt</span>
|
||||
)}
|
||||
{step.roadmapPhase ? ` · ${step.roadmapPhase}` : ''}
|
||||
{step.isFromGraph ? ' · bereits im Graph' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
|
@ -2072,7 +2210,11 @@ export default function ExerciseProgressionPathBuilder({
|
|||
disabled={disabled || saving || pathSteps.filter((s) => s.exerciseId).length < 2}
|
||||
onClick={savePathToGraph}
|
||||
>
|
||||
{saving ? 'Speichern …' : 'Pfad in Graph speichern'}
|
||||
{saving
|
||||
? 'Speichern …'
|
||||
: graphChainEdgeIds?.length
|
||||
? 'Pfad im Graph speichern (ersetzen)'
|
||||
: 'Pfad im Graph speichern'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
|
|||
onRefresh,
|
||||
onPickExercise,
|
||||
loadVariantsForExercise,
|
||||
singlePathMode = false,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
|
|
@ -258,7 +259,7 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
|
|||
if (!graphId) return null
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '12px' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -270,20 +271,25 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
|
|||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Reihen im Graph</h3>
|
||||
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>
|
||||
{singlePathMode ? 'Manuell bearbeiten' : 'Reihen im Graph'}
|
||||
</h4>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '4px 0 0', lineHeight: 1.45 }}>
|
||||
Jede Reihe ist ein linearer Pfad: Schritt 1 → 2 → … Reihenfolge ändern, Übungen tauschen oder
|
||||
dazwischen einfügen, dann speichern.
|
||||
Schritt 1 → 2 → …: Reihenfolge ändern, Übungen tauschen oder dazwischen einfügen, dann speichern.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={addNewChain}>
|
||||
+ Neue Reihe
|
||||
{!singlePathMode || drafts.length === 0 ? (
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={addNewChain}>
|
||||
{singlePathMode ? '+ Pfad anlegen' : '+ Neue Reihe'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)', margin: 0, fontSize: '13px' }}>
|
||||
Noch keine Reihen in diesem Graph. Legen Sie eine neue Reihe an oder nutzen Sie den KI-Planer unten.
|
||||
{singlePathMode
|
||||
? 'Noch kein gespeicherter Pfad — manuell anlegen oder mit dem KI-Planer unten.'
|
||||
: 'Noch keine Reihen in diesem Graph.'}
|
||||
</p>
|
||||
) : (
|
||||
drafts.map((draft, chainIdx) => (
|
||||
|
|
@ -310,7 +316,9 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
|
|||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: '13px' }}>Reihe {chainIdx + 1}</strong>
|
||||
<strong style={{ fontSize: '13px' }}>
|
||||
{singlePathMode ? 'Gespeicherter Pfad' : `Reihe ${chainIdx + 1}`}
|
||||
</strong>
|
||||
{draft.dirty ? (
|
||||
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
||||
Ungespeichert
|
||||
|
|
@ -471,7 +479,7 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
|
|||
disabled={busy || savingKey === draft.key || !draft.dirty}
|
||||
onClick={() => saveDraft(draft)}
|
||||
>
|
||||
{savingKey === draft.key ? 'Speichern …' : 'Reihe speichern'}
|
||||
{savingKey === draft.key ? 'Speichern …' : singlePathMode ? 'Pfad speichern' : 'Reihe speichern'}
|
||||
</button>
|
||||
{draft.dirty ? (
|
||||
<button
|
||||
|
|
@ -495,7 +503,7 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
|
|||
disabled={busy || savingKey === draft.key}
|
||||
onClick={() => deleteChain(draft)}
|
||||
>
|
||||
Reihe löschen
|
||||
{singlePathMode ? 'Pfad löschen' : 'Reihe löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user