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

{title}

-

{subtitle}

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

{card.label}

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

Live-Status

- {!selectedId &&

Waehle eine gepairte Box aus.

} - {selectedId && !status &&

Status wird geladen...

} - {status && status.error &&

{status.error}

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

{currentTitle || playlistTitle || 'Playlist'}

-

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

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

- Steuerung ist nur moeglich, wenn die Box gepairt ist. -

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

Neue Boxen

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

Gepairte Boxen

- {paired.length === 0 &&

Noch keine gepairten Boxen.

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

Medien Explorer

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

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

- {mediaError &&

{mediaError}

} - {!mediaError && !mediaTree &&

Medienliste wird geladen...

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

Tags nur auf dieser Box

-
- {localBoxError &&

{localBoxError}

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

Keine Dateien im Box-Ordner gefunden.

- )} -
-
-
-
- ))} -
- )} - - -

Tags (Datenbank)

- {tags.length === 0 &&

Keine Tags vorhanden.

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

Tag-Matrix (Sperren)

- {paired.length === 0 && ( -

Keine gepairten Boxen vorhanden.

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

Keine Tags vorhanden.

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

System

-

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

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

Live Status

- -
-

System Health und wichtige Events.

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

Speicherkapazitaet

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

Keine Boxen verbunden.

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

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

-

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

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

- Hardware-UID erkannt: {lastHardwareUid.hardwareUid} -

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

- Hardware-UID erkannt: {activeNfc.hardwareUid} -

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

- Dieser Tag ist bekannt, aber noch nicht zugewiesen. -

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

Zusammenfassung

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

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

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

{uploadNameError}

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

Umbenennen

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

Eintraege loeschen

-

{selectedPaths.length} Eintraege werden geloescht.

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

Verschieben

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

Tag entfernen

-

- Soll der Tag komplett geloescht werden oder nur die Medienzuweisung? -

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

Box entfernen

-

Soll die Box wirklich entkoppelt werden?

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

Tag vom Box-Speicher uebertragen

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

Keine gepairten Boxen.

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