diff --git a/copy_from.sh b/docker_release.sh
similarity index 100%
rename from copy_from.sh
rename to docker_release.sh
diff --git a/klang/App.jsx b/klang/App.jsx
deleted file mode 100644
index 8c13188..0000000
--- a/klang/App.jsx
+++ /dev/null
@@ -1,3381 +0,0 @@
-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
-
-
-
-
- )}
-
- );
-}
diff --git a/klang/api.js b/klang/api.js
deleted file mode 100644
index 6fc0a95..0000000
--- a/klang/api.js
+++ /dev/null
@@ -1,244 +0,0 @@
-const defaultHost =
- typeof window !== 'undefined' && window.location ? window.location.hostname : '127.0.0.1';
-const API_BASE = '/api';
-
-async function readJson(response) {
- const text = await response.text();
- try {
- return { ok: response.ok, status: response.status, data: JSON.parse(text) };
- } catch (error) {
- return { ok: response.ok, status: response.status, data: { detail: text } };
- }
-}
-
-export async function getBoxes() {
- const response = await fetch(`${API_BASE}/boxes`);
- return readJson(response);
-}
-
-export async function pairBox(boxId) {
- const response = await fetch(`${API_BASE}/boxes/pair`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ box_id: boxId }),
- });
- return readJson(response);
-}
-
-export async function getStatus(boxId) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/status`);
- return readJson(response);
-}
-
-export async function getMediaTree() {
- const response = await fetch(`${API_BASE}/media-tree`);
- return readJson(response);
-}
-
-export async function createMediaFolder(parentPath, name) {
- const response = await fetch(`${API_BASE}/media/folder`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ parent_path: parentPath, name }),
- });
- return readJson(response);
-}
-
-export async function renameMedia(path, name) {
- const response = await fetch(`${API_BASE}/media/rename`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path, name }),
- });
- return readJson(response);
-}
-
-export async function moveMedia(path, targetParent) {
- const response = await fetch(`${API_BASE}/media/move`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path, target_parent: targetParent }),
- });
- return readJson(response);
-}
-
-export async function deleteMedia(path) {
- const response = await fetch(`${API_BASE}/media/delete`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path }),
- });
- return readJson(response);
-}
-
-export function uploadMedia(targetPath, files, onProgress) {
- const formData = new FormData();
- formData.append('target_path', targetPath);
- files.forEach((file) => {
- formData.append('files', file);
- });
-
- return new Promise((resolve) => {
- const request = new XMLHttpRequest();
- request.open('POST', `${API_BASE}/media/upload`);
- request.upload.onprogress = (event) => {
- if (!event.lengthComputable || !onProgress) return;
- const percent = Math.round((event.loaded / event.total) * 100);
- onProgress(percent);
- };
- request.onload = () => {
- try {
- const data = JSON.parse(request.responseText || '{}');
- resolve({ ok: request.status >= 200 && request.status < 300, status: request.status, data });
- } catch (error) {
- resolve({
- ok: request.status >= 200 && request.status < 300,
- status: request.status,
- data: { detail: request.responseText || '' },
- });
- }
- };
- request.onerror = () => {
- resolve({ ok: false, status: 0, data: { detail: 'network error' } });
- };
- request.send(formData);
- });
-}
-
-export async function getTags() {
- const response = await fetch(`${API_BASE}/tags`);
- return readJson(response);
-}
-
-export async function generateTag(label) {
- const response = await fetch(`${API_BASE}/tags/generate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ label }),
- });
- return readJson(response);
-}
-
-export async function claimTag(uid, label) {
- const response = await fetch(`${API_BASE}/tags/claim`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ uid, label }),
- });
- return readJson(response);
-}
-
-export async function markTagWritten(uid) {
- const response = await fetch(`${API_BASE}/tags/${uid}/write`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- });
- return readJson(response);
-}
-
-export async function getBoxTags(boxId) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/tags`);
- return readJson(response);
-}
-
-export async function getBoxLocalTags(boxId) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/local-tags`);
- return readJson(response);
-}
-
-export async function getBoxStorage(boxId) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/storage`);
- return readJson(response);
-}
-
-export async function getTagBlocks(boxId) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/tag-blocks`);
- return readJson(response);
-}
-
-export async function setTagBlock(boxId, uid, blocked) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/tag-blocks`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ uid, blocked }),
- });
- return readJson(response);
-}
-
-export async function setBoxAlias(boxId, alias) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/alias`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ alias }),
- });
- return readJson(response);
-}
-
-export async function setTagAlias(uid, alias) {
- const response = await fetch(`${API_BASE}/tags/${uid}/alias`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ alias }),
- });
- return readJson(response);
-}
-
-export async function assignTag(uid, boxId) {
- const response = await fetch(`${API_BASE}/tags/${uid}/assign`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ box_id: boxId }),
- });
- return readJson(response);
-}
-
-export async function unassignTag(uid, boxId) {
- const response = await fetch(`${API_BASE}/tags/${uid}/unassign`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ box_id: boxId }),
- });
- return readJson(response);
-}
-
-export async function deleteTag(uid) {
- const response = await fetch(`${API_BASE}/tags/${uid}`, {
- method: 'DELETE',
- });
- return readJson(response);
-}
-
-export async function setTagMedia(uid, mediaPath) {
- const response = await fetch(`${API_BASE}/tags/${uid}/media`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ media_path: mediaPath }),
- });
- return readJson(response);
-}
-
-export async function pullTagFromBox(boxId, uid, targetFolder) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/pull-tag`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ uid, target_folder: targetFolder }),
- });
- return readJson(response);
-}
-
-export async function sendCommand(boxId, command, payload = {}) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/command`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ command, payload }),
- });
- return readJson(response);
-}
-
-export async function unpairBox(boxId) {
- const response = await fetch(`${API_BASE}/boxes/${boxId}/unpair`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- });
- return readJson(response);
-}
diff --git a/klang/main.jsx b/klang/main.jsx
deleted file mode 100644
index 7057e78..0000000
--- a/klang/main.jsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom/client';
-import { PrimeReactProvider } from 'primereact/api';
-import 'primereact/resources/themes/lara-dark-teal/theme.css';
-import 'primereact/resources/primereact.min.css';
-import 'primeicons/primeicons.css';
-import App from './App.jsx';
-import './styles.css';
-
-ReactDOM.createRoot(document.getElementById('root')).render(
-
-
-
-
-
-);
diff --git a/klang/styles.css b/klang/styles.css
deleted file mode 100644
index 98f8b8f..0000000
--- a/klang/styles.css
+++ /dev/null
@@ -1,1929 +0,0 @@
-@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');
-
-:root {
- color-scheme: dark;
- font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif;
- background-color: #0b0f14;
- color: #e6edf6;
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- min-height: 100vh;
- background:
- radial-gradient(circle at 20% 10%, rgba(50, 112, 124, 0.35), transparent 45%),
- radial-gradient(circle at 80% 0%, rgba(24, 45, 67, 0.6), transparent 40%),
- linear-gradient(130deg, #0b0f14 0%, #111826 45%, #0d1220 100%);
-}
-
-#root {
- min-height: 100vh;
-}
-
-.app-shell {
- min-height: 100vh;
- padding-bottom: 80px;
-}
-
-.topbar {
- position: sticky;
- top: 0;
- z-index: 10;
- display: flex;
- gap: 16px;
- align-items: center;
- padding: 18px 24px;
- background: rgba(11, 15, 20, 0.92);
- backdrop-filter: blur(14px);
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
-}
-
-.brand {
- display: flex;
- align-items: center;
- gap: 12px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- font-size: 12px;
- color: rgba(230, 237, 246, 0.7);
-}
-
-.brand strong {
- display: block;
- font-size: 14px;
- color: #e6edf6;
-}
-
-.brand span {
- display: block;
- margin-top: 2px;
-}
-
-.brand-mark {
- width: 36px;
- height: 36px;
- border-radius: 12px;
- display: grid;
- place-items: center;
- background: linear-gradient(130deg, #19a4a1, #0b6f84);
- color: #061018;
- font-weight: 700;
-}
-
-
-.topbar-actions {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-left: auto;
-}
-
-.header-box-indicator {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 10px;
- border-radius: 999px;
- background: rgba(25, 164, 161, 0.18);
- border: 1px solid rgba(25, 164, 161, 0.4);
- color: rgba(230, 237, 246, 0.9);
- font-size: 13px;
- margin-left: auto;
-}
-
-.user-avatar {
- background: #1c2735;
- color: #e6edf6;
-}
-
-.layout {
- display: grid;
- grid-template-columns: 240px minmax(0, 1fr) 280px;
- gap: 24px;
- padding: 24px;
-}
-
-.sidebar,
-.right-panel {
- position: sticky;
- top: 96px;
- align-self: start;
- height: fit-content;
-}
-
-.sidebar {
- padding: 16px;
- border-radius: 20px;
- background: rgba(10, 17, 26, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.sidebar-title {
- text-transform: uppercase;
- letter-spacing: 0.08em;
- font-size: 11px;
- color: rgba(230, 237, 246, 0.55);
- margin: 0 0 12px;
-}
-
-.sidebar-section {
- margin-bottom: 16px;
-}
-
-.nav-list {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.nav-button {
- justify-content: flex-start;
- color: rgba(230, 237, 246, 0.7);
-}
-
-.nav-button.active {
- background: rgba(25, 164, 161, 0.15);
- color: #e6edf6;
- border-radius: 12px;
-}
-
-.button-stack {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.session-card {
- display: grid;
- grid-template-columns: auto 1fr auto;
- gap: 12px;
- align-items: center;
- padding: 12px;
- border-radius: 14px;
- background: rgba(10, 17, 26, 0.7);
- border: 1px solid rgba(255, 255, 255, 0.08);
- text-align: left;
-}
-
-.sheet-card .session-card {
- width: 100%;
- height: 72px;
-}
-
-.session-card strong {
- color: #2dd4bf;
-}
-
-.session-card .meta {
- display: block;
- font-size: 12px;
- color: rgba(230, 237, 246, 0.6);
-}
-
-.session-list {
- display: grid;
- gap: 8px;
-}
-
-.sheet-card .session-list {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- align-items: stretch;
-}
-
-.session-card.active {
- border-color: rgba(25, 164, 161, 0.6);
- box-shadow: 0 0 0 2px rgba(25, 164, 161, 0.15);
-}
-
-.session-card:focus-visible {
- outline: 2px solid rgba(25, 164, 161, 0.6);
- outline-offset: 2px;
-}
-
-.content {
- display: flex;
- flex-direction: column;
- gap: 20px;
-}
-
-.section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
-}
-
-.section-header h1 {
- margin: 0 0 4px;
- font-size: 28px;
-}
-
-.section-header p {
- margin: 0;
- color: rgba(230, 237, 246, 0.6);
-}
-
-.section-actions {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.section-stack {
- display: flex;
- flex-direction: column;
- gap: 20px;
-}
-
-.stat-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
- gap: 16px;
-}
-
-.stat-card {
- background: rgba(14, 24, 35, 0.9);
- border: 1px solid rgba(255, 255, 255, 0.04);
-}
-
-.stat-label {
- margin: 0;
- font-size: 12px;
- color: rgba(230, 237, 246, 0.6);
-}
-
-.stat-value {
- font-size: 24px;
- font-weight: 600;
- margin: 8px 0;
-}
-
-.stat-helper {
- font-size: 12px;
- color: rgba(25, 164, 161, 0.9);
-}
-
-.split-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
- gap: 16px;
-}
-
-.wide-card,
-.content-card,
-.panel-card {
- background: rgba(14, 24, 35, 0.92);
- border: 1px solid rgba(255, 255, 255, 0.04);
-}
-
-.card-title-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-
-.progress-row {
- display: grid;
- grid-template-columns: 120px 1fr;
- gap: 12px;
- align-items: center;
- margin-top: 12px;
- font-size: 12px;
- color: rgba(230, 237, 246, 0.7);
-}
-
-.activity-list {
- display: flex;
- flex-direction: column;
- gap: 12px;
- margin-top: 12px;
-}
-
-.activity-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 12px;
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.03);
- font-size: 13px;
-}
-
-.activity-item span {
- display: block;
- font-size: 11px;
- color: rgba(230, 237, 246, 0.55);
-}
-
-.content-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 16px;
-}
-
-.content-card h3 {
- margin-top: 0;
-}
-
-.placeholder-block {
- height: 120px;
- border-radius: 14px;
- background: linear-gradient(130deg, rgba(25, 164, 161, 0.2), rgba(23, 45, 64, 0.9));
-}
-
-.placeholder-table {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-top: 12px;
-}
-
-.table-row {
- display: grid;
- grid-template-columns: 1fr auto auto;
- gap: 12px;
- align-items: center;
- padding: 8px 12px;
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.03);
- font-size: 13px;
-}
-
-.table-row.header {
- font-size: 12px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: rgba(230, 237, 246, 0.5);
-}
-
-.placeholder-tree {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-top: 12px;
- font-size: 13px;
- color: rgba(230, 237, 246, 0.75);
-}
-
-.placeholder-tree .nested {
- margin-left: 16px;
-}
-
-.queue-item {
- display: grid;
- gap: 8px;
- margin-top: 12px;
- padding: 10px 12px;
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.03);
- font-size: 13px;
-}
-
-.queue-item span {
- display: block;
- font-size: 11px;
- color: rgba(230, 237, 246, 0.5);
-}
-
-.tab-shell .p-tabview-nav {
- background: transparent;
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.tag-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 16px;
- margin-top: 12px;
-}
-
-.tag-card {
- background: rgba(255, 255, 255, 0.03);
-}
-
-.tag-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-
-.settings-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-top: 12px;
-}
-
-.token-chip {
- display: inline-flex;
- align-items: center;
- padding: 6px 12px;
- border-radius: 999px;
- margin-right: 8px;
- margin-top: 8px;
- background: rgba(25, 164, 161, 0.18);
- font-size: 12px;
-}
-
-.right-panel {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.panel-card h3 {
- margin-top: 0;
-}
-
-.status-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-top: 8px;
- font-size: 13px;
-}
-
-.summary {
- display: flex;
- flex-direction: column;
- gap: 6px;
- flex: 1 1 100%;
-}
-
-.summary-title {
- font-size: 14px;
- font-weight: 600;
- color: rgba(230, 237, 246, 0.95);
- margin: 0;
-}
-
-.summary-grid {
- display: grid;
- grid-template-columns: 140px 1fr;
- column-gap: 12px;
- row-gap: 6px;
- font-size: 13px;
-}
-
-.summary-grid span {
- color: var(--text-color-secondary);
- font-size: 11px;
-}
-
-.step-actions {
- width: 100%;
- display: flex;
- justify-content: flex-end;
-}
-
-.step-row {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.step-row .p-inputtext,
-.step-row .p-dropdown {
- flex: 1 1 220px;
- min-width: 0;
-}
-
-.step-row-spacer {
- flex: 1 1 auto;
-}
-
-.step-messages {
- display: grid;
- gap: 6px;
- margin-top: 6px;
-}
-
-.p-messages {
- margin: 5px 0;
-}
-
-.p-message {
- min-height: 30px;
- max-height: 30px;
- padding: 4px 8px;
- font-size: 12px;
- overflow: hidden;
- display: flex;
- align-items: center;
-}
-
-.p-message-summary {
- font-size: 12px;
- line-height: 1;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- display: inline-flex;
- align-items: center;
-}
-
-.p-message-detail {
- font-size: 11px;
- line-height: 1;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- display: inline-flex;
- align-items: center;
-}
-
-.p-message-wrapper {
- gap: 6px;
- align-items: center;
- flex-wrap: nowrap;
- overflow: hidden;
- min-width: 0;
- flex: 1 1 auto;
-}
-
-.p-message-text {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
-}
-
-.is-hidden {
- display: none;
-}
-
-.panel-pill {
- padding: 10px 12px;
- border-radius: 12px;
- margin-top: 8px;
- background: rgba(255, 255, 255, 0.04);
- font-size: 13px;
-}
-
-.capacity-row {
- display: grid;
- gap: 8px;
- margin-top: 10px;
-}
-
-.capacity-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- font-size: 13px;
- color: rgba(230, 237, 246, 0.9);
-}
-
-.capacity-meta {
- font-size: 12px;
- color: rgba(230, 237, 246, 0.6);
-}
-
-.capacity-bar .p-metergroup-meter {
- background: linear-gradient(
- 90deg,
- #39d98a 0%,
- #e8d45a 45%,
- #f2a94f 65%,
- #b68bff 100%
- );
- background-size: calc(var(--capacity-scale, 1) * 100%) 100%;
- background-position: left center;
-}
-
-.capacity-bar .p-metergroup-labels,
-.capacity-bar .p-metergroup-label,
-.capacity-bar .p-metergroup-label-type {
- display: none;
-}
-
-.bottom-nav {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- display: none;
- gap: 6px;
- padding: 10px 12px 16px;
- background: rgba(11, 15, 20, 0.95);
- border-top: 1px solid rgba(255, 255, 255, 0.08);
- z-index: 20;
- grid-template-columns: repeat(6, minmax(0, 1fr));
-}
-
-.bottom-nav .p-button {
- width: 100%;
- min-width: 0;
- display: grid;
- place-items: center; /* bleibt */
- gap: 4px;
- background: transparent;
- border: none;
- color: rgba(230, 237, 246, 0.6);
- font-size: 11px;
-}
-
-.bottom-nav .p-button > span {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-
-
-.bottom-nav .p-button.active {
- color: #19a4a1;
-}
-
-@media (max-width: 550px) {
- /* ❌ nur Text ausblenden */
- .bottom-nav .p-button > span:not(.nav-icon) {
- display: none;
- }
-
- /* ✅ Icon bleibt exakt zentriert */
- .bottom-nav .nav-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- }
-}
-
-
-
-@media (max-width: 1100px) {
- .layout {
- grid-template-columns: 220px minmax(0, 1fr);
- }
-
- .right-panel {
- display: none;
- }
-}
-
-@media (max-width: 1300px) {
- .legacy .boxes-grid {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 980px) {
- .legacy .boxes-grid {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 1200px) {
- .split-grid {
- grid-template-columns: 1fr;
- }
-
- .legacy .boxes-grid {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 900px) {
- .topbar-actions {
- display: none;
- }
-
- .legacy .boxes-grid {
- grid-template-columns: 1fr;
- }
-
- .legacy .boxes-grid .card {
- flex-direction: column;
- align-items: stretch;
- }
-
- .legacy .boxes-grid .box-actions-row {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
- width: 100%;
- }
-
- .legacy .boxes-grid .box-actions-row > * {
- width: 100%;
- }
-
- .sheet-card .session-list {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 760px) {
- .layout {
- grid-template-columns: minmax(0, 1fr);
- padding: 16px;
- }
-
- .sidebar {
- display: none;
- }
-
-
- .section-header {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .bottom-nav {
- display: grid;
- grid-template-columns: repeat(6, 1fr);
- }
-}
-
-@media (max-width: 720px) {
- .sheet-card .session-list {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 520px) {
- .sheet-card .session-list {
- grid-template-columns: 1fr;
- }
-}
-
-.sheet-backdrop {
- position: fixed;
- inset: 0;
- background: rgba(5, 8, 12, 0.6);
- display: grid;
- align-items: end;
- z-index: 50;
-}
-
-.sheet-card {
- background: #0f1622;
- border-radius: 20px 20px 0 0;
- padding: 20px 18px 28px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- max-height: 80vh;
- overflow-y: auto;
-}
-
-.drawer-system {
- display: grid;
- gap: 12px;
-}
-
-.drawer-tabs {
- margin: 12px -18px -28px;
- padding: 10px 12px 16px;
- border-top: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(11, 15, 20, 0.95);
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 6px;
-}
-
-.drawer-tab {
- background: transparent;
- border: none;
- border-radius: 12px;
- color: rgba(230, 237, 246, 0.6);
- padding: 8px 10px;
- font-size: 11px;
- display: grid;
- place-items: center;
- gap: 4px;
- width: 100%;
- min-width: 0;
- cursor: pointer;
-}
-
-.drawer-tab .nav-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
-}
-
-.drawer-tab.active {
- color: #19a4a1;
-}
-
-.drawer-system {
- display: grid;
- gap: 12px;
-}
-
-.sheet-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 12px;
-}
-
-
-.error {
- background: rgba(80, 24, 32, 0.6);
- color: #f4a7a7;
- border: 1px solid rgba(242, 201, 201, 0.4);
- border-radius: 10px;
- padding: 10px 12px;
- margin-bottom: 16px;
-}
-
-.muted {
- color: rgba(230, 237, 246, 0.6);
-}
-
-.status {
- background: #0f1520;
- color: #e6edf6;
- padding: 12px;
- border-radius: 12px;
- font-size: 12px;
- overflow: auto;
- border: 1px solid rgba(255, 255, 255, 0.08);
- max-width: 100%;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.legacy .card {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 12px;
- padding: 12px;
- margin-top: 12px;
- cursor: pointer;
- background: rgba(10, 17, 26, 0.7);
-}
-
-.legacy .card.selected {
- border-color: rgba(25, 164, 161, 0.6);
- box-shadow: 0 0 0 2px rgba(25, 164, 161, 0.15);
-}
-
-.legacy .card.compact {
- padding: 10px;
- margin-top: 10px;
-}
-
-.legacy .tags-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
-}
-
-@media (max-width: 1800px) {
- .legacy .tags-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 1500px) {
- .legacy .tags-grid {
- grid-template-columns: 1fr;
- }
-}
-
-.legacy .boxes-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
-}
-
-.legacy .boxes-grid .card {
- margin-top: 0;
-}
-
-@media (max-width: 1300px) {
- .legacy .tags-grid {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 1200px) {
- .legacy .tags-grid {
- grid-template-columns: 1fr;
- }
-
- .legacy .boxes-grid {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 900px) {
- .legacy .tags-grid {
- grid-template-columns: 1fr;
- }
-
- .legacy .boxes-grid {
- grid-template-columns: 1fr;
- }
-}
-
-.legacy .stack {
- display: flex;
- flex-direction: column;
- gap: 8px;
- align-items: flex-end;
-}
-
-.legacy .stack-inline {
- flex-direction: row;
- align-items: center;
- justify-content: flex-start;
- flex-wrap: nowrap;
- gap: 6px;
-}
-
-.legacy .tag-actions {
- flex-direction: column;
- align-items: flex-end;
- gap: 6px;
-}
-
-.legacy .tag-actions-row {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-wrap: nowrap;
- justify-content: flex-end;
-}
-
-.legacy .box-actions {
- flex-direction: column;
- align-items: flex-end;
- gap: 6px;
-}
-
-.legacy .box-actions-row {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-wrap: nowrap;
- justify-content: flex-end;
-}
-
-.legacy .box-actions .alias-input {
- width: 100%;
- max-width: none;
-}
-
-@media (max-width: 1800px) {
- .legacy .boxes-grid .card {
- flex-direction: column;
- align-items: stretch;
- }
-
- .legacy .boxes-grid .box-actions-row {
- display: grid;
- grid-template-columns: 2fr 1fr 1fr;
- gap: 8px;
- width: 100%;
- }
-
- .legacy .boxes-grid .box-actions-row > * {
- width: 100%;
- }
-
- .legacy .tags-grid .card {
- flex-direction: column;
- align-items: stretch;
- }
-
- .legacy .tags-grid .tag-actions-row {
- display: grid;
- grid-template-columns: 2fr 1fr 1fr;
- gap: 8px;
- width: 100%;
- }
-
- .legacy .tags-grid .tag-actions-row > * {
- width: 100%;
- }
-}
-
-.legacy .tag-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- width: 100%;
- flex-wrap: nowrap;
-}
-
-.legacy .tags-grid .tag-row {
- display: grid;
- grid-template-columns: 1fr 2fr;
- align-items: center;
- gap: 12px;
-}
-
-.legacy .tags-grid .tag-actions {
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: stretch;
- gap: 8px;
-}
-
-.legacy .tags-grid .tag-actions-row {
- width: 100%;
- display: grid;
- grid-template-columns: 2fr 1fr;
- align-items: center;
- gap: 8px;
-}
-
-.legacy .tags-grid .tag-actions-row .alias-input,
-.legacy .tags-grid .tag-actions-row .p-dropdown {
- width: 100%;
- min-width: 0;
-}
-
-.legacy .tags-grid .tag-actions-row .icon-button {
- width: 100%;
-}
-
-.upload-item-meta {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- gap: 8px;
- width: 100%;
-}
-
-.upload-item-name {
- text-align: left;
- justify-self: start;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 100%;
-}
-
-.upload-item-size {
- text-align: right;
- white-space: nowrap;
-}
-
-.upload-item-footer {
- width: 100%;
- display: flex;
- justify-content: flex-end;
- gap: 12px;
- padding-top: 8px;
- color: rgba(230, 237, 246, 0.7);
- font-size: 12px;
-}
-
-.upload-item-total {
- text-align: right;
-}
-
-.upload-item-count {
- text-align: right;
-}
-
-@media (max-width: 600px) {
- .legacy .tags-grid .tag-row {
- grid-template-columns: 1fr;
- }
-
- .legacy .boxes-grid .card {
- flex-direction: column;
- align-items: stretch;
- }
-
- .legacy .tags-grid .tag-actions-row {
- grid-template-columns: 2fr 1fr;
- }
-
- .legacy .boxes-grid .box-actions-row {
- grid-template-columns: 2fr 1fr 1fr;
- }
-}
-
-.legacy .box-tag-row {
- display: flex;
- justify-content: space-between;
- gap: 16px;
- padding: 12px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 12px;
- margin-top: 12px;
- background: rgba(10, 17, 26, 0.7);
-}
-
-.legacy .box-tag-actions {
- display: flex;
- align-items: flex-start;
-}
-
-.legacy .file-list {
- margin: 8px 0 0;
- padding-left: 18px;
- color: rgba(230, 237, 246, 0.7);
- font-size: 13px;
-}
-
-.legacy .file-list li {
- margin-bottom: 4px;
-}
-
-.legacy .tag-info {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.legacy .box-title {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.legacy .alias-input:not(.p-inputtext),
-.legacy input:not(.p-inputtext),
-.modal-card input:not(.p-inputtext) {
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- padding: 6px 8px;
- font-size: 12px;
- min-width: 140px;
- background: rgba(255, 255, 255, 0.04);
- color: #e6edf6;
-}
-
-.legacy select,
-.modal-card select {
- border-radius: 8px;
- border: 1px solid rgba(255, 255, 255, 0.12);
- padding: 8px 10px;
- min-width: 180px;
- background: rgba(255, 255, 255, 0.04);
- color: #e6edf6;
-}
-
-.legacy .tag-matrix {
- display: grid;
- gap: 8px;
- overflow-x: auto;
-}
-
-.legacy .matrix-row {
- display: grid;
- grid-auto-flow: column;
- grid-auto-columns: minmax(120px, 1fr);
- gap: 8px;
- align-items: center;
-}
-
-.legacy .matrix-row.header {
- font-weight: 600;
- color: rgba(230, 237, 246, 0.8);
-}
-
-.legacy .matrix-cell {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.03);
- font-size: 12px;
-}
-
-.legacy .matrix-cell.toggle {
- justify-content: center;
- cursor: pointer;
- width: 100%;
- border: 1px solid rgba(255, 255, 255, 0.12);
- background: rgba(255, 255, 255, 0.02);
- transition: background 0.2s ease, border-color 0.2s ease;
-}
-
-.legacy .matrix-cell.toggle.is-blocked {
- border-color: rgba(239, 68, 68, 0.45);
- background: rgba(239, 68, 68, 0.08);
-}
-
-.legacy .matrix-cell.toggle.p-message {
- width: 100%;
- justify-content: center;
- background: transparent;
- border: 0;
- padding: 0;
-}
-
-.legacy .matrix-cell.toggle.p-message .p-message-text {
- justify-content: center;
- width: 100%;
-}
-
-.legacy .matrix-cell.label {
- font-weight: 600;
- background: rgba(10, 17, 26, 0.8);
-}
-
-.legacy .meta {
- font-size: 12px;
- color: rgba(230, 237, 246, 0.55);
-}
-
-.legacy .pill {
- background: rgba(25, 164, 161, 0.2);
- color: #8fe3df;
- padding: 4px 10px;
- border-radius: 999px;
- font-size: 12px;
-}
-
-.legacy .controls {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-bottom: 12px;
-}
-
-.legacy .controls-inline {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
-}
-
-.legacy button:not(.p-button),
-.modal-card button:not(.p-button) {
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: #1b2736;
- color: #e6edf6;
- padding: 8px 12px;
- border-radius: 8px;
- cursor: pointer;
- font-size: 14px;
-}
-
-.legacy button:not(.p-button):hover,
-.modal-card button:not(.p-button):hover {
- background: #233144;
-}
-
-.legacy .button-ghost,
-.modal-card .button-ghost {
- background: transparent;
- color: rgba(230, 237, 246, 0.9);
- border: 1px solid rgba(255, 255, 255, 0.2);
-}
-
-.legacy .button-ghost:hover,
-.modal-card .button-ghost:hover {
- background: rgba(255, 255, 255, 0.06);
-}
-
-.icon-button {
- border: 1px solid rgba(255, 255, 255, 0.12);
- background: rgba(10, 17, 26, 0.7);
- color: #e6edf6;
- padding: 6px 8px;
- border-radius: 8px;
- cursor: pointer;
- font-size: 14px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
-}
-
-.icon-button:hover {
- background: rgba(255, 255, 255, 0.08);
-}
-
-.icon-button.danger {
- border-color: rgba(239, 68, 68, 0.55);
- color: rgba(248, 113, 113, 0.95);
- background: rgba(239, 68, 68, 0.12);
-}
-
-.icon-button.danger:hover {
- background: rgba(239, 68, 68, 0.18);
-}
-
-.icon-button.upload {
- position: relative;
- overflow: hidden;
-}
-
-.icon-button:disabled {
- cursor: not-allowed;
- opacity: 0.4;
-}
-
-.modal-card .danger {
- border: 1px solid rgba(239, 68, 68, 0.55);
- background: rgba(239, 68, 68, 0.12);
- color: rgba(248, 113, 113, 0.95);
-}
-
-.modal-card .danger:hover {
- background: rgba(239, 68, 68, 0.18);
-}
-
-.icon-button.upload input {
- position: absolute;
- inset: 0;
- opacity: 0;
- cursor: pointer;
-}
-
-
-.panel-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 12px;
-}
-
-.explorer {
- display: grid;
- grid-template-columns: 260px 1fr;
- grid-template-rows: 1fr auto;
- gap: 0;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 12px;
- overflow-x: auto;
- overflow-y: hidden;
- background: rgba(8, 12, 18, 0.7);
- min-height: 200px;
- align-items: stretch;
- font-size: 13px;
-}
-
-.explorer-sidebar {
- grid-row: 1;
- padding: 12px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- background: rgba(10, 17, 26, 0.85);
- height: 100%;
-}
-
-.sidebar-toolbar {
- display: flex;
- align-items: center;
- justify-content: flex-start;
- border: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.03);
- padding: 0 10px;
- min-height: 40px;
- gap: 10px;
-}
-
-.sidebar-tree {
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 10px;
- background: rgba(10, 17, 26, 0.8);
- padding: 8px;
- overflow: auto;
-}
-
-.sidebar-search {
- flex: 1;
- width: 100%;
- border: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.04);
- border-radius: 8px;
- padding: 0 10px;
- font-size: 13px;
- color: #e6edf6;
- min-width: 0;
- height: 28px;
- line-height: 28px;
-}
-
-.sidebar-search:focus {
- outline: none;
- border-color: rgba(25, 164, 161, 0.6);
- box-shadow: 0 0 0 2px rgba(25, 164, 161, 0.2);
-}
-
-.explorer-toolbar,
-.sidebar-toolbar {
- height: 40px;
- min-height: 40px;
- box-shadow: 0 1px 2px rgba(16, 18, 22, 0.2);
- align-items: center;
-}
-
-.explorer-actions.footer {
- padding-top: 6px;
-}
-
-.explorer-main {
- grid-row: 1;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 12px 14px 14px;
- background: rgba(10, 17, 26, 0.7);
- height: 100%;
-}
-
-.explorer-toolbar {
- display: flex;
- align-items: center;
- gap: 10px;
- flex-wrap: wrap;
- padding: 0 10px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.03);
-}
-
-.explorer-toolbar .icon-button,
-.explorer-toolbar label.icon-button {
- width: 30px;
- height: 30px;
- padding: 0;
-}
-
-.explorer-toolbar .icon-button svg,
-.explorer-toolbar label.icon-button svg {
- display: block;
-}
-
-.explorer-path {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- font-size: 13px;
- color: rgba(230, 237, 246, 0.7);
- align-items: center;
- line-height: 1;
-}
-
-.breadcrumb-root {
- font-weight: 600;
- color: #e6edf6;
-}
-
-.path-link {
- background: transparent;
- border: none;
- color: #e6edf6;
- cursor: pointer;
- padding: 0;
- font-size: 13px;
- font-weight: 600;
-}
-
-.path-link:hover,
-.path-link:focus {
- background: transparent;
- color: #e6edf6;
- outline: none;
-}
-
-.toolbar-actions {
- margin-left: auto;
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.explorer-list {
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 10px;
- background: rgba(10, 17, 26, 0.8);
- overflow: hidden;
-}
-
-.explorer-row {
- display: grid;
- grid-template-columns: 2fr repeat(2, minmax(80px, 1fr));
- gap: 12px;
- align-items: center;
- padding: 10px 12px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
- font-size: 13px;
-}
-
-.explorer-row.has-meta,
-.explorer-list.has-meta .explorer-row {
- grid-template-columns: 2fr repeat(4, minmax(100px, 1fr));
-}
-
-.explorer-row.header {
- font-size: 13px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: rgba(230, 237, 246, 0.5);
- background: rgba(255, 255, 255, 0.02);
-}
-
-.explorer-row.footer {
- background: rgba(255, 255, 255, 0.02);
-}
-
-.explorer-row.selected {
- background: rgba(25, 164, 161, 0.15);
-}
-
-.row-name {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.row-icon {
- display: inline-flex;
- align-items: center;
-}
-
-.footer-count {
- font-weight: 600;
- color: rgba(230, 237, 246, 0.9);
-}
-
-.explorer-footer {
- grid-column: 1 / -1;
- padding: 8px 12px;
- font-size: 13px;
- color: rgba(230, 237, 246, 0.6);
- border-top: 1px solid rgba(255, 255, 255, 0.06);
- background: rgba(10, 17, 26, 0.8);
-}
-
-.tree-node {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.tree-label {
- display: inline-block;
- user-select: none;
- cursor: pointer;
-}
-
-.depth-1 .tree-row {
- margin-left: 16px;
-}
-
-.depth-2 .tree-row {
- margin-left: 32px;
-}
-
-.depth-3 .tree-row {
- margin-left: 48px;
-}
-
-.depth-4 .tree-row {
- margin-left: 64px;
-}
-
-.depth-5 .tree-row {
- margin-left: 80px;
-}
-
-.tree-row {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 8px;
- border-radius: 8px;
- cursor: pointer;
- color: rgba(230, 237, 246, 0.75);
-}
-
-.tree-row.active {
- background: rgba(25, 164, 161, 0.18);
- color: #e6edf6;
-}
-
-.tree-caret {
- border: none;
- background: transparent;
- color: rgba(230, 237, 246, 0.7);
- padding: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 18px;
- flex: 0 0 18px;
- cursor: pointer;
-}
-
-.tree-caret.disabled {
- width: 18px;
- flex: 0 0 18px;
-}
-
-.tree-icon.folder {
- color: rgba(25, 164, 161, 0.9);
-}
-
-.tree-label {
- flex: 1;
-}
-
-.tree-tag-count {
- background: rgba(25, 164, 161, 0.2);
- padding: 2px 6px;
- border-radius: 999px;
- font-size: 11px;
-}
-
-.upload-status {
- display: grid;
- gap: 8px;
- padding: 10px 12px;
- margin-bottom: 12px;
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.04);
-}
-
-.upload-progress {
- width: 100%;
- height: 6px;
- background: rgba(255, 255, 255, 0.1);
- border-radius: 999px;
- overflow: hidden;
-}
-
-.upload-progress div {
- height: 100%;
- background: linear-gradient(120deg, #19a4a1, #11709c);
-}
-
-
-.modal-backdrop {
- position: fixed;
- inset: 0;
- background: rgba(5, 8, 12, 0.7);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 90;
- backdrop-filter: blur(6px);
-}
-
-.modal-card {
- background: #0f1622;
- color: #e6edf6;
- border-radius: 14px;
- padding: 20px;
- width: min(520px, 92vw);
- border: 1px solid rgba(255, 255, 255, 0.08);
- display: grid;
- gap: 12px;
- position: relative;
- z-index: 91;
- box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
- max-height: 90vh;
- overflow: auto;
- visibility: visible;
- opacity: 1;
-}
-
-.modal-card.modal-upload {
- width: min(1040px, 96vw);
-}
-
-.modal-card.modal-tag-delete {
- width: min(760px, 96vw);
- overflow: visible;
-}
-
-
-.modal-actions {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- margin-top: 6px;
- flex-wrap: nowrap;
-}
-
-.modal-header h3 {
- margin: 0;
-}
-
-.modal-body {
- display: grid;
- gap: 10px;
-}
-
-.modal-label {
- font-size: 12px;
- color: rgba(230, 237, 246, 0.6);
-}
-
-@media (max-width: 960px) {
- .explorer {
- grid-template-columns: 1fr;
- }
-
- .explorer-sidebar {
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
- }
-}
-
-.player-shell {
- display: grid;
- gap: 16px;
-}
-
-.player-header {
- display: grid;
- grid-template-columns: auto 1fr auto;
- gap: 16px;
- align-items: center;
- padding: 14px;
- border-radius: 16px;
- background: rgba(255, 255, 255, 0.04);
-}
-
-.player-cover {
- width: 86px;
- height: 86px;
- border-radius: 16px;
- background: linear-gradient(135deg, #ff7a18, #af002d 60%, #319197);
- box-shadow: 0 10px 20px rgba(5, 8, 12, 0.4);
-}
-
-.player-meta h3 {
- margin: 0 0 6px;
- font-size: 20px;
-}
-
-.player-meta p {
- margin: 0 0 6px;
- color: rgba(230, 237, 246, 0.6);
- font-size: 12px;
-}
-
-.player-sub {
- display: flex;
- gap: 12px;
- font-size: 12px;
- color: rgba(230, 237, 246, 0.6);
-}
-
-.player-controls {
- display: grid;
- grid-auto-flow: column;
- gap: 8px;
- align-items: center;
- justify-content: end;
- background: rgba(255, 255, 255, 0.06);
- padding: 10px;
- border-radius: 14px;
-}
-
-.player-btn {
- border: none;
- background: transparent;
- color: rgba(230, 237, 246, 0.8);
- width: 36px;
- height: 36px;
- border-radius: 50%;
- display: grid;
- place-items: center;
- cursor: pointer;
-}
-
-.player-btn.primary {
- background: rgba(230, 237, 246, 0.12);
- color: #e6edf6;
- width: 42px;
- height: 42px;
-}
-
-.player-btn:hover {
- background: rgba(255, 255, 255, 0.12);
-}
-
-.volume-wrap {
- position: relative;
-}
-
-.volume-popover {
- position: absolute;
- right: 0;
- bottom: 46px;
- background: rgba(10, 17, 26, 0.95);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 12px;
- padding: 10px 12px;
- display: grid;
- gap: 8px;
- align-items: center;
- justify-items: center;
- z-index: 5;
-}
-
-.volume-label {
- font-size: 11px;
- color: rgba(230, 237, 246, 0.7);
- min-width: 2ch;
- text-align: center;
-}
-
-.player-progress {
- display: grid;
- grid-template-columns: auto 1fr auto;
- gap: 12px;
- align-items: center;
- font-size: 12px;
- color: rgba(230, 237, 246, 0.6);
-}
-
-.player-nfc {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.player-nfc input {
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- padding: 8px 10px;
- background: rgba(255, 255, 255, 0.04);
- color: #e6edf6;
-}
-
-.player-nfc button {
- border: 1px solid rgba(255, 255, 255, 0.12);
- background: rgba(10, 17, 26, 0.7);
- color: #e6edf6;
- padding: 8px 12px;
- border-radius: 8px;
- cursor: pointer;
-}
-
-.player-list {
- border-radius: 16px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- overflow: hidden;
-}
-
-.player-row {
- display: grid;
- grid-template-columns: 40px 2fr 1fr 80px;
- gap: 12px;
- align-items: center;
- padding: 10px 12px;
- font-size: 13px;
- background: rgba(10, 17, 26, 0.65);
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
- cursor: pointer;
- user-select: none;
-}
-
-.player-row.header {
- text-transform: uppercase;
- letter-spacing: 0.08em;
- font-size: 11px;
- color: rgba(230, 237, 246, 0.5);
- background: rgba(255, 255, 255, 0.03);
-}
-
-.player-row.empty {
- color: rgba(230, 237, 246, 0.6);
-}
-
-.player-row.active {
- background: rgb(45 212 191 / 12%);
- color: #f6d08c;
-}
-
-.player-row.active span {
- color: inherit;
-}
-
-@media (max-width: 900px) {
- .player-header {
- grid-template-columns: 1fr;
- justify-items: start;
- }
-
- .player-controls {
- width: 100%;
- justify-content: start;
- }
-}
-.modal-long-button .p-button-label {
- white-space: nowrap;
-}