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
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:
parent
60709df615
commit
2f7e1e50ad
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
195
frontend/src/components/ReportContentModal.jsx
Normal file
195
frontend/src/components/ReportContentModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user