Inline Medien #24

Merged
Lars merged 5 commits from develop into main 2026-05-08 12:39:27 +02:00
8 changed files with 185 additions and 65 deletions
Showing only changes of commit 5cf775c920 - Show all commits

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.63"
APP_VERSION = "0.8.64"
BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508049"
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.64",
"date": "2026-05-08",
"changes": [
"Übung bearbeiten: Auto-Scroll beim Drag von Medien zu Textfeldern; Medien-Kacheln mehrspaltig; Sektion/Ablauf-Zuordnung an Medien entfernt (nur noch Titel bearbeiten); Picker: Video-Vorschau-Frame; Katalogvorschau ohne Anhangs-Medienliste; Ansehen mit Speichern-Hinweis + Zurück zur Bearbeitung von der Ansicht",
],
},
{
"version": "0.8.63",
"date": "2026-05-08",

View File

@ -4018,6 +4018,13 @@ a.analysis-split__nav-item {
height: 100%;
object-fit: cover;
}
.rte-inline-asset-tile__thumb-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.rte-inline-asset-tile__thumb-fallback {
font-size: 11px;
color: var(--text3);
@ -4066,9 +4073,10 @@ a.analysis-split__nav-item {
list-style: none;
padding: 0;
margin: 14px 0 0;
display: flex;
flex-direction: column;
gap: 10px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
align-items: stretch;
}
.exercise-edit-media-strip__item {
display: flex;
@ -4129,15 +4137,10 @@ a.analysis-split__nav-item {
}
.exercise-edit-media-strip__toolbar {
display: grid;
grid-template-columns: 1fr minmax(120px, 160px);
grid-template-columns: 1fr;
gap: 8px;
margin-top: 8px;
}
@media (max-width: 520px) {
.exercise-edit-media-strip__toolbar {
grid-template-columns: 1fr;
}
}
.exercise-edit-media-strip__actions {
display: flex;
flex-wrap: wrap;

View File

@ -4,7 +4,6 @@
import React from 'react'
import { Link } from 'react-router-dom'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from './ExerciseAttachmentMediaStrip'
function TagRow({ exercise }) {
const tags = []
@ -118,7 +117,6 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<ExerciseRichTextBlock html={exercise.execution} exerciseId={resolvedId} media={exercise.media} />
</section>
)}
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={resolvedId} />
{exercise.trainer_notes && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>

View File

@ -11,6 +11,41 @@ import {
} from '../constants/inlineExerciseMedia'
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
function RtePickerAssetThumb({ asset }) {
const id = asset.id
const src = resolveMediaAssetFileUrl(id)
const mt = (asset.mime_type || '').toLowerCase()
if (mt.startsWith('image/') && src) {
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
}
if (mt.startsWith('video/') && src) {
return (
<video
key={`v-${id}`}
className="rte-inline-asset-tile__thumb-video"
src={src}
muted
playsInline
preload="metadata"
onLoadedMetadata={(e) => {
try {
const el = e.currentTarget
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch (_) {
/* ignore */
}
}}
/>
)
}
const nameLow = String(asset.original_filename || '').toLowerCase()
if (mt.includes('pdf') || nameLow.endsWith('.pdf')) {
return <span className="rte-inline-asset-tile__thumb-fallback">PDF</span>
}
return <span className="rte-inline-asset-tile__thumb-fallback">Datei</span>
}
/** MIME/Dateiname → Übungs-media_type */
function inferExerciseMediaType(file) {
if (!file) return 'image'
@ -263,9 +298,6 @@ export default function ExerciseInlineFileMediaModal({
const selected = selectedAssetId === id
const label = it.original_filename || it.copyright_notice || `Archiv #${id}`
const linked = assetToExerciseMedia.has(Number(id))
const src = resolveMediaAssetFileUrl(id)
const isImg = (it.mime_type || '').startsWith('image/')
const isVid = (it.mime_type || '').startsWith('video/')
return (
<button
key={id}
@ -275,13 +307,7 @@ export default function ExerciseInlineFileMediaModal({
disabled={busy}
>
<div className="rte-inline-asset-tile__thumb" aria-hidden>
{isImg && src ? (
<img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
) : isVid ? (
<span className="rte-inline-asset-tile__thumb-fallback"> Video</span>
) : (
<span className="rte-inline-asset-tile__thumb-fallback">PDF / Datei</span>
)}
<RtePickerAssetThumb asset={it} />
</div>
{linked ? (
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>

View File

@ -6,6 +6,7 @@ import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
parseExerciseMediaDragPayload,
} from '../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
function exec(cmd, value = null) {
try {
@ -265,6 +266,7 @@ export default function RichTextEditor({
if (types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
autoScrollForDragNearEdges(e)
}
},
[showInlineToolbar],

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
@ -53,6 +53,7 @@ function metaParts(exercise) {
function ExerciseDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const location = useLocation()
const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
@ -104,16 +105,19 @@ function ExerciseDetailPage() {
if (!exercise) return null
const meta = metaParts(exercise)
const fromExerciseEdit = location.state?.fromExerciseEdit === true
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
</button>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
Bearbeiten
</Link>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'}
</Link>
</div>
</div>
<div className="card exercise-detail-section">

View File

@ -9,6 +9,7 @@ import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload,
} from '../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
@ -373,6 +374,7 @@ function ExerciseFormPage() {
const [mediaList, setMediaList] = useState([])
const [loading, setLoading] = useState(!!isEdit)
const [saving, setSaving] = useState(false)
const [formDirty, setFormDirty] = useState(false)
const [skillPick, setSkillPick] = useState('')
const [variants, setVariants] = useState([])
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
@ -385,7 +387,6 @@ function ExerciseFormPage() {
const [mediaSavingId, setMediaSavingId] = useState(null)
const [archiveOpen, setArchiveOpen] = useState(false)
const [archiveQ, setArchiveQ] = useState('')
const [archiveCtx, setArchiveCtx] = useState('ablauf')
const [archiveLoading, setArchiveLoading] = useState(false)
const [archiveItems, setArchiveItems] = useState([])
const [archiveError, setArchiveError] = useState(null)
@ -396,12 +397,32 @@ function ExerciseFormPage() {
for (const m of mediaList) {
next[m.id] = {
title: m.title || '',
context: m.context || 'ablauf',
}
}
setMediaFields(next)
}, [mediaList])
useEffect(() => {
const onDragOverDoc = (e) => {
const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : []
if (!types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) return
e.preventDefault()
autoScrollForDragNearEdges(e)
}
document.addEventListener('dragover', onDragOverDoc)
return () => document.removeEventListener('dragover', onDragOverDoc)
}, [])
useEffect(() => {
if (!formDirty) return undefined
const warn = (ev) => {
ev.preventDefault()
ev.returnValue = ''
}
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [formDirty])
useEffect(() => {
if (!archiveOpen) return undefined
let cancelled = false
@ -466,6 +487,7 @@ function ExerciseFormPage() {
setVariants([])
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setFormDirty(false)
setLoading(false)
return
}
@ -480,6 +502,7 @@ function ExerciseFormPage() {
setVariants((exercise.variants || []).map(apiVariantToRow))
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setFormDirty(false)
} catch (err) {
if (!cancelled) {
alert(err.message || 'Übung nicht ladbar')
@ -509,6 +532,7 @@ function ExerciseFormPage() {
}, [variantEditSelection])
const updateFormField = (field, value) => {
setFormDirty(true)
setFormData((prev) => ({ ...prev, [field]: value }))
}
@ -646,6 +670,7 @@ function ExerciseFormPage() {
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
setFormDirty(false)
alert('Gespeichert.')
} else {
const created = await api.createExercise(payload)
@ -669,7 +694,7 @@ function ExerciseFormPage() {
try {
await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: assetId,
context: archiveCtx,
context: 'ablauf',
title: '',
description: '',
is_primary: false,
@ -689,7 +714,8 @@ function ExerciseFormPage() {
const handleDeleteMedia = async (mid) => {
if (
!confirm(
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.\n\n' +
'Hinweis: Wenn dieser Eintrag noch als Platzhalter im Fließtext steht, zeigt die Vorschau [Medium nicht verfügbar] oder das Speichern der Übung schlägt fehl, bis der Platzhalter entfernt ist.',
)
) {
return
@ -740,7 +766,6 @@ function ExerciseFormPage() {
try {
await api.updateExerciseMedia(exerciseId, mid, {
title: fld.title.trim() || null,
context: fld.context,
})
await refreshMedia()
} catch (e) {
@ -757,6 +782,7 @@ function ExerciseFormPage() {
}
const updateVariantField = (id, patch) => {
setFormDirty(true)
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
}
@ -860,7 +886,18 @@ function ExerciseFormPage() {
type="button"
className="btn btn-secondary"
style={{ marginLeft: '8px' }}
onClick={() => navigate(`/exercises/${exerciseId}`)}
onClick={() => {
if (
formDirty &&
!window.confirm(
'Es gibt noch nicht über „Speichern“ gesicherte Änderungen (Texte, Zuordnungen, …).\n\n' +
'Zur Ansicht wechseln und diese Änderungen verwerfen?',
)
) {
return
}
navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })
}}
>
Ansehen
</button>
@ -1236,7 +1273,10 @@ function ExerciseFormPage() {
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
<ExerciseVariantFields
row={variantDraft}
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
onPatch={(patch) => {
setFormDirty(true)
setVariantDraft((d) => ({ ...d, ...patch }))
}}
prerequisiteOthers={variants}
rteMinHeight="110px"
inlineExerciseId={isEdit ? exerciseId : null}
@ -1417,37 +1457,17 @@ function ExerciseFormPage() {
<input
type="text"
className="form-input exercise-edit-media-strip__title"
placeholder="Titel"
placeholder="Titel (wird in der Vorschau und im Platzhalter genutzt)"
value={(mediaFields[m.id] || {}).title ?? ''}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: e.target.value,
context: (prev[m.id] || {}).context || 'ablauf',
},
}))
}
/>
<select
className="form-input exercise-edit-media-strip__ctx"
value={(mediaFields[m.id] || {}).context || 'ablauf'}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: (prev[m.id] || {}).title ?? '',
context: e.target.value,
},
}))
}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
</div>
<div className="exercise-edit-media-strip__actions">
{mediaList.length > 1 && (
@ -1498,6 +1518,10 @@ function ExerciseFormPage() {
})}
</ul>
)}
<p style={{ color: 'var(--text3)', fontSize: '12px', marginTop: mediaList.length ? '12px' : 0 }}>
Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über
Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
</p>
{archiveOpen && (
<div
role="dialog"
@ -1534,16 +1558,6 @@ function ExerciseFormPage() {
onChange={(e) => setArchiveQ(e.target.value)}
style={{ marginBottom: '8px' }}
/>
<select
className="form-input"
value={archiveCtx}
onChange={(e) => setArchiveCtx(e.target.value)}
style={{ marginBottom: '12px' }}
>
<option value="ablauf">Sektion: Ablauf</option>
<option value="detail">Sektion: Detail</option>
<option value="trainer_hint">Sektion: Trainer-Hinweis</option>
</select>
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden</p>}
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
{!archiveLoading && !archiveError && archiveItems.length === 0 && (

View File

@ -0,0 +1,66 @@
/**
* Während eines Drags automatischen Bildlauf auslösen (Viewport + scrollbare Bereiche unter dem Cursor).
* @param {DragEvent} e
* @param {{ edgePx?: number, scrollStep?: number }} [opts]
*/
export function autoScrollForDragNearEdges(e, opts = {}) {
const edge = opts.edgePx ?? 80
const step = opts.scrollStep ?? Math.max(24, Math.round(window.innerHeight * 0.05))
const { clientX, clientY } = e
const vh = window.innerHeight || 0
const vw = window.innerWidth || 0
let sy =
clientY < edge ? -step : vh > 0 && clientY > vh - edge ? step : 0
let sx =
clientX < edge ? -step : vw > 0 && clientX > vw - edge ? step : 0
if (sy !== 0) window.scrollBy(0, sy)
if (sx !== 0) window.scrollBy(sx, 0)
/** @type {HTMLElement|null} */
let top = /** @type {HTMLElement|null} */ (document.elementFromPoint(clientX, clientY))
/** @type {Set<HTMLElement>} */
const done = new Set()
/** @type {HTMLElement|null} */
let walk = top
const innerEdge = Math.min(edge, 40)
while (walk && walk !== document.body) {
if (!(walk instanceof HTMLElement)) break
const cs = window.getComputedStyle(walk)
const canY =
walk.scrollHeight - walk.clientHeight > 6 &&
(cs.overflowY === 'auto' || cs.overflowY === 'scroll')
const canX =
walk.scrollWidth - walk.clientWidth > 6 &&
(cs.overflowX === 'auto' || cs.overflowX === 'scroll')
if ((canY || canX) && !done.has(walk)) {
done.add(walk)
const rect = walk.getBoundingClientRect()
const relY = clientY - rect.top
const relX = clientX - rect.left
if (canY) {
if (relY < innerEdge && walk.scrollTop > 0) walk.scrollTop -= step
else if (rect.height - relY < innerEdge && walk.scrollTop < walk.scrollHeight - walk.clientHeight) {
walk.scrollTop += step
}
}
if (canX) {
if (relX < innerEdge && walk.scrollLeft > 0) walk.scrollLeft -= step
else if (rect.width - relX < innerEdge && walk.scrollLeft < walk.scrollWidth - walk.clientWidth) {
walk.scrollLeft += step
}
}
}
walk = walk.parentElement
}
}