Fixxes + Skriptketten
This commit is contained in:
@@ -31,10 +31,31 @@ function App() {
|
||||
}
|
||||
|
||||
if (message.type === 'PIPELINE_PROGRESS') {
|
||||
setPipeline((prev) => ({
|
||||
...prev,
|
||||
...message.payload
|
||||
}));
|
||||
const payload = message.payload;
|
||||
const progressJobId = payload?.activeJobId;
|
||||
setPipeline((prev) => {
|
||||
const next = { ...prev };
|
||||
// Update per-job progress map so concurrent jobs don't overwrite each other.
|
||||
if (progressJobId != null) {
|
||||
next.jobProgress = {
|
||||
...(prev?.jobProgress || {}),
|
||||
[progressJobId]: {
|
||||
state: payload.state,
|
||||
progress: payload.progress,
|
||||
eta: payload.eta,
|
||||
statusText: payload.statusText
|
||||
}
|
||||
};
|
||||
}
|
||||
// Update global snapshot fields only for the primary active job.
|
||||
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
||||
next.state = payload.state ?? prev?.state;
|
||||
next.progress = payload.progress ?? prev?.progress;
|
||||
next.eta = payload.eta ?? prev?.eta;
|
||||
next.statusText = payload.statusText ?? prev?.statusText;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (message.type === 'PIPELINE_QUEUE_CHANGED') {
|
||||
|
||||
@@ -64,6 +64,26 @@ export const api = {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
getScriptChains() {
|
||||
return request('/settings/script-chains');
|
||||
},
|
||||
createScriptChain(payload = {}) {
|
||||
return request('/settings/script-chains', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
updateScriptChain(chainId, payload = {}) {
|
||||
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
deleteScriptChain(chainId) {
|
||||
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
updateSetting(key, value) {
|
||||
return request(`/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -486,14 +486,21 @@ function resolveAudioEncoderPreviewLabel(track, encoderToken, copyMask, fallback
|
||||
: [];
|
||||
|
||||
let canCopy = false;
|
||||
let effectiveCodec = sourceCodec;
|
||||
if (explicitCopyCodec) {
|
||||
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
|
||||
} else if (sourceCodec && normalizedCopyMask.length > 0) {
|
||||
canCopy = normalizedCopyMask.includes(sourceCodec);
|
||||
// DTS-HD MA contains an embedded DTS core. When dtshd is not in the copy
|
||||
// mask but dts is, HandBrake will extract and copy the DTS core layer.
|
||||
if (!canCopy && sourceCodec === 'dtshd' && normalizedCopyMask.includes('dts')) {
|
||||
canCopy = true;
|
||||
effectiveCodec = 'dts';
|
||||
}
|
||||
}
|
||||
|
||||
if (canCopy) {
|
||||
return `Copy (${sourceCodec || track?.format || 'Quelle'})`;
|
||||
return `Copy (${effectiveCodec || track?.format || 'Quelle'})`;
|
||||
}
|
||||
|
||||
const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK_PREVIEW).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK_PREVIEW;
|
||||
@@ -684,7 +691,21 @@ export default function MediaInfoReviewPanel({
|
||||
onAddPostEncodeScript = null,
|
||||
onChangePostEncodeScript = null,
|
||||
onRemovePostEncodeScript = null,
|
||||
onReorderPostEncodeScript = null
|
||||
onReorderPostEncodeScript = null,
|
||||
availablePreScripts = [],
|
||||
selectedPreEncodeScriptIds = [],
|
||||
allowPreScriptSelection = false,
|
||||
onAddPreEncodeScript = null,
|
||||
onChangePreEncodeScript = null,
|
||||
onRemovePreEncodeScript = null,
|
||||
availableChains = [],
|
||||
selectedPreEncodeChainIds = [],
|
||||
selectedPostEncodeChainIds = [],
|
||||
allowChainSelection = false,
|
||||
onAddPreEncodeChain = null,
|
||||
onRemovePreEncodeChain = null,
|
||||
onAddPostEncodeChain = null,
|
||||
onRemovePostEncodeChain = null
|
||||
}) {
|
||||
if (!review) {
|
||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||
@@ -759,6 +780,136 @@ export default function MediaInfoReviewPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Pre-Encode Scripts */}
|
||||
{(allowPreScriptSelection || normalizeScriptIdList(selectedPreEncodeScriptIds).length > 0) ? (
|
||||
<div className="post-script-box">
|
||||
<h4>Pre-Encode Scripte (optional)</h4>
|
||||
{(Array.isArray(availablePreScripts) ? availablePreScripts : []).length === 0 ? (
|
||||
<small>Keine Scripte konfiguriert. In den Settings unter "Scripte" anlegen.</small>
|
||||
) : null}
|
||||
{normalizeScriptIdList(selectedPreEncodeScriptIds).length === 0 ? (
|
||||
<small>Keine Pre-Encode Scripte ausgewählt.</small>
|
||||
) : null}
|
||||
{normalizeScriptIdList(selectedPreEncodeScriptIds).map((scriptId, rowIndex) => {
|
||||
const preCatalog = (Array.isArray(availablePreScripts) ? availablePreScripts : [])
|
||||
.map((item) => ({ id: normalizeScriptId(item?.id), name: String(item?.name || '') }))
|
||||
.filter((item) => item.id !== null);
|
||||
const preById = new Map(preCatalog.map((item) => [item.id, item]));
|
||||
const script = preById.get(scriptId) || null;
|
||||
const selectedElsewhere = new Set(
|
||||
normalizeScriptIdList(selectedPreEncodeScriptIds).filter((_, i) => i !== rowIndex).map((id) => String(id))
|
||||
);
|
||||
const options = preCatalog.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
disabled: selectedElsewhere.has(String(item.id))
|
||||
}));
|
||||
return (
|
||||
<div key={`pre-script-row-${rowIndex}-${scriptId}`} className={`post-script-row${allowPreScriptSelection ? ' editable' : ''}`}>
|
||||
{allowPreScriptSelection ? (
|
||||
<>
|
||||
<Dropdown
|
||||
value={scriptId}
|
||||
options={options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => onChangePreEncodeScript?.(rowIndex, event.value)}
|
||||
className="full-width"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => onRemovePreEncodeScript?.(rowIndex)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<small>{`${rowIndex + 1}. ${script?.name || `Script #${scriptId}`}`}</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allowPreScriptSelection && (Array.isArray(availablePreScripts) ? availablePreScripts : []).length > normalizeScriptIdList(selectedPreEncodeScriptIds).length ? (
|
||||
<Button
|
||||
label="Pre-Script hinzufügen"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => onAddPreEncodeScript?.()}
|
||||
/>
|
||||
) : null}
|
||||
<small>Diese Scripte werden vor dem Encoding ausgeführt. Bei Fehler wird der Encode abgebrochen.</small>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Chain Selections */}
|
||||
{(allowChainSelection || selectedPreEncodeChainIds.length > 0 || selectedPostEncodeChainIds.length > 0) ? (
|
||||
<div className="post-script-box">
|
||||
<h4>Skriptketten (optional)</h4>
|
||||
{(Array.isArray(availableChains) ? availableChains : []).length === 0 ? (
|
||||
<small>Keine Skriptketten konfiguriert. In den Settings unter "Skriptketten" anlegen.</small>
|
||||
) : null}
|
||||
{(Array.isArray(availableChains) ? availableChains : []).length > 0 ? (
|
||||
<div className="chain-selection-groups">
|
||||
<div className="chain-selection-group">
|
||||
<strong>Pre-Encode Ketten</strong>
|
||||
{selectedPreEncodeChainIds.length === 0 ? <small>Keine ausgewählt.</small> : null}
|
||||
{selectedPreEncodeChainIds.map((chainId, index) => {
|
||||
const chain = (Array.isArray(availableChains) ? availableChains : []).find((c) => Number(c.id) === chainId);
|
||||
return (
|
||||
<div key={`pre-chain-${index}-${chainId}`} className="post-script-row editable">
|
||||
<small>{`${index + 1}. ${chain?.name || `Kette #${chainId}`}`}</small>
|
||||
{allowChainSelection ? (
|
||||
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeChain?.(index)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allowChainSelection ? (
|
||||
<Dropdown
|
||||
value={null}
|
||||
options={(Array.isArray(availableChains) ? availableChains : [])
|
||||
.filter((c) => !selectedPreEncodeChainIds.includes(Number(c.id)))
|
||||
.map((c) => ({ label: c.name, value: c.id }))}
|
||||
onChange={(e) => onAddPreEncodeChain?.(e.value)}
|
||||
placeholder="Kette hinzufügen..."
|
||||
className="chain-add-dropdown"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="chain-selection-group">
|
||||
<strong>Post-Encode Ketten</strong>
|
||||
{selectedPostEncodeChainIds.length === 0 ? <small>Keine ausgewählt.</small> : null}
|
||||
{selectedPostEncodeChainIds.map((chainId, index) => {
|
||||
const chain = (Array.isArray(availableChains) ? availableChains : []).find((c) => Number(c.id) === chainId);
|
||||
return (
|
||||
<div key={`post-chain-${index}-${chainId}`} className="post-script-row editable">
|
||||
<small>{`${index + 1}. ${chain?.name || `Kette #${chainId}`}`}</small>
|
||||
{allowChainSelection ? (
|
||||
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeChain?.(index)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allowChainSelection ? (
|
||||
<Dropdown
|
||||
value={null}
|
||||
options={(Array.isArray(availableChains) ? availableChains : [])
|
||||
.filter((c) => !selectedPostEncodeChainIds.includes(Number(c.id)))
|
||||
.map((c) => ({ label: c.name, value: c.id }))}
|
||||
onChange={(e) => onAddPostEncodeChain?.(e.value)}
|
||||
placeholder="Kette hinzufügen..."
|
||||
className="chain-add-dropdown"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="post-script-box">
|
||||
<h4>Post-Encode Scripte (optional)</h4>
|
||||
{scriptCatalog.length === 0 ? (
|
||||
|
||||
@@ -224,16 +224,21 @@ export default function PipelineStatusCard({
|
||||
const [settingsMap, setSettingsMap] = useState({});
|
||||
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
||||
const [scriptCatalog, setScriptCatalog] = useState([]);
|
||||
const [chainCatalog, setChainCatalog] = useState([]);
|
||||
const [selectedPostEncodeScriptIds, setSelectedPostEncodeScriptIds] = useState([]);
|
||||
const [selectedPreEncodeScriptIds, setSelectedPreEncodeScriptIds] = useState([]);
|
||||
const [selectedPostEncodeChainIds, setSelectedPostEncodeChainIds] = useState([]);
|
||||
const [selectedPreEncodeChainIds, setSelectedPreEncodeChainIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const [settingsResponse, presetsResponse, scriptsResponse] = await Promise.allSettled([
|
||||
const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse] = await Promise.allSettled([
|
||||
api.getSettings(),
|
||||
api.getHandBrakePresets(),
|
||||
api.getScripts()
|
||||
api.getScripts(),
|
||||
api.getScriptChains()
|
||||
]);
|
||||
if (!cancelled) {
|
||||
const categories = settingsResponse.status === 'fulfilled'
|
||||
@@ -253,12 +258,17 @@ export default function PipelineStatusCard({
|
||||
name: item?.name
|
||||
}))
|
||||
);
|
||||
const chains = chainsResponse.status === 'fulfilled'
|
||||
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
|
||||
: [];
|
||||
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setSettingsMap({});
|
||||
setPresetDisplayMap({});
|
||||
setScriptCatalog([]);
|
||||
setChainCatalog([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -275,6 +285,17 @@ export default function PipelineStatusCard({
|
||||
setSelectedPostEncodeScriptIds(
|
||||
normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || [])
|
||||
);
|
||||
setSelectedPreEncodeScriptIds(
|
||||
normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || [])
|
||||
);
|
||||
setSelectedPostEncodeChainIds(
|
||||
(Array.isArray(mediaInfoReview?.postEncodeChainIds) ? mediaInfoReview.postEncodeChainIds : [])
|
||||
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||||
);
|
||||
setSelectedPreEncodeChainIds(
|
||||
(Array.isArray(mediaInfoReview?.preEncodeChainIds) ? mediaInfoReview.preEncodeChainIds : [])
|
||||
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||||
);
|
||||
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -434,10 +455,16 @@ export default function PipelineStatusCard({
|
||||
}
|
||||
: null;
|
||||
const selectedPostScriptIds = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
||||
const selectedPreScriptIds = normalizeScriptIdList(selectedPreEncodeScriptIds);
|
||||
const normalizeChainIdList = (raw) =>
|
||||
(Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0);
|
||||
return {
|
||||
encodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostScriptIds
|
||||
selectedPostScriptIds,
|
||||
selectedPreScriptIds,
|
||||
selectedPostChainIds: normalizeChainIdList(selectedPostEncodeChainIds),
|
||||
selectedPreChainIds: normalizeChainIdList(selectedPreEncodeChainIds)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -530,13 +557,19 @@ export default function PipelineStatusCard({
|
||||
const {
|
||||
encodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostScriptIds
|
||||
selectedPostScriptIds,
|
||||
selectedPreScriptIds,
|
||||
selectedPostChainIds,
|
||||
selectedPreChainIds
|
||||
} = buildSelectedTrackSelectionForCurrentTitle();
|
||||
await onStart(retryJobId, {
|
||||
ensureConfirmed: true,
|
||||
selectedEncodeTitleId: encodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostEncodeScriptIds: selectedPostScriptIds
|
||||
selectedPostEncodeScriptIds: selectedPostScriptIds,
|
||||
selectedPreEncodeScriptIds: selectedPreScriptIds,
|
||||
selectedPostEncodeChainIds: selectedPostChainIds,
|
||||
selectedPreEncodeChainIds: selectedPreChainIds
|
||||
});
|
||||
}}
|
||||
loading={busy}
|
||||
@@ -809,6 +842,77 @@ export default function PipelineStatusCard({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
availablePreScripts={scriptCatalog}
|
||||
selectedPreEncodeScriptIds={selectedPreEncodeScriptIds}
|
||||
allowPreScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
||||
onAddPreEncodeScript={() => {
|
||||
setSelectedPreEncodeScriptIds((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];
|
||||
});
|
||||
}}
|
||||
onChangePreEncodeScript={(rowIndex, nextScriptId) => {
|
||||
setSelectedPreEncodeScriptIds((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;
|
||||
}
|
||||
if (normalizedCurrent.some((id, idx) => idx !== rowIndex && String(id) === String(normalizedScriptId))) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
const next = [...normalizedCurrent];
|
||||
next[rowIndex] = normalizedScriptId;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onRemovePreEncodeScript={(rowIndex) => {
|
||||
setSelectedPreEncodeScriptIds((prev) => {
|
||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
return normalizedCurrent.filter((_, idx) => idx !== rowIndex);
|
||||
});
|
||||
}}
|
||||
availableChains={chainCatalog}
|
||||
selectedPreEncodeChainIds={selectedPreEncodeChainIds}
|
||||
selectedPostEncodeChainIds={selectedPostEncodeChainIds}
|
||||
allowChainSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
||||
onAddPreEncodeChain={(chainId) => {
|
||||
setSelectedPreEncodeChainIds((prev) => {
|
||||
const id = Number(chainId);
|
||||
if (!Number.isFinite(id) || id <= 0 || prev.includes(id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
}}
|
||||
onRemovePreEncodeChain={(index) => {
|
||||
setSelectedPreEncodeChainIds((prev) => prev.filter((_, i) => i !== index));
|
||||
}}
|
||||
onAddPostEncodeChain={(chainId) => {
|
||||
setSelectedPostEncodeChainIds((prev) => {
|
||||
const id = Number(chainId);
|
||||
if (!Number.isFinite(id) || id <= 0 || prev.includes(id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
}}
|
||||
onRemovePostEncodeChain={(index) => {
|
||||
setSelectedPostEncodeChainIds((prev) => prev.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -305,12 +305,17 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
};
|
||||
}
|
||||
|
||||
// Use live per-job progress from the backend if available (concurrent jobs).
|
||||
const liveJobProgress = currentPipeline?.jobProgress && jobId
|
||||
? (currentPipeline.jobProgress[jobId] || null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
state: jobStatus,
|
||||
state: liveJobProgress?.state || jobStatus,
|
||||
activeJobId: jobId,
|
||||
progress: Number.isFinite(Number(job?.progress)) ? Number(job.progress) : 0,
|
||||
eta: job?.eta || null,
|
||||
statusText: job?.status_text || job?.error_message || null,
|
||||
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
|
||||
eta: liveJobProgress?.eta || null,
|
||||
statusText: liveJobProgress?.statusText || job?.error_message || null,
|
||||
context: computedContext
|
||||
};
|
||||
}
|
||||
@@ -723,6 +728,9 @@ export default function DashboardPage({
|
||||
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
||||
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
||||
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? [],
|
||||
selectedPreEncodeScriptIds: startOptions.selectedPreEncodeScriptIds ?? [],
|
||||
selectedPostEncodeChainIds: startOptions.selectedPostEncodeChainIds ?? [],
|
||||
selectedPreEncodeChainIds: startOptions.selectedPreEncodeChainIds ?? [],
|
||||
skipPipelineStateUpdate: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +111,15 @@ export default function SettingsPage() {
|
||||
});
|
||||
const [scriptErrors, setScriptErrors] = useState({});
|
||||
const [lastScriptTestResult, setLastScriptTestResult] = useState(null);
|
||||
|
||||
// Script chains state
|
||||
const [chains, setChains] = useState([]);
|
||||
const [chainsLoading, setChainsLoading] = useState(false);
|
||||
const [chainSaving, setChainSaving] = useState(false);
|
||||
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
|
||||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||
const [chainDragSource, setChainDragSource] = useState(null);
|
||||
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const loadScripts = async ({ silent = false } = {}) => {
|
||||
@@ -132,13 +141,32 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadChains = async ({ silent = false } = {}) => {
|
||||
if (!silent) {
|
||||
setChainsLoading(true);
|
||||
}
|
||||
try {
|
||||
const response = await api.getScriptChains();
|
||||
setChains(Array.isArray(response?.chains) ? response.chains : []);
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Skriptketten', detail: error.message });
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setChainsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [settingsResponse, presetsResponse, scriptsResponse] = await Promise.allSettled([
|
||||
const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse] = await Promise.allSettled([
|
||||
api.getSettings(),
|
||||
api.getHandBrakePresets(),
|
||||
api.getScripts()
|
||||
api.getScripts(),
|
||||
api.getScriptChains()
|
||||
]);
|
||||
if (settingsResponse.status !== 'fulfilled') {
|
||||
throw settingsResponse.reason;
|
||||
@@ -174,6 +202,9 @@ export default function SettingsPage() {
|
||||
detail: 'Script-Liste konnte nicht geladen werden.'
|
||||
});
|
||||
}
|
||||
if (chainsResponse.status === 'fulfilled') {
|
||||
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||
} finally {
|
||||
@@ -438,6 +469,162 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Chain editor handlers
|
||||
const openChainEditor = (chain = null) => {
|
||||
if (chain) {
|
||||
setChainEditor({ open: true, id: chain.id, name: chain.name, steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` })) });
|
||||
} else {
|
||||
setChainEditor({ open: true, id: null, name: '', steps: [] });
|
||||
}
|
||||
setChainEditorErrors({});
|
||||
};
|
||||
|
||||
const closeChainEditor = () => {
|
||||
setChainEditor({ open: false, id: null, name: '', steps: [] });
|
||||
setChainEditorErrors({});
|
||||
};
|
||||
|
||||
const addChainStep = (stepType, scriptId = null, scriptName = null) => {
|
||||
setChainEditor((prev) => ({
|
||||
...prev,
|
||||
steps: [
|
||||
...prev.steps,
|
||||
{
|
||||
_key: `new-${Date.now()}-${Math.random()}`,
|
||||
stepType,
|
||||
scriptId: stepType === 'script' ? scriptId : null,
|
||||
scriptName: stepType === 'script' ? scriptName : null,
|
||||
waitSeconds: stepType === 'wait' ? 10 : null
|
||||
}
|
||||
]
|
||||
}));
|
||||
};
|
||||
|
||||
const removeChainStep = (index) => {
|
||||
setChainEditor((prev) => ({ ...prev, steps: prev.steps.filter((_, i) => i !== index) }));
|
||||
};
|
||||
|
||||
const updateChainStepWait = (index, seconds) => {
|
||||
setChainEditor((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s, i) => i === index ? { ...s, waitSeconds: seconds } : s)
|
||||
}));
|
||||
};
|
||||
|
||||
const moveChainStep = (fromIndex, toIndex) => {
|
||||
if (fromIndex === toIndex) {
|
||||
return;
|
||||
}
|
||||
setChainEditor((prev) => {
|
||||
const steps = [...prev.steps];
|
||||
const [moved] = steps.splice(fromIndex, 1);
|
||||
steps.splice(toIndex, 0, moved);
|
||||
return { ...prev, steps };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveChain = async () => {
|
||||
const name = String(chainEditor.name || '').trim();
|
||||
if (!name) {
|
||||
setChainEditorErrors({ name: 'Name darf nicht leer sein.' });
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name,
|
||||
steps: chainEditor.steps.map((s) => ({
|
||||
stepType: s.stepType,
|
||||
scriptId: s.stepType === 'script' ? s.scriptId : null,
|
||||
waitSeconds: s.stepType === 'wait' ? Number(s.waitSeconds || 10) : null
|
||||
}))
|
||||
};
|
||||
setChainSaving(true);
|
||||
try {
|
||||
if (chainEditor.id) {
|
||||
await api.updateScriptChain(chainEditor.id, payload);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Skriptkette', detail: 'Kette aktualisiert.' });
|
||||
} else {
|
||||
await api.createScriptChain(payload);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Skriptkette', detail: 'Kette angelegt.' });
|
||||
}
|
||||
await loadChains({ silent: true });
|
||||
closeChainEditor();
|
||||
} catch (error) {
|
||||
const details = Array.isArray(error?.details) ? error.details : [];
|
||||
if (details.length > 0) {
|
||||
const errs = {};
|
||||
for (const item of details) {
|
||||
if (item?.field) {
|
||||
errs[item.field] = item.message || 'Ungültig';
|
||||
}
|
||||
}
|
||||
setChainEditorErrors(errs);
|
||||
}
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Kette speichern fehlgeschlagen', detail: error.message });
|
||||
} finally {
|
||||
setChainSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChain = async (chain) => {
|
||||
const chainId = Number(chain?.id);
|
||||
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Skriptkette "${chain?.name || chainId}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteScriptChain(chainId);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Skriptketten', detail: 'Kette gelöscht.' });
|
||||
await loadChains({ silent: true });
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Kette löschen fehlgeschlagen', detail: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Chain DnD handlers
|
||||
const handleChainPaletteDragStart = (event, data) => {
|
||||
setChainDragSource({ origin: 'palette', ...data });
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('text/plain', JSON.stringify(data));
|
||||
};
|
||||
|
||||
const handleChainStepDragStart = (event, index) => {
|
||||
setChainDragSource({ origin: 'step', index });
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(index));
|
||||
};
|
||||
|
||||
const handleChainDropzoneDrop = (event, targetIndex) => {
|
||||
event.preventDefault();
|
||||
if (!chainDragSource) {
|
||||
return;
|
||||
}
|
||||
if (chainDragSource.origin === 'palette') {
|
||||
const newStep = {
|
||||
_key: `new-${Date.now()}-${Math.random()}`,
|
||||
stepType: chainDragSource.stepType,
|
||||
scriptId: chainDragSource.stepType === 'script' ? chainDragSource.scriptId : null,
|
||||
scriptName: chainDragSource.stepType === 'script' ? chainDragSource.scriptName : null,
|
||||
waitSeconds: chainDragSource.stepType === 'wait' ? 10 : null
|
||||
};
|
||||
setChainEditor((prev) => {
|
||||
const steps = [...prev.steps];
|
||||
const insertAt = targetIndex != null ? targetIndex : steps.length;
|
||||
steps.splice(insertAt, 0, newStep);
|
||||
return { ...prev, steps };
|
||||
});
|
||||
} else if (chainDragSource.origin === 'step') {
|
||||
moveChainStep(chainDragSource.index, targetIndex != null ? targetIndex : chainEditor.steps.length - 1);
|
||||
}
|
||||
setChainDragSource(null);
|
||||
};
|
||||
|
||||
const handleChainDragOver = (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = chainDragSource?.origin === 'palette' ? 'copy' : 'move';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
@@ -677,6 +864,240 @@ export default function SettingsPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Skriptketten">
|
||||
<div className="script-manager-wrap">
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Neue Kette erstellen"
|
||||
icon="pi pi-plus"
|
||||
severity="success"
|
||||
outlined
|
||||
onClick={() => openChainEditor()}
|
||||
/>
|
||||
<Button
|
||||
label="Ketten neu laden"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={() => loadChains()}
|
||||
loading={chainsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<small>
|
||||
Skriptketten kombinieren einzelne Scripte und Systemblöcke (z.B. Warten) zu einer ausführbaren Sequenz.
|
||||
Ketten können an Jobs als Pre- oder Post-Encode-Aktion hinterlegt werden.
|
||||
</small>
|
||||
|
||||
<div className="script-list-box">
|
||||
<h4>Verfügbare Skriptketten</h4>
|
||||
{chainsLoading ? (
|
||||
<p>Lade Skriptketten...</p>
|
||||
) : chains.length === 0 ? (
|
||||
<p>Keine Skriptketten vorhanden.</p>
|
||||
) : (
|
||||
<div className="script-list">
|
||||
{chains.map((chain) => (
|
||||
<div key={chain.id} className="script-list-item">
|
||||
<div className="script-list-main">
|
||||
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
|
||||
<small>
|
||||
{chain.steps?.length ?? 0} Schritt(e):
|
||||
{' '}
|
||||
{(chain.steps || []).map((s, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 ? ' → ' : ''}
|
||||
{s.stepType === 'wait'
|
||||
? `⏱ ${s.waitSeconds}s`
|
||||
: (s.scriptName || `Script #${s.scriptId}`)}
|
||||
</span>
|
||||
))}
|
||||
</small>
|
||||
</div>
|
||||
<div className="script-list-actions">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
label="Bearbeiten"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => openChainEditor(chain)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
label="Löschen"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteChain(chain)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chain editor dialog */}
|
||||
<Dialog
|
||||
header={chainEditor.id ? `Skriptkette bearbeiten (#${chainEditor.id})` : 'Neue Skriptkette'}
|
||||
visible={chainEditor.open}
|
||||
onHide={closeChainEditor}
|
||||
style={{ width: 'min(70rem, calc(100vw - 1.5rem))' }}
|
||||
className="script-edit-dialog chain-editor-dialog"
|
||||
dismissableMask={false}
|
||||
draggable={false}
|
||||
>
|
||||
<div className="chain-editor-name-row">
|
||||
<label htmlFor="chain-name">Name der Kette</label>
|
||||
<InputText
|
||||
id="chain-name"
|
||||
value={chainEditor.name}
|
||||
onChange={(e) => {
|
||||
setChainEditor((prev) => ({ ...prev, name: e.target.value }));
|
||||
setChainEditorErrors((prev) => ({ ...prev, name: null }));
|
||||
}}
|
||||
placeholder="z.B. Plex-Refresh + Cleanup"
|
||||
/>
|
||||
{chainEditorErrors.name ? <small className="error-text">{chainEditorErrors.name}</small> : null}
|
||||
</div>
|
||||
|
||||
<div className="chain-editor-body">
|
||||
{/* Palette */}
|
||||
<div className="chain-palette">
|
||||
<h4>Bausteine</h4>
|
||||
<p className="chain-palette-hint">Auf Schritt klicken oder in die Kette ziehen</p>
|
||||
|
||||
<div className="chain-palette-section">
|
||||
<strong>Systemblöcke</strong>
|
||||
<div
|
||||
className="chain-palette-item chain-palette-item--system"
|
||||
draggable
|
||||
onDragStart={(e) => handleChainPaletteDragStart(e, { stepType: 'wait' })}
|
||||
onClick={() => addChainStep('wait')}
|
||||
title="Wartezeit zwischen zwei Schritten"
|
||||
>
|
||||
<i className="pi pi-clock" />
|
||||
{' '}Warten (Sekunden)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scripts.length > 0 ? (
|
||||
<div className="chain-palette-section">
|
||||
<strong>Scripte</strong>
|
||||
{scripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className="chain-palette-item chain-palette-item--script"
|
||||
draggable
|
||||
onDragStart={(e) => handleChainPaletteDragStart(e, { stepType: 'script', scriptId: script.id, scriptName: script.name })}
|
||||
onClick={() => addChainStep('script', script.id, script.name)}
|
||||
title={`Script #${script.id} hinzufügen`}
|
||||
>
|
||||
<i className="pi pi-code" />
|
||||
{' '}{script.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<small>Keine Scripte verfügbar. Zuerst Scripte anlegen.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chain canvas */}
|
||||
<div className="chain-canvas">
|
||||
<h4>Kette ({chainEditor.steps.length} Schritt{chainEditor.steps.length !== 1 ? 'e' : ''})</h4>
|
||||
|
||||
{chainEditor.steps.length === 0 ? (
|
||||
<div
|
||||
className="chain-canvas-empty"
|
||||
onDragOver={handleChainDragOver}
|
||||
onDrop={(e) => handleChainDropzoneDrop(e, 0)}
|
||||
>
|
||||
Bausteine hierhin ziehen oder links anklicken
|
||||
</div>
|
||||
) : (
|
||||
<div className="chain-steps-list">
|
||||
{chainEditor.steps.map((step, index) => (
|
||||
<div key={step._key || index} className="chain-step-wrapper">
|
||||
{/* Drop zone before step */}
|
||||
<div
|
||||
className="chain-drop-zone"
|
||||
onDragOver={handleChainDragOver}
|
||||
onDrop={(e) => handleChainDropzoneDrop(e, index)}
|
||||
/>
|
||||
<div
|
||||
className={`chain-step chain-step--${step.stepType}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleChainStepDragStart(e, index)}
|
||||
onDragEnd={() => setChainDragSource(null)}
|
||||
>
|
||||
<div className="chain-step-drag-handle">
|
||||
<i className="pi pi-bars" />
|
||||
</div>
|
||||
<div className="chain-step-content">
|
||||
{step.stepType === 'wait' ? (
|
||||
<div className="chain-step-wait">
|
||||
<i className="pi pi-clock" />
|
||||
<span>Warten:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="3600"
|
||||
value={step.waitSeconds ?? 10}
|
||||
onChange={(e) => updateChainStepWait(index, Number(e.target.value))}
|
||||
className="chain-wait-input"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span>Sekunden</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chain-step-script">
|
||||
<i className="pi pi-code" />
|
||||
<span>{step.scriptName || `Script #${step.scriptId}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
className="chain-step-remove"
|
||||
onClick={() => removeChainStep(index)}
|
||||
title="Schritt entfernen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Drop zone after last step */}
|
||||
<div
|
||||
className="chain-drop-zone chain-drop-zone--end"
|
||||
onDragOver={handleChainDragOver}
|
||||
onDrop={(e) => handleChainDropzoneDrop(e, chainEditor.steps.length)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
||||
<Button
|
||||
label={chainEditor.id ? 'Kette aktualisieren' : 'Kette erstellen'}
|
||||
icon="pi pi-save"
|
||||
onClick={handleSaveChain}
|
||||
loading={chainSaving}
|
||||
/>
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={closeChainEditor}
|
||||
disabled={chainSaving}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1704,4 +1704,245 @@ body {
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
max-height: 78vh;
|
||||
}
|
||||
|
||||
.chain-editor-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chain-palette,
|
||||
.chain-canvas {
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Chain Editor ─────────────────────────────────────── */
|
||||
.chain-editor-name-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chain-editor-body {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
.chain-palette {
|
||||
min-width: 14rem;
|
||||
max-width: 18rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
}
|
||||
|
||||
.chain-palette h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.chain-palette-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--rip-muted);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.chain-palette-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.chain-palette-section strong {
|
||||
font-size: 0.78rem;
|
||||
color: var(--rip-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.chain-palette-item {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 0.3rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
cursor: grab;
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.chain-palette-item:hover {
|
||||
background: var(--rip-gold-200);
|
||||
border-color: var(--rip-gold-400);
|
||||
}
|
||||
|
||||
.chain-palette-item--system {
|
||||
background: #fff3cd;
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.chain-palette-item--script {
|
||||
background: var(--rip-cream-100);
|
||||
}
|
||||
|
||||
.chain-canvas {
|
||||
flex: 1;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: var(--rip-panel);
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.chain-canvas h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.chain-canvas-empty {
|
||||
flex: 1;
|
||||
border: 2px dashed var(--rip-border);
|
||||
border-radius: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--rip-muted);
|
||||
font-size: 0.85rem;
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.chain-steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chain-step-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chain-drop-zone {
|
||||
height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: height 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.chain-drop-zone--end {
|
||||
flex: 1;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.chain-drop-zone:hover,
|
||||
.chain-drop-zone:focus-within {
|
||||
height: 1.5rem;
|
||||
background: var(--rip-gold-200);
|
||||
}
|
||||
|
||||
.chain-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
|
||||
.chain-step--wait {
|
||||
background: #fffbe6;
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.chain-step--script {
|
||||
background: var(--rip-cream-100);
|
||||
}
|
||||
|
||||
.chain-step-drag-handle {
|
||||
color: var(--rip-muted);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chain-step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chain-step-wait,
|
||||
.chain-step-script {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.chain-wait-input {
|
||||
width: 4rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
font-size: 0.88rem;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chain-step-remove {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chain-selection-groups {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chain-selection-group {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px dashed var(--rip-border);
|
||||
}
|
||||
|
||||
.chain-selection-group:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.chain-selection-group strong {
|
||||
font-size: 0.82rem;
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.chain-add-dropdown {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chain-selection-groups {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chain-selection-group {
|
||||
flex: unset;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user