0.10.2-3 Layout

This commit is contained in:
2026-03-16 07:15:17 +00:00
parent 3c694d06df
commit fbd439f318
2 changed files with 426 additions and 389 deletions

View File

@@ -2058,6 +2058,8 @@ export default function DashboardPage({
<div className="page-grid">
<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.">
<div className="hardware-monitor-head">
<Tag
@@ -2235,6 +2237,277 @@ export default function DashboardPage({
)}
</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.">
<div className="actions-row">
<input
@@ -2596,273 +2869,8 @@ export default function DashboardPage({
</div>
)}
</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>
<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
visible={metadataDialogVisible}

View File

@@ -314,6 +314,35 @@ body {
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 {
display: flex;
align-items: center;