3382 lines
117 KiB
React
3382 lines
117 KiB
React
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 (
|
||
<div className="section-header">
|
||
<div>
|
||
<h1>{title}</h1>
|
||
<p>{subtitle}</p>
|
||
</div>
|
||
<div className="section-actions">{actions}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatGrid({ items }) {
|
||
return (
|
||
<div className="stat-grid">
|
||
{items.map((card) => (
|
||
<Card key={card.label} className="stat-card">
|
||
<p className="stat-label">{card.label}</p>
|
||
<div className="stat-value">{card.value}</div>
|
||
<span className="stat-helper">{card.helper}</span>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={className} style={{ backgroundColor: 'transparent', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
{chooseButton}
|
||
{cancelButton}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const fileItemTemplate = (file, props) => (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', width: '100%', padding: '0.5rem 0', borderBottom: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||
<Button
|
||
icon="pi pi-times"
|
||
severity="danger"
|
||
text
|
||
rounded
|
||
aria-label="Entfernen"
|
||
onClick={() => {
|
||
setUploadSize((prev) => Math.max(0, prev - file.size));
|
||
setPendingUploadFiles((prev) =>
|
||
prev.filter((entry) => entry.name !== file.name)
|
||
);
|
||
props.onRemove(file);
|
||
}}
|
||
/>
|
||
<div className="upload-item-meta">
|
||
<strong className="upload-item-name">{file.name}</strong>
|
||
<small className="upload-item-size">{formatSize(file.size)}</small>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const fileEmptyTemplate = () => (
|
||
<div className="upload-item-footer">
|
||
<span className="upload-item-total">Gesamt: {formatSize(uploadSize)}</span>
|
||
</div>
|
||
);
|
||
|
||
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 (
|
||
<div key={node.path || 'root'} className={`tree-node depth-${depth}`}>
|
||
<div
|
||
className={`tree-row folder ${isActive ? 'active' : ''}`}
|
||
onClick={() => handleOpen(node)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') handleOpen(node);
|
||
}}
|
||
>
|
||
{hasChildren ? (
|
||
<div
|
||
className="tree-caret"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
toggleFolder(node.path || '');
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.stopPropagation();
|
||
toggleFolder(node.path || '');
|
||
}
|
||
}}
|
||
aria-label="Toggle"
|
||
>
|
||
{isExpanded ? <CaretDown size={14} /> : <CaretRight size={14} />}
|
||
</div>
|
||
) : (
|
||
<span className="tree-caret disabled" aria-hidden="true" />
|
||
)}
|
||
<span className="tree-icon folder">
|
||
<Folder size={16} weight="fill" />
|
||
</span>
|
||
<span className="tree-label">{node.name}</span>
|
||
{tagCount > 0 && (
|
||
<span className="tree-tag-count" aria-label={`Zugewiesene Tags: ${tagCount}`}>
|
||
{tagCount}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{isExpanded &&
|
||
hasChildren &&
|
||
childFolders.map((child) => renderFolderTree(child, depth + 1))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="section-stack">
|
||
<StatGrid items={dashboardStats} />
|
||
<div className="split-grid">
|
||
<Card className="wide-card">
|
||
<h3>Live-Status</h3>
|
||
{!selectedId && <p className="muted">Waehle eine gepairte Box aus.</p>}
|
||
{selectedId && !status && <p className="muted">Status wird geladen...</p>}
|
||
{status && status.error && <p className="error">{status.error}</p>}
|
||
{status && !status.error && (
|
||
<pre className="status">{JSON.stringify(statusWithHardware, null, 2)}</pre>
|
||
)}
|
||
</Card>
|
||
<Card className="wide-card">
|
||
<div className="player-shell">
|
||
<div className="player-header">
|
||
<div className="player-cover" />
|
||
<div className="player-meta">
|
||
<h3>{currentTitle || playlistTitle || 'Playlist'}</h3>
|
||
<p>{currentArtist || playlistRoot || 'Kein Medium zugeordnet'}</p>
|
||
<div className="player-sub">
|
||
<span>{playlistItems.length} Tracks</span>
|
||
<span>{formatClock(playlistDuration)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="player-controls">
|
||
<Button
|
||
className="player-btn"
|
||
icon="pi pi-step-backward"
|
||
onClick={() => handlePlayerCommand('prev')}
|
||
aria-label="Zurueck"
|
||
/>
|
||
<Button
|
||
className="player-btn primary"
|
||
icon={isPlaying ? 'pi pi-pause' : 'pi pi-play'}
|
||
onClick={() => handlePlayerCommand('play_pause')}
|
||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||
/>
|
||
<Button
|
||
className="player-btn"
|
||
icon="pi pi-step-forward"
|
||
onClick={() => handlePlayerCommand('next')}
|
||
aria-label="Weiter"
|
||
/>
|
||
<Button
|
||
className="player-btn"
|
||
icon="pi pi-stop"
|
||
onClick={() => handlePlayerCommand('stop')}
|
||
aria-label="Stopp"
|
||
/>
|
||
<div className="volume-wrap" ref={volumeRef}>
|
||
<Button
|
||
className="player-btn"
|
||
icon="pi pi-volume-up"
|
||
onClick={() => setShowVolume((prev) => !prev)}
|
||
aria-label="Lautstaerke"
|
||
/>
|
||
{showVolume && (
|
||
<div className="volume-popover">
|
||
<Slider
|
||
orientation="vertical"
|
||
min={0}
|
||
max={100}
|
||
step={1}
|
||
value={volumeDraft}
|
||
onChange={(event) => {
|
||
setVolumeDraft(event.value ?? 0);
|
||
}}
|
||
onSlideEnd={(event) => {
|
||
handleVolumeCommit(event.value ?? 0);
|
||
}}
|
||
/>
|
||
<span className="volume-label">{volumeDraft}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="player-progress">
|
||
<span>{formatClock(playbackPosition)}</span>
|
||
<Slider
|
||
value={seekPercent}
|
||
min={0}
|
||
max={100}
|
||
step={1}
|
||
onChange={(event) => {
|
||
setSeekPercent(event.value ?? 0);
|
||
setIsSeeking(true);
|
||
}}
|
||
onSlideEnd={(event) => {
|
||
setIsSeeking(false);
|
||
handleSeekCommit(event.value ?? 0);
|
||
}}
|
||
/>
|
||
<span>{formatClock(playbackDuration)}</span>
|
||
</div>
|
||
<div className="player-nfc">
|
||
<InputText
|
||
value={nfcUid}
|
||
onChange={(event) => setNfcUid(event.target.value)}
|
||
placeholder="UID_1"
|
||
className="p-inputtext-sm"
|
||
/>
|
||
<Button label="NFC on" onClick={() => handlePlayerCommand('nfc_on', { uid: nfcUid })} />
|
||
<Button label="NFC off" onClick={() => handlePlayerCommand('nfc_off', { uid: nfcUid })} />
|
||
</div>
|
||
<div className="player-list">
|
||
<div className="player-row header">
|
||
<span>#</span>
|
||
<span>Titel</span>
|
||
<span>Interpret</span>
|
||
<span>Dauer</span>
|
||
</div>
|
||
{playlistItems.length === 0 && (
|
||
<div className="player-row empty">
|
||
<span />
|
||
<span>Keine Playlist gefunden.</span>
|
||
<span />
|
||
<span />
|
||
</div>
|
||
)}
|
||
{playlistItems.map((track, index) => (
|
||
<div
|
||
key={track.path || track.name || index}
|
||
className={`player-row${currentIndex === index ? ' active' : ''}`}
|
||
onDoubleClick={(event) => {
|
||
event.preventDefault();
|
||
handlePlayerCommand('jump_to_index', { index });
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
handlePlayerCommand('jump_to_index', { index });
|
||
}
|
||
}}
|
||
>
|
||
<span>{index + 1}</span>
|
||
<span>{track.title || track.name}</span>
|
||
<span>{track.artist || '-'}</span>
|
||
<span>{formatClock(track.duration)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="muted">
|
||
Steuerung ist nur moeglich, wenn die Box gepairt ist.
|
||
</p>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeSection === 'boxes') {
|
||
return (
|
||
<div className="section-stack legacy">
|
||
{unpaired.length > 0 && (
|
||
<Card className="panel-card">
|
||
<h3>Neue Boxen</h3>
|
||
{unpaired.map((box) => (
|
||
<div key={box.box_id} className="card">
|
||
<div>
|
||
<strong>{box.alias || box.box_id}</strong>
|
||
<div className="meta">Zuletzt gesehen: {formatTime(box.last_seen)}</div>
|
||
<div className="meta">Firmware: {box.firmware_version}</div>
|
||
<div className="meta">ID: {box.box_id}</div>
|
||
</div>
|
||
<Button label="Pairen" onClick={() => handlePair(box.box_id)} />
|
||
</div>
|
||
))}
|
||
</Card>
|
||
)}
|
||
<Card className="panel-card">
|
||
<h3>Gepairte Boxen</h3>
|
||
{paired.length === 0 && <p className="muted">Noch keine gepairten Boxen.</p>}
|
||
{paired.length > 0 && (
|
||
<div className="boxes-grid">
|
||
{paired.map((box) => (
|
||
<div
|
||
key={box.box_id}
|
||
className={`card ${selectedId === box.box_id ? 'selected' : ''}`}
|
||
onClick={() => setSelectedId(box.box_id)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') setSelectedId(box.box_id);
|
||
}}
|
||
>
|
||
<div>
|
||
<div className="box-title">
|
||
<strong>{box.alias || box.box_id}</strong>
|
||
<span className="pill">{getAvailability(box.last_seen).label}</span>
|
||
</div>
|
||
<div className="meta">Zuletzt gesehen: {formatTime(box.last_seen)}</div>
|
||
<div className="meta">Capabilities: {parseCapabilities(box.capabilities_json)}</div>
|
||
<div className="meta">ID: {box.box_id}</div>
|
||
</div>
|
||
<div className="stack box-actions">
|
||
<div className="box-actions-row">
|
||
<InputText
|
||
className="alias-input p-inputtext-sm"
|
||
placeholder="Alias"
|
||
value={
|
||
boxAliasDrafts[box.box_id] !== undefined
|
||
? boxAliasDrafts[box.box_id]
|
||
: box.alias || ''
|
||
}
|
||
onClick={(event) => event.stopPropagation()}
|
||
onChange={(event) => {
|
||
event.stopPropagation();
|
||
setBoxAliasDrafts((prev) => ({
|
||
...prev,
|
||
[box.box_id]: event.target.value,
|
||
}));
|
||
}}
|
||
/>
|
||
<Button
|
||
className="icon-button"
|
||
icon={<FloppyDisk size={16} />}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
handleSaveBoxAlias(box.box_id);
|
||
}}
|
||
title="Alias speichern"
|
||
aria-label="Alias speichern"
|
||
/>
|
||
<Button
|
||
className="icon-button danger"
|
||
icon={<Trash size={16} />}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setBoxDeleteTarget(box.box_id);
|
||
}}
|
||
title="Unpair"
|
||
aria-label="Unpair"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeSection === 'media') {
|
||
return (
|
||
<div className="section-stack legacy">
|
||
<Card className="panel-card">
|
||
<div className="panel-header">
|
||
<h3>Medien Explorer</h3>
|
||
<Button className="button-ghost" label="Refresh" onClick={handleMediaRefresh} />
|
||
</div>
|
||
{uploadInProgress && (
|
||
<div className="upload-status">
|
||
<span>{activeUploadLabel || 'Upload laeuft...'}</span>
|
||
<div className="upload-progress">
|
||
<div style={{ width: `${uploadProgress}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<p className="muted">Ordnerverwaltung, Upload und Datei-Listen im Server-Medienordner.</p>
|
||
{mediaError && <p className="error">{mediaError}</p>}
|
||
{!mediaError && !mediaTree && <p className="muted">Medienliste wird geladen...</p>}
|
||
{!mediaError && mediaTree && (
|
||
<div className="explorer" ref={explorerRef}>
|
||
<div className="explorer-sidebar">
|
||
<div className="explorer-toolbar sidebar-toolbar">
|
||
<InputText
|
||
className="sidebar-search p-inputtext-sm"
|
||
type="search"
|
||
placeholder="Suchen..."
|
||
value={sidebarQuery}
|
||
onChange={(event) => setSidebarQuery(event.target.value)}
|
||
aria-label="Ordner suchen"
|
||
/>
|
||
</div>
|
||
<div className="sidebar-tree">
|
||
{topLevelFolders.length === 0 && (
|
||
<div className="muted">Keine Ordner gefunden.</div>
|
||
)}
|
||
{filteredTree && renderFolderTree(filteredTree, 0)}
|
||
</div>
|
||
</div>
|
||
<div className="explorer-main">
|
||
<div className="explorer-toolbar">
|
||
<Button
|
||
className="icon-button"
|
||
icon={<ArrowUp size={16} />}
|
||
onClick={handleGoUp}
|
||
title="Hoch"
|
||
aria-label="Hoch"
|
||
/>
|
||
<div className="explorer-path">
|
||
<span
|
||
className="breadcrumb-root path-link"
|
||
onClick={() => setCurrentPath('')}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') setCurrentPath('');
|
||
}}
|
||
>
|
||
media
|
||
</span>
|
||
{buildBreadcrumb(currentPath).map((crumb) => (
|
||
<span
|
||
key={crumb.path}
|
||
className="path-link"
|
||
onClick={() => setCurrentPath(crumb.path)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') setCurrentPath(crumb.path);
|
||
}}
|
||
>
|
||
/ {crumb.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="toolbar-actions">
|
||
<Button
|
||
className="icon-button"
|
||
icon={<Plus size={16} />}
|
||
onClick={() => setActiveModal('new-folder')}
|
||
title="Neuer Ordner"
|
||
aria-label="Neuer Ordner"
|
||
/>
|
||
<Button
|
||
className="icon-button"
|
||
icon={<PencilSimple size={16} />}
|
||
onClick={() => setActiveModal('rename')}
|
||
title="Umbenennen"
|
||
disabled={selectedPaths.length !== 1}
|
||
aria-label="Umbenennen"
|
||
/>
|
||
<Button
|
||
className="icon-button danger"
|
||
icon={<Trash size={16} />}
|
||
onClick={() => setActiveModal('delete')}
|
||
title="Loeschen"
|
||
disabled={selectedPaths.length === 0}
|
||
aria-label="Loeschen"
|
||
/>
|
||
<Button
|
||
className="icon-button"
|
||
icon={<ArrowRight size={16} />}
|
||
onClick={() => {
|
||
setMoveTarget('');
|
||
setActiveModal('move');
|
||
}}
|
||
title="Verschieben"
|
||
disabled={selectedPaths.length === 0}
|
||
aria-label="Verschieben"
|
||
/>
|
||
<Button
|
||
className="icon-button upload"
|
||
icon={<UploadSimple size={16} />}
|
||
title="Upload"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setUploadAfterCreate(true);
|
||
setPendingUploadFiles([]);
|
||
setNewFolderName('');
|
||
setActiveModal('new-folder');
|
||
}}
|
||
aria-label="Upload"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className={`explorer-list ${showMeta ? 'has-meta' : ''}`}>
|
||
<div className="explorer-row header">
|
||
<span>Name</span>
|
||
{showMeta ? (
|
||
<>
|
||
<span>Interpret</span>
|
||
<span>Titel</span>
|
||
<span>Laenge</span>
|
||
<span>Groesse</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span>Typ</span>
|
||
<span>Groesse</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
{currentItems.map((item) => (
|
||
<div
|
||
key={item.path || item.name}
|
||
className={`explorer-row ${
|
||
isSelected(item.path) ? 'selected' : ''
|
||
}`}
|
||
onMouseDown={(event) => {
|
||
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);
|
||
}}
|
||
>
|
||
<span className="row-name">
|
||
<span className="row-icon">
|
||
{item.type === 'folder' ? (
|
||
<Folder size={16} weight="fill" />
|
||
) : (
|
||
<FileAudio size={16} />
|
||
)}
|
||
</span>
|
||
{item.name}
|
||
</span>
|
||
{showMeta ? (
|
||
<>
|
||
<span>{item.type === 'folder' ? '-' : item.artist || '-'}</span>
|
||
<span>{item.type === 'folder' ? '-' : item.title || '-'}</span>
|
||
<span>
|
||
{item.type === 'folder' ? '-' : formatDuration(item.duration)}
|
||
</span>
|
||
<span>{formatSize(item.size)}</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span>{item.type === 'folder' ? 'Ordner' : 'Datei'}</span>
|
||
<span>{formatSize(item.size)}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
{selectedPaths.length > 0 && (
|
||
<div className="explorer-row footer">
|
||
<span />
|
||
{showMeta ? (
|
||
<>
|
||
<span />
|
||
<span />
|
||
<span />
|
||
<span className="footer-count">
|
||
{selectedPaths.length} ausgewaehlt
|
||
</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span />
|
||
<span className="footer-count">
|
||
{selectedPaths.length} ausgewaehlt
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="explorer-footer">
|
||
<span>
|
||
Medienordner: {formatSize(mediaBytes)} · Verfuegbar:{' '}
|
||
{formatSize(freeBytes)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeSection === 'tags') {
|
||
return (
|
||
<div className="section-stack legacy">
|
||
{(localBoxError || localBoxTags.length > 0) && (
|
||
<Card className="panel-card">
|
||
<div className="panel-header">
|
||
<h3>Tags nur auf dieser Box</h3>
|
||
</div>
|
||
{localBoxError && <p className="error">{localBoxError}</p>}
|
||
{localBoxTags.map((tag) => (
|
||
<div key={tag.uid} className="box-tag-row">
|
||
<div>
|
||
<strong>{tag.uid}</strong>
|
||
<div className="muted">
|
||
Dateien: {tag.file_count} · {formatSize(tag.total_size)}
|
||
</div>
|
||
{tag.media_exists && tag.files?.length > 0 && (
|
||
<ul className="file-list">
|
||
{tag.files.map((file) => (
|
||
<li key={file}>{file}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{!tag.media_exists && (
|
||
<p className="muted">Keine Dateien im Box-Ordner gefunden.</p>
|
||
)}
|
||
</div>
|
||
<div className="box-tag-actions">
|
||
<Button
|
||
label="Auf Server uebertragen"
|
||
onClick={() => {
|
||
setImportTargetUid(tag.uid);
|
||
setActiveModal('import-tag');
|
||
}}
|
||
disabled={!tag.media_exists}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</Card>
|
||
)}
|
||
|
||
<Card className="panel-card">
|
||
<h3>Tags (Datenbank)</h3>
|
||
{tags.length === 0 && <p className="muted">Keine Tags vorhanden.</p>}
|
||
{tags.length > 0 && (
|
||
<div className="tags-grid">
|
||
{tags.map((tag) => (
|
||
<div key={tag.uid} className="card compact">
|
||
<div className="tag-row">
|
||
<div className="tag-info">
|
||
<strong>
|
||
{tag.alias ? `${tag.alias} (${tag.uid})` : tag.uid}
|
||
</strong>
|
||
<div className="meta">Status: {tag.status}</div>
|
||
<div className="meta">Medium: {tag.media_path || '-'}</div>
|
||
{tag.label ? <div className="meta">Label: {tag.label}</div> : null}
|
||
</div>
|
||
<div className="stack stack-inline tag-actions">
|
||
<div className="tag-actions-row">
|
||
<InputText
|
||
className="alias-input p-inputtext-sm"
|
||
placeholder="Alias"
|
||
value={
|
||
tagAliasDrafts[tag.uid] !== undefined
|
||
? tagAliasDrafts[tag.uid]
|
||
: tag.alias || ''
|
||
}
|
||
onChange={(event) =>
|
||
setTagAliasDrafts((prev) => ({
|
||
...prev,
|
||
[tag.uid]: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
<Button
|
||
className="icon-button"
|
||
icon={<FloppyDisk size={16} />}
|
||
onClick={() => handleSaveTagAlias(tag.uid)}
|
||
title="Alias speichern"
|
||
aria-label="Alias speichern"
|
||
/>
|
||
</div>
|
||
<div className="tag-actions-row">
|
||
<Dropdown
|
||
value={dbTagMedia[tag.uid] ?? tag.media_path ?? null}
|
||
onChange={(event) =>
|
||
setDbTagMedia((prev) => ({
|
||
...prev,
|
||
[tag.uid]: event.value,
|
||
}))
|
||
}
|
||
options={collectTopLevelFolders(mediaTree).map((folderPath) => ({
|
||
label: folderPath,
|
||
value: folderPath,
|
||
}))}
|
||
placeholder="Medienordner waehlen"
|
||
filter
|
||
className="p-inputtext-sm"
|
||
/>
|
||
<Button
|
||
className="icon-button danger"
|
||
icon={<Trash size={16} />}
|
||
onClick={() => {
|
||
setTagDeleteTarget(tag.uid);
|
||
setActiveModal('tag-delete');
|
||
}}
|
||
title="Loeschen"
|
||
aria-label="Loeschen"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
<Card className="panel-card">
|
||
<h3>Tag-Matrix (Sperren)</h3>
|
||
{paired.length === 0 && (
|
||
<p className="muted">Keine gepairten Boxen vorhanden.</p>
|
||
)}
|
||
{tags.length === 0 && <p className="muted">Keine Tags vorhanden.</p>}
|
||
{paired.length > 0 && tags.length > 0 && (
|
||
<div className="tag-matrix">
|
||
<div className="matrix-row header">
|
||
<span className="matrix-cell label">Tag</span>
|
||
{paired.map((box) => (
|
||
<span key={box.box_id} className="matrix-cell">
|
||
{box.alias || box.box_id}
|
||
</span>
|
||
))}
|
||
</div>
|
||
{tags.map((tag) => (
|
||
<div key={tag.uid} className="matrix-row">
|
||
<span className="matrix-cell label">{tag.alias || tag.uid}</span>
|
||
{paired.map((box) => {
|
||
const blocked = (blockedByBox[box.box_id] || []).includes(tag.uid);
|
||
return (
|
||
<Message
|
||
key={box.box_id}
|
||
className={`matrix-cell toggle${blocked ? ' is-blocked' : ''}`}
|
||
severity={blocked ? 'error' : 'success'}
|
||
text={blocked ? 'Gesperrt' : 'OK'}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() =>
|
||
handleToggleTagBlock(box.box_id, tag.uid, !blocked)
|
||
}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
handleToggleTagBlock(box.box_id, tag.uid, !blocked);
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="section-stack">
|
||
<Card className="wide-card">
|
||
<h3>System</h3>
|
||
<p>Backend: {import.meta.env.VITE_BACKEND_URL || 'http://127.0.0.1:5001'}</p>
|
||
<div className="settings-row">
|
||
<span>Gepairte Boxen</span>
|
||
<span>{paired.length}</span>
|
||
</div>
|
||
<div className="settings-row">
|
||
<span>Tags</span>
|
||
<span>{tags.length}</span>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const systemPanels = (
|
||
<>
|
||
<Card className="panel-card">
|
||
<div className="card-title-row">
|
||
<h3>Live Status</h3>
|
||
<Tag value="Stable" severity="success" />
|
||
</div>
|
||
<p>System Health und wichtige Events.</p>
|
||
<div className="status-row">
|
||
<span>API</span>
|
||
<Tag value="OK" severity="success" />
|
||
</div>
|
||
<div className="status-row">
|
||
<span>Speicher</span>
|
||
<Tag value={mediaTree ? formatSize(mediaTree.free_bytes) : '-'} severity="warning" />
|
||
</div>
|
||
<div className="status-row">
|
||
<span>Letzter Sync</span>
|
||
<span>{status?.last_sync_at ? formatTime(status.last_sync_at) : '-'}</span>
|
||
</div>
|
||
<div className="status-row">
|
||
<span>Hardware-UID</span>
|
||
<span>
|
||
{lastHardwareUid.uid === status?.last_nfc?.uid && lastHardwareUid.hardwareUid
|
||
? lastHardwareUid.hardwareUid
|
||
: activeNfc?.hardwareUid || '-'}
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
<Card className="panel-card">
|
||
<h3>Speicherkapazitaet</h3>
|
||
<div className="capacity-row">
|
||
<div className="capacity-header">
|
||
<strong>System</strong>
|
||
<span>
|
||
{systemStorage
|
||
? `${formatSize(systemStorage.used)} / ${formatSize(systemStorage.total)}`
|
||
: '-'}
|
||
</span>
|
||
</div>
|
||
<MeterGroup
|
||
className="capacity-bar"
|
||
values={[{ label: '', value: systemStorage?.percent || 0 }]}
|
||
style={{ '--capacity-scale': systemStorage?.scale || 1 }}
|
||
/>
|
||
<div className="capacity-meta">
|
||
Frei: {systemStorage ? formatSize(systemStorage.free) : '-'}
|
||
</div>
|
||
</div>
|
||
<Divider />
|
||
{boxes.length === 0 && <p className="muted">Keine Boxen verbunden.</p>}
|
||
{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 (
|
||
<div key={box.box_id} className="capacity-row">
|
||
<div className="capacity-header">
|
||
<strong>{box.alias || box.box_id}</strong>
|
||
<span>
|
||
{totalValue !== null && usedValue !== null
|
||
? `${formatSize(usedValue)} / ${formatSize(totalValue)}`
|
||
: '-'}
|
||
</span>
|
||
</div>
|
||
<MeterGroup
|
||
className="capacity-bar"
|
||
values={[{ label: '', value: percent }]}
|
||
style={{ '--capacity-scale': scale }}
|
||
/>
|
||
<div className="capacity-meta">
|
||
{free !== null ? `Frei: ${formatSize(free)}` : 'Keine Kapazitaet gemeldet'}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</Card>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<header className="topbar">
|
||
<div className="brand">
|
||
<div className="brand-mark">K</div>
|
||
<div>
|
||
<strong>Klangkiste</strong>
|
||
<span>PrimeReact GUI</span>
|
||
</div>
|
||
</div>
|
||
{selectedId && (
|
||
<div className="header-box-indicator">
|
||
<i className="pi pi-check-circle" />
|
||
<span>{activeBoxLabel || selectedId}</span>
|
||
</div>
|
||
)}
|
||
<div className="topbar-actions">
|
||
<Button icon="pi pi-bell" text rounded aria-label="Benachrichtigungen" />
|
||
<Avatar label="MK" shape="circle" className="user-avatar" />
|
||
</div>
|
||
</header>
|
||
|
||
<Toast ref={toastRef} position="top-right" />
|
||
|
||
<div className="layout">
|
||
<aside className="sidebar">
|
||
<div className="sidebar-section">
|
||
<p className="sidebar-title">Navigation</p>
|
||
<div className="nav-list">
|
||
{NAV_ITEMS.map((item) => {
|
||
const Icon = item.icon;
|
||
const iconProp = typeof Icon === 'string' ? Icon : <Icon size={16} />;
|
||
return (
|
||
<Button
|
||
key={item.id}
|
||
label={item.label}
|
||
icon={iconProp}
|
||
text
|
||
className={`nav-button${activeSection === item.id ? ' active' : ''}`}
|
||
onClick={() => setActiveSection(item.id)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<Divider />
|
||
{/* <div className="sidebar-section">
|
||
<p className="sidebar-title">Quick Actions</p>
|
||
<div className="button-stack">
|
||
<Button label="NFC Tag erstellen" icon="pi pi-plus" className="p-button-sm" />
|
||
<Button label="Upload starten" icon="pi pi-upload" outlined className="p-button-sm" />
|
||
<Button label="Box koppeln" icon="pi pi-link" text className="p-button-sm" />
|
||
</div>
|
||
</div>
|
||
<Divider /> */}
|
||
<div className="sidebar-section">
|
||
<p className="sidebar-title">Aktive Sessions</p>
|
||
{paired.length === 0 && (
|
||
<p className="muted">Keine gepairten Boxen.</p>
|
||
)}
|
||
<div className="session-list">
|
||
{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 (
|
||
<Button
|
||
key={box.box_id}
|
||
className={`session-card${isActive ? ' active' : ''}`}
|
||
onClick={() => setSelectedId(box.box_id)}
|
||
title={`Aktiv setzen: ${box.alias || box.box_id}`}
|
||
>
|
||
<Avatar label={label || 'BX'} shape="circle" />
|
||
<div>
|
||
<strong>{box.alias || box.box_id}</strong>
|
||
{isActive && <span className="meta">Aktiv</span>}
|
||
</div>
|
||
<Tag value={availability.label} severity={availability.severity} rounded />
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="content">
|
||
<SectionHeader
|
||
title={activeMeta?.label || 'Bereich'}
|
||
subtitle="Live Daten und Steuerung der Klangkiste."
|
||
/>
|
||
{error && <div className="error">{error}</div>}
|
||
{activeNfc && dismissedNfcKey !== getNfcKey(activeNfc) && (
|
||
<div className="legacy">
|
||
<Card className="panel-card">
|
||
<h3>
|
||
{activeNfc.uid &&
|
||
tags.find((tag) => tag.uid === activeNfc.uid && !tag.media_path)
|
||
? 'Leerer Tag erkannt'
|
||
: 'Neuer Tag erkannt'}
|
||
</h3>
|
||
<p className="muted">
|
||
{activeNfc.uid ? (
|
||
<>
|
||
UID erkannt: <strong>{activeNfc.uid}</strong>
|
||
</>
|
||
) : (
|
||
<>keine UID erkannt</>
|
||
)}
|
||
</p>
|
||
{activeNfc.uid &&
|
||
lastHardwareUid.uid === activeNfc.uid &&
|
||
lastHardwareUid.hardwareUid && (
|
||
<p className="muted">
|
||
Hardware-UID erkannt: <strong>{lastHardwareUid.hardwareUid}</strong>
|
||
</p>
|
||
)}
|
||
{!activeNfc.uid && activeNfc.hardwareUid && (
|
||
<p className="muted">
|
||
Hardware-UID erkannt: <strong>{activeNfc.hardwareUid}</strong>
|
||
</p>
|
||
)}
|
||
{activeNfc.uid && tags.find((tag) => tag.uid === activeNfc.uid) && (
|
||
<p className="muted">
|
||
Dieser Tag ist bekannt, aber noch nicht zugewiesen.
|
||
</p>
|
||
)}
|
||
<div className="step-messages">
|
||
<Messages ref={tagUidMsgRef} />
|
||
<Messages ref={mediaMsgRef} />
|
||
</div>
|
||
<Stepper
|
||
linear={false}
|
||
ref={tagStepperRef}
|
||
onChangeStep={(event) => {
|
||
if (event.index <= tagStepMax) {
|
||
setTagStep(event.index);
|
||
return;
|
||
}
|
||
tagStepperRef.current?.setActiveStep(tagStep);
|
||
}}
|
||
>
|
||
<StepperPanel header="IDs">
|
||
<div className="controls">
|
||
<div className="step-row">
|
||
<InputText
|
||
value={scanTagUid}
|
||
onChange={(event) => setScanTagUid(event.target.value)}
|
||
readOnly={tagUidMode === 'keep'}
|
||
placeholder="Tag-ID (10 Zeichen)"
|
||
className="p-inputtext-sm"
|
||
/>
|
||
<Button
|
||
label="Neue UID vergeben"
|
||
size="small"
|
||
outlined={tagUidMode !== 'new'}
|
||
onClick={() => {
|
||
setTagUidMode('new');
|
||
setScanTagUid(generateTagId());
|
||
}}
|
||
/>
|
||
<span className="step-row-spacer" />
|
||
<Button
|
||
label="Weiter zu Medien"
|
||
size="small"
|
||
onClick={() => {
|
||
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);
|
||
}}
|
||
/>
|
||
</div>
|
||
<InputText
|
||
value={scanTagLabel}
|
||
onChange={(event) => setScanTagLabel(event.target.value)}
|
||
placeholder="Label (optional)"
|
||
className="p-inputtext-sm is-hidden"
|
||
/>
|
||
</div>
|
||
</StepperPanel>
|
||
<StepperPanel header="Medienzuordnung">
|
||
<div className="controls">
|
||
<div className="step-row">
|
||
<Dropdown
|
||
value={scanMediaPath}
|
||
onChange={(event) => setScanMediaPath(event.value)}
|
||
options={collectTopLevelFolders(mediaTree).map((folderPath) => ({
|
||
label: folderPath,
|
||
value: folderPath,
|
||
}))}
|
||
placeholder="Medienordner waehlen"
|
||
filter
|
||
className="p-inputtext-sm"
|
||
disabled={!tagStepOneDone}
|
||
/>
|
||
<span className="step-row-spacer" />
|
||
<Button
|
||
label="Weiter zur Anlage"
|
||
size="small"
|
||
disabled={!tagStepOneDone}
|
||
onClick={() => {
|
||
setTagStepTwoDone(true);
|
||
setTagStepMax(2);
|
||
setTagStep(2);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</StepperPanel>
|
||
<StepperPanel header="Anlage">
|
||
<div className="controls">
|
||
<div className="summary">
|
||
<p className="summary-title">Zusammenfassung</p>
|
||
<div className="summary-grid">
|
||
<span>System-UID</span>
|
||
<strong>{scanTagUid || '-'}</strong>
|
||
<span>UID-Modus</span>
|
||
<strong>{tagUidMode === 'keep' ? 'Beibehalten' : 'Neu vergeben'}</strong>
|
||
<span>Label</span>
|
||
<strong>{scanTagLabel?.trim() || '-'}</strong>
|
||
<span>Medienordner</span>
|
||
<strong>{scanMediaPath || 'Spaeter zuordnen'}</strong>
|
||
<span>Hardware-UID</span>
|
||
<strong>
|
||
{lastHardwareUid.uid === activeNfc?.uid && lastHardwareUid.hardwareUid
|
||
? lastHardwareUid.hardwareUid
|
||
: activeNfc?.hardwareUid || '-'}
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
<div className="step-actions">
|
||
<Button
|
||
label="Daten zum Tag speichern"
|
||
size="small"
|
||
disabled={!tagStepOneDone || !tagStepTwoDone}
|
||
onClick={handleClaimTagForScan}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</StepperPanel>
|
||
</Stepper>
|
||
{tags.some((tag) => tag.status === 'IMPORTED') && (
|
||
<div className="controls">
|
||
<select className="p-inputtext-sm"
|
||
value={reuseTagUid}
|
||
onChange={(event) => setReuseTagUid(event.target.value)}
|
||
>
|
||
<option value="" disabled>
|
||
Gespeicherte Tag-ID waehlen
|
||
</option>
|
||
{tags
|
||
.filter((tag) => tag.status === 'IMPORTED')
|
||
.map((tag) => (
|
||
<option key={tag.uid} value={tag.uid}>
|
||
{tag.alias ? `${tag.alias} (${tag.uid})` : tag.uid}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<Button
|
||
label="Vorhandene ID schreiben"
|
||
size="small"
|
||
onClick={handleReuseImportedTag}
|
||
/>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
)}
|
||
{renderSection()}
|
||
</main>
|
||
|
||
<aside className="right-panel">{systemPanels}</aside>
|
||
</div>
|
||
|
||
<nav className="bottom-nav">
|
||
{NAV_ITEMS.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<Button
|
||
key={item.id}
|
||
className={activeSection === item.id ? 'active' : ''}
|
||
onClick={() => setActiveSection(item.id)}
|
||
>
|
||
{typeof Icon === 'string' ? (
|
||
<i className={Icon} />
|
||
) : (
|
||
<span className="nav-icon">
|
||
<Icon size={18} />
|
||
</span>
|
||
)}
|
||
<span>{item.label}</span>
|
||
</Button>
|
||
);
|
||
})}
|
||
<Button
|
||
className="bottom-action"
|
||
onClick={() => setShowSessionSheet(true)}
|
||
>
|
||
<span className="nav-icon">
|
||
<Wrench size={18} />
|
||
</span>
|
||
<span>ToolHub</span>
|
||
</Button>
|
||
</nav>
|
||
|
||
{activeModal === 'new-folder' && (
|
||
<div
|
||
className="modal-backdrop"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setUploadAfterCreate(false);
|
||
setPendingUploadFiles([]);
|
||
setUploadSize(0);
|
||
setUploadNameError('');
|
||
setTagDeleteTarget('');
|
||
}}
|
||
>
|
||
<div
|
||
className="modal-card modal-upload"
|
||
onClick={(event) => event.stopPropagation()}
|
||
ref={modalRef}
|
||
>
|
||
<h3>{uploadAfterCreate ? 'Upload vorbereiten' : 'Neuen Ordner anlegen'}</h3>
|
||
<InputText
|
||
value={newFolderName}
|
||
onChange={(event) => {
|
||
setNewFolderName(event.target.value);
|
||
if (uploadNameError) setUploadNameError('');
|
||
}}
|
||
placeholder={
|
||
uploadAfterCreate && !currentPath
|
||
? 'Ordnername'
|
||
: uploadAfterCreate
|
||
? 'Optionaler Ordnername (leer = aktueller Ordner)'
|
||
: 'Ordnername'
|
||
}
|
||
className="p-inputtext-sm"
|
||
/>
|
||
{uploadAfterCreate && uploadNameError && (
|
||
<p className="error">{uploadNameError}</p>
|
||
)}
|
||
{uploadAfterCreate && (
|
||
<>
|
||
<FileUpload
|
||
ref={fileUploadRef}
|
||
name="files[]"
|
||
multiple
|
||
accept="audio/*"
|
||
customUpload
|
||
auto={false}
|
||
disabled={uploadInProgress}
|
||
chooseOptions={{ icon: 'pi pi-file-arrow-up', iconOnly: true, className: 'p-button-rounded p-button-outlined' }}
|
||
uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-success p-button-rounded p-button-outlined' }}
|
||
cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-danger p-button-rounded p-button-outlined' }}
|
||
headerTemplate={fileHeaderTemplate}
|
||
itemTemplate={fileItemTemplate}
|
||
onSelect={(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)`
|
||
: ''
|
||
);
|
||
}}
|
||
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={
|
||
<div style={{ display: 'flex', alignItems: 'center', flexDirection: 'column', gap: '0.75rem' }}>
|
||
<i className="pi pi-file-arrow-up" style={{ fontSize: '5em' }} />
|
||
<span style={{ fontSize: '1.2em', color: 'var(--text-color-secondary)' }}>
|
||
Drag and Drop Audio Here
|
||
</span>
|
||
<div className="upload-item-footer">
|
||
<span className="upload-item-total">Gesamt: {formatSize(uploadSize)}</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
/>
|
||
{pendingUploadFiles.length > 0 && (
|
||
<div className="upload-item-footer">
|
||
<span className="upload-item-total">Gesamt: {formatSize(uploadSize)}</span>
|
||
<span className="upload-item-count">
|
||
{pendingUploadFiles.length} Datei(en)
|
||
</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
<div className="modal-actions">
|
||
<Button
|
||
className="button-ghost"
|
||
label="Abbrechen"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setUploadAfterCreate(false);
|
||
setPendingUploadFiles([]);
|
||
setUploadSize(0);
|
||
setUploadNameError('');
|
||
setTagDeleteTarget('');
|
||
}}
|
||
/>
|
||
<Button
|
||
label={uploadAfterCreate ? 'Upload starten' : 'Ordner anlegen'}
|
||
onClick={() => {
|
||
if (uploadAfterCreate) {
|
||
setActiveModal('');
|
||
}
|
||
handleCreateFolder();
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeModal === 'rename' && (
|
||
<div
|
||
className="modal-backdrop"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setRenameName('');
|
||
}}
|
||
>
|
||
<div
|
||
className="modal-card modal-tag-delete"
|
||
onClick={(event) => event.stopPropagation()}
|
||
ref={modalRef}
|
||
>
|
||
<h3>Umbenennen</h3>
|
||
<InputText
|
||
value={renameName}
|
||
onChange={(event) => setRenameName(event.target.value)}
|
||
placeholder="Neuer Name"
|
||
className="p-inputtext-sm"
|
||
/>
|
||
<div className="modal-actions">
|
||
<Button
|
||
className="button-ghost"
|
||
label="Abbrechen"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setRenameName('');
|
||
}}
|
||
/>
|
||
<Button label="Speichern" onClick={handleRename} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeModal === 'delete' && (
|
||
<div
|
||
className="modal-backdrop"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
}}
|
||
>
|
||
<div
|
||
className="modal-card"
|
||
onClick={(event) => event.stopPropagation()}
|
||
ref={modalRef}
|
||
>
|
||
<h3>Eintraege loeschen</h3>
|
||
<p className="muted">{selectedPaths.length} Eintraege werden geloescht.</p>
|
||
<div className="modal-actions">
|
||
<Button className="button-ghost" label="Abbrechen" onClick={() => setActiveModal('')} />
|
||
<Button severity="danger" onClick={handleDeleteSelected} label="Loeschen" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeModal === 'move' && (
|
||
<div
|
||
className="modal-backdrop"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setMoveTarget('');
|
||
}}
|
||
>
|
||
<div
|
||
className="modal-card"
|
||
onClick={(event) => event.stopPropagation()}
|
||
ref={modalRef}
|
||
>
|
||
<h3>Verschieben</h3>
|
||
<Dropdown
|
||
value={moveTarget || null}
|
||
onChange={(event) => setMoveTarget(event.value)}
|
||
options={[
|
||
{ label: 'media/', value: '__root__' },
|
||
...collectFolderPaths(mediaTree).map((folder) => ({
|
||
label: folder,
|
||
value: folder,
|
||
})),
|
||
]}
|
||
placeholder="Zielordner waehlen"
|
||
filter
|
||
className="p-inputtext-sm"
|
||
/>
|
||
<div className="modal-actions">
|
||
<Button
|
||
className="button-ghost"
|
||
label="Abbrechen"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setMoveTarget('');
|
||
}}
|
||
/>
|
||
<Button label="Verschieben" onClick={handleMoveSelected} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeModal === 'tag-delete' && (
|
||
<div
|
||
className="modal-backdrop"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setTagDeleteTarget('');
|
||
}}
|
||
>
|
||
<div
|
||
className="modal-card"
|
||
onClick={(event) => event.stopPropagation()}
|
||
ref={modalRef}
|
||
>
|
||
<h3>Tag entfernen</h3>
|
||
<p className="muted">
|
||
Soll der Tag komplett geloescht werden oder nur die Medienzuweisung?
|
||
</p>
|
||
<div className="modal-actions">
|
||
<Button
|
||
className="button-ghost"
|
||
label="Abbrechen"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setTagDeleteTarget('');
|
||
}}
|
||
/>
|
||
<Button
|
||
label="Nur Medienzuordnung entfernen"
|
||
className="modal-long-button"
|
||
onClick={async () => {
|
||
if (!tagDeleteTarget) return;
|
||
await handleClearTagMedia(tagDeleteTarget);
|
||
setTagDeleteTarget('');
|
||
setActiveModal('');
|
||
}}
|
||
/>
|
||
<Button
|
||
severity="danger"
|
||
label="Tag loeschen"
|
||
className="modal-long-button"
|
||
onClick={async () => {
|
||
if (!tagDeleteTarget) return;
|
||
await handleClearTagMedia(tagDeleteTarget);
|
||
await handleDeleteTag(tagDeleteTarget);
|
||
setTagDeleteTarget('');
|
||
setActiveModal('');
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{boxDeleteTarget && (
|
||
<div
|
||
className="modal-backdrop"
|
||
onClick={() => setBoxDeleteTarget('')}
|
||
>
|
||
<div
|
||
className="modal-card"
|
||
onClick={(event) => event.stopPropagation()}
|
||
ref={modalRef}
|
||
>
|
||
<h3>Box entfernen</h3>
|
||
<p className="muted">Soll die Box wirklich entkoppelt werden?</p>
|
||
<div className="modal-actions">
|
||
<Button
|
||
className="button-ghost"
|
||
label="Abbrechen"
|
||
onClick={() => setBoxDeleteTarget('')}
|
||
/>
|
||
<Button
|
||
severity="danger"
|
||
label="Box entfernen"
|
||
onClick={async () => {
|
||
if (!boxDeleteTarget) return;
|
||
await handleUnpair(boxDeleteTarget);
|
||
setBoxDeleteTarget('');
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeModal === 'import-tag' && (
|
||
<div className="modal-backdrop" role="presentation">
|
||
<div className="modal-card" ref={modalRef}>
|
||
<div className="modal-header">
|
||
<h3>Tag vom Box-Speicher uebertragen</h3>
|
||
</div>
|
||
<div className="modal-body">
|
||
<label className="modal-label" htmlFor="import-folder">
|
||
Neuer Ordnername auf dem Server
|
||
</label>
|
||
<InputText
|
||
id="import-folder"
|
||
value={importTargetFolder}
|
||
onChange={(event) => setImportTargetFolder(event.target.value)}
|
||
placeholder="z. B. grimm_volume_3"
|
||
className="p-inputtext-sm"
|
||
/>
|
||
{uploadInProgress && (
|
||
<div className="upload-progress">
|
||
<div style={{ width: `${uploadProgress}%` }} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="modal-actions">
|
||
<Button
|
||
className="button-ghost"
|
||
label="Abbrechen"
|
||
onClick={() => {
|
||
setActiveModal('');
|
||
setImportTargetFolder('');
|
||
setImportTargetUid('');
|
||
}}
|
||
/>
|
||
<Button label="Uebertragen" onClick={handlePullTagFromBox} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showSessionSheet && (
|
||
<div
|
||
className="sheet-backdrop"
|
||
onClick={() => setShowSessionSheet(false)}
|
||
>
|
||
<div
|
||
className="sheet-card"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="sheet-header">
|
||
<strong>ToolHub</strong>
|
||
<Button
|
||
className="icon-button"
|
||
icon="pi pi-times"
|
||
onClick={() => setShowSessionSheet(false)}
|
||
aria-label="Schliessen"
|
||
/>
|
||
</div>
|
||
{drawerTab === 'boxes' && (
|
||
<>
|
||
{paired.length === 0 && (
|
||
<p className="muted">Keine gepairten Boxen.</p>
|
||
)}
|
||
<div className="session-list">
|
||
{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 (
|
||
<Button
|
||
key={box.box_id}
|
||
className={`session-card${isActive ? ' active' : ''}`}
|
||
onClick={() => {
|
||
setSelectedId(box.box_id);
|
||
setShowSessionSheet(false);
|
||
}}
|
||
>
|
||
<Avatar label={label || 'BX'} shape="circle" />
|
||
<div>
|
||
<strong>{box.alias || box.box_id}</strong>
|
||
{isActive && <span className="meta">Aktiv</span>}
|
||
</div>
|
||
<Tag value={availability.label} severity={availability.severity} rounded />
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
{drawerTab === 'system' && (
|
||
<div className="drawer-system">{systemPanels}</div>
|
||
)}
|
||
<div className="drawer-tabs">
|
||
<Button
|
||
className={`drawer-tab${drawerTab === 'boxes' ? ' active' : ''}`}
|
||
onClick={() => setDrawerTab('boxes')}
|
||
>
|
||
<span className="nav-icon">
|
||
<Cube size={18} />
|
||
</span>
|
||
<span>Boxen</span>
|
||
</Button>
|
||
<Button
|
||
className={`drawer-tab${drawerTab === 'system' ? ' active' : ''}`}
|
||
onClick={() => setDrawerTab('system')}
|
||
>
|
||
<span className="nav-icon">
|
||
<GearSix size={18} />
|
||
</span>
|
||
<span>System</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|