shinkan-jinkendo/frontend/src/components/ExerciseInlineEmbedModal.jsx
Lars 4bc24b4caf
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 33s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Failing after 1m2s
feat(p06): Copyright-Feld und Einwilligungskontext in Rechte-Erklaerung
Migration 049: 4 optionale TEXT-Spalten in media_asset_rights_declarations
(person_consent_context, parental_consent_context, music_rights_context,
third_party_rights_context) fuer Freitext zum Einwilligungskontext.

Backend:
- media_rights.py: write_rights_declaration speichert 4 Kontextfelder
- media_assets.py: copyright_notice + 4 Kontextfelder in Bulk-Upload,
  RightsDeclarationBody, MediaAssetPatch, MediaBulkPatchBody
- exercises.py: copyright_notice + 4 Kontextfelder in upload_exercise_media,
  wird in INSERT gespeichert

Frontend (alle 3 Formulare):
- RightsDeclarationDialog: Copyright-Eingabefeld (immer sichtbar) +
  Freitext-Textarea bei jeder Ja-Antwort (Personen, Minderjaehrige,
  Musik, Fremdinhalte)
- ExerciseInlineFileMediaModal: gleiche Felder inline im Upload-Tab
- ExerciseInlineEmbedModal: gleiche Felder inline
- api.js: copyright_notice + 4 Kontextfelder in bulkUploadMediaAssets

version: 0.8.77
module: media_rights 1.1.0, media_assets 1.14.0, exercises 2.21.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:06:47 +02:00

289 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Modal: Embed-URL als exercise_media anlegen und §11-Platzhalter einfügen.
*/
import React, { useEffect, useState } from 'react'
import api from '../utils/api'
import {
INLINE_MEDIA_SIZES,
DEFAULT_INLINE_MEDIA_SIZE,
sanitizeInlineMediaSize,
} from '../constants/inlineExerciseMedia'
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
const DECL_INIT = {
copyright_notice: '',
rights_holder_confirmed: false,
contains_identifiable_persons: null,
person_consent_confirmed: false,
person_consent_context: '',
contains_minors: null,
parental_consent_confirmed: false,
parental_consent_context: '',
contains_music: null,
music_rights_confirmed: false,
music_rights_context: '',
contains_third_party_content: null,
third_party_rights_confirmed: false,
third_party_rights_context: '',
}
function validateDecl(decl) {
if (!decl.rights_holder_confirmed)
return 'Bitte bestätigen, dass du die erforderlichen Rechte an diesem Medium besitzt.'
if (decl.contains_identifiable_persons === null)
return 'Bitte angeben, ob erkennbare Personen abgebildet sind.'
if (decl.contains_identifiable_persons && !decl.person_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen aller erkennbaren Personen vorliegen.'
if (decl.contains_minors === null)
return 'Bitte angeben, ob Minderjährige abgebildet sind.'
if (decl.contains_minors && !decl.parental_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen der Sorgeberechtigten vorliegen.'
if (decl.contains_music === null)
return 'Bitte angeben, ob das Medium Musik enthält.'
if (decl.contains_music && !decl.music_rights_confirmed)
return 'Bitte bestätigen, dass die erforderlichen Musikrechte vorliegen.'
if (decl.contains_third_party_content === null)
return 'Bitte angeben, ob fremde geschützte Inhalte enthalten sind.'
if (decl.contains_third_party_content && !decl.third_party_rights_confirmed)
return 'Bitte bestätigen, dass die Rechte an allen enthaltenen Fremdmaterialien vorliegen.'
return ''
}
/**
* @param {{
* open: boolean,
* onClose: () => void,
* exerciseId: number,
* onMediaListChanged: () => Promise<void>,
* onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void,
* }} props
*/
export default function ExerciseInlineEmbedModal({
open,
onClose,
exerciseId,
onMediaListChanged,
onInserted,
}) {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE)
const [busy, setBusy] = useState(false)
const [decl, setDecl] = useState({ ...DECL_INIT })
const [declErr, setDeclErr] = useState('')
const setDeclField = (key, val) => setDecl((d) => ({ ...d, [key]: val }))
useEffect(() => {
if (!open) return
setUrl('')
setTitle('')
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
setDecl({ ...DECL_INIT })
setDeclErr('')
}, [open])
const submit = async () => {
const u = url.trim()
if (!u) {
alert('Bitte eine Embed-URL eingeben (https://…).')
return
}
const validErr = validateDecl(decl)
if (validErr) {
setDeclErr(validErr)
return
}
setDeclErr('')
const size = sanitizeInlineMediaSize(displaySize)
const fd = new FormData()
fd.append('embed_url', u)
fd.append('media_type', 'video')
fd.append('title', title.trim())
fd.append('description', '')
fd.append('context', 'ablauf')
fd.append('is_primary', 'false')
for (const [k, v] of Object.entries(decl)) {
fd.append(k, String(v))
}
setBusy(true)
try {
const row = await api.uploadExerciseMedia(exerciseId, fd)
const mid = row?.id
if (mid == null) {
throw new Error('Antwort ohne exercise_media-ID')
}
await onMediaListChanged()
const cap = sanitizeInlineMediaCaption(
title.trim() || u.replace(/^https?:\/\//i, '').slice(0, 96),
)
onInserted(Number(mid), size, cap)
onClose()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
if (!open) return null
return (
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && !busy && onClose()}>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="rte-inline-embed-title"
style={{ maxWidth: '480px', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="rte-inline-embed-title" className="admin-modal-sheet__title">
Embed im Textfeld
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" disabled={busy} onClick={onClose}>
Schließen
</button>
</div>
<div style={{ overflowY: 'auto', flex: 1, padding: '14px 16px' }}>
<label className="form-label">Embed-URL</label>
<input
type="url"
className="form-input"
placeholder="https://…"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={busy}
/>
<label className="form-label" style={{ marginTop: '12px' }}>
Titel (optional)
</label>
<input
type="text"
className="form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={busy}
/>
<label className="form-label" style={{ marginTop: '12px' }}>
Darstellung im Text
</label>
<select className="form-input" value={displaySize} onChange={(e) => setDisplaySize(e.target.value)} disabled={busy}>
{INLINE_MEDIA_SIZES.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{/* P-06 Rechte-Erklärung */}
<div style={{ marginTop: '18px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}>
<p style={{ fontSize: '0.82rem', fontWeight: 600, marginBottom: '10px', color: 'var(--text2)' }}>
Rechte-Erklärung <span style={{ fontWeight: 400, color: 'var(--text3)' }}>(VORLÄUFIG p06-v1-conservative)</span>
</p>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="emb-cr" style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: '3px' }}>Copyright-Angabe (optional)</label>
<input id="emb-cr" type="text" className="form-input" placeholder="z.B. © 2026 Lars Stommer, CC BY-NC"
value={decl.copyright_notice} onChange={(e) => setDeclField('copyright_notice', e.target.value)}
disabled={busy} style={{ fontSize: '0.85rem' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '10px' }}>
<input type="checkbox" id="emb-rhc" checked={decl.rights_holder_confirmed}
onChange={(e) => setDeclField('rights_holder_confirmed', e.target.checked)}
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-rhc" style={{ fontSize: '0.85rem' }}>
Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze oder zur Veröffentlichung berechtigt bin. *
</label>
</div>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Erkennbare Personen abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cip" checked={decl.contains_identifiable_persons === true} onChange={() => setDeclField('contains_identifiable_persons', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cip" checked={decl.contains_identifiable_persons === false} onChange={() => setDeclField('contains_identifiable_persons', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_identifiable_persons === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="emb-pcc" checked={decl.person_consent_confirmed} onChange={(e) => setDeclField('person_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-pcc" style={{ fontSize: '0.85rem' }}>Einwilligungen aller erkennbaren Personen liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Schriftliche Einwilligung vom 2026-05-01" value={decl.person_consent_context} onChange={(e) => setDeclField('person_consent_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Minderjährige abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cm" checked={decl.contains_minors === true} onChange={() => setDeclField('contains_minors', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cm" checked={decl.contains_minors === false} onChange={() => setDeclField('contains_minors', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_minors === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="emb-pcc2" checked={decl.parental_consent_confirmed} onChange={(e) => setDeclField('parental_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-pcc2" style={{ fontSize: '0.85rem' }}>Einwilligungen der Sorgeberechtigten liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Elterliche Einwilligung per E-Mail vom 2026-04-15" value={decl.parental_consent_context} onChange={(e) => setDeclField('parental_consent_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Musik enthalten? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cmu" checked={decl.contains_music === true} onChange={() => setDeclField('contains_music', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cmu" checked={decl.contains_music === false} onChange={() => setDeclField('contains_music', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_music === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="emb-mrc" checked={decl.music_rights_confirmed} onChange={(e) => setDeclField('music_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-mrc" style={{ fontSize: '0.85rem' }}>Musikrechte (GEMA / Lizenz) liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Lizenz / GEMA-Kontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. CC BY 4.0 oder GEMA-Freimeldung Nr. …" value={decl.music_rights_context} onChange={(e) => setDeclField('music_rights_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '4px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-ctpc" checked={decl.contains_third_party_content === true} onChange={() => setDeclField('contains_third_party_content', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-ctpc" checked={decl.contains_third_party_content === false} onChange={() => setDeclField('contains_third_party_content', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_third_party_content === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="emb-tprc" checked={decl.third_party_rights_confirmed} onChange={(e) => setDeclField('third_party_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-tprc" style={{ fontSize: '0.85rem' }}>Rechte an allen enthaltenen Fremdmaterialien liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Rechtsgrundlage (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Verbandslogo mit Genehmigung vom 2026-03-10" value={decl.third_party_rights_context} onChange={(e) => setDeclField('third_party_rights_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
{declErr && (
<p style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '8px' }}>{declErr}</p>
)}
</div>
</div>
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<button type="button" className="btn btn-primary btn-full" disabled={busy} onClick={submit}>
{busy ? 'Speichern…' : 'Hinzufügen & in Text einfügen'}
</button>
</div>
</div>
</div>
)
}