Skript Integration + UI Anpassungen
This commit is contained in:
@@ -36,6 +36,34 @@ export const api = {
|
||||
getSettings() {
|
||||
return request('/settings');
|
||||
},
|
||||
getHandBrakePresets() {
|
||||
return request('/settings/handbrake-presets');
|
||||
},
|
||||
getScripts() {
|
||||
return request('/settings/scripts');
|
||||
},
|
||||
createScript(payload = {}) {
|
||||
return request('/settings/scripts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
},
|
||||
updateScript(scriptId, payload = {}) {
|
||||
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
},
|
||||
deleteScript(scriptId) {
|
||||
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
testScript(scriptId) {
|
||||
return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
updateSetting(key, value) {
|
||||
return request(`/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -38,8 +38,8 @@ function buildToolSections(settings) {
|
||||
{
|
||||
id: 'output',
|
||||
title: 'Output',
|
||||
description: 'Container-Format und Dateinamen-Template.',
|
||||
match: (key) => key === 'output_extension' || key === 'filename_template'
|
||||
description: 'Container-Format sowie Datei- und Ordnernamen-Template.',
|
||||
match: (key) => key === 'output_extension' || key === 'filename_template' || key === 'output_folder_template'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -95,6 +95,10 @@ function buildSectionsForCategory(categoryName, settings) {
|
||||
];
|
||||
}
|
||||
|
||||
function isHandBrakePresetSetting(setting) {
|
||||
return String(setting?.key || '').trim().toLowerCase() === 'handbrake_preset';
|
||||
}
|
||||
|
||||
export default function DynamicSettingsForm({
|
||||
categories,
|
||||
values,
|
||||
@@ -194,11 +198,24 @@ export default function DynamicSettingsForm({
|
||||
options={setting.options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<small>{setting.description || ''}</small>
|
||||
{isHandBrakePresetSetting(setting) ? (
|
||||
<small>
|
||||
Preset-Erklärung:{' '}
|
||||
<a
|
||||
href="https://handbrake.fr/docs/en/latest/technical/official-presets.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
HandBrake Official Presets
|
||||
</a>
|
||||
</small>
|
||||
) : null}
|
||||
{error ? (
|
||||
<small className="error-text">{error}</small>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Button } from 'primereact/button';
|
||||
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
|
||||
function JsonView({ title, value }) {
|
||||
return (
|
||||
@@ -12,6 +13,67 @@ function JsonView({ title, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||
}
|
||||
|
||||
function statusBadgeMeta(status) {
|
||||
const normalized = String(status || '').trim().toUpperCase();
|
||||
if (normalized === 'FINISHED') {
|
||||
return { label: normalized, icon: 'pi-check-circle', tone: 'success' };
|
||||
}
|
||||
if (normalized === 'ERROR') {
|
||||
return { label: normalized, icon: 'pi-times-circle', tone: 'danger' };
|
||||
}
|
||||
if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') {
|
||||
return { label: normalized, icon: 'pi-play-circle', tone: 'info' };
|
||||
}
|
||||
if (normalized === 'WAITING_FOR_USER_DECISION') {
|
||||
return { label: normalized, icon: 'pi-exclamation-circle', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'METADATA_SELECTION') {
|
||||
return { label: normalized, icon: 'pi-list', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'ANALYZING') {
|
||||
return { label: normalized, icon: 'pi-search', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'RIPPING') {
|
||||
return { label: normalized, icon: 'pi-download', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'MEDIAINFO_CHECK') {
|
||||
return { label: normalized, icon: 'pi-sliders-h', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'ENCODING') {
|
||||
return { label: normalized, icon: 'pi-cog', tone: 'warning' };
|
||||
}
|
||||
return { label: normalized || '-', icon: 'pi-info-circle', tone: 'secondary' };
|
||||
}
|
||||
|
||||
function omdbField(value) {
|
||||
const raw = String(value || '').trim();
|
||||
return raw || '-';
|
||||
}
|
||||
|
||||
function omdbRottenTomatoesScore(omdbInfo) {
|
||||
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
|
||||
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes');
|
||||
return omdbField(entry?.Value);
|
||||
}
|
||||
|
||||
function BoolState({ value }) {
|
||||
const isTrue = Boolean(value);
|
||||
return isTrue ? (
|
||||
<span className="job-step-inline-ok" title="Ja">
|
||||
<i className="pi pi-check-circle" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="job-step-inline-no" title="Nein">
|
||||
<i className="pi pi-times-circle" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobDetailDialog({
|
||||
visible,
|
||||
job,
|
||||
@@ -46,6 +108,12 @@ export default function JobDetailDialog({
|
||||
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||
const logTruncated = Boolean(logMeta?.truncated);
|
||||
const mediaType = resolveMediaType(job);
|
||||
const mediaTypeLabel = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
|
||||
const mediaTypeIcon = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
||||
const mediaTypeAlt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
||||
const statusMeta = statusBadgeMeta(job?.status);
|
||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -68,22 +136,80 @@ export default function JobDetailDialog({
|
||||
<div className="poster-large poster-fallback">Kein Poster</div>
|
||||
)}
|
||||
|
||||
<div className="job-meta-grid">
|
||||
<div className="job-film-info-grid">
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>Film-Infos</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Titel:</strong>
|
||||
<span>{job.title || job.detected_title || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Jahr:</strong>
|
||||
<span>{job.year || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>IMDb:</strong>
|
||||
<span>{job.imdb_id || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>OMDb Match:</strong>
|
||||
<BoolState value={job.selected_from_omdb} />
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Medium:</strong>
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||
<span>{mediaTypeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>OMDb Details</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Regisseur:</strong>
|
||||
<span>{omdbField(omdbInfo?.Director)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Schauspieler:</strong>
|
||||
<span>{omdbField(omdbInfo?.Actors)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Laufzeit:</strong>
|
||||
<span>{omdbField(omdbInfo?.Runtime)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Genre:</strong>
|
||||
<span>{omdbField(omdbInfo?.Genre)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Rotten Tomatoes:</strong>
|
||||
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>imdbRating:</strong>
|
||||
<span>{omdbField(omdbInfo?.imdbRating)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Job-Infos</h4>
|
||||
<div className="job-meta-grid job-meta-grid-compact">
|
||||
<div>
|
||||
<strong>Titel:</strong> {job.title || job.detected_title || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Jahr:</strong> {job.year || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>IMDb:</strong> {job.imdb_id || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>OMDb Match:</strong>{' '}
|
||||
<Tag value={job.selected_from_omdb ? 'Ja' : 'Nein'} severity={job.selected_from_omdb ? 'success' : 'secondary'} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong> <Tag value={job.status} />
|
||||
<strong>Aktueller Status:</strong>{' '}
|
||||
<span
|
||||
className={`job-status-icon tone-${statusMeta.tone}`}
|
||||
title={statusMeta.label}
|
||||
aria-label={statusMeta.label}
|
||||
>
|
||||
<i className={`pi ${statusMeta.icon}`} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Start:</strong> {job.start_time || '-'}
|
||||
@@ -101,40 +227,29 @@ export default function JobDetailDialog({
|
||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Mediainfo bestätigt:</strong> {job.encode_review_confirmed ? 'ja' : 'nein'}
|
||||
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>RAW vorhanden:</strong> {job.rawStatus?.exists ? 'ja' : 'nein'}
|
||||
<strong>Movie Datei vorhanden:</strong> <BoolState value={job.outputStatus?.exists} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>RAW leer:</strong> {job.rawStatus?.isEmpty === null ? '-' : job.rawStatus?.isEmpty ? 'ja' : 'nein'}
|
||||
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Movie Datei vorhanden:</strong> {job.outputStatus?.exists ? 'ja' : 'nein'}
|
||||
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Movie-Dir leer:</strong> {job.movieDirStatus?.isEmpty === null ? '-' : job.movieDirStatus?.isEmpty ? 'ja' : 'nein'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Backup erfolgreich:</strong>{' '}
|
||||
{job?.backupSuccess ? <span className="job-step-inline-ok"><i className="pi pi-check-circle" aria-hidden="true" /><span>ja</span></span> : 'nein'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Encode erfolgreich:</strong>{' '}
|
||||
{job?.encodeSuccess ? <span className="job-step-inline-ok"><i className="pi pi-check-circle" aria-hidden="true" /><span>ja</span></span> : 'nein'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Fehler:</strong> {job.error_message || '-'}
|
||||
<div className="job-meta-col-span-2">
|
||||
<strong>Letzter Fehler:</strong> {job.error_message || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="job-json-grid">
|
||||
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
||||
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
||||
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
||||
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
|
||||
<JsonView title="Encode Plan" value={job.encodePlan} />
|
||||
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
||||
</div>
|
||||
|
||||
{job.encodePlan ? (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
|
||||
function formatDuration(minutes) {
|
||||
const value = Number(minutes || 0);
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -636,15 +639,50 @@ function normalizeTitleId(value) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeScriptId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeScriptIdList(values) {
|
||||
const list = Array.isArray(values) ? values : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const value of list) {
|
||||
const normalized = normalizeScriptId(value);
|
||||
if (normalized === null) {
|
||||
continue;
|
||||
}
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export default function MediaInfoReviewPanel({
|
||||
review,
|
||||
presetDisplayValue = '',
|
||||
commandOutputPath = null,
|
||||
selectedEncodeTitleId = null,
|
||||
allowTitleSelection = false,
|
||||
onSelectEncodeTitle = null,
|
||||
allowTrackSelection = false,
|
||||
trackSelectionByTitle = {},
|
||||
onTrackSelectionChange = null
|
||||
onTrackSelectionChange = null,
|
||||
availablePostScripts = [],
|
||||
selectedPostEncodeScriptIds = [],
|
||||
allowPostScriptSelection = false,
|
||||
onAddPostEncodeScript = null,
|
||||
onChangePostEncodeScript = null,
|
||||
onRemovePostEncodeScript = null,
|
||||
onReorderPostEncodeScript = null
|
||||
}) {
|
||||
if (!review) {
|
||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||
@@ -656,11 +694,35 @@ export default function MediaInfoReviewPanel({
|
||||
const processedFiles = Number(review.processedFiles || titles.length || 0);
|
||||
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
||||
const playlistRecommendation = review.playlistRecommendation || null;
|
||||
const presetLabel = String(presetDisplayValue || review.selectors?.preset || '').trim() || '-';
|
||||
const scriptRows = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
||||
const scriptCatalog = (Array.isArray(availablePostScripts) ? availablePostScripts : [])
|
||||
.map((item) => ({
|
||||
id: normalizeScriptId(item?.id),
|
||||
name: String(item?.name || '').trim()
|
||||
}))
|
||||
.filter((item) => item.id !== null && item.name.length > 0);
|
||||
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
|
||||
const canAddScriptRow = allowPostScriptSelection && scriptCatalog.length > 0 && scriptRows.length < scriptCatalog.length;
|
||||
const canReorderScriptRows = allowPostScriptSelection && scriptRows.length > 1;
|
||||
|
||||
const handleScriptDrop = (event, targetIndex) => {
|
||||
if (!allowPostScriptSelection || typeof onReorderPostEncodeScript !== 'function') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const fromText = event.dataTransfer?.getData('text/plain');
|
||||
const fromIndex = Number(fromText);
|
||||
if (!Number.isInteger(fromIndex)) {
|
||||
return;
|
||||
}
|
||||
onReorderPostEncodeScript(fromIndex, targetIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="media-review-wrap">
|
||||
<div className="media-review-meta">
|
||||
<div><strong>Preset:</strong> {review.selectors?.preset || '-'}</div>
|
||||
<div><strong>Preset:</strong> {presetLabel}</div>
|
||||
<div><strong>Extra Args:</strong> {review.selectors?.extraArgs || '(keine)'}</div>
|
||||
<div><strong>Preset-Profil:</strong> {review.selectors?.presetProfileSource || '-'}</div>
|
||||
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
|
||||
@@ -695,6 +757,87 @@ export default function MediaInfoReviewPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="post-script-box">
|
||||
<h4>Post-Encode Scripte (optional)</h4>
|
||||
{scriptCatalog.length === 0 ? (
|
||||
<small>Keine Scripte konfiguriert. In den Settings unter "Scripte" anlegen.</small>
|
||||
) : null}
|
||||
{scriptRows.length === 0 ? (
|
||||
<small>Keine Post-Encode Scripte ausgewählt.</small>
|
||||
) : null}
|
||||
{scriptRows.map((scriptId, rowIndex) => {
|
||||
const script = scriptById.get(scriptId) || null;
|
||||
const selectedInOtherRows = new Set(
|
||||
scriptRows.filter((id, index) => index !== rowIndex).map((id) => String(id))
|
||||
);
|
||||
const options = scriptCatalog.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
disabled: selectedInOtherRows.has(String(item.id))
|
||||
}));
|
||||
return (
|
||||
<div
|
||||
key={`post-script-row-${rowIndex}-${scriptId}`}
|
||||
className={`post-script-row${allowPostScriptSelection ? ' editable' : ''}`}
|
||||
onDragOver={(event) => {
|
||||
if (!canReorderScriptRows) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => handleScriptDrop(event, rowIndex)}
|
||||
>
|
||||
{allowPostScriptSelection ? (
|
||||
<>
|
||||
<span
|
||||
className={`post-script-drag-handle pi pi-bars${canReorderScriptRows ? '' : ' disabled'}`}
|
||||
title={canReorderScriptRows ? 'Ziehen zum Umordnen' : 'Mindestens zwei Scripte zum Umordnen'}
|
||||
draggable={canReorderScriptRows}
|
||||
onDragStart={(event) => {
|
||||
if (!canReorderScriptRows) {
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(rowIndex));
|
||||
}}
|
||||
/>
|
||||
<Dropdown
|
||||
value={scriptId}
|
||||
options={options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => onChangePostEncodeScript?.(rowIndex, event.value)}
|
||||
className="full-width"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => onRemovePostEncodeScript?.(rowIndex)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<small>{`${rowIndex + 1}. ${script?.name || `Script #${scriptId}`}`}</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canAddScriptRow ? (
|
||||
<Button
|
||||
label="Script hinzufügen"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => onAddPostEncodeScript?.()}
|
||||
/>
|
||||
) : null}
|
||||
<small>Ausführung erfolgt nur nach erfolgreichem Encode, strikt nacheinander in genau dieser Reihenfolge (Drag-and-Drop möglich).</small>
|
||||
</div>
|
||||
|
||||
<h4>Titel</h4>
|
||||
<div className="media-title-list">
|
||||
{titles.length === 0 ? (
|
||||
|
||||
@@ -65,6 +65,33 @@ function normalizeTrackIdList(values) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeScriptId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeScriptIdList(values) {
|
||||
const list = Array.isArray(values) ? values : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const value of list) {
|
||||
const normalized = normalizeScriptId(value);
|
||||
if (normalized === null) {
|
||||
continue;
|
||||
}
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildDefaultTrackSelection(review) {
|
||||
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
||||
const selection = {};
|
||||
@@ -108,6 +135,23 @@ function buildSettingsMap(categories) {
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildPresetDisplayMap(options) {
|
||||
const map = {};
|
||||
const list = Array.isArray(options) ? options : [];
|
||||
for (const option of list) {
|
||||
if (!option || option.disabled) {
|
||||
continue;
|
||||
}
|
||||
const value = String(option.value || '').trim();
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
const category = String(option.category || '').trim();
|
||||
map[value] = category ? `${category}/${value}` : value;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function sanitizeFileName(input) {
|
||||
return String(input || 'untitled')
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
@@ -135,9 +179,10 @@ function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
||||
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||
const year = metadata?.year || new Date().getFullYear();
|
||||
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||
const template = settings?.filename_template || '${title} (${year})';
|
||||
const folderName = sanitizeFileName(renderTemplate('${title} (${year})', { title, year, imdbId }));
|
||||
const baseName = sanitizeFileName(renderTemplate(template, { title, year, imdbId }));
|
||||
const fileTemplate = settings?.filename_template || '${title} (${year})';
|
||||
const folderTemplate = String(settings?.output_folder_template || '').trim() || fileTemplate;
|
||||
const folderName = sanitizeFileName(renderTemplate(folderTemplate, { title, year, imdbId }));
|
||||
const baseName = sanitizeFileName(renderTemplate(fileTemplate, { title, year, imdbId }));
|
||||
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
|
||||
const root = movieDir.replace(/\/+$/g, '');
|
||||
return `${root}/${folderName}/${baseName}.${ext}`;
|
||||
@@ -171,18 +216,43 @@ export default function PipelineStatusCard({
|
||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
||||
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
||||
const [settingsMap, setSettingsMap] = useState({});
|
||||
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
||||
const [scriptCatalog, setScriptCatalog] = useState([]);
|
||||
const [selectedPostEncodeScriptIds, setSelectedPostEncodeScriptIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.getSettings();
|
||||
const [settingsResponse, presetsResponse, scriptsResponse] = await Promise.allSettled([
|
||||
api.getSettings(),
|
||||
api.getHandBrakePresets(),
|
||||
api.getScripts()
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setSettingsMap(buildSettingsMap(response?.categories || []));
|
||||
const categories = settingsResponse.status === 'fulfilled'
|
||||
? (settingsResponse.value?.categories || [])
|
||||
: [];
|
||||
setSettingsMap(buildSettingsMap(categories));
|
||||
const presetOptions = presetsResponse.status === 'fulfilled'
|
||||
? (presetsResponse.value?.options || [])
|
||||
: [];
|
||||
setPresetDisplayMap(buildPresetDisplayMap(presetOptions));
|
||||
const scripts = scriptsResponse.status === 'fulfilled'
|
||||
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
|
||||
: [];
|
||||
setScriptCatalog(
|
||||
scripts.map((item) => ({
|
||||
id: item?.id,
|
||||
name: item?.name
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setSettingsMap({});
|
||||
setPresetDisplayMap({});
|
||||
setScriptCatalog([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -196,6 +266,9 @@ export default function PipelineStatusCard({
|
||||
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
|
||||
setSelectedEncodeTitleId(fromReview);
|
||||
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
||||
setSelectedPostEncodeScriptIds(
|
||||
normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || [])
|
||||
);
|
||||
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -312,6 +385,13 @@ export default function PipelineStatusCard({
|
||||
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
|
||||
[settingsMap, selectedMetadata, retryJobId]
|
||||
);
|
||||
const presetDisplayValue = useMemo(() => {
|
||||
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
||||
if (!preset) {
|
||||
return '';
|
||||
}
|
||||
return presetDisplayMap[preset] || preset;
|
||||
}, [mediaInfoReview?.selectors?.preset, presetDisplayMap]);
|
||||
const buildSelectedTrackSelectionForCurrentTitle = () => {
|
||||
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
|
||||
const selectionEntry = encodeTitleId
|
||||
@@ -329,9 +409,11 @@ export default function PipelineStatusCard({
|
||||
}
|
||||
}
|
||||
: null;
|
||||
const selectedPostScriptIds = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
||||
return {
|
||||
encodeTitleId,
|
||||
selectedTrackSelection
|
||||
selectedTrackSelection,
|
||||
selectedPostScriptIds
|
||||
};
|
||||
};
|
||||
|
||||
@@ -399,11 +481,16 @@ export default function PipelineStatusCard({
|
||||
return;
|
||||
}
|
||||
|
||||
const { encodeTitleId, selectedTrackSelection } = buildSelectedTrackSelectionForCurrentTitle();
|
||||
const {
|
||||
encodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostScriptIds
|
||||
} = buildSelectedTrackSelectionForCurrentTitle();
|
||||
await onStart(retryJobId, {
|
||||
ensureConfirmed: true,
|
||||
selectedEncodeTitleId: encodeTitleId,
|
||||
selectedTrackSelection
|
||||
selectedTrackSelection,
|
||||
selectedPostEncodeScriptIds: selectedPostScriptIds
|
||||
});
|
||||
}}
|
||||
loading={busy}
|
||||
@@ -561,6 +648,7 @@ export default function PipelineStatusCard({
|
||||
) : null}
|
||||
<MediaInfoReviewPanel
|
||||
review={mediaInfoReview}
|
||||
presetDisplayValue={presetDisplayValue}
|
||||
commandOutputPath={commandOutputPath}
|
||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||
@@ -594,6 +682,72 @@ export default function PipelineStatusCard({
|
||||
};
|
||||
});
|
||||
}}
|
||||
availablePostScripts={scriptCatalog}
|
||||
selectedPostEncodeScriptIds={selectedPostEncodeScriptIds}
|
||||
allowPostScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||
onAddPostEncodeScript={() => {
|
||||
setSelectedPostEncodeScriptIds((prev) => {
|
||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||
const selectedSet = new Set(normalizedCurrent.map((id) => String(id)));
|
||||
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
||||
.map((item) => normalizeScriptId(item?.id))
|
||||
.find((id) => id !== null && !selectedSet.has(String(id)));
|
||||
if (nextCandidate === undefined || nextCandidate === null) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
return [...normalizedCurrent, nextCandidate];
|
||||
});
|
||||
}}
|
||||
onChangePostEncodeScript={(rowIndex, nextScriptId) => {
|
||||
setSelectedPostEncodeScriptIds((prev) => {
|
||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
const normalizedScriptId = normalizeScriptId(nextScriptId);
|
||||
if (normalizedScriptId === null) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
const duplicateAtOtherIndex = normalizedCurrent.some((id, idx) =>
|
||||
idx !== rowIndex && String(id) === String(normalizedScriptId)
|
||||
);
|
||||
if (duplicateAtOtherIndex) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
const next = [...normalizedCurrent];
|
||||
next[rowIndex] = normalizedScriptId;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onRemovePostEncodeScript={(rowIndex) => {
|
||||
setSelectedPostEncodeScriptIds((prev) => {
|
||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
return normalizedCurrent.filter((_, idx) => idx !== rowIndex);
|
||||
});
|
||||
}}
|
||||
onReorderPostEncodeScript={(fromIndex, toIndex) => {
|
||||
setSelectedPostEncodeScriptIds((prev) => {
|
||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||
const from = Number(fromIndex);
|
||||
const to = Number(toIndex);
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to)) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
if (from < 0 || to < 0 || from >= normalizedCurrent.length || to >= normalizedCurrent.length) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
if (from === to) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
const next = [...normalizedCurrent];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -424,7 +424,8 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
|
||||
if (startOptions.ensureConfirmed) {
|
||||
await api.confirmEncodeReview(normalizedJobId, {
|
||||
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
||||
selectedTrackSelection: startOptions.selectedTrackSelection ?? null
|
||||
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
||||
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? []
|
||||
});
|
||||
}
|
||||
await api.startJob(normalizedJobId);
|
||||
@@ -438,12 +439,18 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReview = async (jobId, selectedEncodeTitleId = null, selectedTrackSelection = null) => {
|
||||
const handleConfirmReview = async (
|
||||
jobId,
|
||||
selectedEncodeTitleId = null,
|
||||
selectedTrackSelection = null,
|
||||
selectedPostEncodeScriptIds = undefined
|
||||
) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.confirmEncodeReview(jobId, {
|
||||
selectedEncodeTitleId,
|
||||
selectedTrackSelection
|
||||
selectedTrackSelection,
|
||||
selectedPostEncodeScriptIds
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Alle', value: '' },
|
||||
@@ -34,6 +36,11 @@ function statusSeverity(status) {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||
}
|
||||
|
||||
export default function DatabasePage() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [orphanRows, setOrphanRows] = useState([]);
|
||||
@@ -425,6 +432,19 @@ export default function DatabasePage() {
|
||||
);
|
||||
|
||||
const stateBody = (row) => <Tag value={row.status} severity={statusSeverity(row.status)} />;
|
||||
const mediaBody = (row) => {
|
||||
const mediaType = resolveMediaType(row);
|
||||
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
||||
const alt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
||||
const title = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
|
||||
const label = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges';
|
||||
return (
|
||||
<span className="job-step-cell">
|
||||
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const orphanTitleBody = (row) => (
|
||||
<div>
|
||||
<div><strong>{row.title || '-'}</strong></div>
|
||||
@@ -483,6 +503,7 @@ export default function DatabasePage() {
|
||||
>
|
||||
<Column field="id" header="ID" style={{ width: '6rem' }} />
|
||||
<Column header="Bild" body={posterBody} style={{ width: '7rem' }} />
|
||||
<Column header="Medium" body={mediaBody} style={{ width: '10rem' }} />
|
||||
<Column header="Titel" body={titleBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="Status" body={stateBody} style={{ width: '11rem' }} />
|
||||
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { api } from '../api/client';
|
||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||
|
||||
@@ -22,26 +26,154 @@ function isSameValue(a, b) {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
function injectHandBrakePresetOptions(categories, presetPayload) {
|
||||
const list = Array.isArray(categories) ? categories : [];
|
||||
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
||||
|
||||
return list.map((category) => ({
|
||||
...category,
|
||||
settings: (category?.settings || []).map((setting) => {
|
||||
if (setting?.key !== 'handbrake_preset') {
|
||||
return setting;
|
||||
}
|
||||
|
||||
const normalizedOptions = [];
|
||||
const seenValues = new Set();
|
||||
const seenGroupLabels = new Set();
|
||||
const addGroupOption = (option) => {
|
||||
const rawLabel = String(option?.label || '').trim();
|
||||
if (!rawLabel || seenGroupLabels.has(rawLabel)) {
|
||||
return;
|
||||
}
|
||||
seenGroupLabels.add(rawLabel);
|
||||
normalizedOptions.push({
|
||||
...option,
|
||||
label: rawLabel,
|
||||
value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`),
|
||||
disabled: true
|
||||
});
|
||||
};
|
||||
const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => {
|
||||
const value = String(optionValue || '').trim();
|
||||
if (!value || seenValues.has(value)) {
|
||||
return;
|
||||
}
|
||||
seenValues.add(value);
|
||||
normalizedOptions.push({
|
||||
...(option && typeof option === 'object' ? option : {}),
|
||||
label: String(optionLabel ?? value),
|
||||
value,
|
||||
disabled: false
|
||||
});
|
||||
};
|
||||
|
||||
for (const option of sourceOptions) {
|
||||
if (option?.disabled) {
|
||||
addGroupOption(option);
|
||||
continue;
|
||||
}
|
||||
addSelectableOption(option?.value, option?.label, option);
|
||||
}
|
||||
addSelectableOption(setting?.value);
|
||||
addSelectableOption(setting?.defaultValue);
|
||||
|
||||
if (normalizedOptions.length === 0) {
|
||||
return setting;
|
||||
}
|
||||
|
||||
return {
|
||||
...setting,
|
||||
type: 'select',
|
||||
options: normalizedOptions
|
||||
};
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingPushover, setTestingPushover] = useState(false);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [draftValues, setDraftValues] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [scripts, setScripts] = useState([]);
|
||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||
const [scriptSaving, setScriptSaving] = useState(false);
|
||||
const [scriptActionBusyId, setScriptActionBusyId] = useState(null);
|
||||
const [scriptEditor, setScriptEditor] = useState({
|
||||
mode: 'none',
|
||||
id: null,
|
||||
name: '',
|
||||
scriptBody: ''
|
||||
});
|
||||
const [scriptErrors, setScriptErrors] = useState({});
|
||||
const [lastScriptTestResult, setLastScriptTestResult] = useState(null);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const loadScripts = async ({ silent = false } = {}) => {
|
||||
if (!silent) {
|
||||
setScriptsLoading(true);
|
||||
}
|
||||
try {
|
||||
const response = await api.getScripts();
|
||||
const next = Array.isArray(response?.scripts) ? response.scripts : [];
|
||||
setScripts(next);
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Script-Liste', detail: error.message });
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setScriptsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getSettings();
|
||||
const nextCategories = response.categories || [];
|
||||
const [settingsResponse, presetsResponse, scriptsResponse] = await Promise.allSettled([
|
||||
api.getSettings(),
|
||||
api.getHandBrakePresets(),
|
||||
api.getScripts()
|
||||
]);
|
||||
if (settingsResponse.status !== 'fulfilled') {
|
||||
throw settingsResponse.reason;
|
||||
}
|
||||
let nextCategories = settingsResponse.value?.categories || [];
|
||||
const presetPayload = presetsResponse.status === 'fulfilled' ? presetsResponse.value : null;
|
||||
nextCategories = injectHandBrakePresetOptions(nextCategories, presetPayload);
|
||||
if (presetsResponse.status === 'fulfilled' && presetsResponse.value?.message) {
|
||||
toastRef.current?.show({
|
||||
severity: presetsResponse.value?.source === 'fallback' ? 'warn' : 'info',
|
||||
summary: 'HandBrake Presets',
|
||||
detail: presetsResponse.value.message
|
||||
});
|
||||
}
|
||||
if (presetsResponse.status === 'rejected') {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'HandBrake Presets',
|
||||
detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.'
|
||||
});
|
||||
}
|
||||
const values = buildValuesMap(nextCategories);
|
||||
setCategories(nextCategories);
|
||||
setInitialValues(values);
|
||||
setDraftValues(values);
|
||||
setErrors({});
|
||||
if (scriptsResponse.status === 'fulfilled') {
|
||||
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
|
||||
} else {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Scripte',
|
||||
detail: 'Script-Liste konnte nicht geladen werden.'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||
} finally {
|
||||
@@ -148,56 +280,404 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScriptEditorChange = (key, value) => {
|
||||
setScriptEditor((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
setScriptErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: null
|
||||
}));
|
||||
};
|
||||
|
||||
const clearScriptEditor = () => {
|
||||
setScriptEditor({
|
||||
mode: 'none',
|
||||
id: null,
|
||||
name: '',
|
||||
scriptBody: ''
|
||||
});
|
||||
setScriptErrors({});
|
||||
};
|
||||
|
||||
const startCreateScript = () => {
|
||||
setScriptEditor({
|
||||
mode: 'create',
|
||||
id: null,
|
||||
name: '',
|
||||
scriptBody: ''
|
||||
});
|
||||
setScriptErrors({});
|
||||
setLastScriptTestResult(null);
|
||||
};
|
||||
|
||||
const startEditScript = (script) => {
|
||||
setScriptEditor({
|
||||
mode: 'edit',
|
||||
id: script?.id || null,
|
||||
name: script?.name || '',
|
||||
scriptBody: script?.scriptBody || ''
|
||||
});
|
||||
setScriptErrors({});
|
||||
setLastScriptTestResult(null);
|
||||
};
|
||||
|
||||
const handleSaveScript = async () => {
|
||||
if (scriptEditor?.mode !== 'create' && scriptEditor?.mode !== 'edit') {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: String(scriptEditor?.name || '').trim(),
|
||||
scriptBody: String(scriptEditor?.scriptBody || '')
|
||||
};
|
||||
setScriptSaving(true);
|
||||
try {
|
||||
if (scriptEditor?.id) {
|
||||
await api.updateScript(scriptEditor.id, payload);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Scripte',
|
||||
detail: 'Script aktualisiert.'
|
||||
});
|
||||
} else {
|
||||
await api.createScript(payload);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Scripte',
|
||||
detail: 'Script angelegt.'
|
||||
});
|
||||
}
|
||||
await loadScripts({ silent: true });
|
||||
setScriptErrors({});
|
||||
clearScriptEditor();
|
||||
} catch (error) {
|
||||
const details = Array.isArray(error?.details) ? error.details : [];
|
||||
if (details.length > 0) {
|
||||
const nextErrors = {};
|
||||
for (const item of details) {
|
||||
if (item?.field) {
|
||||
nextErrors[item.field] = item.message || 'Ungültiger Wert';
|
||||
}
|
||||
}
|
||||
setScriptErrors(nextErrors);
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Script speichern fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setScriptSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteScript = async (script) => {
|
||||
const scriptId = Number(script?.id);
|
||||
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(`Script "${script?.name || scriptId}" wirklich löschen?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setScriptActionBusyId(scriptId);
|
||||
try {
|
||||
await api.deleteScript(scriptId);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Scripte',
|
||||
detail: 'Script gelöscht.'
|
||||
});
|
||||
await loadScripts({ silent: true });
|
||||
if (scriptEditor?.mode === 'edit' && Number(scriptEditor?.id) === scriptId) {
|
||||
clearScriptEditor();
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Script löschen fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setScriptActionBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestScript = async (script) => {
|
||||
const scriptId = Number(script?.id);
|
||||
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||||
return;
|
||||
}
|
||||
setScriptActionBusyId(scriptId);
|
||||
try {
|
||||
const response = await api.testScript(scriptId);
|
||||
const result = response?.result || null;
|
||||
setLastScriptTestResult(result);
|
||||
if (result?.success) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Script-Test',
|
||||
detail: `"${script?.name || scriptId}" erfolgreich ausgeführt.`
|
||||
});
|
||||
} else {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Script-Test',
|
||||
detail: `"${script?.name || scriptId}" fehlgeschlagen (exit=${result?.exitCode ?? 'n/a'}).`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Script-Test fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setScriptActionBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Einstellungen" subTitle="Änderungen werden erst beim Speichern in die Datenbank übernommen">
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Änderungen speichern"
|
||||
icon="pi pi-save"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!hasUnsavedChanges}
|
||||
/>
|
||||
<Button
|
||||
label="Änderungen verwerfen"
|
||||
icon="pi pi-undo"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleDiscard}
|
||||
disabled={!hasUnsavedChanges || saving}
|
||||
/>
|
||||
<Button
|
||||
label="Neu laden"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={load}
|
||||
loading={loading}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button
|
||||
label="PushOver Test"
|
||||
icon="pi pi-send"
|
||||
severity="info"
|
||||
onClick={handlePushoverTest}
|
||||
loading={testingPushover}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<TabView
|
||||
className="settings-root-tabview"
|
||||
activeIndex={activeTabIndex}
|
||||
onTabChange={(event) => setActiveTabIndex(Number(event.index || 0))}
|
||||
>
|
||||
<TabPanel header="Konfiguration">
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Änderungen speichern"
|
||||
icon="pi pi-save"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!hasUnsavedChanges}
|
||||
/>
|
||||
<Button
|
||||
label="Änderungen verwerfen"
|
||||
icon="pi pi-undo"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleDiscard}
|
||||
disabled={!hasUnsavedChanges || saving}
|
||||
/>
|
||||
<Button
|
||||
label="Neu laden"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={load}
|
||||
loading={loading}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button
|
||||
label="PushOver Test"
|
||||
icon="pi pi-send"
|
||||
severity="info"
|
||||
onClick={handlePushoverTest}
|
||||
loading={testingPushover}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p>Lade Settings ...</p>
|
||||
) : (
|
||||
<DynamicSettingsForm
|
||||
categories={categories}
|
||||
values={draftValues}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
)}
|
||||
{loading ? (
|
||||
<p>Lade Settings ...</p>
|
||||
) : (
|
||||
<DynamicSettingsForm
|
||||
categories={categories}
|
||||
values={draftValues}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel header="Scripte">
|
||||
<div className="script-manager-wrap">
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Neues Skript hinzufügen"
|
||||
icon="pi pi-plus"
|
||||
onClick={startCreateScript}
|
||||
severity="success"
|
||||
outlined
|
||||
disabled={scriptSaving || scriptEditor?.mode === 'create'}
|
||||
/>
|
||||
<Button
|
||||
label="Scripts neu laden"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={() => loadScripts()}
|
||||
loading={scriptsLoading}
|
||||
disabled={scriptSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<small>
|
||||
Die ausgewählten Scripts werden später pro Job nach erfolgreichem Encode in Reihenfolge ausgeführt.
|
||||
</small>
|
||||
|
||||
<div className="script-list-box">
|
||||
<h4>Verfügbare Scripts</h4>
|
||||
{scriptsLoading ? (
|
||||
<p>Lade Scripts ...</p>
|
||||
) : (
|
||||
<div className="script-list">
|
||||
{scriptEditor?.mode === 'create' ? (
|
||||
<div className="script-list-item script-list-item-editing">
|
||||
<div className="script-list-main">
|
||||
<div className="script-title-line">
|
||||
<strong className="script-id-title">NEU - Titel</strong>
|
||||
<InputText
|
||||
id="script-name-new"
|
||||
value={scriptEditor?.name || ''}
|
||||
onChange={(event) => handleScriptEditorChange('name', event.target.value)}
|
||||
placeholder="z.B. Library Refresh"
|
||||
className="script-title-input"
|
||||
/>
|
||||
</div>
|
||||
{scriptErrors?.name ? <small className="error-text">{scriptErrors.name}</small> : null}
|
||||
</div>
|
||||
<div className="script-editor-fields">
|
||||
<label htmlFor="script-body-new">Bash Script</label>
|
||||
<InputTextarea
|
||||
id="script-body-new"
|
||||
value={scriptEditor?.scriptBody || ''}
|
||||
onChange={(event) => handleScriptEditorChange('scriptBody', event.target.value)}
|
||||
rows={12}
|
||||
autoResize={false}
|
||||
placeholder={'#!/usr/bin/env bash\necho "Post-Encode Script"'}
|
||||
/>
|
||||
{scriptErrors?.scriptBody ? <small className="error-text">{scriptErrors.scriptBody}</small> : null}
|
||||
</div>
|
||||
<div className="script-list-actions">
|
||||
<Button
|
||||
label="Speichern"
|
||||
icon="pi pi-save"
|
||||
onClick={handleSaveScript}
|
||||
loading={scriptSaving}
|
||||
/>
|
||||
<Button
|
||||
label="Verwerfen"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={clearScriptEditor}
|
||||
disabled={scriptSaving}
|
||||
/>
|
||||
<span className="script-action-spacer" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scripts.length === 0 ? <p>Keine Scripts vorhanden.</p> : null}
|
||||
|
||||
{scripts.map((script) => {
|
||||
return (
|
||||
<div key={script.id} className="script-list-item">
|
||||
<div className="script-list-main">
|
||||
<strong className="script-id-title">{`ID #${script.id} - ${script.name}`}</strong>
|
||||
</div>
|
||||
|
||||
<div className="script-list-actions">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
label="Bearbeiten"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => startEditScript(script)}
|
||||
disabled={Boolean(scriptActionBusyId) || scriptSaving || scriptEditor?.mode === 'create'}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-play"
|
||||
label="Test"
|
||||
severity="info"
|
||||
onClick={() => handleTestScript(script)}
|
||||
loading={scriptActionBusyId === script.id}
|
||||
disabled={Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
label="Löschen"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteScript(script)}
|
||||
loading={scriptActionBusyId === script.id}
|
||||
disabled={Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
header={scriptEditor?.id ? `Script bearbeiten (#${scriptEditor.id})` : 'Script bearbeiten'}
|
||||
visible={scriptEditor?.mode === 'edit'}
|
||||
onHide={clearScriptEditor}
|
||||
style={{ width: 'min(52rem, calc(100vw - 1.5rem))' }}
|
||||
className="script-edit-dialog"
|
||||
dismissableMask
|
||||
draggable={false}
|
||||
>
|
||||
<div className="script-editor-fields">
|
||||
<label htmlFor="script-edit-name">Name</label>
|
||||
<InputText
|
||||
id="script-edit-name"
|
||||
value={scriptEditor?.name || ''}
|
||||
onChange={(event) => handleScriptEditorChange('name', event.target.value)}
|
||||
placeholder="z.B. Library Refresh"
|
||||
/>
|
||||
{scriptErrors?.name ? <small className="error-text">{scriptErrors.name}</small> : null}
|
||||
<label htmlFor="script-edit-body">Bash Script</label>
|
||||
<InputTextarea
|
||||
id="script-edit-body"
|
||||
value={scriptEditor?.scriptBody || ''}
|
||||
onChange={(event) => handleScriptEditorChange('scriptBody', event.target.value)}
|
||||
rows={14}
|
||||
autoResize={false}
|
||||
placeholder={'#!/usr/bin/env bash\necho "Post-Encode Script"'}
|
||||
/>
|
||||
{scriptErrors?.scriptBody ? <small className="error-text">{scriptErrors.scriptBody}</small> : null}
|
||||
</div>
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Script aktualisieren"
|
||||
icon="pi pi-save"
|
||||
onClick={handleSaveScript}
|
||||
loading={scriptSaving}
|
||||
/>
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={clearScriptEditor}
|
||||
disabled={scriptSaving}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{lastScriptTestResult ? (
|
||||
<div className="script-test-result">
|
||||
<h4>Letzter Script-Test: {lastScriptTestResult.scriptName}</h4>
|
||||
<small>
|
||||
Status: {lastScriptTestResult.success ? 'SUCCESS' : 'ERROR'}
|
||||
{' | '}exit={lastScriptTestResult.exitCode ?? 'n/a'}
|
||||
{' | '}timeout={lastScriptTestResult.timedOut ? 'ja' : 'nein'}
|
||||
{' | '}dauer={Number(lastScriptTestResult.durationMs || 0)}ms
|
||||
</small>
|
||||
<pre>{`${lastScriptTestResult.stdout || ''}${lastScriptTestResult.stderr ? `\n${lastScriptTestResult.stderr}` : ''}`.trim() || 'Keine Ausgabe.'}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -317,6 +317,19 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.job-step-inline-no {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
color: #9c2d2d;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.job-step-inline-no .pi {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.job-step-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -458,6 +471,14 @@ body {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-root-tabview {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-root-tabview .p-tabview-panels {
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
|
||||
.settings-tabview .p-tabview-panels {
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
@@ -503,6 +524,131 @@ body {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.script-manager-wrap {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.script-list-box {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.55rem;
|
||||
background: #fff7ea;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.script-list-box h4 {
|
||||
margin: 0;
|
||||
color: var(--rip-brown-800);
|
||||
}
|
||||
|
||||
.script-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.script-list-item {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--rip-panel-soft);
|
||||
padding: 0.5rem 0.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.script-list-item-editing {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.script-list-main {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.script-title-line {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.script-id-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.script-title-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.script-list-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.script-list-actions .p-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.38rem 0.7rem;
|
||||
}
|
||||
|
||||
.script-list-actions .p-button .p-button-icon {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.script-action-spacer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.script-editor-fields {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.script-editor-fields .p-inputtextarea {
|
||||
width: 100%;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.script-test-result {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
padding: 0.6rem 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.script-test-result h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.script-test-result pre {
|
||||
margin: 0;
|
||||
background: #f7ecd7;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.5rem 0.55rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #9d261b;
|
||||
margin-left: 0.25rem;
|
||||
@@ -579,6 +725,103 @@ body {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.job-detail-meta-wrap {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.job-film-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.job-meta-block {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.job-meta-block.job-meta-block-film {
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.job-meta-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.job-meta-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 8rem minmax(0, 1fr);
|
||||
gap: 0.35rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.job-meta-item strong,
|
||||
.job-meta-item span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.job-meta-block h4 {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.job-meta-grid.job-meta-grid-compact {
|
||||
margin-bottom: 0;
|
||||
gap: 0.4rem 0.65rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.job-meta-grid.job-meta-grid-compact strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.job-meta-col-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.job-status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.job-status-icon .pi {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.job-status-icon.tone-success {
|
||||
color: #1c8a3a;
|
||||
}
|
||||
|
||||
.job-status-icon.tone-danger {
|
||||
color: #9c2d2d;
|
||||
}
|
||||
|
||||
.job-status-icon.tone-warning {
|
||||
color: #7a4f00;
|
||||
}
|
||||
|
||||
.job-status-icon.tone-info {
|
||||
color: #0d5a86;
|
||||
}
|
||||
|
||||
.job-status-icon.tone-secondary {
|
||||
color: #46556a;
|
||||
}
|
||||
|
||||
.job-json-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -771,6 +1014,53 @@ body {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.post-script-box {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.post-script-box h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-script-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.post-script-row.editable {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.post-script-drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--rip-muted);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.post-script-drag-handle:hover {
|
||||
border-color: var(--rip-border);
|
||||
background: color-mix(in srgb, var(--rip-panel-soft) 75%, #ffffff 25%);
|
||||
}
|
||||
|
||||
.post-script-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.post-script-drag-handle.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.media-title-list,
|
||||
.media-track-list {
|
||||
display: grid;
|
||||
@@ -919,6 +1209,7 @@ body {
|
||||
.media-review-meta,
|
||||
.media-track-grid,
|
||||
.job-meta-grid,
|
||||
.job-film-info-grid,
|
||||
.table-filters,
|
||||
.job-head-row,
|
||||
.job-json-grid,
|
||||
@@ -940,6 +1231,22 @@ body {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.script-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.script-list-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.post-script-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.post-script-row.editable {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.orphan-path-cell {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -974,6 +1281,19 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.script-title-line {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.script-list-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.script-action-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-job-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user