0.10.2-3 Layout
This commit is contained in:
@@ -2058,6 +2058,8 @@ export default function DashboardPage({
|
|||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
|
<div className="dashboard-3col-grid">
|
||||||
|
<div className="dashboard-col dashboard-col-left">
|
||||||
<Card title="Hardware Monitoring" subTitle="CPU (inkl. Temperatur), RAM, GPU und freier Speicher in den konfigurierten Pfaden.">
|
<Card title="Hardware Monitoring" subTitle="CPU (inkl. Temperatur), RAM, GPU und freier Speicher in den konfigurierten Pfaden.">
|
||||||
<div className="hardware-monitor-head">
|
<div className="hardware-monitor-head">
|
||||||
<Tag
|
<Tag
|
||||||
@@ -2235,6 +2237,277 @@ export default function DashboardPage({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Disk-Information">
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label="Laufwerk neu lesen"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
onClick={handleRescan}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!canRescan}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Disk neu analysieren"
|
||||||
|
icon="pi pi-search"
|
||||||
|
severity="warning"
|
||||||
|
onClick={handleReanalyze}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!canReanalyze}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Metadaten-Modal öffnen"
|
||||||
|
icon="pi pi-list"
|
||||||
|
onClick={() => handleOpenMetadataDialog()}
|
||||||
|
disabled={!canOpenMetadataModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{device ? (
|
||||||
|
<div className="device-meta">
|
||||||
|
<div>
|
||||||
|
<strong>Pfad:</strong> {device.path || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Modell:</strong> {device.model || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Disk-Label:</strong> {device.discLabel || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Laufwerks-Label:</strong> {device.label || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Mount:</strong> {device.mountpoint || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>Aktuell keine Disk erkannt.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-col dashboard-col-center">
|
||||||
|
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
||||||
|
{jobsLoading ? (
|
||||||
|
<p>Jobs werden geladen ...</p>
|
||||||
|
) : dashboardJobs.length === 0 ? (
|
||||||
|
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p>
|
||||||
|
) : (
|
||||||
|
<div className="dashboard-job-list">
|
||||||
|
{dashboardJobs.map((job) => {
|
||||||
|
const jobId = normalizeJobId(job?.id);
|
||||||
|
if (!jobId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedStatus = normalizeStatus(job?.status);
|
||||||
|
const isQueued = queuedJobIdSet.has(jobId);
|
||||||
|
const statusBadgeValue = getStatusLabel(job?.status, { queued: isQueued });
|
||||||
|
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
|
||||||
|
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
||||||
|
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
|
||||||
|
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
|
||||||
|
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
||||||
|
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||||
|
const mediaIndicator = mediaIndicatorMeta(job);
|
||||||
|
const isResumable = (
|
||||||
|
normalizedStatus === 'READY_TO_ENCODE'
|
||||||
|
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
||||||
|
) && !isCurrentSession;
|
||||||
|
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
||||||
|
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||||
|
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
||||||
|
const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
|
||||||
|
const isCdJob = jobState.startsWith('CD_')
|
||||||
|
|| pipelineStage.startsWith('CD_')
|
||||||
|
|| mediaProfile === 'cd'
|
||||||
|
|| mediaIndicator.mediaType === 'cd'
|
||||||
|
|| pipelineStatusText.includes('CD_');
|
||||||
|
const isAudiobookJob = mediaProfile === 'audiobook'
|
||||||
|
|| mediaIndicator.mediaType === 'audiobook'
|
||||||
|
|| String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === 'audiobook';
|
||||||
|
const rawProgress = Number(pipelineForJob?.progress ?? 0);
|
||||||
|
const clampedProgress = Number.isFinite(rawProgress)
|
||||||
|
? Math.max(0, Math.min(100, rawProgress))
|
||||||
|
: 0;
|
||||||
|
const progressLabel = `${Math.round(clampedProgress)}%`;
|
||||||
|
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
||||||
|
|
||||||
|
const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
|
||||||
|
? pipelineForJob.context.selectedMetadata
|
||||||
|
: {};
|
||||||
|
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
return (
|
||||||
|
<div key={jobId} className="dashboard-job-expanded">
|
||||||
|
<div className="dashboard-job-expanded-head">
|
||||||
|
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
||||||
|
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
||||||
|
) : (
|
||||||
|
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||||
|
)}
|
||||||
|
<div className="dashboard-job-expanded-title">
|
||||||
|
<strong className="dashboard-job-title-line">
|
||||||
|
<img
|
||||||
|
src={mediaIndicator.src}
|
||||||
|
alt={mediaIndicator.alt}
|
||||||
|
title={mediaIndicator.title}
|
||||||
|
className="media-indicator-icon"
|
||||||
|
/>
|
||||||
|
<span>#{jobId} | {jobTitle}</span>
|
||||||
|
</strong>
|
||||||
|
<div className="dashboard-job-badges">
|
||||||
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
|
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||||
|
: null}
|
||||||
|
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="Einklappen"
|
||||||
|
icon="pi pi-angle-up"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => setExpandedJobId(null)}
|
||||||
|
disabled={busyJobIds.has(jobId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
if (isCdJob) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isCdJob ? (
|
||||||
|
<CdRipConfigPanel
|
||||||
|
pipeline={pipelineForJob}
|
||||||
|
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
||||||
|
onCancel={() => handleCancel(jobId, jobState)}
|
||||||
|
onRetry={() => handleRetry(jobId)}
|
||||||
|
onOpenMetadata={() => {
|
||||||
|
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||||
|
? pipelineForJob.context
|
||||||
|
: pipeline?.context || {};
|
||||||
|
setCdMetadataDialogContext({ ...ctx, jobId });
|
||||||
|
setCdMetadataDialogVisible(true);
|
||||||
|
}}
|
||||||
|
busy={busyJobIds.has(jobId)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isAudiobookJob) {
|
||||||
|
const needsBytes = pendingActivationJobIds.has(jobId);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{needsBytes && (
|
||||||
|
<div style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', background: 'var(--yellow-100)', border: '1px solid var(--yellow-400)', borderRadius: '6px', color: 'var(--yellow-900)', fontSize: '0.875rem' }}>
|
||||||
|
<i className="pi pi-lock" style={{ marginRight: '0.5rem' }} />
|
||||||
|
<strong>Activation Bytes fehlen.</strong>{' '}
|
||||||
|
<button type="button" style={{ background: 'none', border: 'none', color: 'var(--primary-color)', cursor: 'pointer', textDecoration: 'underline', padding: 0 }} onClick={() => handleAudiobookStart(jobId, null)}>
|
||||||
|
Jetzt eintragen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AudiobookConfigPanel
|
||||||
|
pipeline={pipelineForJob}
|
||||||
|
onStart={(config) => handleAudiobookStart(jobId, config)}
|
||||||
|
onCancel={() => handleCancel(jobId, jobState)}
|
||||||
|
onRetry={() => handleRetry(jobId)}
|
||||||
|
busy={busyJobIds.has(jobId) || needsBytes}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
{!isCdJob && !isAudiobookJob ? (
|
||||||
|
<PipelineStatusCard
|
||||||
|
pipeline={pipelineForJob}
|
||||||
|
onAnalyze={handleAnalyze}
|
||||||
|
onReanalyze={handleReanalyze}
|
||||||
|
onOpenMetadata={handleOpenMetadataDialog}
|
||||||
|
onReassignOmdb={handleOpenReassignOmdbDialog}
|
||||||
|
onStart={handleStartJob}
|
||||||
|
onRestartEncode={handleRestartEncodeWithLastSettings}
|
||||||
|
onRestartReview={handleRestartReviewFromRaw}
|
||||||
|
onConfirmReview={handleConfirmReview}
|
||||||
|
onSelectPlaylist={handleSelectPlaylist}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
onRemoveFromQueue={handleRemoveQueuedJob}
|
||||||
|
isQueued={isQueued}
|
||||||
|
busy={busyJobIds.has(jobId)}
|
||||||
|
liveJobLog={isCurrentSession ? liveJobLog : ''}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={jobId}
|
||||||
|
type="button"
|
||||||
|
className="dashboard-job-row"
|
||||||
|
onClick={() => setExpandedJobId(jobId)}
|
||||||
|
>
|
||||||
|
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
||||||
|
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
||||||
|
) : (
|
||||||
|
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||||
|
)}
|
||||||
|
<div className="dashboard-job-row-content">
|
||||||
|
<div className="dashboard-job-row-main">
|
||||||
|
<strong className="dashboard-job-title-line">
|
||||||
|
<img
|
||||||
|
src={mediaIndicator.src}
|
||||||
|
alt={mediaIndicator.alt}
|
||||||
|
title={mediaIndicator.title}
|
||||||
|
className="media-indicator-icon"
|
||||||
|
/>
|
||||||
|
<span>{jobTitle}</span>
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
#{jobId}
|
||||||
|
{isAudiobookJob
|
||||||
|
? (
|
||||||
|
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
|
||||||
|
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
|
||||||
|
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
`${job?.year ? ` | ${job.year}` : ''}`
|
||||||
|
+ `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-job-badges">
|
||||||
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
|
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||||
|
: null}
|
||||||
|
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
||||||
|
<ProgressBar value={clampedProgress} showValue={false} />
|
||||||
|
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-col dashboard-col-right">
|
||||||
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, analysieren und danach Format/Qualität vor dem Start auswählen.">
|
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, analysieren und danach Format/Qualität vor dem Start auswählen.">
|
||||||
<div className="actions-row">
|
<div className="actions-row">
|
||||||
<input
|
<input
|
||||||
@@ -2596,273 +2869,8 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
|
||||||
{jobsLoading ? (
|
|
||||||
<p>Jobs werden geladen ...</p>
|
|
||||||
) : dashboardJobs.length === 0 ? (
|
|
||||||
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p>
|
|
||||||
) : (
|
|
||||||
<div className="dashboard-job-list">
|
|
||||||
{dashboardJobs.map((job) => {
|
|
||||||
const jobId = normalizeJobId(job?.id);
|
|
||||||
if (!jobId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedStatus = normalizeStatus(job?.status);
|
|
||||||
const isQueued = queuedJobIdSet.has(jobId);
|
|
||||||
const statusBadgeValue = getStatusLabel(job?.status, { queued: isQueued });
|
|
||||||
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
|
|
||||||
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
|
||||||
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
|
|
||||||
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
|
|
||||||
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
|
||||||
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
|
||||||
const mediaIndicator = mediaIndicatorMeta(job);
|
|
||||||
const isResumable = (
|
|
||||||
normalizedStatus === 'READY_TO_ENCODE'
|
|
||||||
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
|
||||||
) && !isCurrentSession;
|
|
||||||
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
|
||||||
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
|
||||||
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
|
||||||
const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
|
|
||||||
const isCdJob = jobState.startsWith('CD_')
|
|
||||||
|| pipelineStage.startsWith('CD_')
|
|
||||||
|| mediaProfile === 'cd'
|
|
||||||
|| mediaIndicator.mediaType === 'cd'
|
|
||||||
|| pipelineStatusText.includes('CD_');
|
|
||||||
const isAudiobookJob = mediaProfile === 'audiobook'
|
|
||||||
|| mediaIndicator.mediaType === 'audiobook'
|
|
||||||
|| String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === 'audiobook';
|
|
||||||
const rawProgress = Number(pipelineForJob?.progress ?? 0);
|
|
||||||
const clampedProgress = Number.isFinite(rawProgress)
|
|
||||||
? Math.max(0, Math.min(100, rawProgress))
|
|
||||||
: 0;
|
|
||||||
const progressLabel = `${Math.round(clampedProgress)}%`;
|
|
||||||
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
|
||||||
|
|
||||||
const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
|
|
||||||
? pipelineForJob.context.selectedMetadata
|
|
||||||
: {};
|
|
||||||
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
return (
|
|
||||||
<div key={jobId} className="dashboard-job-expanded">
|
|
||||||
<div className="dashboard-job-expanded-head">
|
|
||||||
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
|
||||||
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
|
||||||
) : (
|
|
||||||
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
|
||||||
)}
|
|
||||||
<div className="dashboard-job-expanded-title">
|
|
||||||
<strong className="dashboard-job-title-line">
|
|
||||||
<img
|
|
||||||
src={mediaIndicator.src}
|
|
||||||
alt={mediaIndicator.alt}
|
|
||||||
title={mediaIndicator.title}
|
|
||||||
className="media-indicator-icon"
|
|
||||||
/>
|
|
||||||
<span>#{jobId} | {jobTitle}</span>
|
|
||||||
</strong>
|
|
||||||
<div className="dashboard-job-badges">
|
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
|
||||||
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
|
||||||
: null}
|
|
||||||
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
label="Einklappen"
|
|
||||||
icon="pi pi-angle-up"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
onClick={() => setExpandedJobId(null)}
|
|
||||||
disabled={busyJobIds.has(jobId)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
if (isCdJob) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isCdJob ? (
|
|
||||||
<CdRipConfigPanel
|
|
||||||
pipeline={pipelineForJob}
|
|
||||||
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
|
||||||
onCancel={() => handleCancel(jobId, jobState)}
|
|
||||||
onRetry={() => handleRetry(jobId)}
|
|
||||||
onOpenMetadata={() => {
|
|
||||||
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
|
||||||
? pipelineForJob.context
|
|
||||||
: pipeline?.context || {};
|
|
||||||
setCdMetadataDialogContext({ ...ctx, jobId });
|
|
||||||
setCdMetadataDialogVisible(true);
|
|
||||||
}}
|
|
||||||
busy={busyJobIds.has(jobId)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isAudiobookJob) {
|
|
||||||
const needsBytes = pendingActivationJobIds.has(jobId);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{needsBytes && (
|
|
||||||
<div style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', background: 'var(--yellow-100)', border: '1px solid var(--yellow-400)', borderRadius: '6px', color: 'var(--yellow-900)', fontSize: '0.875rem' }}>
|
|
||||||
<i className="pi pi-lock" style={{ marginRight: '0.5rem' }} />
|
|
||||||
<strong>Activation Bytes fehlen.</strong>{' '}
|
|
||||||
<button type="button" style={{ background: 'none', border: 'none', color: 'var(--primary-color)', cursor: 'pointer', textDecoration: 'underline', padding: 0 }} onClick={() => handleAudiobookStart(jobId, null)}>
|
|
||||||
Jetzt eintragen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<AudiobookConfigPanel
|
|
||||||
pipeline={pipelineForJob}
|
|
||||||
onStart={(config) => handleAudiobookStart(jobId, config)}
|
|
||||||
onCancel={() => handleCancel(jobId, jobState)}
|
|
||||||
onRetry={() => handleRetry(jobId)}
|
|
||||||
busy={busyJobIds.has(jobId) || needsBytes}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
{!isCdJob && !isAudiobookJob ? (
|
|
||||||
<PipelineStatusCard
|
|
||||||
pipeline={pipelineForJob}
|
|
||||||
onAnalyze={handleAnalyze}
|
|
||||||
onReanalyze={handleReanalyze}
|
|
||||||
onOpenMetadata={handleOpenMetadataDialog}
|
|
||||||
onReassignOmdb={handleOpenReassignOmdbDialog}
|
|
||||||
onStart={handleStartJob}
|
|
||||||
onRestartEncode={handleRestartEncodeWithLastSettings}
|
|
||||||
onRestartReview={handleRestartReviewFromRaw}
|
|
||||||
onConfirmReview={handleConfirmReview}
|
|
||||||
onSelectPlaylist={handleSelectPlaylist}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
onRemoveFromQueue={handleRemoveQueuedJob}
|
|
||||||
isQueued={isQueued}
|
|
||||||
busy={busyJobIds.has(jobId)}
|
|
||||||
liveJobLog={isCurrentSession ? liveJobLog : ''}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={jobId}
|
|
||||||
type="button"
|
|
||||||
className="dashboard-job-row"
|
|
||||||
onClick={() => setExpandedJobId(jobId)}
|
|
||||||
>
|
|
||||||
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
|
||||||
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
|
||||||
) : (
|
|
||||||
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
|
||||||
)}
|
|
||||||
<div className="dashboard-job-row-content">
|
|
||||||
<div className="dashboard-job-row-main">
|
|
||||||
<strong className="dashboard-job-title-line">
|
|
||||||
<img
|
|
||||||
src={mediaIndicator.src}
|
|
||||||
alt={mediaIndicator.alt}
|
|
||||||
title={mediaIndicator.title}
|
|
||||||
className="media-indicator-icon"
|
|
||||||
/>
|
|
||||||
<span>{jobTitle}</span>
|
|
||||||
</strong>
|
|
||||||
<small>
|
|
||||||
#{jobId}
|
|
||||||
{isAudiobookJob
|
|
||||||
? (
|
|
||||||
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
|
|
||||||
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
|
|
||||||
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
`${job?.year ? ` | ${job.year}` : ''}`
|
|
||||||
+ `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`
|
|
||||||
)}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div className="dashboard-job-badges">
|
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
|
||||||
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
|
||||||
: null}
|
|
||||||
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
|
||||||
</div>
|
|
||||||
<div className="dashboard-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
|
||||||
<ProgressBar value={clampedProgress} showValue={false} />
|
|
||||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<i className="pi pi-angle-down" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Disk-Information">
|
|
||||||
<div className="actions-row">
|
|
||||||
<Button
|
|
||||||
label="Laufwerk neu lesen"
|
|
||||||
icon="pi pi-refresh"
|
|
||||||
severity="secondary"
|
|
||||||
onClick={handleRescan}
|
|
||||||
loading={busy}
|
|
||||||
disabled={!canRescan}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Disk neu analysieren"
|
|
||||||
icon="pi pi-search"
|
|
||||||
severity="warning"
|
|
||||||
onClick={handleReanalyze}
|
|
||||||
loading={busy}
|
|
||||||
disabled={!canReanalyze}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Metadaten-Modal öffnen"
|
|
||||||
icon="pi pi-list"
|
|
||||||
onClick={() => handleOpenMetadataDialog()}
|
|
||||||
disabled={!canOpenMetadataModal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{device ? (
|
|
||||||
<div className="device-meta">
|
|
||||||
<div>
|
|
||||||
<strong>Pfad:</strong> {device.path || '-'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Modell:</strong> {device.model || '-'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Disk-Label:</strong> {device.discLabel || '-'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Laufwerks-Label:</strong> {device.label || '-'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Mount:</strong> {device.mountpoint || '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p>Aktuell keine Disk erkannt.</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<MetadataSelectionDialog
|
<MetadataSelectionDialog
|
||||||
visible={metadataDialogVisible}
|
visible={metadataDialogVisible}
|
||||||
|
|||||||
@@ -314,6 +314,35 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-3col-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.dashboard-3col-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
|
||||||
|
}
|
||||||
|
.dashboard-col-right {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.dashboard-3col-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.status-row {
|
.status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user