feat(P-13): add content reporting functionality with modal and update version to 0.8.88
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 56s

This commit is contained in:
Lars 2026-05-11 18:08:57 +02:00
parent 60709df615
commit 2f7e1e50ad
5 changed files with 280 additions and 4 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.87"
APP_VERSION = "0.8.88"
BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511052"
@ -30,10 +30,19 @@ MODULE_VERSIONS = {
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
"content_reports": "1.0.0", # P-13: Content-Melde-Backend (DSA-konform, Inbox-Integration, P-11 Legal Hold)
"content_reports": "1.1.0", # P-13: Melde-Button in Medienbibliothek + ExerciseAttachmentMediaStrip
}
CHANGELOG = [
{
"version": "0.8.88",
"date": "2026-05-11",
"changes": [
"Feat P-13: Melde-Button in Medienbibliothek (Grid + Liste) — öffnet ReportContentModal; nur aktive Medien ohne Legal Hold.",
"Feat P-13: Melde-Link an jedem Medium in ExerciseAttachmentMediaStrip (Lesemodus Übung).",
"Feat P-13: ReportContentModal — wiederverwendbares Formular (Grund, Beschreibung, Name, E-Mail, Gutglaubenserklärung); Vorausfüllung für eingeloggte Nutzer.",
],
},
{
"version": "0.8.87",
"date": "2026-05-11",

View File

@ -4,6 +4,7 @@
import React, { useMemo, useState } from 'react'
import ExerciseMediaEmbed from './ExerciseMediaEmbed'
import ExerciseMediaThumbTile from './ExerciseMediaThumbTile'
import ReportContentModal from './ReportContentModal'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import {
collectInlineExerciseMediaIdsFromExercise,
@ -15,6 +16,7 @@ function isTrashHidden(m) {
export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
const [preview, setPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null)
const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise])
const orphans = useMemo(() => {
@ -56,10 +58,37 @@ export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
</div>
</div>
<ExerciseMediaEmbed exerciseId={exerciseId} media={m} layoutSize="medium" />
{(m.asset_lifecycle_state || 'active') === 'active' && !m.asset_legal_hold_active && (
<button
type="button"
onClick={() => setReportTarget(m)}
style={{
background: 'none',
border: 'none',
padding: '4px 0 0',
cursor: 'pointer',
fontSize: '0.72rem',
color: 'var(--text3)',
textDecoration: 'underline',
textAlign: 'left',
}}
>
Inhalt melden
</button>
)}
</article>
)
})}
</div>
{reportTarget && (
<ReportContentModal
targetType="media_asset"
targetId={reportTarget.media_asset_id || reportTarget.id}
targetLabel={reportTarget.title || reportTarget.original_filename || `Medium #${reportTarget.id}`}
onClose={() => setReportTarget(null)}
/>
)}
{preview && (
<div
role="dialog"

View File

@ -0,0 +1,195 @@
/**
* Wiederverwendbares Melde-Modal (P-13).
* Props:
* targetType 'media_asset' | 'exercise'
* targetId ID des Zielobjekts
* targetLabel Anzeigename für den Header (z.B. Dateiname oder Übungstitel)
* onClose Callback zum Schließen
*/
import React, { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
const REASON_OPTIONS = [
{ value: '', label: '— Grund auswählen —' },
{ value: 'copyright', label: 'Urheberrechtsverletzung' },
{ value: 'image_rights', label: 'Bildrechtsverletzung / Recht am eigenen Bild' },
{ value: 'privacy', label: 'Datenschutz / Persönlichkeitsrecht' },
{ value: 'minors', label: 'Darstellung Minderjähriger' },
{ value: 'illegal_content', label: 'Rechtswidriger Inhalt' },
{ value: 'youth_protection', label: 'Jugendschutz' },
{ value: 'offensive_content', label: 'Beleidigender / anstößiger Inhalt' },
{ value: 'other', label: 'Sonstiges' },
]
export default function ReportContentModal({ targetType, targetId, targetLabel, onClose }) {
const { user } = useAuth()
const [reason, setReason] = useState('')
const [description, setDescription] = useState('')
const [name, setName] = useState(user?.name || '')
const [email, setEmail] = useState(user?.email || '')
const [confirmed, setConfirmed] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!reason) { setError('Bitte einen Meldegrund auswählen.'); return }
if (description.trim().length < 10) { setError('Beschreibung muss mindestens 10 Zeichen haben.'); return }
if (!name.trim()) { setError('Bitte deinen Namen eingeben.'); return }
if (!email.trim()) { setError('Bitte deine E-Mail-Adresse eingeben.'); return }
if (!confirmed) { setError('Bitte bestätige die Gutglaubenserklärung.'); return }
setSaving(true)
setError(null)
try {
await api.submitContentReport({
target_type: targetType,
target_id: targetId,
report_reason: reason,
report_description: description.trim(),
reporter_name: name.trim(),
reporter_email: email.trim(),
good_faith_confirmed: true,
})
setSuccess(true)
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
overflowY: 'auto',
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
marginTop: '2rem',
marginBottom: '2rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Inhalt melden</h2>
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}></button>
</div>
{targetLabel && (
<p className="muted" style={{ fontSize: '0.85rem', marginBottom: '1rem', marginTop: 0 }}>
{targetType === 'media_asset' ? 'Medium' : 'Übung'}: <strong>{targetLabel}</strong>
</p>
)}
{success ? (
<div>
<p style={{ color: 'var(--accent)', fontWeight: 600 }}>Meldung eingegangen.</p>
<p className="muted" style={{ fontSize: '0.9rem' }}>
Deine Meldung wurde gespeichert und wird von unseren Moderatoren geprüft. Vielen Dank.
</p>
<button type="button" className="btn btn-primary btn-full" onClick={onClose}>
Schließen
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="form-row">
<label className="form-label">Meldegrund *</label>
<select
className="form-input"
value={reason}
onChange={(e) => setReason(e.target.value)}
required
>
{REASON_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Beschreibung * (mind. 10 Zeichen)</label>
<textarea
className="form-input"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Bitte erläutere kurz, warum du diesen Inhalt meldest."
required
/>
</div>
<div className="form-row">
<label className="form-label">Dein Name *</label>
<input
type="text"
className="form-input"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Deine E-Mail-Adresse *</label>
<input
type="email"
className="form-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-row">
<label style={{ display: 'flex', gap: '0.6rem', alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
style={{ marginTop: '3px', flexShrink: 0 }}
/>
<span style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
Ich melde diesen Inhalt nach bestem Wissen und Gewissen. Ich bestätige, dass meine Meldung
nicht missbräuchlich ist und ich der Überzeugung bin, dass der gemeldete Inhalt rechtswidrig
ist oder gegen die Nutzungsbedingungen verstößt.
</span>
</label>
</div>
{error && (
<p style={{ color: 'var(--danger)', fontSize: '0.88rem', margin: '0.5rem 0' }}>{error}</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }} disabled={saving}>
{saving ? 'Wird gesendet…' : 'Meldung absenden'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>
Abbrechen
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@ -25,6 +25,7 @@ import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RightsDeclarationDialog from '../components/RightsDeclarationDialog'
import ReportContentModal from '../components/ReportContentModal'
const LC_OPTIONS = [
{ value: 'active', label: 'Aktiv' },
@ -317,6 +318,7 @@ export default function MediaLibraryPage() {
const [bulkApplyVis, setBulkApplyVis] = useState(false)
const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null)
const [mediaKind, setMediaKind] = useState('all')
const [filterClubId, setFilterClubId] = useState('')
const [filterUploaderId, setFilterUploaderId] = useState('')
@ -986,6 +988,24 @@ export default function MediaLibraryPage() {
</div>
) : null}
<MediaUsageBlock usage={it.usage} compact />
{(it.lifecycle_state || 'active') === 'active' && !it.legal_hold_active && (
<button
type="button"
onClick={() => setReportTarget(it)}
style={{
background: 'none',
border: 'none',
padding: '2px 0 0',
cursor: 'pointer',
fontSize: '0.72rem',
color: 'var(--text3)',
textDecoration: 'underline',
textAlign: 'left',
}}
>
Melden
</button>
)}
</div>
</div>
)
@ -1045,7 +1065,7 @@ export default function MediaLibraryPage() {
{viewer?.show_uploader_meta ? (
<td className="media-library__td-sub">{uploaderLabel(it, viewer) || '—'}</td>
) : null}
<td>
<td style={{ whiteSpace: 'nowrap' }}>
<button
type="button"
className="media-library__icon-btn"
@ -1054,6 +1074,18 @@ export default function MediaLibraryPage() {
>
<MoreVertical size={18} />
</button>
{(it.lifecycle_state || 'active') === 'active' && !it.legal_hold_active && (
<button
type="button"
className="media-library__icon-btn"
onClick={() => setReportTarget(it)}
aria-label="Melden"
title="Inhalt melden"
style={{ color: 'var(--text3)', fontSize: '0.72rem', padding: '4px 6px' }}
>
Melden
</button>
)}
</td>
</tr>
)
@ -2026,6 +2058,15 @@ export default function MediaLibraryPage() {
</div>
</div>
) : null}
{reportTarget && (
<ReportContentModal
targetType="media_asset"
targetId={reportTarget.id}
targetLabel={reportTarget.original_filename || `Medium #${reportTarget.id}`}
onClose={() => setReportTarget(null)}
/>
)}
</div>
)
}

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.87"
export const APP_VERSION = "0.8.88"
export const BUILD_DATE = "2026-05-11"
export const PAGE_VERSIONS = {
@ -26,4 +26,6 @@ export const PAGE_VERSIONS = {
ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei
ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau
InboxPage: "2.0.0", // P-13: Inhaltsmeldungen-Abschnitt integriert
MediaLibraryPage: "1.7.0", // P-13: Melde-Button an Medienkacheln
ExerciseAttachmentMediaStrip: "1.1.0", // P-13: Melde-Link an angehängten Medien
}