0.10.2-1 Downloads
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { 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 { api } from './api/client';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import DatabasePage from './pages/DatabasePage';
|
||||
import DownloadsPage from './pages/DownloadsPage';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
@@ -77,6 +79,32 @@ function getAudiobookUploadTagMeta(phase) {
|
||||
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: {} });
|
||||
@@ -85,9 +113,12 @@ function App() {
|
||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
|
||||
const [downloadSummary, setDownloadSummary] = useState(null);
|
||||
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const globalToastRef = useRef(null);
|
||||
|
||||
const refreshPipeline = async () => {
|
||||
const response = await api.getPipelineState();
|
||||
@@ -196,6 +227,11 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshPipeline().catch(() => null);
|
||||
api.getDownloadsSummary()
|
||||
.then((response) => {
|
||||
setDownloadSummary(response?.summary || null);
|
||||
})
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
@@ -270,13 +306,47 @@ function App() {
|
||||
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
|
||||
setHardwareMonitoring(message.payload || 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: 'Dashboard', path: '/' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
{ label: 'Historie', path: '/history' }
|
||||
{ label: 'Historie', path: '/history' },
|
||||
{ label: 'Downloads', path: '/downloads' }
|
||||
];
|
||||
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
||||
@@ -290,9 +360,12 @@ function App() {
|
||||
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
|
||||
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
|
||||
const isDashboardRoute = location.pathname === '/';
|
||||
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Toast ref={globalToastRef} position="top-right" />
|
||||
|
||||
<header className="app-header">
|
||||
<div className="brand-block">
|
||||
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||
@@ -316,6 +389,15 @@ function App() {
|
||||
outlined={location.pathname !== item.path}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`zip-status-indicator ${downloadIndicator.className}`}
|
||||
onClick={() => navigate('/downloads')}
|
||||
title="Downloads-Seite oeffnen"
|
||||
>
|
||||
<i className={downloadIndicator.icon} aria-hidden="true" />
|
||||
<span>{downloadIndicator.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -394,6 +476,7 @@ function App() {
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||
<Route path="/downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||
<Route path="/database" element={<DatabasePage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -693,10 +693,25 @@ export const api = {
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
downloadJobArchive(jobId, target = 'raw') {
|
||||
const query = new URLSearchParams();
|
||||
query.set('target', String(target || 'raw').trim());
|
||||
return download(`/history/${jobId}/download?${query.toString()}`);
|
||||
requestJobArchive(jobId, target = 'raw') {
|
||||
return request(`/downloads/history/${jobId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target })
|
||||
});
|
||||
},
|
||||
getDownloads() {
|
||||
return request('/downloads');
|
||||
},
|
||||
getDownloadsSummary() {
|
||||
return request('/downloads/summary');
|
||||
},
|
||||
downloadPreparedArchive(downloadId) {
|
||||
return download(`/downloads/${encodeURIComponent(downloadId)}/file`);
|
||||
},
|
||||
deleteDownload(downloadId) {
|
||||
return request(`/downloads/${encodeURIComponent(downloadId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
getJob(jobId, options = {}) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
@@ -175,6 +175,7 @@ const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template
|
||||
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
||||
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'];
|
||||
|
||||
function buildSectionsForCategory(categoryName, settings) {
|
||||
@@ -384,6 +385,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const dvdSettings = list.filter((s) => DVD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DVD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const cdSettings = list.filter((s) => CD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && CD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
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 defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||
@@ -391,6 +393,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
||||
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
|
||||
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
|
||||
const defaultDownloads = effectivePaths?.defaults?.downloads || 'data/downloads';
|
||||
|
||||
const ep = effectivePaths || {};
|
||||
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||
@@ -401,6 +404,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const cdMovies = ep.cd?.movies || cdRaw;
|
||||
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
||||
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
|
||||
const downloadPath = ep.downloads?.path || defaultDownloads;
|
||||
|
||||
const isDefault = (path, def) => path === def;
|
||||
|
||||
@@ -467,6 +471,11 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="path-overview-extra">
|
||||
<strong>ZIP-Downloads:</strong>
|
||||
<code>{downloadPath}</code>
|
||||
{isDefault(downloadPath, defaultDownloads) && <span className="path-default-badge">Standard</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Medium-Karten */}
|
||||
@@ -507,6 +516,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<PathMediumCard
|
||||
title="Downloads"
|
||||
pathSettings={downloadSettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Log-Ordner */}
|
||||
|
||||
@@ -439,8 +439,8 @@ function PathField({
|
||||
rounded
|
||||
size="small"
|
||||
className="job-path-download-button"
|
||||
aria-label={`${label} als ZIP herunterladen`}
|
||||
tooltip={`${label} als ZIP herunterladen`}
|
||||
aria-label={`${label} als ZIP vorbereiten`}
|
||||
tooltip={`${label} als ZIP vorbereiten`}
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
onClick={onDownload}
|
||||
disabled={downloadDisabled || downloadLoading}
|
||||
|
||||
305
frontend/src/pages/DownloadsPage.jsx
Normal file
305
frontend/src/pages/DownloadsPage.jsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: 'Alle Stati', value: '' },
|
||||
{ label: 'Wartend', value: 'queued' },
|
||||
{ label: 'Laufend', value: 'processing' },
|
||||
{ label: 'Bereit', value: 'ready' },
|
||||
{ label: 'Fehlgeschlagen', value: 'failed' }
|
||||
];
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return '-';
|
||||
}
|
||||
if (parsed === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let current = parsed;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const digits = unitIndex === 0 ? 0 : 2;
|
||||
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||
}
|
||||
|
||||
function getStatusMeta(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'queued') {
|
||||
return { label: 'Wartend', severity: 'warning' };
|
||||
}
|
||||
if (normalized === 'processing') {
|
||||
return { label: 'Laeuft', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'ready') {
|
||||
return { label: 'Bereit', severity: 'success' };
|
||||
}
|
||||
return { label: 'Fehlgeschlagen', severity: 'danger' };
|
||||
}
|
||||
|
||||
export default function DownloadsPage({ refreshToken = 0 }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadBusyId, setDownloadBusyId] = useState(null);
|
||||
const [deleteBusyId, setDeleteBusyId] = useState(null);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const hasActiveItems = useMemo(
|
||||
() => items.some((item) => ['queued', 'processing'].includes(String(item?.status || '').trim().toLowerCase())),
|
||||
[items]
|
||||
);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const searchText = normalizeSearchText(search);
|
||||
return items.filter((item) => {
|
||||
const matchesStatus = !statusFilter || String(item?.status || '').trim().toLowerCase() === statusFilter;
|
||||
if (!matchesStatus) {
|
||||
return false;
|
||||
}
|
||||
if (!searchText) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
item?.displayTitle,
|
||||
item?.archiveName,
|
||||
item?.label,
|
||||
item?.sourcePath,
|
||||
item?.jobId ? `job ${item.jobId}` : ''
|
||||
]
|
||||
.map((value) => normalizeSearchText(value))
|
||||
.join(' ');
|
||||
return haystack.includes(searchText);
|
||||
});
|
||||
}, [items, search, statusFilter]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getDownloads();
|
||||
setItems(Array.isArray(response?.items) ? response.items : []);
|
||||
setSummary(response?.summary && typeof response.summary === 'object' ? response.summary : null);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Downloads konnten nicht geladen werden',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveItems) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasActiveItems]);
|
||||
|
||||
const handleDownload = async (row) => {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
setDownloadBusyId(id);
|
||||
try {
|
||||
await api.downloadPreparedArchive(id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'ZIP-Download fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const label = row?.archiveName || `ZIP ${id}`;
|
||||
const confirmed = window.confirm(`"${label}" wirklich loeschen?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteBusyId(id);
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'ZIP geloescht',
|
||||
detail: `"${label}" wurde entfernt.`,
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Loeschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDeleteBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBody = (row) => {
|
||||
const meta = getStatusMeta(row?.status);
|
||||
return <Tag value={meta.label} severity={meta.severity} />;
|
||||
};
|
||||
|
||||
const titleBody = (row) => (
|
||||
<div className="downloads-title-cell">
|
||||
<strong>{row?.displayTitle || '-'}</strong>
|
||||
<small>
|
||||
{row?.jobId ? `Job #${row.jobId}` : 'Ohne Job'} | {row?.label || '-'}
|
||||
</small>
|
||||
{row?.errorMessage ? <small className="downloads-error-text">{row.errorMessage}</small> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const archiveBody = (row) => (
|
||||
<div className="downloads-path-cell">
|
||||
<code>{row?.archiveName || '-'}</code>
|
||||
<small>{row?.downloadDir || '-'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sourceBody = (row) => (
|
||||
<div className="downloads-path-cell">
|
||||
<code>{row?.sourcePath || '-'}</code>
|
||||
<small>{row?.sourceType === 'file' ? 'Datei' : 'Ordner'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const actionBody = (row) => {
|
||||
const normalizedStatus = String(row?.status || '').trim().toLowerCase();
|
||||
const canDownload = normalizedStatus === 'ready';
|
||||
const canDelete = !['queued', 'processing'].includes(normalizedStatus);
|
||||
const id = String(row?.id || '').trim();
|
||||
|
||||
return (
|
||||
<div className="downloads-actions">
|
||||
<Button
|
||||
label="Download"
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
onClick={() => handleDownload(row)}
|
||||
disabled={!canDownload || Boolean(deleteBusyId)}
|
||||
loading={downloadBusyId === id}
|
||||
/>
|
||||
<Button
|
||||
label="Loeschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => handleDelete(row)}
|
||||
disabled={!canDelete || Boolean(downloadBusyId)}
|
||||
loading={deleteBusyId === id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Downloadbare Dateien" subTitle="Vorbereitete ZIP-Dateien aus RAW- und Encode-Inhalten">
|
||||
<div className="table-filters">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel, ZIP-Datei oder Pfad"
|
||||
/>
|
||||
<Dropdown
|
||||
value={statusFilter}
|
||||
options={STATUS_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setStatusFilter(event.value || '')}
|
||||
placeholder="Status"
|
||||
/>
|
||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||
</div>
|
||||
|
||||
<div className="downloads-summary-tags">
|
||||
<Tag value={`${summary?.activeCount || 0} aktiv`} severity={(summary?.activeCount || 0) > 0 ? 'info' : 'secondary'} />
|
||||
<Tag value={`${summary?.readyCount || 0} bereit`} severity={(summary?.readyCount || 0) > 0 ? 'success' : 'secondary'} />
|
||||
<Tag value={`${summary?.failedCount || 0} Fehler`} severity={(summary?.failedCount || 0) > 0 ? 'danger' : 'secondary'} />
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-wide">
|
||||
<DataTable
|
||||
value={visibleItems}
|
||||
dataKey="id"
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[10, 20, 50]}
|
||||
loading={loading}
|
||||
responsiveLayout="scroll"
|
||||
emptyMessage="Keine ZIP-Dateien vorhanden"
|
||||
>
|
||||
<Column header="Status" body={statusBody} style={{ width: '10rem' }} />
|
||||
<Column header="Inhalt" body={titleBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="ZIP-Datei" body={archiveBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="Quelle" body={sourceBody} style={{ minWidth: '22rem' }} />
|
||||
<Column header="Erstellt" body={(row) => formatDateTime(row?.createdAt)} style={{ width: '11rem' }} />
|
||||
<Column header="Fertig" body={(row) => formatDateTime(row?.finishedAt)} style={{ width: '11rem' }} />
|
||||
<Column header="Groesse" body={(row) => formatBytes(row?.sizeBytes)} style={{ width: '9rem' }} />
|
||||
<Column header="Aktion" body={actionBody} style={{ width: '14rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -567,7 +567,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
|
||||
setDownloadBusyTarget(normalizedTarget);
|
||||
try {
|
||||
await api.downloadJobArchive(jobId, normalizedTarget);
|
||||
const response = await api.requestJobArchive(jobId, normalizedTarget);
|
||||
const item = response?.item && typeof response.item === 'object' ? response.item : null;
|
||||
const label = normalizedTarget === 'raw' ? 'RAW' : 'Encode';
|
||||
const isReady = String(item?.status || '').trim().toLowerCase() === 'ready';
|
||||
const detail = isReady
|
||||
? `${label}-ZIP ist bereits auf der Downloads-Seite verfuegbar.`
|
||||
: `${label}-ZIP wird im Hintergrund erstellt und erscheint danach auf der Downloads-Seite.`;
|
||||
toastRef.current?.show({
|
||||
severity: isReady ? 'success' : 'info',
|
||||
summary: isReady ? 'ZIP bereit' : 'ZIP wird erstellt',
|
||||
detail,
|
||||
life: 4000
|
||||
});
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
|
||||
@@ -168,6 +168,7 @@ body {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-btn.p-button {
|
||||
@@ -195,6 +196,47 @@ body {
|
||||
box-shadow: 0 0 0 1px rgba(58, 29, 18, 0.3);
|
||||
}
|
||||
|
||||
.zip-status-indicator {
|
||||
border: 1px solid rgba(58, 29, 18, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 247, 232, 0.55);
|
||||
color: var(--rip-brown-900);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.zip-status-indicator:hover {
|
||||
background: rgba(255, 247, 232, 0.82);
|
||||
border-color: var(--rip-brown-700);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.zip-status-indicator i {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.zip-status-indicator span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zip-status-indicator-ready {
|
||||
background: rgba(231, 247, 233, 0.9);
|
||||
border-color: rgba(28, 138, 58, 0.28);
|
||||
color: #1f6d35;
|
||||
}
|
||||
|
||||
.zip-status-indicator-error {
|
||||
background: rgba(255, 236, 230, 0.9);
|
||||
border-color: rgba(184, 74, 39, 0.26);
|
||||
color: #9f3b1f;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
width: min(1280px, 96vw);
|
||||
margin: 1rem auto 2rem;
|
||||
@@ -1574,6 +1616,22 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-overview-extra {
|
||||
margin-top: 0.85rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.path-overview-extra code {
|
||||
font-size: 0.8rem;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.path-medium-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
@@ -1838,6 +1896,47 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.downloads-summary-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.downloads-title-cell,
|
||||
.downloads-path-cell {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downloads-title-cell strong,
|
||||
.downloads-title-cell small,
|
||||
.downloads-path-cell code,
|
||||
.downloads-path-cell small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.downloads-path-cell code {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.downloads-error-text {
|
||||
color: #9f3b1f;
|
||||
}
|
||||
|
||||
.downloads-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.history-dataview .p-dataview-header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -2667,6 +2766,11 @@ body {
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.app-upload-banner {
|
||||
width: calc(100% - 1.5rem);
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2831,6 +2935,11 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.zip-status-indicator {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-dv-item-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user