Switch to direct MP3 streaming, add DNB source selection + drag-to-reorder
Player: Replace HLS with direct FileResponse streaming. Token passed as query param (?token=JWT) so browser <audio> can authenticate. Multi-track support: seeks and track transitions handled in AudioPlayer with refs. Removes hls.js dependency from playback path. Admin: Add DNB to match sources list. Replace toggle buttons with ordered drag-to-reorder list (HTML5 drag API) + separate add/remove buttons so source priority is explicit and adjustable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -10,8 +10,7 @@ from ..models.user import User
|
||||
from ..models.media_item import LibraryItem, BookFile, Chapter
|
||||
from ..models.session import PlaybackSession
|
||||
from ..models.progress import MediaProgress
|
||||
from ..services.hls import start_hls_session, wait_for_playlist, cleanup_hls_session
|
||||
from ..config import get_settings
|
||||
from ..services.auth import decode_token
|
||||
|
||||
router = APIRouter(tags=["stream"])
|
||||
|
||||
@@ -53,14 +52,6 @@ async def start_playback(
|
||||
current_time = float(body["startTime"])
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
audio_paths = [f.path for f in files]
|
||||
hls_dir = start_hls_session(session_id, audio_paths, start_time=0.0)
|
||||
|
||||
# Warten bis erste Segmente da sind (max. 60s)
|
||||
ready = await wait_for_playlist(session_id, timeout=60.0)
|
||||
if not ready:
|
||||
cleanup_hls_session(session_id)
|
||||
raise HTTPException(status_code=500, detail="HLS-Konvertierung fehlgeschlagen")
|
||||
|
||||
session = PlaybackSession(
|
||||
id=session_id,
|
||||
@@ -72,18 +63,38 @@ async def start_playback(
|
||||
device_id=body.get("deviceId", ""),
|
||||
device_info=body.get("deviceInfo", {}),
|
||||
media_player=body.get("mediaPlayer", ""),
|
||||
hls_session_path=hls_dir,
|
||||
hls_session_path=None,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
|
||||
hls_url = f"/hls/{session_id}/output.m3u8"
|
||||
chapters_out = [
|
||||
{"id": c.chapter_index, "start": c.start_seconds, "end": c.end_seconds, "title": c.title}
|
||||
for c in chapters
|
||||
]
|
||||
|
||||
_MIME = {
|
||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
|
||||
".aac": "audio/aac", ".ogg": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav",
|
||||
}
|
||||
audio_tracks = []
|
||||
offset = 0.0
|
||||
for i, f in enumerate(files):
|
||||
dur = f.duration_seconds or 0.0
|
||||
ext = os.path.splitext(f.filename or "")[1].lower()
|
||||
audio_tracks.append({
|
||||
"index": i,
|
||||
"startOffset": offset,
|
||||
"duration": dur,
|
||||
"title": f.filename or f"Part {i + 1}",
|
||||
"contentUrl": f"/api/stream/{session_id}?track={i}",
|
||||
"mimeType": _MIME.get(ext, "audio/mpeg"),
|
||||
})
|
||||
offset += dur
|
||||
|
||||
total_duration = item.duration_seconds or offset
|
||||
|
||||
return {
|
||||
"id": session_id,
|
||||
"userId": current_user.id,
|
||||
@@ -95,8 +106,8 @@ async def start_playback(
|
||||
"displayTitle": item.title,
|
||||
"displayAuthor": item.author,
|
||||
"coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None,
|
||||
"duration": item.duration_seconds or 0.0,
|
||||
"playMethod": 0,
|
||||
"duration": total_duration,
|
||||
"playMethod": 2,
|
||||
"mediaPlayer": body.get("mediaPlayer", ""),
|
||||
"deviceInfo": body.get("deviceInfo", {}),
|
||||
"serverVersion": "2.4.0",
|
||||
@@ -107,19 +118,57 @@ async def start_playback(
|
||||
"currentTime": current_time,
|
||||
"startedAt": int(datetime.utcnow().timestamp() * 1000),
|
||||
"updatedAt": int(datetime.utcnow().timestamp() * 1000),
|
||||
"audioTracks": [{
|
||||
"index": 0,
|
||||
"startOffset": 0.0,
|
||||
"duration": item.duration_seconds or 0.0,
|
||||
"title": "Part 1",
|
||||
"contentUrl": hls_url,
|
||||
"mimeType": "application/x-mpegURL",
|
||||
"metadata": {"filename": "output.m3u8", "ext": ".m3u8", "path": hls_url, "relPath": "output.m3u8", "size": 0},
|
||||
}],
|
||||
"audioTracks": audio_tracks,
|
||||
"videoTrack": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/stream/{session_id}")
|
||||
async def stream_file(
|
||||
session_id: str,
|
||||
track: int = Query(0),
|
||||
token: str | None = Query(None),
|
||||
authorization: str | None = Header(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Direktes Audio-Streaming mit Range-Request-Unterstützung."""
|
||||
raw = token
|
||||
if not raw and authorization:
|
||||
parts = authorization.split(" ", 1)
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
raw = parts[1]
|
||||
if not raw or not decode_token(raw):
|
||||
raise HTTPException(status_code=401, detail="Nicht autorisiert")
|
||||
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session nicht gefunden")
|
||||
|
||||
files_result = await db.execute(
|
||||
select(BookFile)
|
||||
.where(BookFile.library_item_id == session.library_item_id)
|
||||
.order_by(BookFile.track_index)
|
||||
)
|
||||
files = files_result.scalars().all()
|
||||
|
||||
if track >= len(files):
|
||||
raise HTTPException(status_code=404, detail="Track nicht gefunden")
|
||||
|
||||
path = files[track].path
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail="Audio-Datei nicht gefunden")
|
||||
|
||||
_MIME = {
|
||||
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
|
||||
".aac": "audio/aac", ".ogg": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav",
|
||||
}
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return FileResponse(path, media_type=_MIME.get(ext, "audio/mpeg"))
|
||||
|
||||
|
||||
@router.post("/api/playback-session/{session_id}/sync")
|
||||
async def sync_session(
|
||||
session_id: str,
|
||||
@@ -129,7 +178,10 @@ async def sync_session(
|
||||
):
|
||||
body = body or {}
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
select(PlaybackSession).where(
|
||||
PlaybackSession.id == session_id,
|
||||
PlaybackSession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
@@ -180,35 +232,14 @@ async def close_session(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
select(PlaybackSession).where(
|
||||
PlaybackSession.id == session_id,
|
||||
PlaybackSession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
session.is_active = False
|
||||
await db.commit()
|
||||
cleanup_hls_session(session_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/hls/{session_id}/{filename:path}")
|
||||
async def serve_hls(
|
||||
session_id: str,
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
settings = get_settings()
|
||||
file_path = os.path.join(settings.hls_cache_dir, session_id, filename)
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="Segment not found")
|
||||
|
||||
if filename.endswith(".m3u8"):
|
||||
return FileResponse(file_path, media_type="application/x-mpegURL")
|
||||
return FileResponse(file_path, media_type="video/MP2T")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
|
||||
List, BookmarkPlus, Moon, X, ChevronLeft
|
||||
@@ -14,51 +13,66 @@ export default function AudioPlayer() {
|
||||
const {
|
||||
item, session, currentTime, duration, isPlaying, playbackRate, volume, chapters,
|
||||
sleepTimerActive, sleepTimer,
|
||||
setPlaying, setCurrentTime, setDuration, setPlaybackRate, setVolume,
|
||||
setPlaying, setCurrentTime, setPlaybackRate, setVolume,
|
||||
seek, stop, setExpanded, setSleepTimer, cancelSleepTimer, syncProgress,
|
||||
} = usePlayerStore()
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const tracksRef = useRef<any[]>([])
|
||||
const currentTrackIdxRef = useRef(0)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
const currentTimeRef = useRef(0)
|
||||
const isPlayingRef = useRef(false)
|
||||
|
||||
const [showChapters, setShowChapters] = useState(false)
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false)
|
||||
const [showSleepMenu, setShowSleepMenu] = useState(false)
|
||||
const [muted, setMuted] = useState(false)
|
||||
|
||||
useEffect(() => { currentTimeRef.current = currentTime }, [currentTime])
|
||||
useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])
|
||||
|
||||
const meta = item?.media?.metadata || {}
|
||||
const title = meta.title || item?.relPath || ''
|
||||
const author = meta.authors?.[0]?.name || ''
|
||||
|
||||
const loadTrack = useCallback((idx: number, startAt: number, autoPlay: boolean) => {
|
||||
const audio = audioRef.current
|
||||
const tracks = tracksRef.current
|
||||
if (!audio || !tracks[idx]) return
|
||||
const token = localStorage.getItem('token') || ''
|
||||
currentTrackIdxRef.current = idx
|
||||
audio.src = `${tracks[idx].contentUrl}&token=${encodeURIComponent(token)}`
|
||||
if (startAt > 0) {
|
||||
pendingSeekRef.current = startAt
|
||||
try { audio.currentTime = startAt } catch {}
|
||||
}
|
||||
if (autoPlay) audio.play().catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load audio when session starts
|
||||
useEffect(() => {
|
||||
if (!session || !audioRef.current) return
|
||||
const hlsUrl = session.audioTracks?.[0]?.contentUrl
|
||||
if (!hlsUrl) return
|
||||
const tracks = session.audioTracks || []
|
||||
if (!tracks.length) return
|
||||
|
||||
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
|
||||
tracksRef.current = tracks
|
||||
const startTime = session.currentTime || 0
|
||||
|
||||
const audio = audioRef.current
|
||||
if (Hls.isSupported()) {
|
||||
const token = localStorage.getItem('token')
|
||||
const hls = new Hls({
|
||||
startPosition: session.currentTime || 0,
|
||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
},
|
||||
})
|
||||
hls.loadSource(hlsUrl)
|
||||
hls.attachMedia(audio)
|
||||
hlsRef.current = hls
|
||||
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
audio.src = hlsUrl
|
||||
audio.currentTime = session.currentTime || 0
|
||||
let startIdx = 0
|
||||
for (let i = tracks.length - 1; i >= 0; i--) {
|
||||
if (startTime >= (tracks[i].startOffset || 0)) { startIdx = i; break }
|
||||
}
|
||||
|
||||
audio.playbackRate = playbackRate
|
||||
audio.volume = volume
|
||||
audio.play().catch(() => {})
|
||||
audioRef.current.playbackRate = playbackRate
|
||||
audioRef.current.volume = muted ? 0 : volume
|
||||
loadTrack(startIdx, startTime - (tracks[startIdx].startOffset || 0), true)
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
}, [session?.id])
|
||||
|
||||
@@ -76,36 +90,67 @@ export default function AudioPlayer() {
|
||||
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume
|
||||
}, [volume, muted])
|
||||
|
||||
const seekToAbsoluteTime = useCallback((t: number) => {
|
||||
const audio = audioRef.current
|
||||
const tracks = tracksRef.current
|
||||
if (!audio || !tracks.length) return
|
||||
|
||||
const lastTrack = tracks[tracks.length - 1]
|
||||
const totalDur = (lastTrack?.startOffset || 0) + (lastTrack?.duration || 0)
|
||||
const clamped = Math.max(0, Math.min(t, totalDur))
|
||||
|
||||
let targetIdx = 0
|
||||
for (let i = tracks.length - 1; i >= 0; i--) {
|
||||
if (clamped >= (tracks[i].startOffset || 0)) { targetIdx = i; break }
|
||||
}
|
||||
|
||||
const timeInTrack = clamped - (tracks[targetIdx].startOffset || 0)
|
||||
|
||||
if (targetIdx !== currentTrackIdxRef.current) {
|
||||
loadTrack(targetIdx, timeInTrack, isPlayingRef.current)
|
||||
} else {
|
||||
audio.currentTime = timeInTrack
|
||||
}
|
||||
seek(clamped)
|
||||
}, [loadTrack, seek])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) }
|
||||
if (e.code === 'ArrowRight') audioRef.current && (audioRef.current.currentTime += 30)
|
||||
if (e.code === 'ArrowLeft') audioRef.current && (audioRef.current.currentTime -= 10)
|
||||
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlayingRef.current) }
|
||||
if (e.code === 'ArrowRight') seekToAbsoluteTime(currentTimeRef.current + 30)
|
||||
if (e.code === 'ArrowLeft') seekToAbsoluteTime(Math.max(0, currentTimeRef.current - 10))
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isPlaying])
|
||||
}, [seekToAbsoluteTime])
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!audioRef.current) return
|
||||
setCurrentTime(audioRef.current.currentTime)
|
||||
const track = tracksRef.current[currentTrackIdxRef.current]
|
||||
setCurrentTime((track?.startOffset || 0) + audioRef.current.currentTime)
|
||||
}
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (!audioRef.current) return
|
||||
setDuration(audioRef.current.duration)
|
||||
if (pendingSeekRef.current !== null && audioRef.current) {
|
||||
audioRef.current.currentTime = pendingSeekRef.current
|
||||
pendingSeekRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
const tracks = tracksRef.current
|
||||
const nextIdx = currentTrackIdxRef.current + 1
|
||||
if (nextIdx < tracks.length) {
|
||||
loadTrack(nextIdx, 0, true)
|
||||
} else {
|
||||
setPlaying(false)
|
||||
syncProgress()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeekBar = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const t = parseFloat(e.target.value)
|
||||
if (audioRef.current) audioRef.current.currentTime = t
|
||||
seek(t)
|
||||
seekToAbsoluteTime(parseFloat(e.target.value))
|
||||
}
|
||||
|
||||
const fmtTime = (s: number) => {
|
||||
@@ -175,22 +220,14 @@ export default function AudioPlayer() {
|
||||
<div className="mb-4">
|
||||
<div className="relative bg-muted2 rounded-full mb-2" style={{ height: '4px' }}>
|
||||
{chapterMarkers.map((m: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-0 w-px h-full bg-muted"
|
||||
style={{ left: `${m.pct}%` }}
|
||||
/>
|
||||
<div key={i} className="absolute top-0 w-px h-full bg-muted" style={{ left: `${m.pct}%` }} />
|
||||
))}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-ink rounded-full pointer-events-none"
|
||||
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={1}
|
||||
value={currentTime}
|
||||
type="range" min={0} max={duration || 0} step={1} value={currentTime}
|
||||
onChange={handleSeekBar}
|
||||
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
|
||||
/>
|
||||
@@ -203,29 +240,19 @@ export default function AudioPlayer() {
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-6 mb-6">
|
||||
<button
|
||||
className="text-muted hover:text-ink transition-colors"
|
||||
onClick={() => audioRef.current && (audioRef.current.currentTime -= 30)}
|
||||
>
|
||||
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime - 30)}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 52, height: 52, borderRadius: '50%',
|
||||
background: '#e4ede5', color: '#000',
|
||||
transition: 'transform 0.1s',
|
||||
}}
|
||||
style={{ width: 52, height: 52, borderRadius: '50%', background: '#e4ede5', color: '#000', transition: 'transform 0.1s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.background = '#fff' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = '#e4ede5' }}
|
||||
onClick={() => setPlaying(!isPlaying)}
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<button
|
||||
className="text-muted hover:text-ink transition-colors"
|
||||
onClick={() => audioRef.current && (audioRef.current.currentTime += 30)}
|
||||
>
|
||||
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime + 30)}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -235,7 +262,7 @@ export default function AudioPlayer() {
|
||||
{/* Speed */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className="text-muted hover:text-ink border border-divider px-2 py-1 transition-colors"
|
||||
className="text-muted hover:text-ink border border-divider transition-colors"
|
||||
style={{ fontSize: '11px', fontWeight: 600, borderRadius: '4px', padding: '3px 8px' }}
|
||||
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||
>
|
||||
@@ -306,7 +333,7 @@ export default function AudioPlayer() {
|
||||
<ChapterList
|
||||
chapters={chapters}
|
||||
currentTime={currentTime}
|
||||
onSeek={(t) => { if (audioRef.current) audioRef.current.currentTime = t; seek(t) }}
|
||||
onSeek={seekToAbsoluteTime}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles, ScrollText } from 'lucide-react'
|
||||
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles, ScrollText, GripVertical } from 'lucide-react'
|
||||
import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
|
||||
import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
|
||||
import { getLog, clearLog } from '../api/logs'
|
||||
@@ -131,6 +131,7 @@ const MATCH_SOURCES = [
|
||||
{ id: 'musicbrainz', label: 'MusicBrainz' },
|
||||
{ id: 'open_library', label: 'Open Library' },
|
||||
{ id: 'google_books', label: 'Google Books' },
|
||||
{ id: 'dnb', label: 'DNB' },
|
||||
]
|
||||
|
||||
function LibraryForm({
|
||||
@@ -144,6 +145,7 @@ function LibraryForm({
|
||||
const [form, setForm] = useState(initial)
|
||||
const [showBrowser, setShowBrowser] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
@@ -182,32 +184,64 @@ function LibraryForm({
|
||||
|
||||
{form.mediaType === 'book' && (
|
||||
<div>
|
||||
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>Matching-Quellen (Reihenfolge = Priorität)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MATCH_SOURCES.map((s) => {
|
||||
const checked = form.matchSources.includes(s.id)
|
||||
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>
|
||||
Matching-Quellen · Reihenfolge = Priorität · per Drag verschieben
|
||||
</p>
|
||||
{/* Selected sources — sortable */}
|
||||
<div className="space-y-1 mb-2">
|
||||
{form.matchSources.map((id, i) => {
|
||||
const src = MATCH_SOURCES.find((s) => s.id === id)
|
||||
if (!src) return null
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setForm({
|
||||
...form,
|
||||
matchSources: checked
|
||||
? form.matchSources.filter((x) => x !== s.id)
|
||||
: [...form.matchSources, s.id],
|
||||
})}
|
||||
className={`px-3 py-1 rounded-full border transition-colors ${
|
||||
checked
|
||||
? 'bg-primary-dim border-primary text-primary'
|
||||
: 'bg-card border-divider text-muted hover:text-ink'
|
||||
<div
|
||||
key={id}
|
||||
draggable
|
||||
onDragStart={(e) => { e.dataTransfer.setData('text/plain', String(i)); e.dataTransfer.effectAllowed = 'move' }}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i) }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const from = parseInt(e.dataTransfer.getData('text/plain'))
|
||||
if (from === i) { setDragOverIdx(null); return }
|
||||
const next = [...form.matchSources]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(i, 0, moved)
|
||||
setForm({ ...form, matchSources: next })
|
||||
setDragOverIdx(null)
|
||||
}}
|
||||
onDragEnd={() => setDragOverIdx(null)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border cursor-grab active:cursor-grabbing transition-colors ${
|
||||
dragOverIdx === i ? 'border-primary bg-primary-dim' : 'border-primary/40 bg-primary-dim'
|
||||
}`}
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
{s.label}
|
||||
<GripVertical size={12} className="text-primary opacity-50 flex-shrink-0" />
|
||||
<span className="text-primary flex-1" style={{ fontSize: '12px' }}>{src.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, matchSources: form.matchSources.filter((x) => x !== id) })}
|
||||
className="text-primary opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Unselected sources — add button */}
|
||||
{MATCH_SOURCES.filter((s) => !form.matchSources.includes(s.id)).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MATCH_SOURCES.filter((s) => !form.matchSources.includes(s.id)).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, matchSources: [...form.matchSources, s.id] })}
|
||||
className="flex items-center gap-1 px-3 py-1 rounded-full border border-divider bg-card text-muted hover:text-ink transition-colors"
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
<Plus size={10} /> {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
|
||||
Reference in New Issue
Block a user