0.12.0-15 Fix DVD Review
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-14",
|
||||
"version": "0.12.0-15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-14",
|
||||
"version": "0.12.0-15",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-14",
|
||||
"version": "0.12.0-15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,7 @@ import SettingsPage from './pages/SettingsPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import DatabasePage from './pages/DatabasePage';
|
||||
import DownloadsPage from './pages/DownloadsPage';
|
||||
import ConverterPage from './pages/ConverterPage';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
@@ -524,6 +525,7 @@ function App() {
|
||||
|
||||
const nav = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Converter', path: '/converter' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
{ label: 'Historie', path: '/history' },
|
||||
{ label: 'Downloads', path: '/downloads' },
|
||||
@@ -653,6 +655,7 @@ function App() {
|
||||
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||
<Route path="database" element={<DatabasePage />} />
|
||||
<Route path="converter" element={<ConverterPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -895,6 +895,69 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cronExpression })
|
||||
});
|
||||
},
|
||||
|
||||
// ── Converter ──────────────────────────────────────────────────────────────
|
||||
converterGetTree() {
|
||||
return request('/converter/tree');
|
||||
},
|
||||
converterBrowse(parent = null) {
|
||||
const q = parent ? `?parent=${encodeURIComponent(parent)}` : '';
|
||||
return request(`/converter/browse${q}`);
|
||||
},
|
||||
converterScan() {
|
||||
return request('/converter/scan', { method: 'POST' });
|
||||
},
|
||||
converterCreateJobs(entries) {
|
||||
return request('/converter/create-jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entries })
|
||||
});
|
||||
},
|
||||
converterCreateJobsFromSelection(relPaths, audioMode = 'individual') {
|
||||
return request('/converter/jobs/from-selection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ relPaths, audioMode })
|
||||
});
|
||||
},
|
||||
converterUploadFiles(formData) {
|
||||
return request('/converter/upload', { method: 'POST', body: formData });
|
||||
},
|
||||
getConverterJobs() {
|
||||
return request('/converter/jobs');
|
||||
},
|
||||
startConverterJob(jobId, config = {}) {
|
||||
afterMutationInvalidate(['/converter/jobs']);
|
||||
return request(`/converter/jobs/${encodeURIComponent(jobId)}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
},
|
||||
cancelConverterJob(jobId) {
|
||||
afterMutationInvalidate(['/converter/jobs']);
|
||||
return request(`/converter/jobs/${encodeURIComponent(jobId)}/cancel`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
deleteConverterJob(jobId) {
|
||||
afterMutationInvalidate(['/converter/jobs']);
|
||||
return request(`/converter/jobs/${encodeURIComponent(jobId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
|
||||
// ── Converter Datei-Operationen ──────────────────────────────────────────
|
||||
converterDeleteFile(relPath) {
|
||||
return request('/converter/files', { method: 'DELETE', body: JSON.stringify({ relPath }) });
|
||||
},
|
||||
converterRenameFile(relPath, newName) {
|
||||
return request('/converter/files/rename', { method: 'POST', body: JSON.stringify({ relPath, newName }) });
|
||||
},
|
||||
converterMoveFile(relPath, targetParentRelPath) {
|
||||
return request('/converter/files/move', { method: 'POST', body: JSON.stringify({ relPath, targetParentRelPath }) });
|
||||
},
|
||||
converterCreateFolder(parentRelPath, name) {
|
||||
return request('/converter/files/folder', { method: 'POST', body: JSON.stringify({ parentRelPath, name }) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0; let v = n;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(i <= 1 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function mediaTypeBadge(type) {
|
||||
if (!type) return null;
|
||||
const map = { video: { label: 'Video', severity: 'info' }, audio: { label: 'Audio', severity: 'success' }, iso: { label: 'ISO', severity: 'warning' } };
|
||||
const m = map[type] || { label: type, severity: 'secondary' };
|
||||
return <Tag value={m.label} severity={m.severity} />;
|
||||
}
|
||||
|
||||
/** Navigiert den Baum per Pfad-String */
|
||||
function getNodeByPath(root, targetPath) {
|
||||
if (!root) return null;
|
||||
if ((root.path || '') === (targetPath || '')) return root;
|
||||
for (const child of (root.children || [])) {
|
||||
if (child.type === 'folder') {
|
||||
const found = getNodeByPath(child, targetPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Kinder des aktuellen Knotens (Ordner zuerst, alphabetisch) */
|
||||
function listChildren(node) {
|
||||
if (!node || !Array.isArray(node.children)) return [];
|
||||
return node.children; // buildRawTree liefert bereits sortiert
|
||||
}
|
||||
|
||||
/** Breadcrumb aus Pfad-String */
|
||||
function buildBreadcrumb(pathStr) {
|
||||
if (!pathStr) return [];
|
||||
const parts = String(pathStr).split('/').filter(Boolean);
|
||||
return parts.map((part, i) => ({ name: part, path: parts.slice(0, i + 1).join('/') }));
|
||||
}
|
||||
|
||||
/** Baum nach Ordnername filtern (nur Ordner in der Seitenleiste) */
|
||||
function filterTree(node, query) {
|
||||
if (!node || node.type !== 'folder') return null;
|
||||
if (!query || !query.trim()) return node;
|
||||
const q = query.toLowerCase();
|
||||
const filteredChildren = (node.children || [])
|
||||
.filter((c) => c.type === 'folder')
|
||||
.map((c) => filterTree(c, query))
|
||||
.filter(Boolean);
|
||||
const nameMatches = node.name.toLowerCase().includes(q);
|
||||
if (nameMatches || filteredChildren.length > 0) {
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Alle Datei-Pfade innerhalb eines Knotens rekursiv sammeln */
|
||||
function collectDescendantFilePaths(node) {
|
||||
const result = [];
|
||||
for (const child of (node?.children || [])) {
|
||||
if (child.type === 'file') result.push(child.path || '');
|
||||
else if (child.type === 'folder') result.push(...collectDescendantFilePaths(child));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Nur "Wurzel"-Selektionen: Pfade, die keinen ausgewählten Elternpfad haben */
|
||||
function getRootSelections(paths) {
|
||||
const set = new Set(paths);
|
||||
return paths.filter((p) =>
|
||||
!paths.some((other) => other !== p && p.startsWith(other + '/') && set.has(other))
|
||||
);
|
||||
}
|
||||
|
||||
/** Alle Ordner-Pfade für das Verschieben-Dropdown sammeln */
|
||||
function collectFolderPaths(node, result = []) {
|
||||
if (!node || node.type !== 'folder') return result;
|
||||
result.push({ label: node.path ? node.path : '/ (Root)', value: node.path || '' });
|
||||
for (const child of (node.children || [])) {
|
||||
if (child.type === 'folder') collectFolderPaths(child, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function ConverterFileExplorer({ onSelectionChange, refreshToken, navigateToPath }) {
|
||||
const toastRef = useRef(null);
|
||||
const explorerRef = useRef(null);
|
||||
|
||||
// Daten
|
||||
const [tree, setTree] = useState(null);
|
||||
const [rawDir, setRawDir] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Navigation
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
|
||||
|
||||
// Auswahl
|
||||
const [selectedPaths, setSelectedPaths] = useState([]);
|
||||
|
||||
// Seitenleiste
|
||||
const [sidebarQuery, setSidebarQuery] = useState('');
|
||||
|
||||
// Modals
|
||||
const [activeModal, setActiveModal] = useState('');
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [renameName, setRenameName] = useState('');
|
||||
const [moveTarget, setMoveTarget] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Stabile Ref für onSelectionChange
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
|
||||
|
||||
// ── Baum laden ─────────────────────────────────────────────────────────────
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.converterGetTree();
|
||||
setTree(data.tree || null);
|
||||
setRawDir(data.rawDir || null);
|
||||
} catch (err) {
|
||||
console.error('ConverterFileExplorer: tree load error', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTree(); }, [loadTree, refreshToken]);
|
||||
|
||||
// Auto-Navigation nach Upload: Baum neu laden, dann in Zielordner navigieren
|
||||
// navigateToPath ist { path: string, ts: number } damit jeder Upload einen neuen Trigger auslöst
|
||||
useEffect(() => {
|
||||
if (!navigateToPath?.path) return;
|
||||
loadTree().then(() => {
|
||||
navigateTo(navigateToPath.path);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navigateToPath]);
|
||||
|
||||
// Auswahl an Eltern melden: nur Wurzel-Selektionen (keine redundanten Kind-Pfade)
|
||||
useEffect(() => {
|
||||
if (!tree) return;
|
||||
const rootPaths = getRootSelections(selectedPaths);
|
||||
const report = rootPaths.map((p) => {
|
||||
const node = getNodeByPath(tree, p);
|
||||
if (!node) return null;
|
||||
return {
|
||||
relPath: node.path,
|
||||
entryType: node.type === 'folder' ? 'directory' : 'file',
|
||||
detectedMediaType: node.detectedMediaType || null,
|
||||
detectedFormat: node.detectedFormat || null
|
||||
};
|
||||
}).filter(Boolean);
|
||||
onSelectionChangeRef.current?.(report);
|
||||
}, [selectedPaths, tree]);
|
||||
|
||||
// Outside-Click → Auswahl aufheben
|
||||
useEffect(() => {
|
||||
function handleOutside(event) {
|
||||
if (activeModal) return;
|
||||
if (!explorerRef.current) return;
|
||||
if (!explorerRef.current.contains(event.target)) {
|
||||
setSelectedPaths([]);
|
||||
}
|
||||
}
|
||||
window.addEventListener('mousedown', handleOutside);
|
||||
return () => window.removeEventListener('mousedown', handleOutside);
|
||||
}, [activeModal]);
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
|
||||
const currentNode = getNodeByPath(tree, currentPath);
|
||||
const currentItems = listChildren(currentNode);
|
||||
const filteredTree = filterTree(tree, sidebarQuery);
|
||||
const topLevelFolders = tree ? (tree.children || []).filter((c) => c.type === 'folder') : [];
|
||||
|
||||
function navigateTo(pathStr) {
|
||||
setCurrentPath(pathStr || '');
|
||||
setSelectedPaths([]);
|
||||
setExpandedFolders((prev) => new Set(prev).add(pathStr || ''));
|
||||
}
|
||||
|
||||
function handleGoUp() {
|
||||
if (!currentPath) return;
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
navigateTo(parts.join('/'));
|
||||
}
|
||||
|
||||
function toggleFolder(pathValue) {
|
||||
const key = pathValue || '';
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auswahl ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Gibt true wenn item.path direkt oder als Kind eines ausgewählten Ordners selektiert ist */
|
||||
function isSelected(pathValue) {
|
||||
const p = pathValue || '';
|
||||
if (selectedPaths.includes(p)) return true;
|
||||
return selectedPaths.some((sel) => p.startsWith(sel + '/'));
|
||||
}
|
||||
|
||||
/** Unbestimmter Zustand: Ordner, von dem nur Teile selektiert sind */
|
||||
function isIndeterminate(item) {
|
||||
if (item.type !== 'folder') return false;
|
||||
const p = item.path || '';
|
||||
if (selectedPaths.includes(p)) return false;
|
||||
const folderNode = getNodeByPath(tree, p);
|
||||
const allFiles = collectDescendantFilePaths(folderNode);
|
||||
return allFiles.length > 0 && allFiles.some((fp) => selectedPaths.includes(fp));
|
||||
}
|
||||
|
||||
function handleCheckboxChange(item, checked) {
|
||||
const p = item.path || '';
|
||||
|
||||
if (item.type === 'folder') {
|
||||
const folderNode = getNodeByPath(tree, p);
|
||||
const descendantFiles = collectDescendantFilePaths(folderNode);
|
||||
if (checked) {
|
||||
setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles])));
|
||||
} else {
|
||||
const toRemove = new Set([p, ...descendantFiles]);
|
||||
setSelectedPaths((prev) => prev.filter((x) => !toRemove.has(x)));
|
||||
}
|
||||
} else {
|
||||
const allFilesInCurrentView = currentItems.filter((i) => i.type === 'file').map((i) => i.path || '');
|
||||
if (checked) {
|
||||
setSelectedPaths((prev) => {
|
||||
const next = Array.from(new Set([...prev, p]));
|
||||
// Wenn alle Dateien im aktuellen Ordner gewählt → Ordner auch auswählen
|
||||
if (currentPath && allFilesInCurrentView.length > 0 &&
|
||||
allFilesInCurrentView.every((fp) => next.includes(fp)) &&
|
||||
!next.includes(currentPath)) {
|
||||
return [...next, currentPath];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedPaths((prev) => {
|
||||
const next = prev.filter((x) => x !== p);
|
||||
// Ordner abwählen wenn eine seiner Dateien abgewählt wird
|
||||
if (currentPath && next.includes(currentPath)) {
|
||||
return next.filter((x) => x !== currentPath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen(item) {
|
||||
if (item.type !== 'folder') return;
|
||||
navigateTo(item.path || '');
|
||||
}
|
||||
|
||||
// ── Datei-Operationen ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleCreateFolder() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.converterCreateFolder(currentPath, name);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Ordner erstellt', detail: name, life: 2500 });
|
||||
setNewFolderName('');
|
||||
setActiveModal('');
|
||||
await loadTree();
|
||||
} catch (err) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!selectedPaths.length) return;
|
||||
const name = renameName.trim();
|
||||
if (!name) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.converterRenameFile(rootSelected[0], name);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `→ ${name}`, life: 2500 });
|
||||
setRenameName('');
|
||||
setActiveModal('');
|
||||
setSelectedPaths([]);
|
||||
await loadTree();
|
||||
} catch (err) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
if (!rootSelected.length) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const p of rootSelected) {
|
||||
await api.converterDeleteFile(p);
|
||||
}
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${rootSelected.length} Eintrag/Einträge`, life: 2500 });
|
||||
setSelectedPaths([]);
|
||||
setActiveModal('');
|
||||
await loadTree();
|
||||
} catch (err) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleMoveSelected() {
|
||||
if (!selectedPaths.length) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const isRoot = moveTarget === '' || moveTarget === '__root__';
|
||||
await api.converterMoveFile(rootSelected[0], isRoot ? '' : moveTarget);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 });
|
||||
setMoveTarget('');
|
||||
setSelectedPaths([]);
|
||||
setActiveModal('');
|
||||
await loadTree();
|
||||
} catch (err) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// ── Ordnerbaum (Seitenleiste) ───────────────────────────────────────────────
|
||||
|
||||
function renderFolderTree(node, depth) {
|
||||
if (!node || node.type !== 'folder') return null;
|
||||
const isActive = (node.path || '') === currentPath;
|
||||
const key = node.path || '';
|
||||
const isExpanded = expandedFolders.has(key) || depth === 0;
|
||||
const childFolders = (node.children || []).filter((c) => c.type === 'folder');
|
||||
const hasChildren = childFolders.length > 0;
|
||||
|
||||
return (
|
||||
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
|
||||
<div
|
||||
className={`tree-row folder${isActive ? ' active' : ''}`}
|
||||
onClick={() => handleOpen(node)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleOpen(node); }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<div
|
||||
className="tree-caret"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); toggleFolder(node.path || ''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); toggleFolder(node.path || ''); } }}
|
||||
>
|
||||
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} style={{ fontSize: '0.65rem' }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="tree-caret disabled" aria-hidden="true" />
|
||||
)}
|
||||
<span className="tree-icon folder">
|
||||
<i className="pi pi-folder" />
|
||||
</span>
|
||||
<span className="tree-label">{node.name || 'raw'}</span>
|
||||
</div>
|
||||
{isExpanded && hasChildren && childFolders.map((child) => renderFolderTree(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Abgeleitete Werte ──────────────────────────────────────────────────────
|
||||
|
||||
const allFolders = tree ? collectFolderPaths(tree) : [{ label: '/ (Root)', value: '' }];
|
||||
const breadcrumb = buildBreadcrumb(currentPath);
|
||||
const rootSelected = getRootSelections(selectedPaths);
|
||||
const canRename = rootSelected.length === 1;
|
||||
const canDelete = selectedPaths.length > 0;
|
||||
const canMove = rootSelected.length === 1;
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="cfx-wrap">
|
||||
<Toast ref={toastRef} position="top-right" />
|
||||
|
||||
{/* Obere Leiste: rawdir-Pfad + Aktualisieren */}
|
||||
<div className="cfx-top-bar">
|
||||
<span className="cfx-rawdir" title={rawDir || ''}>
|
||||
{rawDir
|
||||
? <><i className="pi pi-server" style={{ marginRight: 4 }} />{rawDir}</>
|
||||
: <em>Kein Ordner konfiguriert</em>}
|
||||
</span>
|
||||
<Button
|
||||
label={loading ? 'Laden …' : 'Aktualisieren'}
|
||||
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
|
||||
size="small"
|
||||
outlined
|
||||
disabled={loading}
|
||||
onClick={loadTree}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && !tree ? (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<ProgressSpinner style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
) : !rawDir ? (
|
||||
<div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--rip-muted)', fontSize: '0.85rem' }}>
|
||||
Bitte Converter Raw-Ordner in den Settings konfigurieren.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explorer" ref={explorerRef}>
|
||||
|
||||
{/* Linke Seitenleiste */}
|
||||
<div className="explorer-sidebar">
|
||||
<div className="explorer-toolbar sidebar-toolbar">
|
||||
<InputText
|
||||
className="sidebar-search p-inputtext-sm"
|
||||
type="search"
|
||||
placeholder="Suchen..."
|
||||
value={sidebarQuery}
|
||||
onChange={(e) => setSidebarQuery(e.target.value)}
|
||||
aria-label="Ordner suchen"
|
||||
/>
|
||||
</div>
|
||||
<div className="sidebar-tree">
|
||||
{topLevelFolders.length === 0 && !sidebarQuery && (
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: 'var(--rip-muted)', fontStyle: 'italic' }}>
|
||||
Keine Ordner gefunden.
|
||||
</div>
|
||||
)}
|
||||
{filteredTree && renderFolderTree(filteredTree, 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechter Hauptbereich */}
|
||||
<div className="explorer-main">
|
||||
<div className="explorer-toolbar">
|
||||
{/* ArrowUp */}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={handleGoUp}
|
||||
disabled={!currentPath}
|
||||
title="Eine Ebene hoch"
|
||||
aria-label="Hoch"
|
||||
>
|
||||
<i className="pi pi-arrow-up" />
|
||||
</button>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="explorer-path">
|
||||
<span
|
||||
className="breadcrumb-root path-link"
|
||||
onClick={() => navigateTo('')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(''); }}
|
||||
>
|
||||
raw
|
||||
</span>
|
||||
{breadcrumb.map((crumb) => (
|
||||
<span
|
||||
key={crumb.path}
|
||||
className="path-link"
|
||||
onClick={() => navigateTo(crumb.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(crumb.path); }}
|
||||
>
|
||||
/ {crumb.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar-Aktionen rechts */}
|
||||
<div className="toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => { setNewFolderName(''); setActiveModal('new-folder'); }}
|
||||
title="Neuer Ordner"
|
||||
aria-label="Neuer Ordner"
|
||||
>
|
||||
<i className="pi pi-folder-plus" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
const node = getNodeByPath(tree, rootSelected[0]);
|
||||
setRenameName(node?.name || '');
|
||||
setActiveModal('rename');
|
||||
}}
|
||||
disabled={!canRename}
|
||||
title="Umbenennen"
|
||||
aria-label="Umbenennen"
|
||||
>
|
||||
<i className="pi pi-pencil" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={() => setActiveModal('delete')}
|
||||
disabled={!canDelete}
|
||||
title="Löschen"
|
||||
aria-label="Löschen"
|
||||
>
|
||||
<i className="pi pi-trash" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => { setMoveTarget(''); setActiveModal('move'); }}
|
||||
disabled={!canMove}
|
||||
title="Verschieben"
|
||||
aria-label="Verschieben"
|
||||
>
|
||||
<i className="pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dateiliste */}
|
||||
<div className="explorer-list">
|
||||
{/* Header */}
|
||||
<div className="explorer-row header">
|
||||
<span />
|
||||
<span>Name</span>
|
||||
<span>Typ</span>
|
||||
<span>Größe</span>
|
||||
</div>
|
||||
|
||||
{/* Zeilen */}
|
||||
{currentItems.length === 0 ? (
|
||||
<div style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--rip-muted)', fontStyle: 'italic' }}>
|
||||
Leer.
|
||||
</div>
|
||||
) : currentItems.map((item) => {
|
||||
const sel = isSelected(item.path);
|
||||
const indet = isIndeterminate(item);
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={`explorer-row${sel ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (item.type === 'folder') {
|
||||
handleOpen(item);
|
||||
} else {
|
||||
handleCheckboxChange(item, !sel);
|
||||
}
|
||||
}}
|
||||
role="row"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
|
||||
>
|
||||
<span className="row-checkbox" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sel}
|
||||
ref={(el) => { if (el) el.indeterminate = indet; }}
|
||||
onChange={(e) => handleCheckboxChange(item, e.target.checked)}
|
||||
aria-label={`${item.name} auswählen`}
|
||||
/>
|
||||
</span>
|
||||
<span className="row-name">
|
||||
<span className="row-icon">
|
||||
<i className={`pi ${item.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
|
||||
</span>
|
||||
{item.name}
|
||||
</span>
|
||||
<span>
|
||||
{item.type === 'folder'
|
||||
? <Tag value="Ordner" severity="secondary" />
|
||||
: (item.detectedMediaType ? mediaTypeBadge(item.detectedMediaType) : <span style={{ color: 'var(--rip-muted)', fontSize: '0.78rem' }}>Datei</span>)}
|
||||
</span>
|
||||
<span style={{ textAlign: 'right', color: 'var(--rip-muted)', fontSize: '0.78rem' }}>
|
||||
{formatBytes(item.size)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Footer: Auswahl-Anzahl */}
|
||||
{selectedPaths.length > 0 && (
|
||||
<div className="explorer-row footer">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span className="footer-count">{getRootSelections(selectedPaths).length} ausgewählt</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Untere Leiste (ganzeBreite) */}
|
||||
<div className="explorer-footer">
|
||||
<span>{currentItems.length} Einträge{rawDir ? ` · ${rawDir}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Dialoge ─────────────────────────────────────────────────────────── */}
|
||||
|
||||
{/* Neuer Ordner */}
|
||||
<Dialog
|
||||
header="Neuen Ordner erstellen"
|
||||
visible={activeModal === 'new-folder'}
|
||||
onHide={() => { setActiveModal(''); setNewFolderName(''); }}
|
||||
style={{ width: '380px' }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button label="Abbrechen" outlined onClick={() => { setActiveModal(''); setNewFolderName(''); }} disabled={busy} />
|
||||
<Button label="Erstellen" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy || !newFolderName.trim()} onClick={handleCreateFolder} />
|
||||
</div>
|
||||
}
|
||||
modal
|
||||
>
|
||||
<div className="field">
|
||||
<label>Ordnername</label>
|
||||
<InputText
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFolder(); }}
|
||||
autoFocus
|
||||
style={{ width: '100%', marginTop: 6 }}
|
||||
placeholder={`In: ${currentPath || '/'}`}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Umbenennen */}
|
||||
<Dialog
|
||||
header="Umbenennen"
|
||||
visible={activeModal === 'rename'}
|
||||
onHide={() => { setActiveModal(''); setRenameName(''); }}
|
||||
style={{ width: '380px' }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button label="Abbrechen" outlined onClick={() => { setActiveModal(''); setRenameName(''); }} disabled={busy} />
|
||||
<Button label="Umbenennen" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy || !renameName.trim()} onClick={handleRename} />
|
||||
</div>
|
||||
}
|
||||
modal
|
||||
>
|
||||
<div className="field">
|
||||
<label>Neuer Name</label>
|
||||
<InputText
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); }}
|
||||
autoFocus
|
||||
style={{ width: '100%', marginTop: 6 }}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Löschen */}
|
||||
<Dialog
|
||||
header="Löschen bestätigen"
|
||||
visible={activeModal === 'delete'}
|
||||
onHide={() => setActiveModal('')}
|
||||
style={{ width: '380px' }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button label="Abbrechen" outlined onClick={() => setActiveModal('')} disabled={busy} />
|
||||
<Button label="Löschen" severity="danger" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'} disabled={busy} onClick={handleDeleteSelected} />
|
||||
</div>
|
||||
}
|
||||
modal
|
||||
>
|
||||
<p style={{ margin: 0 }}>
|
||||
{rootSelected.length === 1
|
||||
? <><strong>{getNodeByPath(tree, rootSelected[0])?.name}</strong> wirklich löschen?</>
|
||||
: <>{rootSelected.length} Einträge wirklich löschen?</>}
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
{/* Verschieben */}
|
||||
<Dialog
|
||||
header="Verschieben"
|
||||
visible={activeModal === 'move'}
|
||||
onHide={() => setActiveModal('')}
|
||||
style={{ width: '420px' }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button label="Abbrechen" outlined onClick={() => setActiveModal('')} disabled={busy} />
|
||||
<Button label="Verschieben" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy} onClick={handleMoveSelected} />
|
||||
</div>
|
||||
}
|
||||
modal
|
||||
>
|
||||
<div className="field">
|
||||
<label>Zielordner</label>
|
||||
<Dropdown
|
||||
value={moveTarget}
|
||||
options={allFolders.filter((f) => {
|
||||
const src = rootSelected[0] || '';
|
||||
return f.value !== src && !f.value.startsWith(src + '/');
|
||||
})}
|
||||
onChange={(e) => setMoveTarget(e.value)}
|
||||
style={{ width: '100%', marginTop: 6 }}
|
||||
placeholder="Zielordner auswählen …"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { SelectButton } from 'primereact/selectbutton';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Message } from 'primereact/message';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const VIDEO_OUTPUT_FORMATS = [
|
||||
{ label: 'MKV', value: 'mkv' },
|
||||
{ label: 'MP4', value: 'mp4' },
|
||||
{ label: 'M4V', value: 'm4v' }
|
||||
];
|
||||
|
||||
const AUDIO_OUTPUT_FORMATS = [
|
||||
{ label: 'FLAC', value: 'flac' },
|
||||
{ label: 'MP3', value: 'mp3' },
|
||||
{ label: 'AAC', value: 'aac' },
|
||||
{ label: 'Opus', value: 'opus' },
|
||||
{ label: 'OGG', value: 'ogg' },
|
||||
{ label: 'WAV', value: 'wav' }
|
||||
];
|
||||
|
||||
const MP3_MODES = [
|
||||
{ label: 'CBR', value: 'cbr' },
|
||||
{ label: 'VBR', value: 'vbr' }
|
||||
];
|
||||
|
||||
function getStatusMeta(status) {
|
||||
const s = String(status || '').toUpperCase();
|
||||
const map = {
|
||||
READY_TO_START: { label: 'Bereit', severity: 'warning', icon: 'pi-clock' },
|
||||
ANALYZING: { label: 'Analysiere', severity: 'info', icon: 'pi-spin pi-spinner' },
|
||||
RIPPING: { label: 'Rippen', severity: 'info', icon: 'pi-spin pi-spinner' },
|
||||
ENCODING: { label: 'Encoding', severity: 'info', icon: 'pi-spin pi-spinner' },
|
||||
MEDIAINFO_CHECK:{ label: 'MediaInfo', severity: 'info', icon: 'pi-spin pi-spinner' },
|
||||
DONE: { label: 'Fertig', severity: 'success', icon: 'pi-check' },
|
||||
ERROR: { label: 'Fehler', severity: 'danger', icon: 'pi-exclamation-triangle' },
|
||||
CANCELLED: { label: 'Abgebrochen', severity: 'secondary', icon: 'pi-times' }
|
||||
};
|
||||
return map[s] || { label: s, severity: 'secondary', icon: 'pi-question' };
|
||||
}
|
||||
|
||||
function mediaTypeBadge(type) {
|
||||
const map = {
|
||||
video: { label: 'Video', severity: 'info' },
|
||||
audio: { label: 'Audio', severity: 'success' },
|
||||
iso: { label: 'ISO', severity: 'warning' }
|
||||
};
|
||||
const m = map[type] || { label: type || '?', severity: 'secondary' };
|
||||
return <Tag value={m.label} severity={m.severity} style={{ fontSize: 11 }} />;
|
||||
}
|
||||
|
||||
function isActiveStatus(status) {
|
||||
return ['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK'].includes(String(status || '').toUpperCase());
|
||||
}
|
||||
|
||||
function isTerminal(status) {
|
||||
return ['DONE', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline-Konfigurationsbereich für READY_TO_START Jobs.
|
||||
* Zeigt alle nötigen Optionen direkt in der Karte an.
|
||||
*/
|
||||
function InlineConfig({ job, plan, onStarted }) {
|
||||
const converterMediaType = plan?.converterMediaType || 'video';
|
||||
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
|
||||
const isAudio = converterMediaType === 'audio';
|
||||
|
||||
const [outputFormat, setOutputFormat] = useState(isAudio ? 'flac' : 'mkv');
|
||||
const [userPresets, setUserPresets] = useState([]);
|
||||
const [hbPresets, setHbPresets] = useState([]);
|
||||
const [selectedUserPreset, setSelectedUserPreset] = useState(null);
|
||||
const [selectedHbPreset, setSelectedHbPreset] = useState('');
|
||||
const [audioFormatOptions, setAudioFormatOptions] = useState({
|
||||
flacCompression: 5,
|
||||
mp3Mode: 'cbr',
|
||||
mp3Bitrate: 192,
|
||||
mp3Quality: 4,
|
||||
aacBitrate: 256,
|
||||
opusBitrate: 160,
|
||||
oggQuality: 6
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Presets laden
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
api.getUserPresets?.('video')
|
||||
.then((d) => setUserPresets(Array.isArray(d?.presets) ? d.presets : []))
|
||||
.catch(() => setUserPresets([]));
|
||||
api.getHandBrakePresets?.()
|
||||
.then((d) => setHbPresets(Array.isArray(d?.presets) ? d.presets : []))
|
||||
.catch(() => setHbPresets([]));
|
||||
}, [isVideo]);
|
||||
|
||||
const handleStart = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
let userPreset = null;
|
||||
if (selectedUserPreset) {
|
||||
const found = userPresets.find((p) => p.id === selectedUserPreset);
|
||||
if (found) userPreset = { id: found.id, handbrakePreset: found.handbrake_preset || null, extraArgs: found.extra_args || '' };
|
||||
} else if (selectedHbPreset) {
|
||||
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
|
||||
}
|
||||
|
||||
await api.startConverterJob(job.id, {
|
||||
converterMediaType,
|
||||
outputFormat,
|
||||
userPreset,
|
||||
audioFormatOptions: isAudio ? audioFormatOptions : undefined
|
||||
});
|
||||
|
||||
onStarted?.(job.id);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Starten.');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS;
|
||||
|
||||
return (
|
||||
<div className="cjc-config">
|
||||
{error && (
|
||||
<Message severity="error" text={error} style={{ marginBottom: 10, width: '100%' }} />
|
||||
)}
|
||||
|
||||
<div className="cjc-config-row">
|
||||
{/* Medientyp (read-only) */}
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">Medientyp</label>
|
||||
<div className="cjc-config-value">
|
||||
{mediaTypeBadge(converterMediaType)}
|
||||
{converterMediaType === 'iso' && (
|
||||
<small style={{ marginLeft: 6, color: 'var(--text-color-secondary)' }}>MakeMKV → HandBrake</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ausgabeformat */}
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">Ausgabeformat</label>
|
||||
<Dropdown
|
||||
value={outputFormat}
|
||||
options={outputFormats}
|
||||
onChange={(e) => setOutputFormat(e.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video: Presets */}
|
||||
{isVideo && (
|
||||
<div className="cjc-config-row">
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">User-Preset</label>
|
||||
<Dropdown
|
||||
value={selectedUserPreset}
|
||||
options={[
|
||||
{ label: '— Kein User-Preset —', value: null },
|
||||
...userPresets.map((p) => ({ label: p.name, value: p.id }))
|
||||
]}
|
||||
onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
{!selectedUserPreset && (
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">HandBrake-Preset</label>
|
||||
<Dropdown
|
||||
value={selectedHbPreset}
|
||||
options={[
|
||||
{ label: '— Kein Preset (Standard) —', value: '' },
|
||||
...hbPresets.map((p) => ({ label: p.category ? `[${p.category}] ${p.name}` : p.name, value: p.name }))
|
||||
]}
|
||||
onChange={(e) => setSelectedHbPreset(e.value)}
|
||||
filter
|
||||
placeholder="Preset auswählen …"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio: Format-Optionen */}
|
||||
{isAudio && (
|
||||
<div className="cjc-config-row">
|
||||
{outputFormat === 'flac' && (
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">Komprimierung (0–12)</label>
|
||||
<InputNumber
|
||||
value={audioFormatOptions.flacCompression}
|
||||
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))}
|
||||
min={0} max={12} step={1}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{outputFormat === 'mp3' && (
|
||||
<>
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">Modus</label>
|
||||
<SelectButton
|
||||
value={audioFormatOptions.mp3Mode}
|
||||
options={MP3_MODES}
|
||||
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="cjc-config-field">
|
||||
{audioFormatOptions.mp3Mode === 'cbr' ? (
|
||||
<>
|
||||
<label className="cjc-config-label">Bitrate (kbps)</label>
|
||||
<Dropdown
|
||||
value={audioFormatOptions.mp3Bitrate}
|
||||
options={[128, 160, 192, 224, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
|
||||
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="cjc-config-label">VBR Qualität (0=beste, 9=schlechteste)</label>
|
||||
<InputNumber
|
||||
value={audioFormatOptions.mp3Quality}
|
||||
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))}
|
||||
min={0} max={9} step={1}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{outputFormat === 'aac' && (
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">Bitrate (kbps)</label>
|
||||
<Dropdown
|
||||
value={audioFormatOptions.aacBitrate}
|
||||
options={[128, 160, 192, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
|
||||
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{outputFormat === 'opus' && (
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">Bitrate (kbps)</label>
|
||||
<Dropdown
|
||||
value={audioFormatOptions.opusBitrate}
|
||||
options={[64, 96, 128, 160, 192, 256].map((v) => ({ label: `${v} kbps`, value: v }))}
|
||||
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{outputFormat === 'ogg' && (
|
||||
<div className="cjc-config-field">
|
||||
<label className="cjc-config-label">Qualität (-1 bis 10)</label>
|
||||
<InputNumber
|
||||
value={audioFormatOptions.oggQuality}
|
||||
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))}
|
||||
min={-1} max={10} step={1}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cjc-config-actions">
|
||||
<Button
|
||||
label="Encoding starten"
|
||||
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
|
||||
disabled={busy}
|
||||
onClick={handleStart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status-Karte für einen einzelnen Converter-Job.
|
||||
* Zeigt Konfiguration inline an wenn der Job READY_TO_START ist.
|
||||
*/
|
||||
export default function ConverterJobCard({
|
||||
job,
|
||||
jobProgress,
|
||||
onStarted,
|
||||
onDeleted,
|
||||
onCancelled
|
||||
}) {
|
||||
const [cancelBusy, setCancelBusy] = useState(false);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
|
||||
const plan = (() => {
|
||||
try { return JSON.parse(job.encode_plan_json || '{}'); } catch (_e) { return {}; }
|
||||
})();
|
||||
|
||||
const title = job.title || job.detected_title || 'Converter Job';
|
||||
const status = job.status || 'UNKNOWN';
|
||||
const statusMeta = getStatusMeta(status);
|
||||
const converterMediaType = plan?.converterMediaType;
|
||||
const active = isActiveStatus(status);
|
||||
const terminal = isTerminal(status);
|
||||
const isReady = String(status).toUpperCase() === 'READY_TO_START';
|
||||
|
||||
const liveProgress = jobProgress?.progress ?? null;
|
||||
const liveEta = jobProgress?.eta || null;
|
||||
const liveStatusText = jobProgress?.statusText || null;
|
||||
const progressValue = liveProgress !== null ? Math.round(liveProgress) : null;
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelBusy(true);
|
||||
try {
|
||||
await api.cancelConverterJob(job.id);
|
||||
onCancelled?.(job.id);
|
||||
} catch (err) {
|
||||
console.error('Cancel error:', err);
|
||||
} finally {
|
||||
setCancelBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Job "${title}" wirklich löschen?`)) return;
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
await api.deleteConverterJob(job.id);
|
||||
onDeleted?.(job.id);
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err);
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="converter-job-card-header">
|
||||
<div className="converter-job-card-title-row">
|
||||
<span className="converter-job-card-title" title={title}>{title}</span>
|
||||
{converterMediaType && mediaTypeBadge(converterMediaType)}
|
||||
<Tag
|
||||
value={statusMeta.label}
|
||||
severity={statusMeta.severity}
|
||||
icon={`pi ${statusMeta.icon}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isReady && plan?.outputFormat && (
|
||||
<div className="converter-job-card-meta">
|
||||
<small>Format: <strong>{String(plan.outputFormat).toUpperCase()}</strong></small>
|
||||
{plan?.inputPath && (
|
||||
<small title={plan.inputPath} style={{ marginLeft: 8 }}>
|
||||
← {plan.inputPath.split('/').pop()}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isReady && plan?.inputPath && (
|
||||
<div className="converter-job-card-meta">
|
||||
<small title={plan.inputPath}>← {plan.inputPath.split('/').pop()}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline-Konfiguration für READY_TO_START */}
|
||||
{isReady && (
|
||||
<InlineConfig job={job} plan={plan} onStarted={onStarted} />
|
||||
)}
|
||||
|
||||
{/* Fortschrittsbalken */}
|
||||
{active && (
|
||||
<div className="converter-job-card-progress">
|
||||
{progressValue !== null ? (
|
||||
<ProgressBar value={progressValue} style={{ height: 6 }} displayValueTemplate={() => `${progressValue}%`} />
|
||||
) : (
|
||||
<ProgressBar mode="indeterminate" style={{ height: 6 }} />
|
||||
)}
|
||||
{(liveStatusText || liveEta) && (
|
||||
<div className="converter-job-card-progress-meta">
|
||||
{liveStatusText && <small>{liveStatusText}</small>}
|
||||
{liveEta && <small>ETA: {liveEta}</small>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fehler */}
|
||||
{status === 'ERROR' && job.error_message && (
|
||||
<div className="converter-job-card-error">
|
||||
<small>{job.error_message}</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ausgabe-Pfad */}
|
||||
{terminal && job.output_path && (
|
||||
<div className="converter-job-card-output">
|
||||
<small title={job.output_path}>→ {job.output_path.split('/').slice(-2).join('/')}</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aktions-Buttons */}
|
||||
<div className="converter-job-card-actions">
|
||||
{active && (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon={cancelBusy ? 'pi pi-spin pi-spinner' : 'pi pi-times'}
|
||||
size="small"
|
||||
severity="warning"
|
||||
outlined
|
||||
disabled={cancelBusy}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
)}
|
||||
{terminal && (
|
||||
<Button
|
||||
label="Löschen"
|
||||
icon={deleteBusy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
disabled={deleteBusy}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { SelectButton } from 'primereact/selectbutton';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Message } from 'primereact/message';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const VIDEO_OUTPUT_FORMATS = [
|
||||
{ label: 'MKV', value: 'mkv' },
|
||||
{ label: 'MP4', value: 'mp4' },
|
||||
{ label: 'M4V', value: 'm4v' }
|
||||
];
|
||||
|
||||
const AUDIO_OUTPUT_FORMATS = [
|
||||
{ label: 'FLAC', value: 'flac' },
|
||||
{ label: 'MP3', value: 'mp3' },
|
||||
{ label: 'AAC', value: 'aac' },
|
||||
{ label: 'Opus', value: 'opus' },
|
||||
{ label: 'OGG', value: 'ogg' },
|
||||
{ label: 'WAV', value: 'wav' }
|
||||
];
|
||||
|
||||
const MP3_MODES = [
|
||||
{ label: 'CBR', value: 'cbr' },
|
||||
{ label: 'VBR', value: 'vbr' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Konfigurationsmodal für einen Converter-Job.
|
||||
* Erscheint nachdem ein Job aus dem File-Explorer oder Upload erstellt wurde.
|
||||
*/
|
||||
export default function ConverterJobConfigDialog({
|
||||
visible,
|
||||
job,
|
||||
onHide,
|
||||
onStarted
|
||||
}) {
|
||||
const [converterMediaType, setConverterMediaType] = useState('video');
|
||||
const [outputFormat, setOutputFormat] = useState('mkv');
|
||||
const [userPresets, setUserPresets] = useState([]);
|
||||
const [hbPresets, setHbPresets] = useState([]);
|
||||
const [selectedUserPreset, setSelectedUserPreset] = useState(null);
|
||||
const [selectedHbPreset, setSelectedHbPreset] = useState('');
|
||||
const [audioFormatOptions, setAudioFormatOptions] = useState({
|
||||
flacCompression: 5,
|
||||
mp3Mode: 'cbr',
|
||||
mp3Bitrate: 192,
|
||||
mp3Quality: 4,
|
||||
aacBitrate: 256,
|
||||
opusBitrate: 160,
|
||||
oggQuality: 6
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Beim Öffnen: Medientyp und Format aus dem Job-Plan lesen
|
||||
useEffect(() => {
|
||||
if (!job || !visible) return;
|
||||
|
||||
let plan = null;
|
||||
try {
|
||||
plan = job.encode_plan_json ? JSON.parse(job.encode_plan_json) : null;
|
||||
} catch (_err) { /* ignore */ }
|
||||
|
||||
const mediaType = plan?.converterMediaType || 'video';
|
||||
setConverterMediaType(mediaType);
|
||||
setOutputFormat(mediaType === 'audio' ? 'flac' : 'mkv');
|
||||
setSelectedUserPreset(null);
|
||||
setSelectedHbPreset('');
|
||||
setError(null);
|
||||
}, [job, visible]);
|
||||
|
||||
// User-Presets laden
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
api.getUserPresets?.('video')
|
||||
.then((data) => setUserPresets(Array.isArray(data?.presets) ? data.presets : []))
|
||||
.catch(() => setUserPresets([]));
|
||||
}, [visible]);
|
||||
|
||||
// HandBrake Built-in Presets laden
|
||||
useEffect(() => {
|
||||
if (!visible || converterMediaType !== 'video') return;
|
||||
api.getHandBrakePresets?.()
|
||||
.then((data) => {
|
||||
const list = Array.isArray(data?.presets) ? data.presets : [];
|
||||
setHbPresets(list);
|
||||
})
|
||||
.catch(() => setHbPresets([]));
|
||||
}, [visible, converterMediaType]);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
let userPreset = null;
|
||||
if (selectedUserPreset) {
|
||||
const found = userPresets.find((p) => p.id === selectedUserPreset);
|
||||
if (found) {
|
||||
userPreset = {
|
||||
id: found.id,
|
||||
handbrakePreset: found.handbrake_preset || null,
|
||||
extraArgs: found.extra_args || ''
|
||||
};
|
||||
}
|
||||
} else if (selectedHbPreset) {
|
||||
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
|
||||
}
|
||||
|
||||
await api.startConverterJob(job.id, {
|
||||
converterMediaType,
|
||||
outputFormat,
|
||||
userPreset,
|
||||
audioFormatOptions: converterMediaType === 'audio' ? audioFormatOptions : undefined
|
||||
});
|
||||
|
||||
onStarted?.(job.id);
|
||||
onHide?.();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Starten.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
|
||||
const isAudio = converterMediaType === 'audio';
|
||||
const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS;
|
||||
|
||||
const footer = (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
|
||||
<Button
|
||||
label="Encoding starten"
|
||||
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
|
||||
disabled={busy}
|
||||
onClick={handleStart}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header={`Converter konfigurieren — ${job?.title || job?.detected_title || 'Job'}`}
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
footer={footer}
|
||||
style={{ width: '520px' }}
|
||||
modal
|
||||
>
|
||||
{error && (
|
||||
<Message severity="error" text={error} style={{ marginBottom: 16, width: '100%' }} />
|
||||
)}
|
||||
|
||||
{/* Medientyp anzeigen (read-only) */}
|
||||
<div className="field">
|
||||
<label>Medientyp</label>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<strong>{converterMediaType === 'iso' ? 'ISO (Video)' : converterMediaType === 'video' ? 'Video' : 'Audio'}</strong>
|
||||
{converterMediaType === 'iso' && (
|
||||
<small style={{ marginLeft: 8, color: '#888' }}>→ MakeMKV Rip, dann HandBrake</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ausgabeformat */}
|
||||
<div className="field" style={{ marginTop: 12 }}>
|
||||
<label>Ausgabeformat</label>
|
||||
<Dropdown
|
||||
value={outputFormat}
|
||||
options={outputFormats}
|
||||
onChange={(e) => setOutputFormat(e.value)}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Video: Preset-Auswahl */}
|
||||
{isVideo && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="field">
|
||||
<label>User-Preset (optional)</label>
|
||||
<Dropdown
|
||||
value={selectedUserPreset}
|
||||
options={[
|
||||
{ label: '— Kein User-Preset —', value: null },
|
||||
...userPresets.map((p) => ({ label: p.name, value: p.id }))
|
||||
]}
|
||||
onChange={(e) => {
|
||||
setSelectedUserPreset(e.value);
|
||||
if (e.value) setSelectedHbPreset('');
|
||||
}}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
{!selectedUserPreset && (
|
||||
<div className="field" style={{ marginTop: 12 }}>
|
||||
<label>HandBrake-Preset (optional)</label>
|
||||
<Dropdown
|
||||
value={selectedHbPreset}
|
||||
options={[
|
||||
{ label: '— Kein Preset (HB-Standard) —', value: '' },
|
||||
...hbPresets.map((p) => ({
|
||||
label: p.category ? `[${p.category}] ${p.name}` : p.name,
|
||||
value: p.name
|
||||
}))
|
||||
]}
|
||||
onChange={(e) => setSelectedHbPreset(e.value)}
|
||||
filter
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
placeholder="Preset auswählen …"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Audio: Format-Optionen */}
|
||||
{isAudio && (
|
||||
<>
|
||||
<Divider />
|
||||
{outputFormat === 'flac' && (
|
||||
<div className="field">
|
||||
<label>Komprimierung (0–12)</label>
|
||||
<InputNumber
|
||||
value={audioFormatOptions.flacCompression}
|
||||
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))}
|
||||
min={0} max={12} step={1}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{outputFormat === 'mp3' && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label>Modus</label>
|
||||
<SelectButton
|
||||
value={audioFormatOptions.mp3Mode}
|
||||
options={MP3_MODES}
|
||||
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
{audioFormatOptions.mp3Mode === 'cbr' ? (
|
||||
<div className="field" style={{ marginTop: 12 }}>
|
||||
<label>Bitrate (kbps)</label>
|
||||
<Dropdown
|
||||
value={audioFormatOptions.mp3Bitrate}
|
||||
options={[128, 160, 192, 224, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
|
||||
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field" style={{ marginTop: 12 }}>
|
||||
<label>VBR Qualität (0=beste, 9=schlechteste)</label>
|
||||
<InputNumber
|
||||
value={audioFormatOptions.mp3Quality}
|
||||
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))}
|
||||
min={0} max={9} step={1}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{outputFormat === 'aac' && (
|
||||
<div className="field">
|
||||
<label>Bitrate (kbps)</label>
|
||||
<Dropdown
|
||||
value={audioFormatOptions.aacBitrate}
|
||||
options={[128, 160, 192, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
|
||||
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{outputFormat === 'opus' && (
|
||||
<div className="field">
|
||||
<label>Bitrate (kbps)</label>
|
||||
<Dropdown
|
||||
value={audioFormatOptions.opusBitrate}
|
||||
options={[64, 96, 128, 160, 192, 256].map((v) => ({ label: `${v} kbps`, value: v }))}
|
||||
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{outputFormat === 'ogg' && (
|
||||
<div className="field">
|
||||
<label>Qualität (-1 bis 10)</label>
|
||||
<InputNumber
|
||||
value={audioFormatOptions.oggQuality}
|
||||
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))}
|
||||
min={-1} max={10} step={1}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
|
||||
const ACCEPTED_EXTENSIONS = [
|
||||
'.mkv', '.mp4', '.m2ts', '.avi', '.mov', '.m4v', '.wmv', '.ts',
|
||||
'.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape',
|
||||
'.iso'
|
||||
].join(',');
|
||||
|
||||
/**
|
||||
* Upload-Panel für den Converter.
|
||||
* Unterstützt Mehrfach-Dateien und Ordner-Upload (webkitdirectory).
|
||||
*/
|
||||
export default function ConverterUploadPanel({ onUploaded }) {
|
||||
const [phase, setPhase] = useState('idle'); // idle | uploading | done | error
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [statusText, setStatusText] = useState('');
|
||||
const [errorMsg, setErrorMsg] = useState(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
const dirInputRef = useRef(null);
|
||||
|
||||
const reset = () => {
|
||||
setPhase('idle');
|
||||
setProgress(0);
|
||||
setStatusText('');
|
||||
setErrorMsg(null);
|
||||
};
|
||||
|
||||
const uploadFiles = useCallback(async (fileList) => {
|
||||
const files = Array.from(fileList).filter((f) => {
|
||||
if (f.size === 0) return false;
|
||||
// Dot-Dateien (.DS_Store, .gitkeep, __MACOSX etc.) ignorieren
|
||||
const relPath = f.webkitRelativePath || f.name;
|
||||
return !relPath.split('/').some((seg) => seg.startsWith('.') || seg === '__MACOSX');
|
||||
});
|
||||
if (files.length === 0) return;
|
||||
|
||||
const isFolderUpload = files.some((f) => f.webkitRelativePath && f.webkitRelativePath.includes('/'));
|
||||
|
||||
setPhase('uploading');
|
||||
setProgress(0);
|
||||
setStatusText(isFolderUpload
|
||||
? `Lade Ordner hoch (${files.length} Datei${files.length !== 1 ? 'en' : ''}) …`
|
||||
: `Lade ${files.length} Datei${files.length !== 1 ? 'en' : ''} hoch …`);
|
||||
setErrorMsg(null);
|
||||
|
||||
// Wie Klangkiste: folderName als eigenes Feld, Dateien mit file.name (kein Pfad im Dateinamen)
|
||||
const formData = new FormData();
|
||||
if (isFolderUpload) {
|
||||
const folderName = files.find((f) => f.webkitRelativePath)?.webkitRelativePath?.split('/')[0] || 'upload';
|
||||
formData.append('folderName', folderName);
|
||||
for (const file of files) {
|
||||
formData.append('files', file, file.name);
|
||||
}
|
||||
} else {
|
||||
for (const file of files) {
|
||||
formData.append('files', file, file.name);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// XHR für Upload-Fortschritt
|
||||
const result = await uploadWithProgress(formData, (pct) => {
|
||||
setProgress(pct);
|
||||
setStatusText(`Upload: ${Math.round(pct)}%`);
|
||||
});
|
||||
|
||||
setPhase('done');
|
||||
setProgress(100);
|
||||
const folders = result.folders || [];
|
||||
setStatusText(`${folders.length} Ordner hochgeladen.`);
|
||||
onUploaded?.(folders);
|
||||
} catch (err) {
|
||||
setPhase('error');
|
||||
setErrorMsg(err.message || 'Upload fehlgeschlagen.');
|
||||
}
|
||||
}, [onUploaded]);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files?.length > 0) {
|
||||
uploadFiles(e.target.files);
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const dt = e.dataTransfer;
|
||||
if (dt?.files?.length > 0) {
|
||||
uploadFiles(dt.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => { e.preventDefault(); setIsDragOver(true); };
|
||||
const handleDragLeave = () => setIsDragOver(false);
|
||||
|
||||
const phaseLabel = {
|
||||
idle: null,
|
||||
uploading: { label: 'Wird hochgeladen', severity: 'warning' },
|
||||
done: { label: 'Fertig', severity: 'success' },
|
||||
error: { label: 'Fehler', severity: 'danger' }
|
||||
}[phase];
|
||||
|
||||
return (
|
||||
<div className="converter-upload-panel">
|
||||
<div
|
||||
className={`converter-upload-dropzone${isDragOver ? ' drag-over' : ''}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<div className="converter-upload-icon">
|
||||
<i className="pi pi-upload" style={{ fontSize: '2rem', color: '#888' }} />
|
||||
</div>
|
||||
<div className="converter-upload-hint">
|
||||
Dateien hierher ziehen oder auswählen
|
||||
<br />
|
||||
<small>Unterstützt: MKV, MP4, ISO, AVI, FLAC, MP3, WAV, AAC, OGG, OPUS …</small>
|
||||
</div>
|
||||
<div className="converter-upload-buttons">
|
||||
<Button
|
||||
label="Dateien auswählen"
|
||||
icon="pi pi-file"
|
||||
size="small"
|
||||
outlined
|
||||
disabled={phase === 'uploading'}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
<Button
|
||||
label="Ordner auswählen"
|
||||
icon="pi pi-folder-open"
|
||||
size="small"
|
||||
outlined
|
||||
disabled={phase === 'uploading'}
|
||||
onClick={() => dirInputRef.current?.click()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Versteckte File-Inputs */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={dirInputRef}
|
||||
type="file"
|
||||
webkitdirectory="true"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{phase !== 'idle' && (
|
||||
<div className="converter-upload-status">
|
||||
<div className="converter-upload-status-head">
|
||||
{phaseLabel && <Tag value={phaseLabel.label} severity={phaseLabel.severity} />}
|
||||
<span>{statusText}</span>
|
||||
{(phase === 'done' || phase === 'error') && (
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
onClick={reset}
|
||||
aria-label="Schließen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{phase === 'uploading' && (
|
||||
<ProgressBar value={progress} style={{ height: 6 }} />
|
||||
)}
|
||||
{phase === 'error' && errorMsg && (
|
||||
<small style={{ color: 'var(--red-500)' }}>{errorMsg}</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uploadWithProgress(formData, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const apiBase = import.meta.env.VITE_API_BASE || '/api';
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch (_err) {
|
||||
resolve({});
|
||||
}
|
||||
} else {
|
||||
let msg = `HTTP ${xhr.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(xhr.responseText);
|
||||
msg = parsed?.error?.message || msg;
|
||||
} catch (_err) { /* ignore */ }
|
||||
reject(new Error(msg));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Netzwerkfehler beim Upload.')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload abgebrochen.')));
|
||||
|
||||
xhr.open('POST', `${apiBase}/converter/upload`);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
@@ -351,6 +351,7 @@ const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
|
||||
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
|
||||
const DOWNLOAD_PATH_KEYS = ['download_dir'];
|
||||
const LOG_PATH_KEYS = ['log_dir'];
|
||||
const CONVERTER_PATH_KEYS = ['converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio'];
|
||||
|
||||
function buildSectionsForCategory(categoryName, settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
@@ -539,6 +540,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const downloadSettings = list.filter((s) => DOWNLOAD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DOWNLOAD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
|
||||
const converterSettings = list.filter((s) => CONVERTER_PATH_KEYS.includes(s.key));
|
||||
|
||||
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
|
||||
@@ -547,6 +549,10 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
|
||||
const defaultDownloads = effectivePaths?.defaults?.downloads || 'data/downloads';
|
||||
|
||||
const defaultConverterRaw = effectivePaths?.defaults?.converterRaw || 'data/output/converter-raw';
|
||||
const defaultConverterMovies = effectivePaths?.defaults?.converterMovies || 'data/output/converted-movies';
|
||||
const defaultConverterAudio = effectivePaths?.defaults?.converterAudio || 'data/output/converted-audio';
|
||||
|
||||
const ep = effectivePaths || {};
|
||||
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
||||
@@ -557,6 +563,9 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
||||
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
|
||||
const downloadPath = ep.downloads?.path || defaultDownloads;
|
||||
const converterRaw = ep.converter?.raw || defaultConverterRaw;
|
||||
const converterMovies = ep.converter?.movies || defaultConverterMovies;
|
||||
const converterAudio = ep.converter?.audio || defaultConverterAudio;
|
||||
|
||||
const isDefault = (path, def) => path === def;
|
||||
|
||||
@@ -572,8 +581,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Medium</th>
|
||||
<th>RAW-Ordner</th>
|
||||
<th>Encode-Ordner</th>
|
||||
<th>RAW / Eingang</th>
|
||||
<th>Encode / Ausgang</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -627,8 +636,30 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
<code>{downloadPath}</code>
|
||||
{isDefault(downloadPath, defaultDownloads) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
<td>---</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Converter Raw</strong></td>
|
||||
<td>
|
||||
---
|
||||
<code>{converterRaw}</code>
|
||||
{isDefault(converterRaw, defaultConverterRaw) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
<td>---</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Converter Video</strong></td>
|
||||
<td>---</td>
|
||||
<td>
|
||||
<code>{converterMovies}</code>
|
||||
{isDefault(converterMovies, defaultConverterMovies) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Converter Audio</strong></td>
|
||||
<td>---</td>
|
||||
<td>
|
||||
<code>{converterAudio}</code>
|
||||
{isDefault(converterAudio, defaultConverterAudio) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -642,6 +673,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
{ title: 'DVD', pathSettings: dvdSettings },
|
||||
{ title: 'CD / Audio', pathSettings: cdSettings },
|
||||
{ title: 'Audiobook', pathSettings: audiobookSettings },
|
||||
{ title: 'Converter', pathSettings: converterSettings },
|
||||
{ title: 'Downloads', pathSettings: downloadSettings },
|
||||
...(logSettings.length > 0 ? [{ title: 'Logs', pathSettings: logSettings }] : [])
|
||||
]
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { RadioButton } from 'primereact/radiobutton';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import ConverterFileExplorer from '../components/ConverterFileExplorer';
|
||||
import ConverterUploadPanel from '../components/ConverterUploadPanel';
|
||||
import ConverterJobCard from '../components/ConverterJobCard';
|
||||
|
||||
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
|
||||
function isAudioEntry(e) {
|
||||
const p = (e.relPath || '').toLowerCase();
|
||||
const dot = p.lastIndexOf('.');
|
||||
if (dot === -1) return false;
|
||||
return AUDIO_EXTS.has(p.slice(dot));
|
||||
}
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
|
||||
}
|
||||
|
||||
export default function ConverterPage() {
|
||||
const toastRef = useRef(null);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [jobProgress, setJobProgress] = useState({});
|
||||
const [loadingJobs, setLoadingJobs] = useState(false);
|
||||
const [explorerRefreshToken, setExplorerRefreshToken] = useState(0);
|
||||
const [explorerNavigateTo, setExplorerNavigateTo] = useState(null);
|
||||
const [selectedEntries, setSelectedEntries] = useState([]);
|
||||
const [jobModeVisible, setJobModeVisible] = useState(false);
|
||||
const [audioMode, setAudioMode] = useState('individual');
|
||||
const [creatingJobs, setCreatingJobs] = useState(false);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setLoadingJobs(true);
|
||||
try {
|
||||
const data = await api.getConverterJobs();
|
||||
setJobs(Array.isArray(data?.jobs) ? data.jobs : []);
|
||||
} catch (err) {
|
||||
console.error('Load converter jobs error:', err);
|
||||
} finally {
|
||||
setLoadingJobs(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
const interval = setInterval(loadJobs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadJobs]);
|
||||
|
||||
useWebSocket((message) => {
|
||||
if (!message?.type || !message?.payload) return;
|
||||
|
||||
if (message.type === 'PIPELINE_PROGRESS') {
|
||||
const payload = message.payload;
|
||||
const jobId = normalizeJobId(payload?.activeJobId);
|
||||
if (jobId) {
|
||||
setJobProgress((prev) => ({
|
||||
...prev,
|
||||
[jobId]: {
|
||||
progress: payload?.progress ?? null,
|
||||
eta: payload?.eta ?? null,
|
||||
statusText: payload?.statusText ?? null,
|
||||
state: payload?.state ?? null
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'PIPELINE_UPDATE' || message.type === 'CONVERTER_SCAN_UPDATE') {
|
||||
loadJobs();
|
||||
if (message.type === 'CONVERTER_SCAN_UPDATE') {
|
||||
setExplorerRefreshToken((t) => t + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelectionChange = (entries) => setSelectedEntries(entries || []);
|
||||
|
||||
const handleOpenJobModal = () => {
|
||||
const hasAudio = selectedEntries.some(isAudioEntry);
|
||||
if (!hasAudio) {
|
||||
// Keine Audio-Dateien (nur Videos/Ordner) → kein Modal, direkt Einzeljobs
|
||||
handleCreateJobsFromSelection('individual');
|
||||
return;
|
||||
}
|
||||
setAudioMode('individual');
|
||||
setJobModeVisible(true);
|
||||
};
|
||||
|
||||
const handleCreateJobsFromSelection = async (explicitMode) => {
|
||||
if (selectedEntries.length === 0) return;
|
||||
setCreatingJobs(true);
|
||||
setJobModeVisible(false);
|
||||
try {
|
||||
const relPaths = selectedEntries.map((e) => e.relPath).filter(Boolean);
|
||||
const result = await api.converterCreateJobsFromSelection(relPaths, explicitMode ?? audioMode);
|
||||
const newJobs = result?.jobs || [];
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Jobs erstellt',
|
||||
detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`,
|
||||
life: 3500
|
||||
});
|
||||
setSelectedEntries([]);
|
||||
setExplorerRefreshToken((t) => t + 1);
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Fehler',
|
||||
detail: err.message || 'Jobs konnten nicht erstellt werden.',
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setCreatingJobs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploaded = (folders) => {
|
||||
setExplorerRefreshToken((t) => t + 1);
|
||||
if (folders?.length > 0) {
|
||||
// Explorer in den ersten hochgeladenen Ordner navigieren
|
||||
setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() });
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Upload abgeschlossen',
|
||||
detail: `${folders.length} Ordner hochgeladen. Dateien auswählen und Jobs anlegen.`,
|
||||
life: 4000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobStarted = async (jobId) => {
|
||||
await loadJobs();
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Gestartet',
|
||||
detail: `Converter-Job ${jobId} läuft.`,
|
||||
life: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const handleJobDeleted = (jobId) => {
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
setJobProgress((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[jobId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleJobCancelled = () => setTimeout(loadJobs, 1000);
|
||||
|
||||
const activeJobs = jobs.filter((j) => !['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
|
||||
const finishedJobs = jobs.filter((j) => ['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
|
||||
|
||||
const jobsCardHeader = (
|
||||
<div className="converter-card-header">
|
||||
<div className="converter-card-title">
|
||||
<span>Converter Jobs</span>
|
||||
{activeJobs.length > 0 && (
|
||||
<Badge value={activeJobs.length} severity="info" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
icon={loadingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
onClick={loadJobs}
|
||||
disabled={loadingJobs}
|
||||
aria-label="Jobs neu laden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dashboard-subpage-content">
|
||||
<Toast ref={toastRef} position="top-right" />
|
||||
|
||||
{/* Import-Ordner */}
|
||||
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
|
||||
<ConverterFileExplorer
|
||||
onSelectionChange={handleSelectionChange}
|
||||
refreshToken={explorerRefreshToken}
|
||||
navigateToPath={explorerNavigateTo}
|
||||
/>
|
||||
{selectedEntries.length > 0 && (
|
||||
<div className="converter-selection-bar">
|
||||
<span>
|
||||
<Badge value={selectedEntries.length} severity="info" /> ausgewählt
|
||||
</span>
|
||||
<Button
|
||||
label="Job(s) anlegen"
|
||||
icon={creatingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
|
||||
disabled={creatingJobs}
|
||||
onClick={handleOpenJobModal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Jobs */}
|
||||
<Card header={jobsCardHeader}>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="converter-jobs-empty-hint">
|
||||
<small>Keine Jobs vorhanden. Dateien im Import-Ordner auswählen oder per Upload hochladen.</small>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{activeJobs.length > 0 && (
|
||||
<>
|
||||
<p className="converter-jobs-group-label">Aktiv</p>
|
||||
<div className="converter-jobs-list">
|
||||
{activeJobs.map((job) => (
|
||||
<ConverterJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
jobProgress={jobProgress[job.id] || null}
|
||||
onStarted={handleJobStarted}
|
||||
onDeleted={handleJobDeleted}
|
||||
onCancelled={handleJobCancelled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{activeJobs.length > 0 && finishedJobs.length > 0 && <Divider />}
|
||||
{finishedJobs.length > 0 && (
|
||||
<>
|
||||
<p className="converter-jobs-group-label">Abgeschlossen</p>
|
||||
<div className="converter-jobs-list">
|
||||
{finishedJobs.slice(0, 20).map((job) => (
|
||||
<ConverterJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
jobProgress={null}
|
||||
onDeleted={handleJobDeleted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Upload */}
|
||||
<Card title="Datei-Upload" subTitle="Einzelne Dateien oder ganze Ordner (Album) hochladen">
|
||||
<ConverterUploadPanel onUploaded={handleUploaded} />
|
||||
</Card>
|
||||
|
||||
<JobModeDialog
|
||||
visible={jobModeVisible}
|
||||
entries={selectedEntries}
|
||||
audioMode={audioMode}
|
||||
onAudioModeChange={setAudioMode}
|
||||
busy={creatingJobs}
|
||||
onConfirm={handleCreateJobsFromSelection}
|
||||
onHide={() => setJobModeVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Job-Modus-Dialog ──────────────────────────────────────────────────────────
|
||||
|
||||
function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, onConfirm, onHide }) {
|
||||
const audioEntries = entries.filter(isAudioEntry);
|
||||
const nonAudioEntries = entries.filter((e) => !isAudioEntry(e));
|
||||
const hasAudio = audioEntries.length > 0;
|
||||
const hasNonAudio = nonAudioEntries.length > 0;
|
||||
const onlyAudio = hasAudio && !hasNonAudio;
|
||||
const mixed = hasAudio && hasNonAudio;
|
||||
|
||||
// Anzahl der Jobs die entstehen werden
|
||||
const audioJobCount = audioMode === 'shared' ? 1 : audioEntries.length;
|
||||
const totalJobs = nonAudioEntries.length + audioJobCount;
|
||||
|
||||
const footer = (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
|
||||
<Button
|
||||
label={`${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`}
|
||||
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header="Jobs anlegen"
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
footer={footer}
|
||||
style={{ width: '440px' }}
|
||||
modal
|
||||
>
|
||||
{/* Nur Videos / Ordner gewählt */}
|
||||
{!hasAudio && (
|
||||
<p style={{ margin: 0, lineHeight: 1.6 }}>
|
||||
Für jede ausgewählte Datei / jeden Ordner wird ein eigener Job angelegt.
|
||||
<br />
|
||||
<strong>{nonAudioEntries.length} Job{nonAudioEntries.length !== 1 ? 's' : ''}</strong> werden erstellt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Nur Audio gewählt */}
|
||||
{onlyAudio && (
|
||||
<div>
|
||||
<p style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
Wie sollen die <strong>{audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''}</strong> verarbeitet werden?
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<RadioButton
|
||||
value="individual"
|
||||
checked={audioMode === 'individual'}
|
||||
onChange={() => onAudioModeChange('individual')}
|
||||
/>
|
||||
<span>Für jede Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<RadioButton
|
||||
value="shared"
|
||||
checked={audioMode === 'shared'}
|
||||
onChange={() => onAudioModeChange('shared')}
|
||||
/>
|
||||
<span>Ein gemeinsamer Job für alle Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gemischte Auswahl */}
|
||||
{mixed && (
|
||||
<div>
|
||||
<p style={{ marginTop: 0, marginBottom: 8 }}>
|
||||
<strong>Videos/Ordner ({nonAudioEntries.length}):</strong> Für jede Datei ein eigener Job.
|
||||
</p>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<p style={{ marginBottom: 10 }}>
|
||||
<strong>Audiodateien ({audioEntries.length}):</strong> Wie verarbeiten?
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<RadioButton
|
||||
value="individual"
|
||||
checked={audioMode === 'individual'}
|
||||
onChange={() => onAudioModeChange('individual')}
|
||||
/>
|
||||
<span>Für jede Audio-Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<RadioButton
|
||||
value="shared"
|
||||
checked={audioMode === 'shared'}
|
||||
onChange={() => onAudioModeChange('shared')}
|
||||
/>
|
||||
<span>Ein gemeinsamer Job für alle Audio-Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -865,6 +865,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
? {
|
||||
...computedContext,
|
||||
...liveContext,
|
||||
// Prefer the DB plan when it has been confirmed but the live (jobProgress) plan hasn't —
|
||||
// this happens when confirmEncodeReview is called with skipPipelineStateUpdate=true
|
||||
// (always the case for queued jobs), leaving jobProgress with the pre-confirmation plan.
|
||||
mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !liveContext.mediaInfoReview?.reviewConfirmedAt)
|
||||
? computedContext.mediaInfoReview
|
||||
: (liveContext.mediaInfoReview || computedContext.mediaInfoReview),
|
||||
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
|
||||
? liveContext.tracks
|
||||
: computedContext.tracks,
|
||||
|
||||
@@ -88,6 +88,9 @@ function resolveMediaType(row) {
|
||||
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||
return 'audiobook';
|
||||
}
|
||||
if (raw === 'converter') {
|
||||
return 'converter';
|
||||
}
|
||||
}
|
||||
const statusCandidates = [
|
||||
row?.status,
|
||||
@@ -151,6 +154,14 @@ function resolveMediaTypeMeta(row) {
|
||||
alt: 'Audiobook'
|
||||
};
|
||||
}
|
||||
if (mediaType === 'converter') {
|
||||
return {
|
||||
mediaType,
|
||||
icon: otherIndicatorIcon,
|
||||
label: 'Converter',
|
||||
alt: 'Converter'
|
||||
};
|
||||
}
|
||||
return {
|
||||
mediaType,
|
||||
icon: otherIndicatorIcon,
|
||||
@@ -1582,11 +1593,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
detailLoading={detailLoading}
|
||||
onLoadLog={handleLoadLog}
|
||||
logLoadingMode={logLoadingMode}
|
||||
onRestartEncode={handleRestartEncode}
|
||||
onRestartReview={handleRestartReview}
|
||||
onRestartCdReview={handleRestartCdReview}
|
||||
onReencode={handleReencode}
|
||||
onRetry={handleRetry}
|
||||
onRestartEncode={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartEncode}
|
||||
onRestartReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartReview}
|
||||
onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview}
|
||||
onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode}
|
||||
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
||||
onAssignOmdb={handleAssignOmdb}
|
||||
onAssignCdMetadata={handleAssignCdMetadata}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
|
||||
@@ -14,6 +14,26 @@ import CronJobsTab from '../components/CronJobsTab';
|
||||
|
||||
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||
|
||||
// Validates template/string fields: only letters, digits, space, _, -, and {placeholders} allowed
|
||||
function validateSettingValue(key, value) {
|
||||
if (!key.includes('template')) return null;
|
||||
const str = String(value ?? '');
|
||||
if (!str) return null;
|
||||
// Strip valid placeholder patterns ${...} and {...}
|
||||
const stripped = str.replace(/\$\{[^}]*\}/g, '').replace(/\{[^}]*\}/g, '');
|
||||
// Standalone { or } after stripping = unclosed placeholder
|
||||
if (/[{}]/.test(stripped)) {
|
||||
return 'Geschweifte Klammern sind nur als vollständige Platzhalter erlaubt, z.B. {title}.';
|
||||
}
|
||||
// Find first invalid special char (anything beyond letters, digits, space, _, -, (), [], /)
|
||||
// . is not allowed (extensions are set separately); / is only for folder separators
|
||||
const invalid = stripped.match(/[^a-zA-Z0-9 ()[\]/_-]/);
|
||||
if (invalid) {
|
||||
return `Ungültiges Zeichen "${invalid[0]}". Erlaubt sind nur Buchstaben, Ziffern, Leerzeichen, _, -, und {Platzhalter}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildValuesMap(categories) {
|
||||
const next = {};
|
||||
for (const category of categories || []) {
|
||||
@@ -427,7 +447,8 @@ export default function SettingsPage() {
|
||||
|
||||
const handleFieldChange = (key, value) => {
|
||||
setDraftValues((prev) => ({ ...prev, [key]: value }));
|
||||
setErrors((prev) => ({ ...prev, [key]: null }));
|
||||
const validationError = validateSettingValue(key, value);
|
||||
setErrors((prev) => ({ ...prev, [key]: validationError || null }));
|
||||
};
|
||||
|
||||
const handleExpertModeToggle = async (checked) => {
|
||||
@@ -468,6 +489,16 @@ export default function SettingsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasValidationErrors = Object.values(errors).some(Boolean);
|
||||
if (hasValidationErrors) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Validierungsfehler',
|
||||
detail: 'Bitte korrigiere die fehlerhaften Felder vor dem Speichern.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const patch = {};
|
||||
for (const key of dirtyKeys) {
|
||||
patch[key] = draftValues[key];
|
||||
|
||||
@@ -4864,3 +4864,577 @@ body {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ===== Converter Page ===== */
|
||||
|
||||
.converter-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem 0;
|
||||
}
|
||||
|
||||
.converter-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.converter-jobs-group-label {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
}
|
||||
|
||||
.converter-jobs-empty-hint {
|
||||
margin: 0;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
}
|
||||
|
||||
/* ===== File Explorer (Klangkiste 1:1 mit Ripster-Farben) ===== */
|
||||
|
||||
/* Äußere Hülle */
|
||||
.cfx-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Obere Infozeile: rawdir + Aktualisieren */
|
||||
.cfx-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cfx-rawdir {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Klangkiste Explorer-Layout ───────────────────────────────────────────── */
|
||||
|
||||
.explorer {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 0;
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background: var(--rip-panel, #fffaf1);
|
||||
min-height: 260px;
|
||||
align-items: stretch;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.explorer-sidebar {
|
||||
grid-row: 1;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--rip-panel-soft, #fdf5e7);
|
||||
border-right: 1px solid var(--rip-border, #d9bc8d);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.explorer-toolbar,
|
||||
.sidebar-toolbar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-toolbar {
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
background: var(--rip-cream-50, #fffaf0);
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree {
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
border-radius: 10px;
|
||||
background: var(--rip-cream-50, #fffaf0);
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
background: var(--rip-cream-50, #fffaf0);
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--rip-ink, #2f180f);
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
outline: none;
|
||||
}
|
||||
.sidebar-search:focus {
|
||||
border-color: var(--rip-gold-400, #d49c56);
|
||||
box-shadow: 0 0 0 2px rgba(212, 156, 86, 0.2);
|
||||
}
|
||||
|
||||
.explorer-main {
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 14px;
|
||||
background: var(--rip-panel, #fffaf1);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.explorer-toolbar {
|
||||
flex-wrap: wrap;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
border-radius: 10px;
|
||||
background: var(--rip-cream-50, #fffaf0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explorer-toolbar .icon-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.explorer-path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 13px;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-root {
|
||||
font-weight: 600;
|
||||
color: var(--rip-ink, #2f180f);
|
||||
}
|
||||
|
||||
.path-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--rip-ink, #2f180f);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.path-link:hover,
|
||||
.path-link:focus { background: transparent; color: var(--primary-color, #8e4d2d); outline: none; text-decoration: underline; }
|
||||
|
||||
.toolbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.explorer-list {
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
border-radius: 10px;
|
||||
background: var(--rip-cream-50, #fffaf0);
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.explorer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 2fr repeat(2, minmax(80px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--rip-border, #d9bc8d);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.08s, border-left 0.08s;
|
||||
}
|
||||
.explorer-row:hover:not(.header):not(.footer) { background: var(--rip-cream-100, #fbf2df); }
|
||||
.explorer-row.selected {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
border-left: 3px solid var(--primary-color);
|
||||
padding-left: 9px;
|
||||
}
|
||||
.explorer-row.header {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
background: var(--rip-panel-soft, #fdf5e7);
|
||||
cursor: default;
|
||||
border-left: 3px solid transparent;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.explorer-row.footer {
|
||||
background: var(--rip-panel-soft, #fdf5e7);
|
||||
cursor: default;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.explorer-row:last-child { border-bottom: none; }
|
||||
|
||||
.row-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--rip-brown-700, #6f3922);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.row-checkbox input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.explorer-row.header .row-checkbox { pointer-events: none; }
|
||||
|
||||
.footer-count {
|
||||
font-weight: 600;
|
||||
color: var(--rip-ink, #2f180f);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.explorer-footer {
|
||||
grid-column: 1 / -1;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
border-top: 1px solid var(--rip-border, #d9bc8d);
|
||||
background: var(--rip-panel-soft, #fdf5e7);
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
|
||||
/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
|
||||
|
||||
.icon-button {
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
background: var(--rip-panel, #fffaf1);
|
||||
color: var(--rip-ink, #2f180f);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
transition: background 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) { background: var(--rip-cream-100, #fbf2df); }
|
||||
.icon-button.danger {
|
||||
border-color: rgba(239, 68, 68, 0.55);
|
||||
color: rgb(220, 38, 38);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.icon-button.danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.15); }
|
||||
.icon-button:disabled { cursor: not-allowed; opacity: 0.4; }
|
||||
|
||||
/* ── Ordnerbaum ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.tree-node { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.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: var(--rip-muted, #6a4d38);
|
||||
user-select: none;
|
||||
}
|
||||
.tree-row:hover { background: var(--rip-cream-100, #fbf2df); color: var(--rip-ink, #2f180f); }
|
||||
.tree-row.active { background: var(--primary-50, #fef6ec); color: var(--rip-ink, #2f180f); font-weight: 600; }
|
||||
|
||||
.tree-caret {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
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: var(--rip-brown-700, #6f3922); }
|
||||
|
||||
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Selection bar */
|
||||
.converter-selection-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--primary-50, #fef6ec);
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ===== Upload Panel ===== */
|
||||
|
||||
.converter-upload-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.converter-upload-dropzone {
|
||||
border: 2px dashed var(--rip-border, #d9bc8d);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--rip-panel, #fffaf1);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.converter-upload-dropzone.drag-over {
|
||||
border-color: var(--primary-color, #8e4d2d);
|
||||
background: var(--primary-50, #fef6ec);
|
||||
}
|
||||
|
||||
.converter-upload-hint {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.converter-upload-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.converter-upload-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--rip-panel-soft, #fdf5e7);
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.converter-upload-status-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ===== Job Card ===== */
|
||||
|
||||
.converter-jobs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.converter-jobs-empty {
|
||||
padding: 1rem 0;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
}
|
||||
|
||||
.converter-job-card {
|
||||
border: 1px solid var(--rip-border, #d9bc8d);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--rip-panel, #fffaf1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.converter-job-card.status-done {
|
||||
border-color: var(--green-300, #74c69d);
|
||||
background: #f6fff9;
|
||||
}
|
||||
|
||||
.converter-job-card.status-error {
|
||||
border-color: var(--red-300, #ff8080);
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.converter-job-card.status-cancelled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.converter-job-card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.converter-job-card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.converter-job-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.converter-job-card-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
}
|
||||
|
||||
.converter-job-card-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.converter-job-card-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
}
|
||||
|
||||
.converter-job-card-error {
|
||||
color: var(--red-600, #c62828);
|
||||
font-size: 0.8rem;
|
||||
background: #fff0f0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.converter-job-card-output {
|
||||
font-size: 0.78rem;
|
||||
color: var(--rip-muted, #6a4d38);
|
||||
}
|
||||
|
||||
.converter-job-card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Inline-Konfiguration für READY_TO_START Jobs */
|
||||
.converter-job-card.is-ready {
|
||||
border-color: var(--amber-400, #f59e0b);
|
||||
background: var(--surface-50, #fafaf9);
|
||||
}
|
||||
|
||||
.cjc-config {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--surface-200, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cjc-config-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cjc-config-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.cjc-config-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.cjc-config-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.cjc-config-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user