0.16.1-1 release snapshot
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { ConfirmDialog } from 'primereact/confirmdialog';
|
||||
import { api } from './api/client';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import RipperPage from './pages/RipperPage';
|
||||
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';
|
||||
import JobsInboxPage from './pages/JobsInboxPage';
|
||||
import AudiobooksPage from './pages/AudiobooksPage';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(100, parsed));
|
||||
}
|
||||
|
||||
function normalizeStage(value) {
|
||||
return String(value || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function isTerminalStage(value) {
|
||||
const normalized = normalizeStage(value);
|
||||
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 'n/a';
|
||||
}
|
||||
if (parsed === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let unitIndex = 0;
|
||||
let current = parsed;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const digits = unitIndex <= 1 ? 0 : 2;
|
||||
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function createInitialAudiobookUploadState() {
|
||||
return {
|
||||
phase: 'idle',
|
||||
fileName: null,
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
progressPercent: 0,
|
||||
statusText: null,
|
||||
errorMessage: null,
|
||||
jobId: null,
|
||||
startedAt: null,
|
||||
finishedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
function getAudiobookUploadTagMeta(phase) {
|
||||
const normalized = String(phase || '').trim().toLowerCase();
|
||||
if (normalized === 'uploading') {
|
||||
return { label: 'Upload läuft', severity: 'warning' };
|
||||
}
|
||||
if (normalized === 'processing') {
|
||||
return { label: 'Server verarbeitet', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'completed') {
|
||||
return { label: 'Bereit', severity: 'success' };
|
||||
}
|
||||
if (normalized === 'error') {
|
||||
return { label: 'Fehler', severity: 'danger' };
|
||||
}
|
||||
return { label: 'Inaktiv', severity: 'secondary' };
|
||||
}
|
||||
|
||||
function getDownloadIndicatorMeta(summary) {
|
||||
const activeCount = Number(summary?.activeCount || 0);
|
||||
const failedCount = Number(summary?.failedCount || 0);
|
||||
const totalCount = Number(summary?.totalCount || 0);
|
||||
|
||||
if (activeCount > 0) {
|
||||
return {
|
||||
icon: 'pi pi-spinner pi-spin',
|
||||
label: activeCount === 1 ? '1 ZIP aktiv' : `${activeCount} ZIPs aktiv`,
|
||||
className: 'zip-status-indicator-active'
|
||||
};
|
||||
}
|
||||
if (totalCount > 0) {
|
||||
return {
|
||||
icon: 'pi pi-check',
|
||||
label: failedCount > 0 ? 'ZIP-Jobs beendet' : 'ZIPs fertig',
|
||||
className: 'zip-status-indicator-ready'
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: 'pi pi-download',
|
||||
label: 'ZIPs',
|
||||
className: 'zip-status-indicator-idle'
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const appVersion = __APP_VERSION__;
|
||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||
const [expertMode, setExpertMode] = useState(false);
|
||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||
const [ripperJobsRefreshToken, setRipperJobsRefreshToken] = useState(0);
|
||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
|
||||
const [downloadSummary, setDownloadSummary] = useState(null);
|
||||
const [pendingRipperJobId, setPendingRipperJobId] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const globalToastRef = useRef(null);
|
||||
const prevCdDrivesRef = useRef({});
|
||||
|
||||
// When a virtual CD drive is removed (CD encode/rip finished or failed),
|
||||
// or when a CD drive transitions away from an active job, force both
|
||||
// Ripper and History to re-fetch so jobs leave the live list reliably.
|
||||
useEffect(() => {
|
||||
const current = pipeline?.cdDrives || {};
|
||||
const prev = prevCdDrivesRef.current;
|
||||
const normalizeState = (value) => String(value || '').trim().toUpperCase();
|
||||
const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']);
|
||||
const TERMINAL_CD_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']);
|
||||
let shouldRefresh = false;
|
||||
|
||||
for (const [devicePath, previousDrive] of Object.entries(prev)) {
|
||||
const previousJobId = normalizeJobId(previousDrive?.jobId);
|
||||
if (!previousJobId) {
|
||||
continue;
|
||||
}
|
||||
const previousState = normalizeState(previousDrive?.state);
|
||||
const currentDrive = current?.[devicePath] || null;
|
||||
const currentJobId = normalizeJobId(currentDrive?.jobId);
|
||||
const currentState = normalizeState(currentDrive?.state);
|
||||
const driveRemoved = !currentDrive;
|
||||
const jobChanged = currentJobId !== previousJobId;
|
||||
const movedToTerminal = currentJobId === previousJobId
|
||||
&& TERMINAL_CD_STATES.has(currentState)
|
||||
&& currentState !== previousState;
|
||||
const leftActiveLifecycle = ACTIVE_CD_STATES.has(previousState)
|
||||
&& (driveRemoved || jobChanged || !ACTIVE_CD_STATES.has(currentState));
|
||||
if (movedToTerminal || leftActiveLifecycle) {
|
||||
shouldRefresh = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRefresh) {
|
||||
setRipperJobsRefreshToken((t) => t + 1);
|
||||
setHistoryJobsRefreshToken((t) => t + 1);
|
||||
}
|
||||
prevCdDrivesRef.current = current;
|
||||
}, [pipeline?.cdDrives]);
|
||||
|
||||
const refreshPipeline = async () => {
|
||||
const response = await api.getPipelineState();
|
||||
setPipeline(response.pipeline);
|
||||
setHardwareMonitoring(response?.hardwareMonitoring || null);
|
||||
return response;
|
||||
};
|
||||
|
||||
const clearAudiobookUpload = () => {
|
||||
setAudiobookUpload(createInitialAudiobookUploadState());
|
||||
};
|
||||
|
||||
const handleAudiobookUpload = async (file, payload = {}) => {
|
||||
if (!file) {
|
||||
throw new Error('Bitte zuerst eine AAX-Datei auswählen.');
|
||||
}
|
||||
|
||||
const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0
|
||||
? Number(file.size)
|
||||
: 0;
|
||||
|
||||
setAudiobookUpload({
|
||||
phase: 'uploading',
|
||||
fileName: String(file.name || '').trim() || 'upload.aax',
|
||||
loadedBytes: 0,
|
||||
totalBytes: fallbackTotalBytes,
|
||||
progressPercent: 0,
|
||||
statusText: 'AAX-Datei wird hochgeladen ...',
|
||||
errorMessage: null,
|
||||
jobId: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await api.uploadAudiobook(file, payload, {
|
||||
onProgress: ({ loaded, total, percent }) => {
|
||||
const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0
|
||||
? Number(loaded)
|
||||
: 0;
|
||||
const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0
|
||||
? Number(total)
|
||||
: fallbackTotalBytes;
|
||||
const nextPercent = Number.isFinite(Number(percent))
|
||||
? clampPercent(Number(percent))
|
||||
: (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0);
|
||||
const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal;
|
||||
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: transferComplete ? 'processing' : 'uploading',
|
||||
loadedBytes: nextLoaded,
|
||||
totalBytes: nextTotal,
|
||||
progressPercent: nextPercent,
|
||||
statusText: transferComplete
|
||||
? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...'
|
||||
: 'AAX-Datei wird hochgeladen ...'
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||
await refreshPipeline().catch(() => null);
|
||||
setRipperJobsRefreshToken((prev) => prev + 1);
|
||||
setHistoryJobsRefreshToken((prev) => prev + 1);
|
||||
if (uploadedJobId) {
|
||||
setPendingRipperJobId(uploadedJobId);
|
||||
}
|
||||
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: 'completed',
|
||||
loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes,
|
||||
totalBytes: prev.totalBytes || fallbackTotalBytes,
|
||||
progressPercent: 100,
|
||||
statusText: uploadedJobId
|
||||
? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.`
|
||||
: 'Upload abgeschlossen.',
|
||||
errorMessage: null,
|
||||
jobId: uploadedJobId,
|
||||
finishedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: 'error',
|
||||
errorMessage: error?.message || 'Upload fehlgeschlagen.',
|
||||
statusText: error?.message || 'Upload fehlgeschlagen.',
|
||||
finishedAt: new Date().toISOString()
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRipperJobFocusConsumed = (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setPendingRipperJobId((prev) => (
|
||||
normalizeJobId(prev) === normalizedJobId ? null : prev
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshPipeline().catch(() => null);
|
||||
api.getDownloadsSummary()
|
||||
.then((response) => {
|
||||
setDownloadSummary(response?.summary || null);
|
||||
})
|
||||
.catch(() => null);
|
||||
api.getSettings()
|
||||
.then((response) => {
|
||||
const allSettings = (response?.categories || []).flatMap((c) => c.settings || []);
|
||||
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
|
||||
setExpertMode(val === 'true' || val === true);
|
||||
})
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
||||
setPipeline(message.payload);
|
||||
}
|
||||
|
||||
if (message.type === 'PIPELINE_PROGRESS') {
|
||||
const payload = message.payload;
|
||||
const progressJobId = payload?.activeJobId;
|
||||
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
|
||||
? payload.contextPatch
|
||||
: null;
|
||||
setPipeline((prev) => {
|
||||
const next = { ...prev };
|
||||
const normalizedProgressJobId = normalizeJobId(progressJobId);
|
||||
const progressStage = normalizeStage(payload?.state);
|
||||
const isCdProgressStage = progressStage === 'CD_ANALYZING'
|
||||
|| progressStage === 'CD_RIPPING'
|
||||
|| progressStage === 'CD_ENCODING';
|
||||
const incomingIsTerminal = isTerminalStage(progressStage);
|
||||
const prevActiveJobId = normalizeJobId(prev?.activeJobId || prev?.context?.jobId);
|
||||
const prevJobProgress = normalizedProgressJobId
|
||||
? (prev?.jobProgress?.[normalizedProgressJobId] || null)
|
||||
: null;
|
||||
const prevJobProgressState = normalizeStage(prevJobProgress?.state);
|
||||
const prevJobProgressIsTerminal = isTerminalStage(prevJobProgressState);
|
||||
const matchingCdDriveEntry = normalizedProgressJobId
|
||||
? Object.values(prev?.cdDrives || {}).find((driveState) => normalizeJobId(driveState?.jobId) === normalizedProgressJobId)
|
||||
: null;
|
||||
const matchingCdDriveIsTerminal = isTerminalStage(matchingCdDriveEntry?.state);
|
||||
const hasKnownBinding = Boolean(
|
||||
normalizedProgressJobId
|
||||
&& (
|
||||
prevActiveJobId === normalizedProgressJobId
|
||||
|| prevJobProgress
|
||||
|| matchingCdDriveEntry
|
||||
)
|
||||
);
|
||||
|
||||
// Ignore late/stale progress packets that arrive after a terminal state
|
||||
// or for jobs that are no longer bound in pipeline state.
|
||||
if (
|
||||
normalizedProgressJobId
|
||||
&& !incomingIsTerminal
|
||||
&& (
|
||||
prevJobProgressIsTerminal
|
||||
|| matchingCdDriveIsTerminal
|
||||
|| !hasKnownBinding
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (progressJobId != null) {
|
||||
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
||||
const previousJobStage = normalizeStage(previousJobProgress?.state);
|
||||
const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal;
|
||||
const mergedJobContext = contextPatch
|
||||
? {
|
||||
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||
? previousJobProgress.context
|
||||
: {}),
|
||||
...contextPatch
|
||||
}
|
||||
: (previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||
? previousJobProgress.context
|
||||
: undefined);
|
||||
next.jobProgress = {
|
||||
...(prev?.jobProgress || {}),
|
||||
[progressJobId]: {
|
||||
...previousJobProgress,
|
||||
state: keepPreviousJobStage ? previousJobProgress.state : payload.state,
|
||||
progress: keepPreviousJobStage ? previousJobProgress.progress : payload.progress,
|
||||
eta: keepPreviousJobStage ? previousJobProgress.eta : payload.eta,
|
||||
statusText: keepPreviousJobStage ? previousJobProgress.statusText : payload.statusText,
|
||||
...(mergedJobContext !== undefined ? { context: mergedJobContext } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
||||
const previousGlobalStage = normalizeStage(prev?.state);
|
||||
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal;
|
||||
next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state);
|
||||
next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress);
|
||||
next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta);
|
||||
next.statusText = keepPreviousGlobalStage ? prev?.statusText : (payload.statusText ?? prev?.statusText);
|
||||
if (contextPatch) {
|
||||
next.context = {
|
||||
...(prev?.context && typeof prev.context === 'object' ? prev.context : {}),
|
||||
...contextPatch
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Keep per-drive CD progress in sync with live progress events.
|
||||
// Backend sends frequent PIPELINE_PROGRESS updates, while cdDrives
|
||||
// snapshots are only broadcast on state transitions.
|
||||
if (isCdProgressStage && prev?.cdDrives && typeof prev.cdDrives === 'object') {
|
||||
const cdDrivesEntries = Object.entries(prev.cdDrives);
|
||||
const nextCdDrives = { ...prev.cdDrives };
|
||||
const patchDrive = (driveState) => {
|
||||
const currentDriveStage = normalizeStage(driveState?.state);
|
||||
if (isTerminalStage(currentDriveStage) && !incomingIsTerminal) {
|
||||
return driveState;
|
||||
}
|
||||
const mergedContext = contextPatch
|
||||
? {
|
||||
...(driveState?.context && typeof driveState.context === 'object' ? driveState.context : {}),
|
||||
...contextPatch
|
||||
}
|
||||
: driveState?.context;
|
||||
return {
|
||||
...driveState,
|
||||
state: payload?.state ?? driveState?.state,
|
||||
progress: payload?.progress ?? driveState?.progress,
|
||||
eta: payload?.eta ?? driveState?.eta,
|
||||
statusText: payload?.statusText ?? driveState?.statusText,
|
||||
...(mergedContext !== undefined ? { context: mergedContext } : {})
|
||||
};
|
||||
};
|
||||
|
||||
let updated = false;
|
||||
if (normalizedProgressJobId) {
|
||||
for (const [drivePath, driveState] of cdDrivesEntries) {
|
||||
if (normalizeJobId(driveState?.jobId) === normalizedProgressJobId) {
|
||||
nextCdDrives[drivePath] = patchDrive(driveState);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
const activeCdEntries = cdDrivesEntries.filter(([, driveState]) => {
|
||||
const driveStage = String(driveState?.state || '').trim().toUpperCase();
|
||||
return driveStage === 'CD_ANALYZING'
|
||||
|| driveStage === 'CD_RIPPING'
|
||||
|| driveStage === 'CD_ENCODING';
|
||||
});
|
||||
if (activeCdEntries.length === 1) {
|
||||
const [drivePath, driveState] = activeCdEntries[0];
|
||||
nextCdDrives[drivePath] = patchDrive(driveState);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
next.cdDrives = nextCdDrives;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (message.type === 'PIPELINE_QUEUE_CHANGED') {
|
||||
setPipeline((prev) => ({
|
||||
...(prev || {}),
|
||||
queue: message.payload || null
|
||||
}));
|
||||
setRipperJobsRefreshToken((prev) => prev + 1);
|
||||
setHistoryJobsRefreshToken((prev) => prev + 1);
|
||||
}
|
||||
|
||||
if (message.type === 'DISC_DETECTED') {
|
||||
setLastDiscEvent(message.payload?.device || null);
|
||||
refreshPipeline().catch(() => null);
|
||||
}
|
||||
|
||||
if (message.type === 'DISC_REMOVED') {
|
||||
setLastDiscEvent(null);
|
||||
}
|
||||
|
||||
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
|
||||
setHardwareMonitoring(message.payload || null);
|
||||
}
|
||||
|
||||
if (message.type === 'SETTINGS_UPDATED') {
|
||||
const setting = message.payload;
|
||||
if (setting?.key === 'ui_expert_mode') {
|
||||
const val = setting?.value;
|
||||
setExpertMode(val === 'true' || val === true);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'SETTINGS_BULK_UPDATED') {
|
||||
const keys = message.payload?.keys || [];
|
||||
if (keys.includes('ui_expert_mode')) {
|
||||
api.getSettings({ forceRefresh: true })
|
||||
.then((response) => {
|
||||
const allSettings = (response?.categories || []).flatMap((c) => c.settings || []);
|
||||
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
|
||||
setExpertMode(val === 'true' || val === true);
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'DOWNLOADS_UPDATED') {
|
||||
const summary = message.payload?.summary && typeof message.payload.summary === 'object'
|
||||
? message.payload.summary
|
||||
: null;
|
||||
const reason = String(message.payload?.reason || '').trim().toLowerCase();
|
||||
const item = message.payload?.item && typeof message.payload.item === 'object'
|
||||
? message.payload.item
|
||||
: null;
|
||||
|
||||
if (summary) {
|
||||
setDownloadSummary(summary);
|
||||
}
|
||||
setDownloadsRefreshToken((prev) => prev + 1);
|
||||
|
||||
if (reason === 'ready' && item) {
|
||||
globalToastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'ZIP fertig',
|
||||
detail: `${item.archiveName || 'ZIP-Datei'} steht jetzt auf der Downloads-Seite bereit.`,
|
||||
life: 4500
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'failed' && item) {
|
||||
globalToastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'ZIP fehlgeschlagen',
|
||||
detail: item.errorMessage || `${item.archiveName || 'ZIP-Datei'} konnte nicht erstellt werden.`,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ label: 'Jobs', path: '/jobs' },
|
||||
{ label: 'Ripper', path: '/ripper' },
|
||||
{ label: 'Converter', path: '/converter' },
|
||||
{ label: 'Audiobooks', path: '/audiobooks' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
{ label: 'Historie', path: '/history' },
|
||||
{ label: 'Downloads', path: '/downloads' },
|
||||
...(expertMode ? [{ label: 'Database', path: '/database' }] : [])
|
||||
];
|
||||
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
||||
const uploadProgress = clampPercent(audiobookUpload?.progressPercent);
|
||||
const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase);
|
||||
const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
|
||||
const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
|
||||
const uploadBytesLabel = uploadTotalBytes > 0
|
||||
? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}`
|
||||
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
|
||||
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
|
||||
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
|
||||
const isAudiobooksRoute = location.pathname === '/audiobooks';
|
||||
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
|
||||
const isNavActive = (path) => {
|
||||
if (path === '/ripper') {
|
||||
return location.pathname === '/' || location.pathname === '/ripper';
|
||||
}
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Toast ref={globalToastRef} position="top-right" />
|
||||
<ConfirmDialog />
|
||||
|
||||
<header className="app-header">
|
||||
<div className="brand-block">
|
||||
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||
<div className="brand-copy">
|
||||
<h1>Ripster</h1>
|
||||
<div className="brand-meta">
|
||||
<p>Disc Ripping Control Center</p>
|
||||
<span className="app-version" aria-label={`Version ${appVersion}`}>
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-buttons">
|
||||
{nav.map((item) => (
|
||||
<Button
|
||||
key={item.path}
|
||||
label={item.label}
|
||||
onClick={() => navigate(item.path)}
|
||||
className={isNavActive(item.path) ? 'nav-btn nav-btn-active' : 'nav-btn'}
|
||||
outlined={!isNavActive(item.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showAudiobookUploadBanner ? (
|
||||
<section className={`app-upload-banner phase-${uploadPhase}`}>
|
||||
<div className="app-upload-banner-copy">
|
||||
<div className="app-upload-banner-head">
|
||||
<strong>Audiobook Upload</strong>
|
||||
<Tag value={uploadTagMeta.label} severity={uploadTagMeta.severity} />
|
||||
</div>
|
||||
<small>{audiobookUpload?.statusText || 'Upload aktiv.'}</small>
|
||||
{audiobookUpload?.fileName ? <small>Datei: {audiobookUpload.fileName}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="app-upload-banner-progress"
|
||||
aria-label={`Audiobook Upload ${Math.trunc(uploadProgress)} Prozent`}
|
||||
>
|
||||
<ProgressBar value={uploadProgress} showValue={false} />
|
||||
<small>
|
||||
{uploadPhase === 'processing'
|
||||
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
|
||||
: uploadBytesLabel
|
||||
? `${Math.trunc(uploadProgress)}% | ${uploadBytesLabel}`
|
||||
: `${Math.trunc(uploadProgress)}%`}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="app-upload-banner-actions">
|
||||
{hasUploadedJob && !isAudiobooksRoute ? (
|
||||
<Button
|
||||
label="Zu Audiobooks"
|
||||
icon="pi pi-arrow-right"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => {
|
||||
navigate('/audiobooks');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{canDismissUploadBanner ? (
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
aria-label="Upload-Hinweis schliessen"
|
||||
onClick={clearAudiobookUpload}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RipperPage
|
||||
pipeline={pipeline}
|
||||
hardwareMonitoring={hardwareMonitoring}
|
||||
lastDiscEvent={lastDiscEvent}
|
||||
refreshPipeline={refreshPipeline}
|
||||
jobsRefreshToken={ripperJobsRefreshToken}
|
||||
pendingExpandedJobId={pendingRipperJobId}
|
||||
onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
|
||||
downloadSummary={downloadSummary}
|
||||
>
|
||||
<Outlet />
|
||||
</RipperPage>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="jobs" replace />} />
|
||||
<Route path="ripper" element={null} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||
<Route path="database" element={<DatabasePage />} />
|
||||
<Route path="jobs" element={<JobsInboxPage />} />
|
||||
<Route path="converter" element={<ConverterPage />} />
|
||||
<Route
|
||||
path="audiobooks"
|
||||
element={
|
||||
<AudiobooksPage
|
||||
audiobookUpload={audiobookUpload}
|
||||
onAudiobookUpload={handleAudiobookUpload}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user