feat(p06): Copyright-Feld und Einwilligungskontext in Rechte-Erklaerung
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

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>
This commit is contained in:
Lars 2026-05-11 09:06:47 +02:00
parent 42aec79ad1
commit 4bc24b4caf
10 changed files with 313 additions and 193 deletions

View File

@ -290,6 +290,12 @@ def assert_rights_for_exercise_link(cur: Any, asset_id: int, exercise_visibility
# Declaration-Log schreiben + Schnellfelder aktualisieren # Declaration-Log schreiben + Schnellfelder aktualisieren
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def _clean_context(val: Any) -> Optional[str]:
"""Leere Strings → None, sonst auf 2000 Zeichen kuerzen."""
s = (val or "").strip()
return s[:2000] if s else None
def write_rights_declaration( def write_rights_declaration(
cur: Any, cur: Any,
asset_id: int, asset_id: int,
@ -307,11 +313,11 @@ def write_rights_declaration(
media_asset_id, declared_by_profile_id, action_type, target_visibility, media_asset_id, declared_by_profile_id, action_type, target_visibility,
declaration_version, declaration_version,
rights_holder_confirmed, rights_holder_confirmed,
contains_identifiable_persons, person_consent_confirmed, contains_identifiable_persons, person_consent_confirmed, person_consent_context,
contains_minors, parental_consent_confirmed, contains_minors, parental_consent_confirmed, parental_consent_context,
contains_music, music_rights_confirmed, contains_music, music_rights_confirmed, music_rights_context,
contains_third_party_content, third_party_rights_confirmed contains_third_party_content, third_party_rights_confirmed, third_party_rights_context
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""", RETURNING id""",
( (
asset_id, asset_id,
@ -322,12 +328,16 @@ def write_rights_declaration(
bool(decl.get("rights_holder_confirmed")), bool(decl.get("rights_holder_confirmed")),
decl.get("contains_identifiable_persons"), decl.get("contains_identifiable_persons"),
decl.get("person_consent_confirmed"), decl.get("person_consent_confirmed"),
_clean_context(decl.get("person_consent_context")),
decl.get("contains_minors"), decl.get("contains_minors"),
decl.get("parental_consent_confirmed"), decl.get("parental_consent_confirmed"),
_clean_context(decl.get("parental_consent_context")),
decl.get("contains_music"), decl.get("contains_music"),
decl.get("music_rights_confirmed"), decl.get("music_rights_confirmed"),
_clean_context(decl.get("music_rights_context")),
decl.get("contains_third_party_content"), decl.get("contains_third_party_content"),
decl.get("third_party_rights_confirmed"), decl.get("third_party_rights_confirmed"),
_clean_context(decl.get("third_party_rights_context")),
), ),
) )
row = cur.fetchone() row = cur.fetchone()

View File

@ -0,0 +1,18 @@
-- Migration 049: P-06 Erweiterung Einwilligungskontext-Felder und Copyright im Upload-Dialog
-- Optionale Freitextfelder fuer den Kontext der Einwilligung (z.B. "Schriftliche Einwilligung
-- vom 2026-05-01 liegt vor") sowie copyright_notice direkt beim Upload erfassbar.
ALTER TABLE media_asset_rights_declarations
ADD COLUMN IF NOT EXISTS person_consent_context TEXT,
ADD COLUMN IF NOT EXISTS parental_consent_context TEXT,
ADD COLUMN IF NOT EXISTS music_rights_context TEXT,
ADD COLUMN IF NOT EXISTS third_party_rights_context TEXT;
COMMENT ON COLUMN media_asset_rights_declarations.person_consent_context IS
'Optionaler Freitext: In welchem Zusammenhang liegt die Einwilligung der abgebildeten Personen vor?';
COMMENT ON COLUMN media_asset_rights_declarations.parental_consent_context IS
'Optionaler Freitext: In welchem Zusammenhang liegt die Einwilligung der Sorgeberechtigten vor?';
COMMENT ON COLUMN media_asset_rights_declarations.music_rights_context IS
'Optionaler Freitext: Welche Lizenz / GEMA-Regelung liegt fuer die Musik vor?';
COMMENT ON COLUMN media_asset_rights_declarations.third_party_rights_context IS
'Optionaler Freitext: Auf welcher Grundlage duerfen die enthaltenen Fremdinhalte verwendet werden?';

View File

@ -2527,16 +2527,21 @@ async def upload_exercise_media(
description: str = Form(""), description: str = Form(""),
context: str = Form("ablauf"), context: str = Form("ablauf"),
is_primary: bool = Form(False), is_primary: bool = Form(False),
copyright_notice: Optional[str] = Form(None),
# P-06: Rechte-Erklaerung (Pflicht bei Datei-Upload mit neuem media_asset) # P-06: Rechte-Erklaerung (Pflicht bei Datei-Upload mit neuem media_asset)
rights_holder_confirmed: Optional[bool] = Form(None), rights_holder_confirmed: Optional[bool] = Form(None),
contains_identifiable_persons: Optional[bool] = Form(None), contains_identifiable_persons: Optional[bool] = Form(None),
person_consent_confirmed: Optional[bool] = Form(None), person_consent_confirmed: Optional[bool] = Form(None),
person_consent_context: Optional[str] = Form(None),
contains_minors: Optional[bool] = Form(None), contains_minors: Optional[bool] = Form(None),
parental_consent_confirmed: Optional[bool] = Form(None), parental_consent_confirmed: Optional[bool] = Form(None),
parental_consent_context: Optional[str] = Form(None),
contains_music: Optional[bool] = Form(None), contains_music: Optional[bool] = Form(None),
music_rights_confirmed: Optional[bool] = Form(None), music_rights_confirmed: Optional[bool] = Form(None),
music_rights_context: Optional[str] = Form(None),
contains_third_party_content: Optional[bool] = Form(None), contains_third_party_content: Optional[bool] = Form(None),
third_party_rights_confirmed: Optional[bool] = Form(None), third_party_rights_confirmed: Optional[bool] = Form(None),
third_party_rights_context: Optional[str] = Form(None),
): ):
profile_id = tenant.profile_id profile_id = tenant.profile_id
if media_type not in ("image", "video", "document", "sketch"): if media_type not in ("image", "video", "document", "sketch"):
@ -2775,11 +2780,12 @@ async def upload_exercise_media(
if not dest_path.is_file(): if not dest_path.is_file():
dest_path.write_bytes(raw) dest_path.write_bytes(raw)
clean_cr = (copyright_notice or "").strip() or None
cur.execute( cur.execute(
"""INSERT INTO media_assets ( """INSERT INTO media_assets (
mime_type, byte_size, sha256, original_filename, visibility, club_id, mime_type, byte_size, sha256, original_filename, visibility, club_id,
uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state
) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active') ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'local', %s, 'active')
RETURNING id""", RETURNING id""",
( (
mime, mime,
@ -2789,6 +2795,7 @@ async def upload_exercise_media(
ex_vis, ex_vis,
dedupe_club, dedupe_club,
profile_id, profile_id,
clean_cr,
storage_key, storage_key,
), ),
) )
@ -2799,12 +2806,16 @@ async def upload_exercise_media(
"rights_holder_confirmed": rights_holder_confirmed, "rights_holder_confirmed": rights_holder_confirmed,
"contains_identifiable_persons": contains_identifiable_persons, "contains_identifiable_persons": contains_identifiable_persons,
"person_consent_confirmed": person_consent_confirmed, "person_consent_confirmed": person_consent_confirmed,
"person_consent_context": person_consent_context,
"contains_minors": contains_minors, "contains_minors": contains_minors,
"parental_consent_confirmed": parental_consent_confirmed, "parental_consent_confirmed": parental_consent_confirmed,
"parental_consent_context": parental_consent_context,
"contains_music": contains_music, "contains_music": contains_music,
"music_rights_confirmed": music_rights_confirmed, "music_rights_confirmed": music_rights_confirmed,
"music_rights_context": music_rights_context,
"contains_third_party_content": contains_third_party_content, "contains_third_party_content": contains_third_party_content,
"third_party_rights_confirmed": third_party_rights_confirmed, "third_party_rights_confirmed": third_party_rights_confirmed,
"third_party_rights_context": third_party_rights_context,
} }
validate_rights_declaration(p06_decl, ex_vis) validate_rights_declaration(p06_decl, ex_vis)
write_rights_declaration(cur, aid, profile_id, "upload", ex_vis, p06_decl) write_rights_declaration(cur, aid, profile_id, "upload", ex_vis, p06_decl)

View File

@ -82,12 +82,16 @@ class MediaAssetPatch(BaseModel):
rights_holder_confirmed: Optional[bool] = None rights_holder_confirmed: Optional[bool] = None
contains_identifiable_persons: Optional[bool] = None contains_identifiable_persons: Optional[bool] = None
person_consent_confirmed: Optional[bool] = None person_consent_confirmed: Optional[bool] = None
person_consent_context: Optional[str] = Field(None, max_length=2000)
contains_minors: Optional[bool] = None contains_minors: Optional[bool] = None
parental_consent_confirmed: Optional[bool] = None parental_consent_confirmed: Optional[bool] = None
parental_consent_context: Optional[str] = Field(None, max_length=2000)
contains_music: Optional[bool] = None contains_music: Optional[bool] = None
music_rights_confirmed: Optional[bool] = None music_rights_confirmed: Optional[bool] = None
music_rights_context: Optional[str] = Field(None, max_length=2000)
contains_third_party_content: Optional[bool] = None contains_third_party_content: Optional[bool] = None
third_party_rights_confirmed: Optional[bool] = None third_party_rights_confirmed: Optional[bool] = None
third_party_rights_context: Optional[str] = Field(None, max_length=2000)
class RightsDeclarationBody(BaseModel): class RightsDeclarationBody(BaseModel):
@ -96,12 +100,16 @@ class RightsDeclarationBody(BaseModel):
rights_holder_confirmed: bool rights_holder_confirmed: bool
contains_identifiable_persons: bool contains_identifiable_persons: bool
person_consent_confirmed: Optional[bool] = None person_consent_confirmed: Optional[bool] = None
person_consent_context: Optional[str] = Field(None, max_length=2000)
contains_minors: bool contains_minors: bool
parental_consent_confirmed: Optional[bool] = None parental_consent_confirmed: Optional[bool] = None
parental_consent_context: Optional[str] = Field(None, max_length=2000)
contains_music: bool contains_music: bool
music_rights_confirmed: Optional[bool] = None music_rights_confirmed: Optional[bool] = None
music_rights_context: Optional[str] = Field(None, max_length=2000)
contains_third_party_content: bool contains_third_party_content: bool
third_party_rights_confirmed: Optional[bool] = None third_party_rights_confirmed: Optional[bool] = None
third_party_rights_context: Optional[str] = Field(None, max_length=2000)
class MediaBulkLifecycleBody(BaseModel): class MediaBulkLifecycleBody(BaseModel):
@ -132,6 +140,11 @@ class MediaBulkPatchBody(BaseModel):
original_filename: Optional[str] = Field(None, max_length=300) original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None club_id: Optional[int] = None
# P-06 Kontext (bei Promotion)
person_consent_context: Optional[str] = Field(None, max_length=2000)
parental_consent_context: Optional[str] = Field(None, max_length=2000)
music_rights_context: Optional[str] = Field(None, max_length=2000)
third_party_rights_context: Optional[str] = Field(None, max_length=2000)
tags: Optional[list[str]] = None tags: Optional[list[str]] = None
# P-06: Rechterklaerung (gilt fuer alle Assets des Batches bei Promotion) # P-06: Rechterklaerung (gilt fuer alle Assets des Batches bei Promotion)
rights_holder_confirmed: Optional[bool] = None rights_holder_confirmed: Optional[bool] = None
@ -671,6 +684,7 @@ def _ingest_library_media_file(
visibility: str, visibility: str,
club_id_form: Optional[int], club_id_form: Optional[int],
decl: Optional[dict] = None, decl: Optional[dict] = None,
copyright_notice: Optional[str] = None,
) -> dict: ) -> dict:
"""Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media. """Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media.
@ -809,11 +823,12 @@ def _ingest_library_media_file(
if not dest_path.is_file(): if not dest_path.is_file():
dest_path.write_bytes(raw) dest_path.write_bytes(raw)
clean_cr = (copyright_notice or "").strip() or None
cur.execute( cur.execute(
"""INSERT INTO media_assets ( """INSERT INTO media_assets (
mime_type, byte_size, sha256, original_filename, visibility, club_id, mime_type, byte_size, sha256, original_filename, visibility, club_id,
uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state
) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active') ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'local', %s, 'active')
RETURNING id""", RETURNING id""",
( (
mime, mime,
@ -823,6 +838,7 @@ def _ingest_library_media_file(
vis, vis,
next_cid, next_cid,
profile_id, profile_id,
clean_cr,
storage_key, storage_key,
), ),
) )
@ -841,16 +857,21 @@ async def bulk_upload_media_assets(
files: list[UploadFile] = File(..., description="Mehrere Dateien (jpeg, png, gif, mp4, pdf)"), files: list[UploadFile] = File(..., description="Mehrere Dateien (jpeg, png, gif, mp4, pdf)"),
visibility: str = Form("private"), visibility: str = Form("private"),
club_id: Optional[int] = Form(None), club_id: Optional[int] = Form(None),
copyright_notice: Optional[str] = Form(None),
# P-06 Rechterklaerung (gilt fuer alle Dateien des Batches) # P-06 Rechterklaerung (gilt fuer alle Dateien des Batches)
rights_holder_confirmed: bool = Form(...), rights_holder_confirmed: bool = Form(...),
contains_identifiable_persons: bool = Form(...), contains_identifiable_persons: bool = Form(...),
person_consent_confirmed: Optional[bool] = Form(None), person_consent_confirmed: Optional[bool] = Form(None),
person_consent_context: Optional[str] = Form(None),
contains_minors: bool = Form(...), contains_minors: bool = Form(...),
parental_consent_confirmed: Optional[bool] = Form(None), parental_consent_confirmed: Optional[bool] = Form(None),
parental_consent_context: Optional[str] = Form(None),
contains_music: bool = Form(...), contains_music: bool = Form(...),
music_rights_confirmed: Optional[bool] = Form(None), music_rights_confirmed: Optional[bool] = Form(None),
music_rights_context: Optional[str] = Form(None),
contains_third_party_content: bool = Form(...), contains_third_party_content: bool = Form(...),
third_party_rights_confirmed: Optional[bool] = Form(None), third_party_rights_confirmed: Optional[bool] = Form(None),
third_party_rights_context: Optional[str] = Form(None),
): ):
"""Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion. """Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion.
@ -869,12 +890,16 @@ async def bulk_upload_media_assets(
"rights_holder_confirmed": rights_holder_confirmed, "rights_holder_confirmed": rights_holder_confirmed,
"contains_identifiable_persons": contains_identifiable_persons, "contains_identifiable_persons": contains_identifiable_persons,
"person_consent_confirmed": person_consent_confirmed, "person_consent_confirmed": person_consent_confirmed,
"person_consent_context": person_consent_context,
"contains_minors": contains_minors, "contains_minors": contains_minors,
"parental_consent_confirmed": parental_consent_confirmed, "parental_consent_confirmed": parental_consent_confirmed,
"parental_consent_context": parental_consent_context,
"contains_music": contains_music, "contains_music": contains_music,
"music_rights_confirmed": music_rights_confirmed, "music_rights_confirmed": music_rights_confirmed,
"music_rights_context": music_rights_context,
"contains_third_party_content": contains_third_party_content, "contains_third_party_content": contains_third_party_content,
"third_party_rights_confirmed": third_party_rights_confirmed, "third_party_rights_confirmed": third_party_rights_confirmed,
"third_party_rights_context": third_party_rights_context,
} }
target_vis = (visibility or "private").strip().lower() target_vis = (visibility or "private").strip().lower()
validate_rights_declaration(decl, target_vis) validate_rights_declaration(decl, target_vis)
@ -901,6 +926,7 @@ async def bulk_upload_media_assets(
visibility, visibility,
club_id, club_id,
decl=decl, decl=decl,
copyright_notice=copyright_notice,
) )
conn.commit() conn.commit()
results.append({"filename": fn, "ok": True, **r}) results.append({"filename": fn, "ok": True, **r})

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.76" APP_VERSION = "0.8.77"
BUILD_DATE = "2026-05-11" BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511048" DB_SCHEMA_VERSION = "20260511048"
@ -14,12 +14,12 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users "admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_rights": "1.0.0", # P-06: zentrales Policy-Modul (validate, coverage, write declaration) "media_rights": "1.1.0", # P-06+: write_rights_declaration + 4 Kontext-Freitextfelder
"media_assets": "1.13.0", # P-06: Rechte-Erklaerung bei Upload/Promotion; Re-Deklarations-Endpoint; Admin-Legacy-Summary "media_assets": "1.14.0", # P-06+: copyright_notice im Upload-Dialog; 4 Kontext-Felder in Bulk-Upload + PATCH + Re-Deklaration
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.20.0", # P-06: upload_exercise_media + from-asset Rechtspruefung "exercises": "2.21.0", # P-06+: copyright_notice + 4 Kontext-Felder in upload_exercise_media
"training_units": "0.2.0", "training_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -31,6 +31,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.77",
"date": "2026-05-11",
"changes": [
"P-06 Erweiterung: Migration 049 — 4 optionale Freitext-Kontextfelder in media_asset_rights_declarations (person_consent_context, parental_consent_context, music_rights_context, third_party_rights_context); copyright_notice direkt im Upload-Dialog erfassbar; alle drei Dialoge (RightsDeclarationDialog, ExerciseInlineFileMediaModal, ExerciseInlineEmbedModal) und alle Backend-Endpoints aktualisiert.",
],
},
{ {
"version": "0.8.76", "version": "0.8.76",
"date": "2026-05-11", "date": "2026-05-11",

View File

@ -11,15 +11,20 @@ import {
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption' import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
const DECL_INIT = { const DECL_INIT = {
copyright_notice: '',
rights_holder_confirmed: false, rights_holder_confirmed: false,
contains_identifiable_persons: null, contains_identifiable_persons: null,
person_consent_confirmed: false, person_consent_confirmed: false,
person_consent_context: '',
contains_minors: null, contains_minors: null,
parental_consent_confirmed: false, parental_consent_confirmed: false,
parental_consent_context: '',
contains_music: null, contains_music: null,
music_rights_confirmed: false, music_rights_confirmed: false,
music_rights_context: '',
contains_third_party_content: null, contains_third_party_content: null,
third_party_rights_confirmed: false, third_party_rights_confirmed: false,
third_party_rights_context: '',
} }
function validateDecl(decl) { function validateDecl(decl) {
@ -178,15 +183,17 @@ export default function ExerciseInlineEmbedModal({
Rechte-Erklärung <span style={{ fontWeight: 400, color: 'var(--text3)' }}>(VORLÄUFIG p06-v1-conservative)</span> Rechte-Erklärung <span style={{ fontWeight: 400, color: 'var(--text3)' }}>(VORLÄUFIG p06-v1-conservative)</span>
</p> </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' }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '10px' }}>
<input <input type="checkbox" id="emb-rhc" checked={decl.rights_holder_confirmed}
type="checkbox"
id="emb-rhc"
checked={decl.rights_holder_confirmed}
onChange={(e) => setDeclField('rights_holder_confirmed', e.target.checked)} onChange={(e) => setDeclField('rights_holder_confirmed', e.target.checked)}
disabled={busy} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
style={{ marginTop: '3px', flexShrink: 0 }}
/>
<label htmlFor="emb-rhc" style={{ fontSize: '0.85rem' }}> <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. * Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze oder zur Veröffentlichung berechtigt bin. *
</label> </label>
@ -194,96 +201,72 @@ export default function ExerciseInlineEmbedModal({
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Erkennbare Personen abgebildet? *</legend> <legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Erkennbare Personen abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px' }}> <div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}> <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>
<input type="radio" name="emb-cip" checked={decl.contains_identifiable_persons === true} <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>
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> </div>
{decl.contains_identifiable_persons === true && ( {decl.contains_identifiable_persons === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginTop: '6px' }}> <div style={{ paddingLeft: 2 }}>
<input type="checkbox" id="emb-pcc" checked={decl.person_consent_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
onChange={(e) => setDeclField('person_consent_confirmed', e.target.checked)} <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 }} />
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> <label htmlFor="emb-pcc" style={{ fontSize: '0.85rem' }}>Einwilligungen aller erkennbaren Personen liegen vor. *</label>
<label htmlFor="emb-pcc" style={{ fontSize: '0.85rem' }}> </div>
Einwilligungen aller erkennbaren Personen liegen vor. * <label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
</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> </div>
)} )}
</fieldset> </fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Minderjährige abgebildet? *</legend> <legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Minderjährige abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px' }}> <div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}> <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>
<input type="radio" name="emb-cm" checked={decl.contains_minors === true} <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>
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> </div>
{decl.contains_minors === true && ( {decl.contains_minors === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginTop: '6px' }}> <div style={{ paddingLeft: 2 }}>
<input type="checkbox" id="emb-pcc2" checked={decl.parental_consent_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
onChange={(e) => setDeclField('parental_consent_confirmed', e.target.checked)} <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 }} />
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> <label htmlFor="emb-pcc2" style={{ fontSize: '0.85rem' }}>Einwilligungen der Sorgeberechtigten liegen vor. *</label>
<label htmlFor="emb-pcc2" style={{ fontSize: '0.85rem' }}> </div>
Einwilligungen der Sorgeberechtigten liegen vor. * <label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
</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> </div>
)} )}
</fieldset> </fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Musik enthalten? *</legend> <legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Musik enthalten? *</legend>
<div style={{ display: 'flex', gap: '14px' }}> <div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}> <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>
<input type="radio" name="emb-cmu" checked={decl.contains_music === true} <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>
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> </div>
{decl.contains_music === true && ( {decl.contains_music === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginTop: '6px' }}> <div style={{ paddingLeft: 2 }}>
<input type="checkbox" id="emb-mrc" checked={decl.music_rights_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
onChange={(e) => setDeclField('music_rights_confirmed', e.target.checked)} <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 }} />
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> <label htmlFor="emb-mrc" style={{ fontSize: '0.85rem' }}>Musikrechte (GEMA / Lizenz) liegen vor. *</label>
<label htmlFor="emb-mrc" style={{ fontSize: '0.85rem' }}> </div>
Musikrechte (GEMA / Lizenz) liegen vor. * <label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Lizenz / GEMA-Kontext (optional)</label>
</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> </div>
)} )}
</fieldset> </fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '4px' }}> <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> <legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend>
<div style={{ display: 'flex', gap: '14px' }}> <div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}> <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>
<input type="radio" name="emb-ctpc" checked={decl.contains_third_party_content === true} <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>
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> </div>
{decl.contains_third_party_content === true && ( {decl.contains_third_party_content === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginTop: '6px' }}> <div style={{ paddingLeft: 2 }}>
<input type="checkbox" id="emb-tprc" checked={decl.third_party_rights_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
onChange={(e) => setDeclField('third_party_rights_confirmed', e.target.checked)} <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 }} />
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> <label htmlFor="emb-tprc" style={{ fontSize: '0.85rem' }}>Rechte an allen enthaltenen Fremdmaterialien liegen vor. *</label>
<label htmlFor="emb-tprc" style={{ fontSize: '0.85rem' }}> </div>
Rechte an allen enthaltenen Fremdmaterialien liegen vor. * <label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Rechtsgrundlage (optional)</label>
</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> </div>
)} )}
</fieldset> </fieldset>

View File

@ -61,15 +61,20 @@ function inferExerciseMediaType(file) {
} }
const DECL_INIT = { const DECL_INIT = {
copyright_notice: '',
rights_holder_confirmed: false, rights_holder_confirmed: false,
contains_identifiable_persons: null, contains_identifiable_persons: null,
person_consent_confirmed: false, person_consent_confirmed: false,
person_consent_context: '',
contains_minors: null, contains_minors: null,
parental_consent_confirmed: false, parental_consent_confirmed: false,
parental_consent_context: '',
contains_music: null, contains_music: null,
music_rights_confirmed: false, music_rights_confirmed: false,
music_rights_context: '',
contains_third_party_content: null, contains_third_party_content: null,
third_party_rights_confirmed: false, third_party_rights_confirmed: false,
third_party_rights_context: '',
} }
function validateDecl(decl) { function validateDecl(decl) {
@ -414,15 +419,19 @@ export default function ExerciseInlineFileMediaModal({
Rechte-Erklärung <span style={{ fontWeight: 400, color: 'var(--text3)' }}>(VORLÄUFIG p06-v1-conservative)</span> Rechte-Erklärung <span style={{ fontWeight: 400, color: 'var(--text3)' }}>(VORLÄUFIG p06-v1-conservative)</span>
</p> </p>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="up-cr" style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: '3px' }}>Copyright-Angabe (optional)</label>
<input id="up-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' }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '10px' }}>
<input <input type="checkbox" id="up-rhc" checked={decl.rights_holder_confirmed}
type="checkbox"
id="up-rhc"
checked={decl.rights_holder_confirmed}
onChange={(e) => setDeclField('rights_holder_confirmed', e.target.checked)} onChange={(e) => setDeclField('rights_holder_confirmed', e.target.checked)}
disabled={busy} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
style={{ marginTop: '3px', flexShrink: 0 }}
/>
<label htmlFor="up-rhc" style={{ fontSize: '0.85rem' }}> <label htmlFor="up-rhc" style={{ fontSize: '0.85rem' }}>
Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze oder zur Veröffentlichung berechtigt bin. * Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze oder zur Veröffentlichung berechtigt bin. *
</label> </label>
@ -430,96 +439,72 @@ export default function ExerciseInlineFileMediaModal({
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Erkennbare Personen abgebildet? *</legend> <legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Erkennbare Personen abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px' }}> <div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}> <label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cip" checked={decl.contains_identifiable_persons === true} onChange={() => setDeclField('contains_identifiable_persons', true)} disabled={busy} /> Ja</label>
<input type="radio" name="up-cip" checked={decl.contains_identifiable_persons === true} <label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cip" checked={decl.contains_identifiable_persons === false} onChange={() => setDeclField('contains_identifiable_persons', false)} disabled={busy} /> Nein</label>
onChange={() => setDeclField('contains_identifiable_persons', true)} disabled={busy} /> Ja
</label>
<label style={{ fontSize: '0.85rem' }}>
<input type="radio" name="up-cip" checked={decl.contains_identifiable_persons === false}
onChange={() => setDeclField('contains_identifiable_persons', false)} disabled={busy} /> Nein
</label>
</div> </div>
{decl.contains_identifiable_persons === true && ( {decl.contains_identifiable_persons === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginTop: '6px' }}> <div style={{ paddingLeft: 2 }}>
<input type="checkbox" id="up-pcc" checked={decl.person_consent_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
onChange={(e) => setDeclField('person_consent_confirmed', e.target.checked)} <input type="checkbox" id="up-pcc" checked={decl.person_consent_confirmed} onChange={(e) => setDeclField('person_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> <label htmlFor="up-pcc" style={{ fontSize: '0.85rem' }}>Einwilligungen aller erkennbaren Personen liegen vor. *</label>
<label htmlFor="up-pcc" style={{ fontSize: '0.85rem' }}> </div>
Einwilligungen aller erkennbaren Personen liegen vor. * <label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
</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> </div>
)} )}
</fieldset> </fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Minderjährige abgebildet? *</legend> <legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Minderjährige abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px' }}> <div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}> <label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cm" checked={decl.contains_minors === true} onChange={() => setDeclField('contains_minors', true)} disabled={busy} /> Ja</label>
<input type="radio" name="up-cm" checked={decl.contains_minors === true} <label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cm" checked={decl.contains_minors === false} onChange={() => setDeclField('contains_minors', false)} disabled={busy} /> Nein</label>
onChange={() => setDeclField('contains_minors', true)} disabled={busy} /> Ja
</label>
<label style={{ fontSize: '0.85rem' }}>
<input type="radio" name="up-cm" checked={decl.contains_minors === false}
onChange={() => setDeclField('contains_minors', false)} disabled={busy} /> Nein
</label>
</div> </div>
{decl.contains_minors === true && ( {decl.contains_minors === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginTop: '6px' }}> <div style={{ paddingLeft: 2 }}>
<input type="checkbox" id="up-pcc2" checked={decl.parental_consent_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
onChange={(e) => setDeclField('parental_consent_confirmed', e.target.checked)} <input type="checkbox" id="up-pcc2" checked={decl.parental_consent_confirmed} onChange={(e) => setDeclField('parental_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> <label htmlFor="up-pcc2" style={{ fontSize: '0.85rem' }}>Einwilligungen der Sorgeberechtigten liegen vor. *</label>
<label htmlFor="up-pcc2" style={{ fontSize: '0.85rem' }}> </div>
Einwilligungen der Sorgeberechtigten liegen vor. * <label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
</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> </div>
)} )}
</fieldset> </fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Musik enthalten? *</legend> <legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Musik enthalten? *</legend>
<div style={{ display: 'flex', gap: '14px' }}> <div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}> <label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cmu" checked={decl.contains_music === true} onChange={() => setDeclField('contains_music', true)} disabled={busy} /> Ja</label>
<input type="radio" name="up-cmu" checked={decl.contains_music === true} <label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cmu" checked={decl.contains_music === false} onChange={() => setDeclField('contains_music', false)} disabled={busy} /> Nein</label>
onChange={() => setDeclField('contains_music', true)} disabled={busy} /> Ja
</label>
<label style={{ fontSize: '0.85rem' }}>
<input type="radio" name="up-cmu" checked={decl.contains_music === false}
onChange={() => setDeclField('contains_music', false)} disabled={busy} /> Nein
</label>
</div> </div>
{decl.contains_music === true && ( {decl.contains_music === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginTop: '6px' }}> <div style={{ paddingLeft: 2 }}>
<input type="checkbox" id="up-mrc" checked={decl.music_rights_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
onChange={(e) => setDeclField('music_rights_confirmed', e.target.checked)} <input type="checkbox" id="up-mrc" checked={decl.music_rights_confirmed} onChange={(e) => setDeclField('music_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> <label htmlFor="up-mrc" style={{ fontSize: '0.85rem' }}>Musikrechte (GEMA / Lizenz) liegen vor. *</label>
<label htmlFor="up-mrc" style={{ fontSize: '0.85rem' }}> </div>
Musikrechte (GEMA / Lizenz) liegen vor. * <label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Lizenz / GEMA-Kontext (optional)</label>
</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> </div>
)} )}
</fieldset> </fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '4px' }}> <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> <legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend>
<div style={{ display: 'flex', gap: '14px' }}> <div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}> <label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-ctpc" checked={decl.contains_third_party_content === true} onChange={() => setDeclField('contains_third_party_content', true)} disabled={busy} /> Ja</label>
<input type="radio" name="up-ctpc" checked={decl.contains_third_party_content === true} <label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-ctpc" checked={decl.contains_third_party_content === false} onChange={() => setDeclField('contains_third_party_content', false)} disabled={busy} /> Nein</label>
onChange={() => setDeclField('contains_third_party_content', true)} disabled={busy} /> Ja
</label>
<label style={{ fontSize: '0.85rem' }}>
<input type="radio" name="up-ctpc" checked={decl.contains_third_party_content === false}
onChange={() => setDeclField('contains_third_party_content', false)} disabled={busy} /> Nein
</label>
</div> </div>
{decl.contains_third_party_content === true && ( {decl.contains_third_party_content === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginTop: '6px' }}> <div style={{ paddingLeft: 2 }}>
<input type="checkbox" id="up-tprc" checked={decl.third_party_rights_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
onChange={(e) => setDeclField('third_party_rights_confirmed', e.target.checked)} <input type="checkbox" id="up-tprc" checked={decl.third_party_rights_confirmed} onChange={(e) => setDeclField('third_party_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> <label htmlFor="up-tprc" style={{ fontSize: '0.85rem' }}>Rechte an allen enthaltenen Fremdmaterialien liegen vor. *</label>
<label htmlFor="up-tprc" style={{ fontSize: '0.85rem' }}> </div>
Rechte an allen enthaltenen Fremdmaterialien liegen vor. * <label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Rechtsgrundlage (optional)</label>
</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> </div>
)} )}
</fieldset> </fieldset>

View File

@ -5,19 +5,20 @@
import React, { useState } from 'react' import React, { useState } from 'react'
const INITIAL = { const INITIAL = {
copyright_notice: '',
rights_holder_confirmed: false, rights_holder_confirmed: false,
contains_identifiable_persons: null, contains_identifiable_persons: null,
person_consent_confirmed: false, person_consent_confirmed: false,
person_consent_context: '',
contains_minors: null, contains_minors: null,
parental_consent_confirmed: false, parental_consent_confirmed: false,
parental_consent_context: '',
contains_music: null, contains_music: null,
music_rights_confirmed: false, music_rights_confirmed: false,
music_rights_context: '',
contains_third_party_content: null, contains_third_party_content: null,
third_party_rights_confirmed: false, third_party_rights_confirmed: false,
} third_party_rights_context: '',
function resetDecl() {
return { ...INITIAL }
} }
/** /**
@ -37,7 +38,7 @@ export default function RightsDeclarationDialog({
isPromotion = false, isPromotion = false,
mode = 'upload', mode = 'upload',
}) { }) {
const [decl, setDecl] = useState(resetDecl) const [decl, setDecl] = useState({ ...INITIAL })
const [error, setError] = useState('') const [error, setError] = useState('')
if (!open) return null if (!open) return null
@ -60,7 +61,7 @@ export default function RightsDeclarationDialog({
if (decl.contains_music && !decl.music_rights_confirmed) if (decl.contains_music && !decl.music_rights_confirmed)
return 'Bitte bestätigen, dass die erforderlichen Musikrechte vorliegen.' return 'Bitte bestätigen, dass die erforderlichen Musikrechte vorliegen.'
if (decl.contains_third_party_content === null) if (decl.contains_third_party_content === null)
return 'Bitte angeben, ob fremde geschützte Inhalte (Logos, Grafiken etc.) enthalten sind.' return 'Bitte angeben, ob fremde geschützte Inhalte enthalten sind.'
if (decl.contains_third_party_content && !decl.third_party_rights_confirmed) if (decl.contains_third_party_content && !decl.third_party_rights_confirmed)
return 'Bitte bestätigen, dass die Rechte an allen enthaltenen Fremdmaterialien vorliegen.' return 'Bitte bestätigen, dass die Rechte an allen enthaltenen Fremdmaterialien vorliegen.'
return '' return ''
@ -71,11 +72,11 @@ export default function RightsDeclarationDialog({
if (err) { setError(err); return } if (err) { setError(err); return }
setError('') setError('')
onConfirm({ ...decl }) onConfirm({ ...decl })
setDecl(resetDecl()) setDecl({ ...INITIAL })
} }
const handleCancel = () => { const handleCancel = () => {
setDecl(resetDecl()) setDecl({ ...INITIAL })
setError('') setError('')
onCancel() onCancel()
} }
@ -108,13 +109,30 @@ export default function RightsDeclarationDialog({
</div> </div>
<div style={{ overflowY: 'auto', flex: 1, padding: '14px 16px' }}> <div style={{ overflowY: 'auto', flex: 1, padding: '14px 16px' }}>
<p style={{ fontSize: '0.82rem', color: 'var(--text3)', marginBottom: 16 }}> <p style={{ fontSize: '0.82rem', color: 'var(--text3)', marginBottom: 14 }}>
VORLÄUFIG Texte noch nicht juristisch geprüft (p06-v1-conservative). VORLÄUFIG Texte noch nicht juristisch geprüft (p06-v1-conservative).
{isPromotion && ( {isPromotion && (
<> Die bestehende Erklärung gilt nicht für die Sichtbarkeit {visLabel}". Bitte erneut bestätigen.</> <> Die bestehende Erklärung gilt nicht für die Sichtbarkeit {visLabel}". Bitte erneut bestätigen.</>
)} )}
</p> </p>
{/* Copyright */}
<div style={{ marginBottom: 14 }}>
<label htmlFor="rdlg-cr" style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, marginBottom: 4 }}>
Copyright-Angabe (optional)
</label>
<input
id="rdlg-cr"
type="text"
className="form-input"
placeholder="z.B. © 2026 Lars Stommer, CC BY-NC"
value={decl.copyright_notice}
onChange={(e) => setField('copyright_notice', e.target.value)}
style={{ fontSize: '0.9rem' }}
/>
</div>
{/* T1: Rechteinhaber */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 14 }}>
<input <input
type="checkbox" type="checkbox"
@ -129,9 +147,10 @@ export default function RightsDeclarationDialog({
</label> </label>
</div> </div>
{/* T2 / T3: Personen */}
<fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}>
<legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Erkennbare Personen abgebildet? *</legend> <legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Erkennbare Personen abgebildet? *</legend>
<div style={{ display: 'flex', gap: 16 }}> <div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
<label style={{ fontSize: '0.9rem' }}> <label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cip" checked={decl.contains_identifiable_persons === true} <input type="radio" name="rdlg-cip" checked={decl.contains_identifiable_persons === true}
onChange={() => setField('contains_identifiable_persons', true)} /> Ja onChange={() => setField('contains_identifiable_persons', true)} /> Ja
@ -142,20 +161,34 @@ export default function RightsDeclarationDialog({
</label> </label>
</div> </div>
{decl.contains_identifiable_persons === true && ( {decl.contains_identifiable_persons === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginTop: 8 }}> <div style={{ paddingLeft: 4 }}>
<input type="checkbox" id="rdlg-pcc" checked={decl.person_consent_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 6 }}>
onChange={(e) => setField('person_consent_confirmed', e.target.checked)} <input type="checkbox" id="rdlg-pcc" checked={decl.person_consent_confirmed}
style={{ marginTop: 3, flexShrink: 0 }} /> onChange={(e) => setField('person_consent_confirmed', e.target.checked)}
<label htmlFor="rdlg-pcc" style={{ fontSize: '0.9rem' }}> style={{ marginTop: 3, flexShrink: 0 }} />
Einwilligungen aller erkennbaren Personen liegen vor. * <label htmlFor="rdlg-pcc" style={{ fontSize: '0.9rem' }}>
Einwilligungen aller erkennbaren Personen liegen vor. *
</label>
</div>
<label style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text2)', marginBottom: 3 }}>
Einwilligungskontext (optional)
</label> </label>
<textarea
className="form-input"
rows={2}
placeholder="z.B. Schriftliche Einwilligung vom 2026-05-01, im Archiv abgelegt"
value={decl.person_consent_context}
onChange={(e) => setField('person_consent_context', e.target.value)}
style={{ fontSize: '0.85rem', resize: 'vertical' }}
/>
</div> </div>
)} )}
</fieldset> </fieldset>
{/* T4 / T5: Minderjährige */}
<fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}>
<legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Minderjährige abgebildet? *</legend> <legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Minderjährige abgebildet? *</legend>
<div style={{ display: 'flex', gap: 16 }}> <div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
<label style={{ fontSize: '0.9rem' }}> <label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cm" checked={decl.contains_minors === true} <input type="radio" name="rdlg-cm" checked={decl.contains_minors === true}
onChange={() => setField('contains_minors', true)} /> Ja onChange={() => setField('contains_minors', true)} /> Ja
@ -166,20 +199,34 @@ export default function RightsDeclarationDialog({
</label> </label>
</div> </div>
{decl.contains_minors === true && ( {decl.contains_minors === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginTop: 8 }}> <div style={{ paddingLeft: 4 }}>
<input type="checkbox" id="rdlg-pcc2" checked={decl.parental_consent_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 6 }}>
onChange={(e) => setField('parental_consent_confirmed', e.target.checked)} <input type="checkbox" id="rdlg-pcc2" checked={decl.parental_consent_confirmed}
style={{ marginTop: 3, flexShrink: 0 }} /> onChange={(e) => setField('parental_consent_confirmed', e.target.checked)}
<label htmlFor="rdlg-pcc2" style={{ fontSize: '0.9rem' }}> style={{ marginTop: 3, flexShrink: 0 }} />
Einwilligungen der Sorgeberechtigten liegen vor. * <label htmlFor="rdlg-pcc2" style={{ fontSize: '0.9rem' }}>
Einwilligungen der Sorgeberechtigten liegen vor. *
</label>
</div>
<label style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text2)', marginBottom: 3 }}>
Einwilligungskontext (optional)
</label> </label>
<textarea
className="form-input"
rows={2}
placeholder="z.B. Elterliche Einwilligung per E-Mail vom 2026-04-15 erhalten"
value={decl.parental_consent_context}
onChange={(e) => setField('parental_consent_context', e.target.value)}
style={{ fontSize: '0.85rem', resize: 'vertical' }}
/>
</div> </div>
)} )}
</fieldset> </fieldset>
{/* T6 / T7: Musik */}
<fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}>
<legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Musik enthalten? *</legend> <legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Musik enthalten? *</legend>
<div style={{ display: 'flex', gap: 16 }}> <div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
<label style={{ fontSize: '0.9rem' }}> <label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cmu" checked={decl.contains_music === true} <input type="radio" name="rdlg-cmu" checked={decl.contains_music === true}
onChange={() => setField('contains_music', true)} /> Ja onChange={() => setField('contains_music', true)} /> Ja
@ -190,20 +237,34 @@ export default function RightsDeclarationDialog({
</label> </label>
</div> </div>
{decl.contains_music === true && ( {decl.contains_music === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginTop: 8 }}> <div style={{ paddingLeft: 4 }}>
<input type="checkbox" id="rdlg-mrc" checked={decl.music_rights_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 6 }}>
onChange={(e) => setField('music_rights_confirmed', e.target.checked)} <input type="checkbox" id="rdlg-mrc" checked={decl.music_rights_confirmed}
style={{ marginTop: 3, flexShrink: 0 }} /> onChange={(e) => setField('music_rights_confirmed', e.target.checked)}
<label htmlFor="rdlg-mrc" style={{ fontSize: '0.9rem' }}> style={{ marginTop: 3, flexShrink: 0 }} />
Musikrechte (GEMA / Lizenz) liegen vor. * <label htmlFor="rdlg-mrc" style={{ fontSize: '0.9rem' }}>
Musikrechte (GEMA / Lizenz) liegen vor. *
</label>
</div>
<label style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text2)', marginBottom: 3 }}>
Lizenz / GEMA-Kontext (optional)
</label> </label>
<textarea
className="form-input"
rows={2}
placeholder="z.B. Creative Commons BY 4.0, Lizenz-Nr. oder GEMA-Freimeldung"
value={decl.music_rights_context}
onChange={(e) => setField('music_rights_context', e.target.value)}
style={{ fontSize: '0.85rem', resize: 'vertical' }}
/>
</div> </div>
)} )}
</fieldset> </fieldset>
{/* T8 / T9: Fremdinhalte */}
<fieldset style={{ border: 'none', padding: 0, marginBottom: 4 }}> <fieldset style={{ border: 'none', padding: 0, marginBottom: 4 }}>
<legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend> <legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend>
<div style={{ display: 'flex', gap: 16 }}> <div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
<label style={{ fontSize: '0.9rem' }}> <label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-ctpc" checked={decl.contains_third_party_content === true} <input type="radio" name="rdlg-ctpc" checked={decl.contains_third_party_content === true}
onChange={() => setField('contains_third_party_content', true)} /> Ja onChange={() => setField('contains_third_party_content', true)} /> Ja
@ -214,13 +275,26 @@ export default function RightsDeclarationDialog({
</label> </label>
</div> </div>
{decl.contains_third_party_content === true && ( {decl.contains_third_party_content === true && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginTop: 8 }}> <div style={{ paddingLeft: 4 }}>
<input type="checkbox" id="rdlg-tprc" checked={decl.third_party_rights_confirmed} <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 6 }}>
onChange={(e) => setField('third_party_rights_confirmed', e.target.checked)} <input type="checkbox" id="rdlg-tprc" checked={decl.third_party_rights_confirmed}
style={{ marginTop: 3, flexShrink: 0 }} /> onChange={(e) => setField('third_party_rights_confirmed', e.target.checked)}
<label htmlFor="rdlg-tprc" style={{ fontSize: '0.9rem' }}> style={{ marginTop: 3, flexShrink: 0 }} />
Rechte an allen enthaltenen Fremdmaterialien liegen vor. * <label htmlFor="rdlg-tprc" style={{ fontSize: '0.9rem' }}>
Rechte an allen enthaltenen Fremdmaterialien liegen vor. *
</label>
</div>
<label style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text2)', marginBottom: 3 }}>
Rechtsgrundlage (optional)
</label> </label>
<textarea
className="form-input"
rows={2}
placeholder="z.B. Verbandslogo mit Genehmigung vom 2026-03-10, Lizenz-Nr. …"
value={decl.third_party_rights_context}
onChange={(e) => setField('third_party_rights_context', e.target.value)}
style={{ fontSize: '0.85rem', resize: 'vertical' }}
/>
</div> </div>
)} )}
</fieldset> </fieldset>

View File

@ -615,20 +615,26 @@ export async function bulkUploadMediaAssets(files, options = {}) {
if (options.club_id != null && options.club_id !== '') { if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id)) formData.append('club_id', String(options.club_id))
} }
// P-06: Rechte-Erklaerung // Copyright + P-06: Rechte-Erklaerung + Kontextfelder
if (options.copyright_notice != null && String(options.copyright_notice).trim())
formData.append('copyright_notice', String(options.copyright_notice).trim())
const p06Fields = [ const p06Fields = [
'rights_holder_confirmed', 'rights_holder_confirmed',
'contains_identifiable_persons', 'contains_identifiable_persons',
'person_consent_confirmed', 'person_consent_confirmed',
'person_consent_context',
'contains_minors', 'contains_minors',
'parental_consent_confirmed', 'parental_consent_confirmed',
'parental_consent_context',
'contains_music', 'contains_music',
'music_rights_confirmed', 'music_rights_confirmed',
'music_rights_context',
'contains_third_party_content', 'contains_third_party_content',
'third_party_rights_confirmed', 'third_party_rights_confirmed',
'third_party_rights_context',
] ]
for (const f of p06Fields) { for (const f of p06Fields) {
if (options[f] != null) formData.append(f, String(options[f])) if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
} }
const arr = Array.isArray(files) ? files : [files] const arr = Array.isArray(files) ? files : [files]
for (const f of arr) { for (const f of arr) {

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version // Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.76" export const APP_VERSION = "0.8.77"
export const BUILD_DATE = "2026-05-11" export const BUILD_DATE = "2026-05-11"
export const PAGE_VERSIONS = { export const PAGE_VERSIONS = {