import { useEffect, useMemo, useRef, useState } from 'react'; import { ArrowRight, ArrowUp, CaretDown, CaretRight, Cube, ContactlessPayment, FileAudio, FloppyDisk, Folder, FolderOpen, GearSix, House, PencilSimple, Plus, Trash, UploadSimple, Wrench, } from '@phosphor-icons/react'; import { Avatar } from 'primereact/avatar'; import { Badge } from 'primereact/badge'; import { Button } from 'primereact/button'; import { Card } from 'primereact/card'; import { Divider } from 'primereact/divider'; import { Dropdown } from 'primereact/dropdown'; import { FileUpload } from 'primereact/fileupload'; import { InputText } from 'primereact/inputtext'; import { Message } from 'primereact/message'; import { MeterGroup } from 'primereact/metergroup'; import { Messages } from 'primereact/messages'; import { ProgressBar } from 'primereact/progressbar'; import { Slider } from 'primereact/slider'; import { Stepper } from 'primereact/stepper'; import { StepperPanel } from 'primereact/stepperpanel'; import { Tag } from 'primereact/tag'; import { Toast } from 'primereact/toast'; import { assignTag, claimTag, createMediaFolder, deleteMedia, deleteTag, getBoxes, getBoxLocalTags, getBoxTags, getBoxStorage, getMediaTree, getStatus, getTagBlocks, getTags, markTagWritten, moveMedia, pairBox, pullTagFromBox, renameMedia, sendCommand, setBoxAlias, setTagAlias, setTagBlock, setTagMedia, unassignTag, unpairBox, uploadMedia, } from './api.js'; const BOX_POLL_MS = 1500; const STATUS_POLL_MS = 1000; const STORAGE_POLL_MS = 5000; const ONLINE_THRESHOLD_SEC = 60; const NAV_ITEMS = [ { id: 'dashboard', label: 'Dashboard', icon: House }, { id: 'boxes', label: 'Boxen', icon: Cube }, { id: 'media', label: 'Medien', icon: FolderOpen }, { id: 'tags', label: 'Tags', icon: ContactlessPayment }, { id: 'settings', label: 'Einstellungen', icon: GearSix }, ]; function formatTime(ts) { if (!ts) return '-'; return new Date(ts * 1000).toLocaleString(); } function getAvailability(lastSeen) { if (!lastSeen) { return { label: 'Offline', severity: 'danger' }; } const nowSec = Date.now() / 1000; const isOnline = nowSec - lastSeen <= ONLINE_THRESHOLD_SEC; return { label: isOnline ? 'Online' : 'Offline', severity: isOnline ? 'success' : 'danger' }; } function parseCapabilities(raw) { if (!raw) return '-'; try { const data = JSON.parse(raw); return Object.entries(data) .map(([key, value]) => `${key}:${value ? '1' : '0'}`) .join(' '); } catch (error) { return '-'; } } function formatSize(bytes) { if (bytes === 0) return '0 B'; if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return '-'; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let idx = 0; while (size >= 1024 && idx < units.length - 1) { size /= 1024; idx += 1; } return `${size.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; } function formatDuration(seconds) { if (seconds === null || seconds === undefined || Number.isNaN(seconds)) return '-'; const total = Math.max(0, Math.floor(seconds)); const mins = Math.floor(total / 60); const secs = total % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; } function formatClock(seconds) { if (seconds === null || seconds === undefined || Number.isNaN(seconds)) return '-'; const total = Math.max(0, Math.floor(seconds)); const hrs = Math.floor(total / 3600); const mins = Math.floor((total % 3600) / 60); const secs = total % 60; if (hrs > 0) { return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${mins}:${secs.toString().padStart(2, '0')}`; } function capacityScale(percent) { const p = Math.max(0, Math.min(100, percent || 0)); if (p === 0) return 1; return 100 / p; } function getNfcKey(nfc) { if (!nfc) return ''; const at = nfc.at || ''; const uid = nfc.uid || ''; const hardware = nfc.hardwareUid || nfc.hardware_uid || ''; return `${at}:${uid}:${hardware}`; } function generateTagId() { const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; let generated = ''; for (let i = 0; i < 10; i += 1) { generated += alphabet[Math.floor(Math.random() * alphabet.length)]; } return generated; } function generateHardwareUid() { const bytes = []; for (let i = 0; i < 7; i += 1) { bytes.push(Math.floor(Math.random() * 256)); } return bytes .map((value) => value.toString(16).padStart(2, '0').toUpperCase()) .join(':'); } function isValidTagUid(uid) { return /^(?:[a-z0-9]{10}|TAG_[a-z0-9]{8})$/.test(uid); } function SectionHeader({ title, subtitle, actions }) { return (

{title}

{subtitle}

{actions}
); } function StatGrid({ items }) { return (
{items.map((card) => (

{card.label}

{card.value}
{card.helper}
))}
); } export default function App() { const [activeSection, setActiveSection] = useState('dashboard'); const [searchValue, setSearchValue] = useState(''); const [boxes, setBoxes] = useState([]); const [boxStorage, setBoxStorage] = useState({}); const [selectedId, setSelectedId] = useState(''); const [status, setStatus] = useState(null); const [error, setError] = useState(''); const [nfcUid, setNfcUid] = useState('UID_1'); const [mediaTree, setMediaTree] = useState(null); const [mediaError, setMediaError] = useState(''); const [currentPath, setCurrentPath] = useState(''); const [selectedPaths, setSelectedPaths] = useState([]); const [newFolderName, setNewFolderName] = useState(''); const [renameName, setRenameName] = useState(''); const [moveTarget, setMoveTarget] = useState(''); const [activeModal, setActiveModal] = useState(''); const [tagDeleteTarget, setTagDeleteTarget] = useState(''); const [expandedFolders, setExpandedFolders] = useState(() => new Set(['__root__'])); const [sidebarQuery, setSidebarQuery] = useState(''); const [uploadAfterCreate, setUploadAfterCreate] = useState(false); const [pendingUploadFiles, setPendingUploadFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); const [uploadInProgress, setUploadInProgress] = useState(false); const [activeUploadLabel, setActiveUploadLabel] = useState(''); const [uploadSize, setUploadSize] = useState(0); const [uploadNameError, setUploadNameError] = useState(''); const transferTimerRef = useRef(null); const explorerRef = useRef(null); const modalRef = useRef(null); const toastRef = useRef(null); const fileUploadRef = useRef(null); const dragSelectRef = useRef(false); const lastDblClickRef = useRef(0); const lastAnchorRef = useRef(''); const explorerInitRef = useRef(false); const expandedInitRef = useRef(false); const volumeRef = useRef(null); const [tags, setTags] = useState([]); const [boxTags, setBoxTags] = useState([]); const [blockedByBox, setBlockedByBox] = useState({}); const [dbTagMedia, setDbTagMedia] = useState({}); const [localBoxTags, setLocalBoxTags] = useState([]); const [localBoxError, setLocalBoxError] = useState(''); const [importTargetFolder, setImportTargetFolder] = useState(''); const [importTargetUid, setImportTargetUid] = useState(''); const [scanTagUid, setScanTagUid] = useState(''); const [tagUidMode, setTagUidMode] = useState('keep'); const [tagUidError, setTagUidError] = useState(''); const [tagStep, setTagStep] = useState(0); const [tagStepOneDone, setTagStepOneDone] = useState(false); const [tagStepTwoDone, setTagStepTwoDone] = useState(false); const [tagStepMax, setTagStepMax] = useState(0); const tagStepperRef = useRef(null); const tagWizardRestoredRef = useRef(''); const tagUidMsgRef = useRef(null); const mediaMsgRef = useRef(null); const autoReplaceMsg = 'Ungueltige ID erkannt – wird automatisch ersetzt.'; const [dismissedNfcKey, setDismissedNfcKey] = useState(''); const [scanTagLabel, setScanTagLabel] = useState(''); const [scanMediaPath, setScanMediaPath] = useState(''); const [reuseTagUid, setReuseTagUid] = useState(''); const lastNfcKeyRef = useRef(''); const [tagAliasDrafts, setTagAliasDrafts] = useState({}); const [boxAliasDrafts, setBoxAliasDrafts] = useState({}); const [showSessionSheet, setShowSessionSheet] = useState(false); const [drawerTab, setDrawerTab] = useState('boxes'); const [boxDeleteTarget, setBoxDeleteTarget] = useState(''); const [lastHardwareUid, setLastHardwareUid] = useState({ uid: '', hardwareUid: '' }); const [simulatedNfc, setSimulatedNfc] = useState(null); const [showVolume, setShowVolume] = useState(false); const [volumeDraft, setVolumeDraft] = useState(50); const [seekPercent, setSeekPercent] = useState(0); const [isSeeking, setIsSeeking] = useState(false); function addToast(type, message) { toastRef.current?.show({ severity: type, summary: type === 'error' ? 'Fehler' : 'Info', detail: message, life: 4000, }); } useEffect(() => { let active = true; async function refreshBoxes() { const response = await getBoxes(); if (!active) return; if (!response.ok) { setError(response.data.detail || 'Fehler beim Laden der Boxen.'); return; } setBoxes(response.data.boxes || []); setError(''); } refreshBoxes(); const handle = setInterval(refreshBoxes, BOX_POLL_MS); return () => { active = false; clearInterval(handle); }; }, []); useEffect(() => { let active = true; async function refreshStorage() { if (!boxes.length) { if (active) setBoxStorage({}); return; } const results = await Promise.all( boxes.map(async (box) => { const response = await getBoxStorage(box.box_id); if (!response.ok) { return { boxId: box.box_id, storage: null }; } return { boxId: box.box_id, storage: response.data.storage || null }; }) ); if (!active) return; const next = {}; results.forEach(({ boxId, storage }) => { if (!storage) { next[boxId] = null; return; } const total = storage.total_bytes ?? null; const free = storage.free_bytes ?? null; const used = storage.used_bytes ?? (total !== null && free !== null ? total - free : null); next[boxId] = { total, free, used }; }); setBoxStorage(next); } refreshStorage(); const handle = setInterval(refreshStorage, STORAGE_POLL_MS); return () => { active = false; clearInterval(handle); }; }, [boxes]); useEffect(() => { function handleOutside(event) { if (activeModal) return; if (!explorerRef.current) return; if (modalRef.current && modalRef.current.contains(event.target)) return; if (!explorerRef.current.contains(event.target)) { setSelectedPaths([]); setRenameName(''); } } function handleMouseUp() { dragSelectRef.current = false; } window.addEventListener('mousedown', handleOutside); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousedown', handleOutside); window.removeEventListener('mouseup', handleMouseUp); }; }, [activeModal]); useEffect(() => { if (!selectedId) { setStatus(null); setMediaTree(null); setMediaError(''); setSelectedPaths([]); setNewFolderName(''); setRenameName(''); setActiveModal(''); setBoxTags([]); setDbTagMedia({}); setScanTagUid(''); setScanTagLabel(''); setScanMediaPath(''); setReuseTagUid(''); setLocalBoxTags([]); setLocalBoxError(''); return; } let active = true; async function refreshStatus() { const response = await getStatus(selectedId); if (!active) return; if (!response.ok) { setStatus({ error: response.data.detail || 'Status nicht verfuegbar.' }); return; } setStatus(response.data); } refreshStatus(); const handle = setInterval(refreshStatus, STATUS_POLL_MS); return () => { active = false; clearInterval(handle); }; }, [selectedId]); useEffect(() => { let active = true; async function refreshMedia() { const response = await getMediaTree(); if (!active) return; if (!response.ok) { setMediaError(response.data.detail || 'Medien nicht verfuegbar.'); setMediaTree(null); return; } setMediaTree(response.data); setMediaError(''); } refreshMedia(); return () => { active = false; }; }, []); function findPathChain(node, target, chain = []) { if (!node) return null; const currentPath = node.path || ''; const nextChain = [...chain, currentPath]; if (currentPath === target) { return nextChain; } if (!Array.isArray(node.children)) return null; for (const child of node.children) { if (child.type !== 'folder') continue; const result = findPathChain(child, target, nextChain); if (result) return result; } return null; } useEffect(() => { if (!mediaTree) return; const saved = localStorage.getItem('klangkiste_explorer_path'); if (saved === null) return; const chain = findPathChain(mediaTree, saved); if (!chain) return; setCurrentPath(saved); if (!expandedInitRef.current) { const savedExpandedRaw = localStorage.getItem('klangkiste_explorer_expanded'); let savedExpanded = []; if (savedExpandedRaw) { try { const parsed = JSON.parse(savedExpandedRaw); if (Array.isArray(parsed)) { savedExpanded = parsed; } } catch (error) { savedExpanded = []; } } const merged = new Set([ ...savedExpanded, ...chain.map((p) => (p ? p : '__root__')), ]); setExpandedFolders(merged); expandedInitRef.current = true; } explorerInitRef.current = true; }, [mediaTree]); useEffect(() => { if (!explorerInitRef.current) return; localStorage.setItem('klangkiste_explorer_path', currentPath || ''); }, [currentPath]); useEffect(() => { if (!expandedInitRef.current) return; localStorage.setItem( 'klangkiste_explorer_expanded', JSON.stringify(Array.from(expandedFolders)) ); }, [expandedFolders]); useEffect(() => { const lastNfc = status?.last_nfc; if (!lastNfc || !lastNfc.uid) { return; } if (!lastNfc.known) { const key = `${status?.last_nfc_at ?? ''}:${lastNfc.uid ?? ''}`; if (lastNfcKeyRef.current === key) { return; } lastNfcKeyRef.current = key; setScanTagLabel(''); setScanMediaPath(''); if (isValidTagUid(lastNfc.uid)) { setScanTagUid(lastNfc.uid); setTagUidMode('keep'); setTagUidError(''); } else { setScanTagUid(generateTagId()); setTagUidMode('new'); setTagUidError(autoReplaceMsg); } setDismissedNfcKey(''); setTagStep(0); setTagStepOneDone(false); setTagStepTwoDone(false); setTagStepMax(0); } }, [status, tags]); useEffect(() => { tagStepperRef.current?.setActiveStep(tagStep); }, [tagStep]); useEffect(() => { const ref = tagUidMsgRef.current; if (!ref) return; ref.clear(); if (tagStep === 0 && tagUidError) { const showMessage = () => { if (!tagUidMsgRef.current) return false; tagUidMsgRef.current.clear(); tagUidMsgRef.current.show({ severity: 'warn', summary: 'Ungueltige ID', detail: tagUidError, sticky: true, closable: false, }); return true; }; if (!showMessage()) { const timer = setTimeout(() => { showMessage(); }, 0); return () => clearTimeout(timer); } } }, [tagUidError, tagStep]); useEffect(() => { const ref = mediaMsgRef.current; if (!ref) return; ref.clear(); if (tagStep === 1 && !scanMediaPath) { const showMessage = () => { if (!mediaMsgRef.current) return false; mediaMsgRef.current.clear(); mediaMsgRef.current.show({ severity: 'info', summary: 'Keine Medienzuordnung', detail: 'Ohne Medienauswahl wird der Tag ohne Zuordnung gespeichert.', sticky: true, closable: false, }); return true; }; if (!showMessage()) { const timer = setTimeout(() => { showMessage(); }, 0); return () => clearTimeout(timer); } } }, [scanMediaPath, tagStep]); useEffect(() => { let active = true; async function refreshTags() { const response = await getTags(); if (!active) return; if (!response.ok) { setError(response.data.detail || 'Tags nicht verfuegbar.'); return; } setTags(response.data.tags || []); } refreshTags(); return () => { active = false; }; }, []); useEffect(() => { if (!selectedId) return; let active = true; async function refreshBoxTags() { const response = await getBoxTags(selectedId); if (!active) return; if (!response.ok) { setError(response.data.detail || 'Box-Tags nicht verfuegbar.'); return; } setBoxTags(response.data.tags || []); } refreshBoxTags(); return () => { active = false; }; }, [selectedId]); useEffect(() => { if (!selectedId) return; let active = true; async function refreshLocalTags() { const response = await getBoxLocalTags(selectedId); if (!active) return; if (!response.ok) { setLocalBoxError(response.data.detail || 'Lokale Box-Tags nicht verfuegbar.'); return; } setLocalBoxTags(response.data.tags || []); setLocalBoxError(''); } refreshLocalTags(); return () => { active = false; }; }, [selectedId]); useEffect(() => { let active = true; async function refreshBlocked() { if (!boxes.length) { setBlockedByBox({}); return; } const results = await Promise.all( boxes.map(async (box) => { const response = await getTagBlocks(box.box_id); return { boxId: box.box_id, response }; }) ); if (!active) return; const next = {}; results.forEach(({ boxId, response }) => { if (response.ok) { next[boxId] = response.data.blocked || []; } }); setBlockedByBox(next); } refreshBlocked(); return () => { active = false; }; }, [boxes]); const unpaired = useMemo( () => boxes.filter((box) => box.state === 'UNPAIRED'), [boxes] ); const paired = useMemo( () => boxes.filter((box) => box.state === 'PAIRED'), [boxes] ); const mediaTagCounts = useMemo(() => { const counts = {}; tags.forEach((tag) => { const mediaPath = tag.media_path || ''; if (!mediaPath) return; counts[mediaPath] = (counts[mediaPath] || 0) + 1; }); return counts; }, [tags]); const topLevelFolders = useMemo(() => { if (!mediaTree || !Array.isArray(mediaTree.children)) return []; const folders = mediaTree.children.filter((child) => child.type === 'folder'); const query = sidebarQuery.trim().toLowerCase(); if (!query) return folders; return folders.filter((folder) => (folder.name || '').toLowerCase().includes(query)); }, [mediaTree, sidebarQuery]); const filteredTree = useMemo(() => { if (!mediaTree) return null; return { ...mediaTree, children: topLevelFolders, }; }, [mediaTree, topLevelFolders]); const dashboardStats = useMemo(() => { const sizeLabel = mediaTree ? formatSize(mediaTree.size) : '-'; const freeLabel = mediaTree ? formatSize(mediaTree.free_bytes) : '-'; return [ { label: 'Gepairte Boxen', value: paired.length, helper: `${unpaired.length} neu`, }, { label: 'Tags gesamt', value: tags.length, helper: `${boxTags.length} auf Box`, }, { label: 'Medien gesamt', value: sizeLabel, helper: `frei ${freeLabel}`, }, { label: 'Letzter NFC', value: status?.last_nfc?.uid || '-', helper: status?.last_nfc_at ? formatTime(status.last_nfc_at) : '-', }, ]; }, [paired.length, unpaired.length, tags.length, boxTags.length, mediaTree, status]); async function handlePair(boxId) { const response = await pairBox(boxId); if (!response.ok) { setError(response.data.detail || 'Box pairing fehlgeschlagen.'); addToast('error', response.data.detail || 'Box pairing fehlgeschlagen.'); return; } addToast('success', 'Box gepairt.'); const updated = await getBoxes(); if (updated.ok) { setBoxes(updated.data.boxes || []); } } async function handleCommand(command, payload = {}) { if (!selectedId) { setError('Bitte zuerst eine Box auswaehlen.'); addToast('error', 'Bitte zuerst eine Box auswaehlen.'); return; } let nextPayload = payload; if (command === 'nfc_on' || command === 'nfc_off') { const rawUid = typeof payload?.uid === 'string' ? payload.uid.trim() : ''; let uid = rawUid; if (!uid) { if (command === 'nfc_on') { setSimulatedNfc({ uid: '', known: false, hardwareUid: generateHardwareUid(), at: Date.now(), }); setScanTagLabel(''); setScanMediaPath(''); if (!scanTagUid) { setScanTagUid(generateTagId()); } setTagUidMode('new'); setTagStep(0); setTagStepOneDone(false); setTagStepTwoDone(false); return; } setError('Bitte eine UID angeben.'); addToast('error', 'Bitte eine UID angeben.'); return; } nextPayload = { ...payload, uid }; if (command === 'nfc_on') { setLastHardwareUid({ uid, hardwareUid: generateHardwareUid() }); setSimulatedNfc(null); } } const response = await sendCommand(selectedId, command, nextPayload); if (!response.ok) { setError(response.data.detail || 'Command fehlgeschlagen.'); addToast('error', response.data.detail || 'Command fehlgeschlagen.'); return; } setError(''); addToast('success', 'Command gesendet.'); } async function handlePlayerCommand(command, payload = {}) { if (!selectedId) { return; } let nextPayload = payload; if (command === 'nfc_on' || command === 'nfc_off') { const rawUid = typeof payload?.uid === 'string' ? payload.uid.trim() : ''; const uid = rawUid; if (!uid) { if (command === 'nfc_on') { setSimulatedNfc({ uid: '', known: false, hardwareUid: generateHardwareUid(), at: Date.now(), }); setScanTagLabel(''); setScanMediaPath(''); if (!scanTagUid) { setScanTagUid(generateTagId()); } setTagUidMode('new'); setTagStep(0); setTagStepOneDone(false); setTagStepTwoDone(false); setTagStepMax(0); } else if (command === 'nfc_off') { setSimulatedNfc(null); setScanTagUid(''); setScanTagLabel(''); setScanMediaPath(''); setLastHardwareUid({ uid: '', hardwareUid: '' }); setTagUidMode('keep'); setTagStep(0); setTagStepOneDone(false); setTagStepTwoDone(false); setTagStepMax(0); } return; } if (command === 'nfc_on') { setLastHardwareUid({ uid, hardwareUid: generateHardwareUid() }); setSimulatedNfc(null); } nextPayload = { ...payload, uid }; } await sendCommand(selectedId, command, nextPayload); } async function handleUnpair(boxId) { const response = await unpairBox(boxId); if (!response.ok) { setError(response.data.detail || 'Unpair fehlgeschlagen.'); addToast('error', response.data.detail || 'Unpair fehlgeschlagen.'); return; } addToast('success', 'Box unpaired.'); const updated = await getBoxes(); if (updated.ok) { setBoxes(updated.data.boxes || []); } } async function handleMediaRefresh() { const response = await getMediaTree(); if (!response.ok) { setMediaError(response.data.detail || 'Medien nicht verfuegbar.'); setMediaTree(null); return; } setMediaTree(response.data); setMediaError(''); } function getNodeByPath(node, targetPath) { if (!node) return null; if ((node.path || '') === targetPath) return node; if (!Array.isArray(node.children)) return null; for (const child of node.children) { const found = getNodeByPath(child, targetPath); if (found) return found; } return null; } function listChildren(node) { if (!node) return []; if (!Array.isArray(node.children)) return []; return node.children; } function buildBreadcrumb(pathValue) { if (!pathValue) return []; const parts = pathValue.split('/').filter(Boolean); return parts.map((part, index) => ({ name: part, path: parts.slice(0, index + 1).join('/'), })); } function collectTopLevelFolders(node) { if (!node || !Array.isArray(node.children)) return []; return node.children .filter((child) => child.type === 'folder') .map((child) => child.path); } function collectFolderPaths(node) { if (!node || node.type !== 'folder') return []; const entries = [node.path || '']; if (!Array.isArray(node.children)) return entries; node.children.forEach((child) => { if (child.type !== 'folder') return; entries.push(...collectFolderPaths(child)); }); return entries; } function isSelected(pathValue) { return selectedPaths.includes(pathValue || ''); } async function handleCreateFolder() { const trimmedName = newFolderName.trim(); if (uploadAfterCreate && pendingUploadFiles.length > 0) { if (!trimmedName && !currentPath) { addToast('error', 'Im Medien-Hauptordner ist ein Ordnername erforderlich.'); setUploadNameError('Im Medien-Hauptordner ist ein Ordnername erforderlich.'); return; } const targetPath = trimmedName ? `${currentPath}/${trimmedName}` : currentPath; setUploadInProgress(true); setActiveUploadLabel( pendingUploadFiles.length ? `Upload: ${pendingUploadFiles.length} Datei(en)` : '' ); const uploadResponse = await uploadMedia( targetPath, pendingUploadFiles, (percent) => setUploadProgress(percent) ); setUploadInProgress(false); setUploadProgress(0); if (!uploadResponse.ok) { setMediaError(uploadResponse.data.detail || 'Upload fehlgeschlagen.'); addToast('error', uploadResponse.data.detail || 'Upload fehlgeschlagen.'); return; } addToast('success', 'Upload abgeschlossen.'); await handleMediaRefresh(); setActiveModal(''); setUploadAfterCreate(false); setPendingUploadFiles([]); setNewFolderName(''); return; } if (!trimmedName && !uploadAfterCreate) { setMediaError('Bitte einen Ordnernamen angeben.'); return; } const response = await createMediaFolder(currentPath, trimmedName); if (!response.ok) { setMediaError(response.data.detail || 'Ordner anlegen fehlgeschlagen.'); addToast('error', response.data.detail || 'Ordner anlegen fehlgeschlagen.'); return; } addToast('success', 'Ordner angelegt.'); setNewFolderName(''); setActiveModal(''); await handleMediaRefresh(); } async function handleRename() { if (!selectedPaths.length) return; const response = await renameMedia(selectedPaths[0], renameName.trim()); if (!response.ok) { setMediaError(response.data.detail || 'Umbenennen fehlgeschlagen.'); addToast('error', response.data.detail || 'Umbenennen fehlgeschlagen.'); return; } addToast('success', 'Eintrag umbenannt.'); setRenameName(''); setActiveModal(''); await handleMediaRefresh(); } async function handleDeleteSelected() { if (!selectedPaths.length) return; for (const pathValue of selectedPaths) { const response = await deleteMedia(pathValue); if (!response.ok) { setMediaError(response.data.detail || 'Loeschen fehlgeschlagen.'); addToast('error', response.data.detail || 'Loeschen fehlgeschlagen.'); return; } } addToast('success', 'Eintraege geloescht.'); setSelectedPaths([]); setActiveModal(''); await handleMediaRefresh(); } async function handleMoveSelected() { if (!selectedPaths.length) return; const pathValue = selectedPaths[0]; const isRootTarget = moveTarget === '__root__' || moveTarget === ''; const response = await moveMedia(pathValue, isRootTarget ? '' : moveTarget); if (!response.ok) { setMediaError(response.data.detail || 'Verschieben fehlgeschlagen.'); addToast('error', response.data.detail || 'Verschieben fehlgeschlagen.'); return; } addToast('success', 'Eintrag verschoben.'); setMoveTarget(''); setSelectedPaths([]); setActiveModal(''); await handleMediaRefresh(); } async function handleUpload(event) { const files = Array.from(event.target.files || []); const audioFiles = files.filter((file) => file.type.startsWith('audio/')); if (!audioFiles.length) return; if (!currentPath) { addToast('error', 'Im Medien-Hauptordner ist ein Ordnername erforderlich.'); setUploadNameError('Im Medien-Hauptordner ist ein Ordnername erforderlich.'); return; } setUploadInProgress(true); setActiveUploadLabel( audioFiles.length ? `Upload: ${audioFiles.length} Datei(en)` : '' ); const response = await uploadMedia(currentPath, audioFiles, (percent) => setUploadProgress(percent) ); setUploadInProgress(false); setUploadProgress(0); if (!response.ok) { setMediaError(response.data.detail || 'Upload fehlgeschlagen.'); addToast('error', response.data.detail || 'Upload fehlgeschlagen.'); return; } addToast('success', 'Upload abgeschlossen.'); await handleMediaRefresh(); } const fileHeaderTemplate = (options) => { const { className, chooseButton, cancelButton } = options; return (
{chooseButton} {cancelButton}
); }; const fileItemTemplate = (file, props) => (
); const fileEmptyTemplate = () => (
Gesamt: {formatSize(uploadSize)}
); function handleSelect(item, event) { const itemPath = item.path || ''; if (event.shiftKey && lastAnchorRef.current) { const items = listChildren(getNodeByPath(mediaTree, currentPath)); const anchorIndex = items.findIndex((entry) => entry.path === lastAnchorRef.current); const currentIndex = items.findIndex((entry) => entry.path === itemPath); if (anchorIndex !== -1 && currentIndex !== -1) { const [start, end] = anchorIndex < currentIndex ? [anchorIndex, currentIndex] : [currentIndex, anchorIndex]; const range = items.slice(start, end + 1).map((entry) => entry.path || ''); setSelectedPaths(Array.from(new Set([...selectedPaths, ...range]))); return; } } if (event.metaKey || event.ctrlKey) { setSelectedPaths((prev) => prev.includes(itemPath) ? prev.filter((pathValue) => pathValue !== itemPath) : [...prev, itemPath] ); lastAnchorRef.current = itemPath; return; } const now = Date.now(); const isDoubleClick = now - lastDblClickRef.current < 250; if (!isDoubleClick) { setSelectedPaths([itemPath]); lastAnchorRef.current = itemPath; } } function handleDragSelect(item) { if (!dragSelectRef.current) return; const itemPath = item.path || ''; setSelectedPaths((prev) => prev.includes(itemPath) ? prev : [...prev, itemPath] ); } function handleOpen(item) { if (item.type !== 'folder') return; setCurrentPath(item.path || ''); setSelectedPaths([]); setRenameName(''); const key = item.path || '__root__'; setExpandedFolders((prev) => new Set(prev).add(key)); } function handleGoUp() { if (!currentPath) return; const parts = currentPath.split('/').filter(Boolean); parts.pop(); const next = parts.join('/'); setCurrentPath(next); setSelectedPaths([]); setRenameName(''); } function toggleFolder(pathValue) { const key = pathValue || '__root__'; setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); } function renderFolderTree(node, depth = 0) { if (!node || node.type !== 'folder') return null; const isActive = (node.path || '') === currentPath; const key = node.path || '__root__'; const isExpanded = expandedFolders.has(key); const childFolders = Array.isArray(node.children) && node.children.filter((child) => child.type === 'folder'); const hasChildren = childFolders && childFolders.length > 0; const tagCount = mediaTagCounts[node.path || ''] || 0; return (
handleOpen(node)} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter') handleOpen(node); }} > {hasChildren ? (
{ event.stopPropagation(); toggleFolder(node.path || ''); }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); toggleFolder(node.path || ''); } }} aria-label="Toggle" > {isExpanded ? : }
) : (
{isExpanded && hasChildren && childFolders.map((child) => renderFolderTree(child, depth + 1))}
); } async function handleClaimTagForScan() { setTagUidError(''); const currentUid = scanTagUid.trim(); const existing = tags.find((tag) => tag.uid === currentUid); if (existing) { if (existing.status === 'NEW') { const written = await markTagWritten(currentUid); if (!written.ok) { if (written.data.detail === 'invalid uid') { setTagUidError( 'UID ungueltig. Bitte 10 Zeichen (a-z, 0-9) oder TAG_ + 8 Zeichen verwenden.' ); return; } setError(written.data.detail || 'Tag schreiben fehlgeschlagen.'); addToast('error', written.data.detail || 'Tag schreiben fehlgeschlagen.'); return; } } } else { const response = await claimTag(currentUid, scanTagLabel.trim()); if (!response.ok) { if (response.data.detail === 'invalid uid') { setTagUidError( 'UID ungueltig. Bitte 10 Zeichen (a-z, 0-9) oder TAG_ + 8 Zeichen verwenden.' ); return; } setError(response.data.detail || 'Tag schreiben fehlgeschlagen.'); addToast('error', response.data.detail || 'Tag schreiben fehlgeschlagen.'); return; } setScanTagUid(response.data.uid || ''); } setError(''); addToast('success', 'Tag geschrieben.'); if (activeNfc) { setDismissedNfcKey(getNfcKey(activeNfc)); } const shouldAssign = selectedId && status?.last_nfc?.known === false && scanMediaPath; if (shouldAssign) { const mediaSet = await setTagMedia(currentUid, scanMediaPath); if (!mediaSet.ok) { setError(mediaSet.data.detail || 'Medium setzen fehlgeschlagen.'); addToast('error', mediaSet.data.detail || 'Medium setzen fehlgeschlagen.'); return; } const assigned = await assignTag(currentUid, selectedId); if (!assigned.ok) { setError(assigned.data.detail || 'Zuordnung fehlgeschlagen.'); addToast('error', assigned.data.detail || 'Zuordnung fehlgeschlagen.'); return; } addToast('success', 'Tag zugeordnet.'); if (activeNfc?.known === false) { await handlePlayerCommand('nfc_on', { uid: currentUid }); } setScanTagUid(''); setScanTagLabel(''); setScanMediaPath(''); setSimulatedNfc(null); setLastHardwareUid({ uid: '', hardwareUid: '' }); } else if (selectedId && status?.last_nfc?.known === false) { addToast('success', 'Tag gespeichert. Medium fehlt noch.'); setSimulatedNfc(null); setLastHardwareUid({ uid: '', hardwareUid: '' }); } const updated = await getTags(); if (updated.ok) { setTags(updated.data.tags || []); } if (selectedId) { const updatedBoxTags = await getBoxTags(selectedId); if (updatedBoxTags.ok) { setBoxTags(updatedBoxTags.data.tags || []); } } } async function handleReuseImportedTag() { if (!reuseTagUid) { setError('Bitte eine gespeicherte Tag-ID waehlen.'); addToast('error', 'Bitte eine gespeicherte Tag-ID waehlen.'); return; } if (!selectedId) { setError('Bitte zuerst eine Box auswaehlen.'); addToast('error', 'Bitte zuerst eine Box auswaehlen.'); return; } const tag = tags.find((entry) => entry.uid === reuseTagUid); if (!tag || !tag.media_path) { setError('Tag hat keine Medienzuordnung.'); addToast('error', 'Tag hat keine Medienzuordnung.'); return; } const response = await markTagWritten(reuseTagUid); if (!response.ok) { setError(response.data.detail || 'Tag schreiben fehlgeschlagen.'); addToast('error', response.data.detail || 'Tag schreiben fehlgeschlagen.'); return; } const assigned = await assignTag(reuseTagUid, selectedId); if (!assigned.ok) { setError(assigned.data.detail || 'Zuordnung fehlgeschlagen.'); addToast('error', assigned.data.detail || 'Zuordnung fehlgeschlagen.'); return; } addToast('success', 'Tag geschrieben und zugeordnet.'); const updated = await getTags(); if (updated.ok) { setTags(updated.data.tags || []); } const updatedBoxTags = await getBoxTags(selectedId); if (updatedBoxTags.ok) { setBoxTags(updatedBoxTags.data.tags || []); } setScanTagUid(reuseTagUid); setReuseTagUid(''); } async function handleWriteTag(uid) { const response = await markTagWritten(uid); if (!response.ok) { setError(response.data.detail || 'Tag schreiben fehlgeschlagen.'); addToast('error', response.data.detail || 'Tag schreiben fehlgeschlagen.'); return; } addToast('success', 'Tag geschrieben.'); const updated = await getTags(); if (updated.ok) { setTags(updated.data.tags || []); } } async function handleStoreTagOnly() { if (!activeNfc?.uid) { setError('Keine UID erkannt.'); addToast('error', 'Keine UID erkannt.'); return; } const existing = tags.find((tag) => tag.uid === activeNfc.uid); if (existing) { addToast('success', 'Tag ist bereits in der Datenbank.'); if (activeNfc) { setDismissedNfcKey(getNfcKey(activeNfc)); } return; } const response = await claimTag(activeNfc.uid, ''); if (!response.ok) { setError(response.data.detail || 'Tag speichern fehlgeschlagen.'); addToast('error', response.data.detail || 'Tag speichern fehlgeschlagen.'); return; } addToast('success', 'Tag in der Datenbank gespeichert.'); if (activeNfc) { setDismissedNfcKey(getNfcKey(activeNfc)); } setSimulatedNfc(null); setLastHardwareUid({ uid: '', hardwareUid: '' }); const updated = await getTags(); if (updated.ok) { setTags(updated.data.tags || []); } } async function handleAssignFromScan() { if (!scanMediaPath) { setError('Bitte zuerst einen Medienordner waehlen.'); addToast('error', 'Bitte zuerst einen Medienordner waehlen.'); return; } const uid = scanTagUid.trim(); if (!uid) { setError('Bitte zuerst eine Tag-ID schreiben.'); addToast('error', 'Bitte zuerst eine Tag-ID schreiben.'); return; } const matching = tags.find((tag) => tag.uid === uid); if (!matching) { setError('Tag-ID existiert nicht. Bitte zuerst schreiben.'); addToast('error', 'Tag-ID existiert nicht. Bitte zuerst schreiben.'); return; } const mediaSet = await setTagMedia(uid, scanMediaPath); if (!mediaSet.ok) { setError(mediaSet.data.detail || 'Medium setzen fehlgeschlagen.'); addToast('error', mediaSet.data.detail || 'Medium setzen fehlgeschlagen.'); return; } const assigned = await assignTag(uid, selectedId); if (!assigned.ok) { setError(assigned.data.detail || 'Zuordnung fehlgeschlagen.'); addToast('error', assigned.data.detail || 'Zuordnung fehlgeschlagen.'); return; } addToast('success', 'Tag zugeordnet.'); if (activeNfc?.known === false) { await handlePlayerCommand('nfc_on', { uid }); } if (activeNfc) { setDismissedNfcKey(getNfcKey(activeNfc)); } setScanTagUid(''); setScanTagLabel(''); setScanMediaPath(''); setSimulatedNfc(null); setLastHardwareUid({ uid: '', hardwareUid: '' }); const updatedTags = await getTags(); if (updatedTags.ok) { setTags(updatedTags.data.tags || []); } const updatedBoxTags = await getBoxTags(selectedId); if (updatedBoxTags.ok) { setBoxTags(updatedBoxTags.data.tags || []); } } async function handlePullTagFromBox() { if (!selectedId) { setError('Bitte zuerst eine Box auswaehlen.'); addToast('error', 'Bitte zuerst eine Box auswaehlen.'); return; } if (!importTargetUid) { setError('Kein Tag ausgewaehlt.'); addToast('error', 'Kein Tag ausgewaehlt.'); return; } const response = await pullTagFromBox( selectedId, importTargetUid, importTargetFolder.trim() ); if (!response.ok) { setError(response.data.detail || 'Import fehlgeschlagen.'); addToast('error', response.data.detail || 'Import fehlgeschlagen.'); return; } addToast('success', 'Medien vom Box-Tag uebertragen.'); setImportTargetUid(''); setImportTargetFolder(''); setActiveModal(''); const updated = await getTags(); if (updated.ok) { setTags(updated.data.tags || []); } const updatedLocal = await getBoxLocalTags(selectedId); if (updatedLocal.ok) { setLocalBoxTags(updatedLocal.data.tags || []); } await handleMediaRefresh(); } async function handleUnassignTag(uid) { const response = await unassignTag(uid, selectedId); if (!response.ok) { setError(response.data.detail || 'Tag loesen fehlgeschlagen.'); addToast('error', response.data.detail || 'Tag loesen fehlgeschlagen.'); return; } addToast('success', 'Tag getrennt.'); const updated = await getBoxTags(selectedId); if (updated.ok) { setBoxTags(updated.data.tags || []); } } async function handleDeleteTag(uid) { const response = await deleteTag(uid); if (!response.ok) { setError(response.data.detail || 'Tag loeschen fehlgeschlagen.'); addToast('error', response.data.detail || 'Tag loeschen fehlgeschlagen.'); return; } addToast('success', 'Tag geloescht.'); const updatedTags = await getTags(); if (updatedTags.ok) { setTags(updatedTags.data.tags || []); } if (selectedId) { const updatedBoxTags = await getBoxTags(selectedId); if (updatedBoxTags.ok) { setBoxTags(updatedBoxTags.data.tags || []); } } if (boxes.length) { const results = await Promise.all( boxes.map(async (box) => ({ boxId: box.box_id, response: await getTagBlocks(box.box_id), })) ); const next = {}; results.forEach(({ boxId, response }) => { if (response.ok) { next[boxId] = response.data.blocked || []; } }); setBlockedByBox(next); } } async function handleSetTagMedia(uid) { const mediaPath = dbTagMedia[uid] || ''; const response = await setTagMedia(uid, mediaPath); if (!response.ok) { setError(response.data.detail || 'Medium setzen fehlgeschlagen.'); addToast('error', response.data.detail || 'Medium setzen fehlgeschlagen.'); return; } const lastNfcUid = status?.last_nfc?.uid || ''; const shouldAssign = selectedId && status?.last_nfc?.known === false && (lastNfcUid === uid || (!lastNfcUid && scanTagUid && scanTagUid === uid)); if (shouldAssign) { const assigned = await assignTag(uid, selectedId); if (!assigned.ok) { setError(assigned.data.detail || 'Zuordnung fehlgeschlagen.'); addToast('error', assigned.data.detail || 'Zuordnung fehlgeschlagen.'); return; } addToast('success', 'Medium gespeichert und Tag zugeordnet.'); } else { addToast('success', 'Medium gespeichert.'); } const updatedTags = await getTags(); if (updatedTags.ok) { setTags(updatedTags.data.tags || []); } if (selectedId) { const updatedBoxTags = await getBoxTags(selectedId); if (updatedBoxTags.ok) { setBoxTags(updatedBoxTags.data.tags || []); } } if (boxes.length) { const results = await Promise.all( boxes.map(async (box) => ({ boxId: box.box_id, response: await getTagBlocks(box.box_id), })) ); const next = {}; results.forEach(({ boxId, response }) => { if (response.ok) { next[boxId] = response.data.blocked || []; } }); setBlockedByBox(next); } } async function handleClearTagMedia(uid) { const response = await setTagMedia(uid, ''); if (!response.ok) { setError(response.data.detail || 'Medium entfernen fehlgeschlagen.'); addToast('error', response.data.detail || 'Medium entfernen fehlgeschlagen.'); return; } setError(''); addToast('success', 'Medienzuweisung entfernt.'); setDbTagMedia((prev) => ({ ...prev, [uid]: '' })); const updatedTags = await getTags(); if (updatedTags.ok) { setTags(updatedTags.data.tags || []); } if (selectedId) { const updatedBoxTags = await getBoxTags(selectedId); if (updatedBoxTags.ok) { setBoxTags(updatedBoxTags.data.tags || []); } } if (boxes.length) { const results = await Promise.all( boxes.map(async (box) => ({ boxId: box.box_id, response: await getTagBlocks(box.box_id), })) ); const next = {}; results.forEach(({ boxId, response }) => { if (response.ok) { next[boxId] = response.data.blocked || []; } }); setBlockedByBox(next); } } async function handleToggleTagBlock(boxId, uid, nextBlocked) { const response = await setTagBlock(boxId, uid, nextBlocked); if (!response.ok) { addToast('error', response.data.detail || 'Tag-Sperre fehlgeschlagen.'); return; } setBlockedByBox((prev) => { const current = new Set(prev[boxId] || []); if (nextBlocked) { current.add(uid); } else { current.delete(uid); } return { ...prev, [boxId]: Array.from(current) }; }); addToast( 'success', nextBlocked ? 'Tag gesperrt.' : 'Tag freigegeben.' ); } async function handleSaveTagAlias(uid) { const value = (tagAliasDrafts[uid] ?? '').trim(); const response = await setTagAlias(uid, value || null); if (!response.ok) { addToast('error', response.data.detail || 'Tag-Alias speichern fehlgeschlagen.'); return; } const updated = await getTags(); if (updated.ok) { setTags(updated.data.tags || []); } addToast('success', 'Tag-Alias gespeichert.'); } async function handleSaveBoxAlias(boxId) { const value = (boxAliasDrafts[boxId] ?? '').trim(); const response = await setBoxAlias(boxId, value || null); if (!response.ok) { addToast('error', response.data.detail || 'Box-Alias speichern fehlgeschlagen.'); return; } const updated = await getBoxes(); if (updated.ok) { setBoxes(updated.data.boxes || []); } addToast('success', 'Box-Alias gespeichert.'); } const currentItems = mediaTree ? listChildren(getNodeByPath(mediaTree, currentPath)).filter( (item) => !(item.name || '').startsWith('.') ) : []; const showMeta = currentItems.some((item) => item.type === 'file'); const mediaBytes = mediaTree?.size ?? null; const freeBytes = mediaTree?.free_bytes ?? null; const systemStorage = useMemo(() => { if (!mediaTree || mediaTree.size === undefined || mediaTree.free_bytes === undefined) { return null; } const used = Math.max(0, mediaTree.size || 0); const free = Math.max(0, mediaTree.free_bytes || 0); const total = used + free; const percent = total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0; return { used, free, total, percent, scale: capacityScale(percent) }; }, [mediaTree]); const activeMeta = useMemo( () => NAV_ITEMS.find((item) => item.id === activeSection), [activeSection] ); const activeBoxLabel = useMemo(() => { const activeBox = boxes.find((box) => box.box_id === selectedId); return activeBox?.alias || activeBox?.box_id || ''; }, [boxes, selectedId]); const activeNfc = useMemo(() => { if (status?.last_nfc && status.last_nfc.known === false) { return { ...status.last_nfc, at: status.last_nfc_at }; } return simulatedNfc; }, [status, simulatedNfc]); const activeNfcKey = useMemo( () => (activeNfc ? getNfcKey(activeNfc) : ''), [activeNfc] ); useEffect(() => { if (!activeNfcKey) return; if (tagWizardRestoredRef.current === activeNfcKey) return; try { const raw = sessionStorage.getItem('klangkiste_tag_wizard'); if (!raw) return; const parsed = JSON.parse(raw); if (!parsed || parsed.key !== activeNfcKey) return; const data = parsed.data || {}; if (typeof data.scanTagUid === 'string') setScanTagUid(data.scanTagUid); if (typeof data.scanTagLabel === 'string') setScanTagLabel(data.scanTagLabel); if (typeof data.scanMediaPath === 'string') setScanMediaPath(data.scanMediaPath); if (typeof data.tagUidMode === 'string') setTagUidMode(data.tagUidMode); if (typeof data.tagStep === 'number') setTagStep(data.tagStep); setTagStepOneDone(Boolean(data.tagStepOneDone)); setTagStepTwoDone(Boolean(data.tagStepTwoDone)); if (typeof data.tagStepMax === 'number') setTagStepMax(data.tagStepMax); if (typeof data.tagUidError === 'string') setTagUidError(data.tagUidError); if (data.lastHardwareUid?.uid || data.lastHardwareUid?.hardwareUid) { setLastHardwareUid(data.lastHardwareUid); } if (!status?.last_nfc && data.simulatedNfc) { setSimulatedNfc(data.simulatedNfc); } if (typeof data.dismissedNfcKey === 'string') { setDismissedNfcKey(data.dismissedNfcKey); } tagWizardRestoredRef.current = activeNfcKey; } catch (error) { // ignore storage restore errors } }, [activeNfcKey, status?.last_nfc]); useEffect(() => { if (!activeNfcKey) return; try { const payload = { key: activeNfcKey, data: { scanTagUid, scanTagLabel, scanMediaPath, tagUidMode, tagStep, tagStepOneDone, tagStepTwoDone, tagStepMax, tagUidError, lastHardwareUid, simulatedNfc, dismissedNfcKey, }, }; sessionStorage.setItem('klangkiste_tag_wizard', JSON.stringify(payload)); } catch (error) { // ignore storage write errors } }, [ activeNfcKey, scanTagUid, scanTagLabel, scanMediaPath, tagUidMode, tagStep, tagStepOneDone, tagStepTwoDone, tagStepMax, tagUidError, lastHardwareUid, simulatedNfc, dismissedNfcKey, ]); const statusWithHardware = useMemo(() => { if (!status) return null; const hardwareUid = lastHardwareUid.uid === status?.last_nfc?.uid && lastHardwareUid.hardwareUid ? lastHardwareUid.hardwareUid : activeNfc?.hardwareUid || null; if (!status.last_nfc) return status; return { ...status, last_nfc: { ...status.last_nfc, hardware_uid: hardwareUid, }, }; }, [status, lastHardwareUid, activeNfc]); const activeTag = useMemo(() => { const uid = status?.last_nfc?.uid || activeNfc?.uid || ''; if (!uid) return null; return tags.find((tag) => tag.uid === uid) || null; }, [status, activeNfc, tags]); const playlistRoot = activeTag?.media_path || ''; const playlistTitle = playlistRoot ? playlistRoot.split('/').filter(Boolean).pop() : ''; const playlistItems = useMemo(() => { if (!mediaTree || !playlistRoot) return []; const node = getNodeByPath(mediaTree, playlistRoot); if (!node) return []; const stack = [node]; const collected = []; while (stack.length) { const current = stack.pop(); if (!current) continue; if (current.type === 'file') { collected.push(current); continue; } if (Array.isArray(current.children)) { for (let i = current.children.length - 1; i >= 0; i -= 1) { stack.push(current.children[i]); } } } return collected; }, [mediaTree, playlistRoot]); const playlistDuration = useMemo(() => { if (!playlistItems.length) return 0; return playlistItems.reduce((sum, item) => sum + (item.duration || 0), 0); }, [playlistItems]); const playbackState = status?.playback_state || {}; const currentIndex = typeof playbackState.file_index === 'number' ? playbackState.file_index : null; const currentTrack = currentIndex !== null && playlistItems[currentIndex] ? playlistItems[currentIndex] : null; const currentTitle = currentTrack?.title || currentTrack?.name || (playbackState.current_file ? playbackState.current_file.split('/').pop() : ''); const currentArtist = currentTrack?.artist || ''; const playbackPosition = typeof playbackState.position === 'number' ? playbackState.position : 0; const playbackDuration = typeof playbackState.duration === 'number' ? playbackState.duration : 0; const playbackProgress = playbackDuration > 0 ? Math.min(100, Math.round((playbackPosition / playbackDuration) * 100)) : 0; const isPlaying = playbackState.state === 'PLAYING'; const volumeValue = typeof playbackState.volume === 'number' ? playbackState.volume : null; function handleSeekCommit(percent) { if (!playbackDuration) return; const targetSeconds = Math.round((percent / 100) * playbackDuration); handlePlayerCommand('seek', { position: targetSeconds }); } function handleVolumeCommit(nextValue) { handlePlayerCommand('set_volume', { volume: nextValue }); } useEffect(() => { if (isSeeking) return; setSeekPercent(playbackProgress); }, [playbackProgress, isSeeking]); useEffect(() => { setIsSeeking(false); setSeekPercent(playbackProgress); }, [currentIndex, playbackProgress]); useEffect(() => { setVolumeDraft(volumeValue ?? 50); }, [volumeValue]); useEffect(() => { if (!showVolume) return; function handleOutside(event) { if (!volumeRef.current) return; if (volumeRef.current.contains(event.target)) return; setShowVolume(false); } document.addEventListener('mousedown', handleOutside); document.addEventListener('touchstart', handleOutside); return () => { document.removeEventListener('mousedown', handleOutside); document.removeEventListener('touchstart', handleOutside); }; }, [showVolume]); function renderSection() { if (activeSection === 'dashboard') { return (

Live-Status

{!selectedId &&

Waehle eine gepairte Box aus.

} {selectedId && !status &&

Status wird geladen...

} {status && status.error &&

{status.error}

} {status && !status.error && (
{JSON.stringify(statusWithHardware, null, 2)}
)}

{currentTitle || playlistTitle || 'Playlist'}

{currentArtist || playlistRoot || 'Kein Medium zugeordnet'}

{playlistItems.length} Tracks {formatClock(playlistDuration)}
{formatClock(playbackPosition)} { setSeekPercent(event.value ?? 0); setIsSeeking(true); }} onSlideEnd={(event) => { setIsSeeking(false); handleSeekCommit(event.value ?? 0); }} /> {formatClock(playbackDuration)}
setNfcUid(event.target.value)} placeholder="UID_1" className="p-inputtext-sm" />
# Titel Interpret Dauer
{playlistItems.length === 0 && (
Keine Playlist gefunden.
)} {playlistItems.map((track, index) => (
{ event.preventDefault(); handlePlayerCommand('jump_to_index', { index }); }} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter') { handlePlayerCommand('jump_to_index', { index }); } }} > {index + 1} {track.title || track.name} {track.artist || '-'} {formatClock(track.duration)}
))}

Steuerung ist nur moeglich, wenn die Box gepairt ist.

); } if (activeSection === 'boxes') { return (
{unpaired.length > 0 && (

Neue Boxen

{unpaired.map((box) => (
{box.alias || box.box_id}
Zuletzt gesehen: {formatTime(box.last_seen)}
Firmware: {box.firmware_version}
ID: {box.box_id}
))}
)}

Gepairte Boxen

{paired.length === 0 &&

Noch keine gepairten Boxen.

} {paired.length > 0 && (
{paired.map((box) => (
setSelectedId(box.box_id)} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter') setSelectedId(box.box_id); }} >
{box.alias || box.box_id} {getAvailability(box.last_seen).label}
Zuletzt gesehen: {formatTime(box.last_seen)}
Capabilities: {parseCapabilities(box.capabilities_json)}
ID: {box.box_id}
event.stopPropagation()} onChange={(event) => { event.stopPropagation(); setBoxAliasDrafts((prev) => ({ ...prev, [box.box_id]: event.target.value, })); }} />
))}
)}
); } if (activeSection === 'media') { return (

Medien Explorer

{uploadInProgress && (
{activeUploadLabel || 'Upload laeuft...'}
)}

Ordnerverwaltung, Upload und Datei-Listen im Server-Medienordner.

{mediaError &&

{mediaError}

} {!mediaError && !mediaTree &&

Medienliste wird geladen...

} {!mediaError && mediaTree && (
setSidebarQuery(event.target.value)} aria-label="Ordner suchen" />
{topLevelFolders.length === 0 && (
Keine Ordner gefunden.
)} {filteredTree && renderFolderTree(filteredTree, 0)}
Name {showMeta ? ( <> Interpret Titel Laenge Groesse ) : ( <> Typ Groesse )}
{currentItems.map((item) => (
{ if (event.button !== 0) return; if (event.metaKey || event.ctrlKey || event.shiftKey) { return; } dragSelectRef.current = true; handleSelect(item, event); }} onMouseEnter={() => handleDragSelect(item)} onClick={(event) => handleSelect(item, event)} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); lastDblClickRef.current = Date.now(); handleOpen(item); }} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter') handleOpen(item); }} > {item.type === 'folder' ? ( ) : ( )} {item.name} {showMeta ? ( <> {item.type === 'folder' ? '-' : item.artist || '-'} {item.type === 'folder' ? '-' : item.title || '-'} {item.type === 'folder' ? '-' : formatDuration(item.duration)} {formatSize(item.size)} ) : ( <> {item.type === 'folder' ? 'Ordner' : 'Datei'} {formatSize(item.size)} )}
))} {selectedPaths.length > 0 && (
{showMeta ? ( <> {selectedPaths.length} ausgewaehlt ) : ( <> {selectedPaths.length} ausgewaehlt )}
)}
Medienordner: {formatSize(mediaBytes)} · Verfuegbar:{' '} {formatSize(freeBytes)}
)}
); } if (activeSection === 'tags') { return (
{(localBoxError || localBoxTags.length > 0) && (

Tags nur auf dieser Box

{localBoxError &&

{localBoxError}

} {localBoxTags.map((tag) => (
{tag.uid}
Dateien: {tag.file_count} · {formatSize(tag.total_size)}
{tag.media_exists && tag.files?.length > 0 && (
    {tag.files.map((file) => (
  • {file}
  • ))}
)} {!tag.media_exists && (

Keine Dateien im Box-Ordner gefunden.

)}
))}
)}

Tags (Datenbank)

{tags.length === 0 &&

Keine Tags vorhanden.

} {tags.length > 0 && (
{tags.map((tag) => (
{tag.alias ? `${tag.alias} (${tag.uid})` : tag.uid}
Status: {tag.status}
Medium: {tag.media_path || '-'}
{tag.label ?
Label: {tag.label}
: null}
setTagAliasDrafts((prev) => ({ ...prev, [tag.uid]: event.target.value, })) } />
setDbTagMedia((prev) => ({ ...prev, [tag.uid]: event.value, })) } options={collectTopLevelFolders(mediaTree).map((folderPath) => ({ label: folderPath, value: folderPath, }))} placeholder="Medienordner waehlen" filter className="p-inputtext-sm" />
))}
)}

Tag-Matrix (Sperren)

{paired.length === 0 && (

Keine gepairten Boxen vorhanden.

)} {tags.length === 0 &&

Keine Tags vorhanden.

} {paired.length > 0 && tags.length > 0 && (
Tag {paired.map((box) => ( {box.alias || box.box_id} ))}
{tags.map((tag) => (
{tag.alias || tag.uid} {paired.map((box) => { const blocked = (blockedByBox[box.box_id] || []).includes(tag.uid); return ( handleToggleTagBlock(box.box_id, tag.uid, !blocked) } onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleToggleTagBlock(box.box_id, tag.uid, !blocked); } }} /> ); })}
))}
)}
); } return (

System

Backend: {import.meta.env.VITE_BACKEND_URL || 'http://127.0.0.1:5001'}

Gepairte Boxen {paired.length}
Tags {tags.length}
); } const systemPanels = ( <>

Live Status

System Health und wichtige Events.

API
Speicher
Letzter Sync {status?.last_sync_at ? formatTime(status.last_sync_at) : '-'}
Hardware-UID {lastHardwareUid.uid === status?.last_nfc?.uid && lastHardwareUid.hardwareUid ? lastHardwareUid.hardwareUid : activeNfc?.hardwareUid || '-'}

Speicherkapazitaet

System {systemStorage ? `${formatSize(systemStorage.used)} / ${formatSize(systemStorage.total)}` : '-'}
Frei: {systemStorage ? formatSize(systemStorage.free) : '-'}
{boxes.length === 0 &&

Keine Boxen verbunden.

} {boxes.map((box) => { const storage = boxStorage[box.box_id] || null; const used = storage?.used ?? null; const free = storage?.free ?? null; const total = storage?.total ?? null; const totalValue = total !== null ? total : used !== null && free !== null ? used + free : null; const usedValue = used !== null ? used : totalValue !== null && free !== null ? totalValue - free : null; const percent = totalValue && usedValue !== null ? Math.min(100, Math.round((usedValue / totalValue) * 100)) : 0; const scale = capacityScale(percent); return (
{box.alias || box.box_id} {totalValue !== null && usedValue !== null ? `${formatSize(usedValue)} / ${formatSize(totalValue)}` : '-'}
{free !== null ? `Frei: ${formatSize(free)}` : 'Keine Kapazitaet gemeldet'}
); })}
); return (
K
Klangkiste PrimeReact GUI
{selectedId && (
{activeBoxLabel || selectedId}
)}
{error &&
{error}
} {activeNfc && dismissedNfcKey !== getNfcKey(activeNfc) && (

{activeNfc.uid && tags.find((tag) => tag.uid === activeNfc.uid && !tag.media_path) ? 'Leerer Tag erkannt' : 'Neuer Tag erkannt'}

{activeNfc.uid ? ( <> UID erkannt: {activeNfc.uid} ) : ( <>keine UID erkannt )}

{activeNfc.uid && lastHardwareUid.uid === activeNfc.uid && lastHardwareUid.hardwareUid && (

Hardware-UID erkannt: {lastHardwareUid.hardwareUid}

)} {!activeNfc.uid && activeNfc.hardwareUid && (

Hardware-UID erkannt: {activeNfc.hardwareUid}

)} {activeNfc.uid && tags.find((tag) => tag.uid === activeNfc.uid) && (

Dieser Tag ist bekannt, aber noch nicht zugewiesen.

)}
{ if (event.index <= tagStepMax) { setTagStep(event.index); return; } tagStepperRef.current?.setActiveStep(tagStep); }} >
setScanTagUid(event.target.value)} readOnly={tagUidMode === 'keep'} placeholder="Tag-ID (10 Zeichen)" className="p-inputtext-sm" />
setScanTagLabel(event.target.value)} placeholder="Label (optional)" className="p-inputtext-sm is-hidden" />
setScanMediaPath(event.value)} options={collectTopLevelFolders(mediaTree).map((folderPath) => ({ label: folderPath, value: folderPath, }))} placeholder="Medienordner waehlen" filter className="p-inputtext-sm" disabled={!tagStepOneDone} />

Zusammenfassung

System-UID {scanTagUid || '-'} UID-Modus {tagUidMode === 'keep' ? 'Beibehalten' : 'Neu vergeben'} Label {scanTagLabel?.trim() || '-'} Medienordner {scanMediaPath || 'Spaeter zuordnen'} Hardware-UID {lastHardwareUid.uid === activeNfc?.uid && lastHardwareUid.hardwareUid ? lastHardwareUid.hardwareUid : activeNfc?.hardwareUid || '-'}
{tags.some((tag) => tag.status === 'IMPORTED') && (
)}
)} {renderSection()}
{activeModal === 'new-folder' && (
{ setActiveModal(''); setUploadAfterCreate(false); setPendingUploadFiles([]); setUploadSize(0); setUploadNameError(''); setTagDeleteTarget(''); }} >
event.stopPropagation()} ref={modalRef} >

{uploadAfterCreate ? 'Upload vorbereiten' : 'Neuen Ordner anlegen'}

{ setNewFolderName(event.target.value); if (uploadNameError) setUploadNameError(''); }} placeholder={ uploadAfterCreate && !currentPath ? 'Ordnername' : uploadAfterCreate ? 'Optionaler Ordnername (leer = aktueller Ordner)' : 'Ordnername' } className="p-inputtext-sm" /> {uploadAfterCreate && uploadNameError && (

{uploadNameError}

)} {uploadAfterCreate && ( <> { const audioFiles = (event.files || []).filter((file) => file.type.startsWith('audio/') ); setPendingUploadFiles(audioFiles); const nextSize = audioFiles.reduce((sum, file) => sum + file.size, 0); setUploadSize(nextSize); setActiveUploadLabel( audioFiles.length ? `Upload: ${audioFiles.length} Datei(en)` : '' ); }} uploadHandler={(event) => { const audioFiles = (event.files || []).filter((file) => file.type.startsWith('audio/') ); setPendingUploadFiles(audioFiles); const nextSize = audioFiles.reduce((sum, file) => sum + file.size, 0); setUploadSize(nextSize); setActiveUploadLabel( audioFiles.length ? `Upload: ${audioFiles.length} Datei(en)` : '' ); handleCreateFolder(audioFiles); }} onClear={() => { setPendingUploadFiles([]); setUploadSize(0); }} emptyTemplate={
Drag and Drop Audio Here
Gesamt: {formatSize(uploadSize)}
} /> {pendingUploadFiles.length > 0 && (
Gesamt: {formatSize(uploadSize)} {pendingUploadFiles.length} Datei(en)
)} )}
)} {activeModal === 'rename' && (
{ setActiveModal(''); setRenameName(''); }} >
event.stopPropagation()} ref={modalRef} >

Umbenennen

setRenameName(event.target.value)} placeholder="Neuer Name" className="p-inputtext-sm" />
)} {activeModal === 'delete' && (
{ setActiveModal(''); }} >
event.stopPropagation()} ref={modalRef} >

Eintraege loeschen

{selectedPaths.length} Eintraege werden geloescht.

)} {activeModal === 'move' && (
{ setActiveModal(''); setMoveTarget(''); }} >
event.stopPropagation()} ref={modalRef} >

Verschieben

setMoveTarget(event.value)} options={[ { label: 'media/', value: '__root__' }, ...collectFolderPaths(mediaTree).map((folder) => ({ label: folder, value: folder, })), ]} placeholder="Zielordner waehlen" filter className="p-inputtext-sm" />
)} {activeModal === 'tag-delete' && (
{ setActiveModal(''); setTagDeleteTarget(''); }} >
event.stopPropagation()} ref={modalRef} >

Tag entfernen

Soll der Tag komplett geloescht werden oder nur die Medienzuweisung?

)} {boxDeleteTarget && (
setBoxDeleteTarget('')} >
event.stopPropagation()} ref={modalRef} >

Box entfernen

Soll die Box wirklich entkoppelt werden?

)} {activeModal === 'import-tag' && (

Tag vom Box-Speicher uebertragen

setImportTargetFolder(event.target.value)} placeholder="z. B. grimm_volume_3" className="p-inputtext-sm" /> {uploadInProgress && (
)}
)} {showSessionSheet && (
setShowSessionSheet(false)} >
event.stopPropagation()} >
ToolHub
{drawerTab === 'boxes' && ( <> {paired.length === 0 && (

Keine gepairten Boxen.

)}
{paired.map((box) => { const isActive = selectedId === box.box_id; const label = (box.alias || box.box_id || '').slice(0, 2).toUpperCase(); const availability = getAvailability(box.last_seen); return ( ); })}
)} {drawerTab === 'system' && (
{systemPanels}
)}
)}
); }