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 (
);
}
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 ? : }
) : (
)}
{node.name}
{tagCount > 0 && (
{tagCount}
)}
{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"
/>
handlePlayerCommand('nfc_on', { uid: nfcUid })} />
handlePlayerCommand('nfc_off', { uid: nfcUid })} />
#
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}
handlePair(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,
}));
}}
/>
}
onClick={(event) => {
event.stopPropagation();
handleSaveBoxAlias(box.box_id);
}}
title="Alias speichern"
aria-label="Alias speichern"
/>
}
onClick={(event) => {
event.stopPropagation();
setBoxDeleteTarget(box.box_id);
}}
title="Unpair"
aria-label="Unpair"
/>
))}
)}
);
}
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)}
}
onClick={handleGoUp}
title="Hoch"
aria-label="Hoch"
/>
setCurrentPath('')}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter') setCurrentPath('');
}}
>
media
{buildBreadcrumb(currentPath).map((crumb) => (
setCurrentPath(crumb.path)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter') setCurrentPath(crumb.path);
}}
>
/ {crumb.name}
))}
}
onClick={() => setActiveModal('new-folder')}
title="Neuer Ordner"
aria-label="Neuer Ordner"
/>
}
onClick={() => setActiveModal('rename')}
title="Umbenennen"
disabled={selectedPaths.length !== 1}
aria-label="Umbenennen"
/>
}
onClick={() => setActiveModal('delete')}
title="Loeschen"
disabled={selectedPaths.length === 0}
aria-label="Loeschen"
/>
}
onClick={() => {
setMoveTarget('');
setActiveModal('move');
}}
title="Verschieben"
disabled={selectedPaths.length === 0}
aria-label="Verschieben"
/>
}
title="Upload"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setUploadAfterCreate(true);
setPendingUploadFiles([]);
setNewFolderName('');
setActiveModal('new-folder');
}}
aria-label="Upload"
/>
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.
)}
{
setImportTargetUid(tag.uid);
setActiveModal('import-tag');
}}
disabled={!tag.media_exists}
/>
))}
)}
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,
}))
}
/>
}
onClick={() => handleSaveTagAlias(tag.uid)}
title="Alias speichern"
aria-label="Alias speichern"
/>
setDbTagMedia((prev) => ({
...prev,
[tag.uid]: event.value,
}))
}
options={collectTopLevelFolders(mediaTree).map((folderPath) => ({
label: folderPath,
value: folderPath,
}))}
placeholder="Medienordner waehlen"
filter
className="p-inputtext-sm"
/>
}
onClick={() => {
setTagDeleteTarget(tag.uid);
setActiveModal('tag-delete');
}}
title="Loeschen"
aria-label="Loeschen"
/>
))}
)}
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 (
{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"
/>
{
setTagUidMode('new');
setScanTagUid(generateTagId());
}}
/>
{
const uid = scanTagUid.trim();
if (!isValidTagUid(uid)) {
setTagUidError(
'UID ungueltig. Bitte 10 Zeichen (a-z, 0-9) oder TAG_ + 8 Zeichen verwenden.'
);
return;
}
if (tagUidError && tagUidError !== autoReplaceMsg) {
setTagUidError('');
}
setTagStepOneDone(true);
setTagStepMax(1);
setTagStep(1);
}}
/>
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}
/>
{
setTagStepTwoDone(true);
setTagStepMax(2);
setTagStep(2);
}}
/>
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)
)}
>
)}
{
setActiveModal('');
setUploadAfterCreate(false);
setPendingUploadFiles([]);
setUploadSize(0);
setUploadNameError('');
setTagDeleteTarget('');
}}
/>
{
if (uploadAfterCreate) {
setActiveModal('');
}
handleCreateFolder();
}}
/>
)}
{activeModal === 'rename' && (
{
setActiveModal('');
setRenameName('');
}}
>
event.stopPropagation()}
ref={modalRef}
>
Umbenennen
setRenameName(event.target.value)}
placeholder="Neuer Name"
className="p-inputtext-sm"
/>
{
setActiveModal('');
setRenameName('');
}}
/>
)}
{activeModal === 'delete' && (
{
setActiveModal('');
}}
>
event.stopPropagation()}
ref={modalRef}
>
Eintraege loeschen
{selectedPaths.length} Eintraege werden geloescht.
setActiveModal('')} />
)}
{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"
/>
{
setActiveModal('');
setMoveTarget('');
}}
/>
)}
{activeModal === 'tag-delete' && (
{
setActiveModal('');
setTagDeleteTarget('');
}}
>
event.stopPropagation()}
ref={modalRef}
>
Tag entfernen
Soll der Tag komplett geloescht werden oder nur die Medienzuweisung?
{
setActiveModal('');
setTagDeleteTarget('');
}}
/>
{
if (!tagDeleteTarget) return;
await handleClearTagMedia(tagDeleteTarget);
setTagDeleteTarget('');
setActiveModal('');
}}
/>
{
if (!tagDeleteTarget) return;
await handleClearTagMedia(tagDeleteTarget);
await handleDeleteTag(tagDeleteTarget);
setTagDeleteTarget('');
setActiveModal('');
}}
/>
)}
{boxDeleteTarget && (
setBoxDeleteTarget('')}
>
event.stopPropagation()}
ref={modalRef}
>
Box entfernen
Soll die Box wirklich entkoppelt werden?
setBoxDeleteTarget('')}
/>
{
if (!boxDeleteTarget) return;
await handleUnpair(boxDeleteTarget);
setBoxDeleteTarget('');
}}
/>
)}
{activeModal === 'import-tag' && (
Tag vom Box-Speicher uebertragen
setImportTargetFolder(event.target.value)}
placeholder="z. B. grimm_volume_3"
className="p-inputtext-sm"
/>
{uploadInProgress && (
)}
{
setActiveModal('');
setImportTargetFolder('');
setImportTargetUid('');
}}
/>
)}
{showSessionSheet && (
setShowSessionSheet(false)}
>
event.stopPropagation()}
>
ToolHub
setShowSessionSheet(false)}
aria-label="Schliessen"
/>
{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 (
{
setSelectedId(box.box_id);
setShowSessionSheet(false);
}}
>
{box.alias || box.box_id}
{isActive && Aktiv}
);
})}
>
)}
{drawerTab === 'system' && (
{systemPanels}
)}
setDrawerTab('boxes')}
>
Boxen
setDrawerTab('system')}
>
System
)}
);
}