1.0.0-rc2 RC2
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-05-21 10:19:00 +02:00
parent 9b38d78042
commit eeef1fdb73
31 changed files with 4046 additions and 1449 deletions
+7
View File
@@ -911,6 +911,13 @@ export default function ConverterPage() {
<div className="ripper-subpage-content">
<Toast ref={toastRef} position="top-right" />
<div className="converter-beta-notice" role="note" aria-live="polite">
<i className="pi pi-exclamation-triangle" aria-hidden="true" />
<span>
Hinweis: Der Converter befindet sich aktuell im Beta-Stadium und ist noch nicht vollständig geprüft.
</span>
</div>
{/* Import-Ordner */}
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
<ConverterFileExplorer
+634
View File
@@ -0,0 +1,634 @@
import { useEffect, useMemo, useState } from 'react';
import 'chart.js/auto';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Chart } from 'primereact/chart';
import { Button } from 'primereact/button';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Math.min(100, parsed));
}
function formatPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return '-';
}
return `${Math.round(parsed)}%`;
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) {
return '-';
}
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const normalized = bytes / (1024 ** exponent);
return `${normalized.toFixed(normalized >= 100 ? 0 : (normalized >= 10 ? 1 : 2))} ${units[exponent]}`;
}
function formatUpdatedAt(value) {
const raw = String(value || '').trim();
if (!raw) {
return '-';
}
const parsed = Date.parse(raw);
if (!Number.isFinite(parsed)) {
return raw;
}
return new Date(parsed).toLocaleString('de-DE');
}
function normalizeHardwareMonitoringPayload(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
return {
enabled: Boolean(payload.enabled),
intervalMs: Number(payload.intervalMs || 0),
updatedAt: payload.updatedAt || null,
sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
error: payload.error ? String(payload.error) : null
};
}
function buildGaugeDataset(value, color) {
const safe = clampPercent(value);
return {
datasets: [
{
data: [safe, Math.max(0, 100 - safe)],
backgroundColor: [color, 'rgba(255,255,255,0.08)'],
borderWidth: 0,
hoverOffset: 0
}
]
};
}
const gaugeOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: '82%',
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
},
animation: false
};
const HISTORY_WINDOWS = [
{ label: '1h', hours: 1 },
{ label: '6h', hours: 6 },
{ label: '24h', hours: 24 },
{ label: '4d', hours: 96 }
];
const HISTORY_STACK_BREAKPOINT = 1700;
const CPU_SERIES_COLOR = '#c43d2f';
const GPU_SERIES_COLOR = '#c9961a';
const RAM_SERIES_COLOR = '#2e7d4f';
const historyChartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
}
},
scales: {
x: {
ticks: {
autoSkip: true,
maxTicksLimit: 8,
color: '#6a4d38'
},
grid: {
color: 'rgba(111,57,34,0.08)'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#6a4d38'
},
grid: {
color: 'rgba(111,57,34,0.08)'
}
}
}
};
function toNumberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function formatTickLabel(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return '-';
}
const date = new Date(parsed);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function buildSeriesToggleButtonStyle(color, active) {
if (active) {
return {
background: color,
borderColor: color,
color: '#fffaf1'
};
}
return {
background: 'transparent',
borderColor: color,
color
};
}
function getHistoryViewportBucket() {
if (typeof window === 'undefined') {
return 'wide';
}
return window.innerWidth < HISTORY_STACK_BREAKPOINT ? 'narrow' : 'wide';
}
function ChartLegendRow({ items = [] }) {
return (
<div className="hardware-history-legend-row" aria-hidden="true">
{items.map((item) => (
<span key={item.label} className="hardware-history-legend-item">
<span className="hardware-history-legend-dot" style={{ background: item.color }} />
<span>{item.label}</span>
</span>
))}
</div>
);
}
function GaugeBlock({ title, subtitle, icon, valuePercent, gaugeColor, bars = [], footer = null }) {
return (
<Card className="hardware-detail-card hardware-detail-card-top">
<div className="hardware-detail-card-head">
<div className="hardware-detail-card-icon"><i className={`pi ${icon}`} /></div>
<div>
<h3>{title}</h3>
{subtitle ? <small>{subtitle}</small> : null}
</div>
</div>
<div className="hardware-detail-gauge-layout">
<div className="hardware-detail-gauge-wrap">
<Chart type="doughnut" data={buildGaugeDataset(valuePercent, gaugeColor)} options={gaugeOptions} className="hardware-detail-gauge-chart" />
<div className="hardware-detail-gauge-center">{formatPercent(valuePercent)}</div>
</div>
<div className="hardware-detail-bar-list">
{bars.map((bar) => (
<div key={bar.label} className="hardware-detail-bar-item">
<div className="hardware-detail-bar-head">
<span>{bar.label}</span>
<span>{bar.valueLabel}</span>
</div>
<ProgressBar value={clampPercent(bar.valuePercent)} showValue={false} />
</div>
))}
</div>
</div>
{footer}
</Card>
);
}
export default function HardwarePage({ hardwareMonitoring }) {
const navigate = useNavigate();
const [historyHours, setHistoryHours] = useState(24);
const [historyViewportBucket, setHistoryViewportBucket] = useState(() => getHistoryViewportBucket());
const [usageSeriesVisible, setUsageSeriesVisible] = useState({
cpu: true,
ram: true,
gpu: true
});
const [tempSeriesVisible, setTempSeriesVisible] = useState({
cpu: true,
gpu: true
});
const [historyState, setHistoryState] = useState({
loading: false,
points: [],
totalPoints: 0,
error: null
});
const monitoringState = useMemo(
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
[hardwareMonitoring]
);
const sample = monitoringState.sample;
const cpu = sample?.cpu && typeof sample.cpu === 'object' ? sample.cpu : {};
const memory = sample?.memory && typeof sample.memory === 'object' ? sample.memory : {};
const gpu = sample?.gpu && typeof sample.gpu === 'object' ? sample.gpu : {};
const cpuPerCore = Array.isArray(cpu?.perCore) ? cpu.perCore : [];
const gpuDevices = Array.isArray(gpu?.devices) ? gpu.devices : [];
const primaryGpu = gpuDevices[0] || null;
const cpuTitle = String(cpu?.name || cpu?.model || cpu?.modelName || cpu?.brand || '').trim() || 'CPU';
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
useEffect(() => {
if (!monitoringState.enabled) {
setHistoryState({
loading: false,
points: [],
totalPoints: 0,
error: null
});
return undefined;
}
let cancelled = false;
const loadHistory = async (forceRefresh = false) => {
setHistoryState((prev) => ({
...prev,
loading: prev.points.length === 0
}));
try {
const response = await api.getHardwareHistory({
hours: historyHours,
maxPoints: 900,
forceRefresh
});
if (cancelled) {
return;
}
const payload = response?.history && typeof response.history === 'object' ? response.history : {};
setHistoryState({
loading: false,
points: Array.isArray(payload.points) ? payload.points : [],
totalPoints: Number(payload.totalPoints || 0),
error: null
});
} catch (error) {
if (cancelled) {
return;
}
setHistoryState((prev) => ({
...prev,
loading: false,
error: error?.message || 'Historische Hardware-Daten konnten nicht geladen werden.'
}));
}
};
loadHistory(true);
const refreshMs = Math.max(5000, Number(monitoringState.intervalMs || 5000) * 2);
const timer = setInterval(() => {
loadHistory(true);
}, refreshMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [historyHours, monitoringState.enabled, monitoringState.intervalMs]);
useEffect(() => {
let frame = null;
const handleResize = () => {
if (frame) {
cancelAnimationFrame(frame);
}
frame = requestAnimationFrame(() => {
frame = null;
setHistoryViewportBucket((prev) => {
const next = getHistoryViewportBucket();
return prev === next ? prev : next;
});
});
};
window.addEventListener('resize', handleResize);
return () => {
if (frame) {
cancelAnimationFrame(frame);
}
window.removeEventListener('resize', handleResize);
};
}, []);
const historyUsageChartData = useMemo(() => ({
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
datasets: [
{
label: 'CPU',
data: historyPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.cpu,
pointRadius: 0,
tension: 0.22
},
{
label: 'RAM',
data: historyPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
borderColor: RAM_SERIES_COLOR,
backgroundColor: RAM_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.ram,
pointRadius: 0,
tension: 0.22
},
{
label: 'GPU',
data: historyPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.gpu,
pointRadius: 0,
tension: 0.22
}
]
}), [historyPoints, usageSeriesVisible]);
const historyTempChartData = useMemo(() => ({
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
datasets: [
{
label: 'CPU',
data: historyPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
hidden: !tempSeriesVisible.cpu,
pointRadius: 0,
tension: 0.22
},
{
label: 'GPU',
data: historyPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
hidden: !tempSeriesVisible.gpu,
pointRadius: 0,
tension: 0.22
}
]
}), [historyPoints, tempSeriesVisible]);
return (
<div className="hardware-detail-page">
<Card className="hardware-detail-hero">
<div className="hardware-detail-hero-head">
<div>
<h2>Hardware Monitoring</h2>
<small>Ausführliche Live-Ansicht für CPU, RAM und GPU</small>
</div>
<div className="hardware-detail-hero-tags">
<Tag value={monitoringState.enabled ? 'Aktiv' : 'Inaktiv'} severity={monitoringState.enabled ? 'success' : 'warning'} />
<Tag value={`Intervall: ${monitoringState.intervalMs > 0 ? `${monitoringState.intervalMs} ms` : '-'}`} severity="secondary" />
<Tag value={`Letztes Update: ${formatUpdatedAt(monitoringState.updatedAt)}`} severity="info" />
</div>
</div>
</Card>
{!monitoringState.enabled ? (
<Card className="hardware-detail-empty">
<h3>Monitoring ist deaktiviert</h3>
<p>
Aktiviere <code>hardware_monitoring_enabled</code> in den Einstellungen,
um die Live-Ansicht auf dieser Seite zu nutzen.
</p>
<Button
label="Zu den Einstellungen"
icon="pi pi-cog"
onClick={() => navigate('/settings')}
/>
</Card>
) : null}
{monitoringState.enabled && !sample ? (
<Card className="hardware-detail-empty">
<h3>Warte auf erste Messung ...</h3>
<p>Die Hardware-Metriken werden gerade aufgebaut.</p>
</Card>
) : null}
{monitoringState.enabled ? (
<Card className="hardware-detail-card hardware-history-card">
<div className="hardware-detail-card-head hardware-history-card-head">
<div>
<h3>Historie</h3>
<small>
Verlauf aus Backend-Log ({historyState.totalPoints} Messpunkte, Ansicht: letzte {historyHours}h)
</small>
</div>
<div className="hardware-history-window-buttons">
{HISTORY_WINDOWS.map((window) => (
<Button
key={`history-window-${window.hours}`}
label={window.label}
size="small"
severity={historyHours === window.hours ? 'primary' : 'secondary'}
outlined={historyHours !== window.hours}
onClick={() => setHistoryHours(window.hours)}
/>
))}
</div>
</div>
{historyState.error ? <small className="error-text">{historyState.error}</small> : null}
{historyState.loading && historyPoints.length === 0 ? (
<p>Lade Verlauf ...</p>
) : historyPoints.length === 0 ? (
<p>Noch keine Verlaufsdaten vorhanden.</p>
) : (
<div className="hardware-history-grid">
<div className="hardware-history-chart-wrap">
<h4>Auslastung (%)</h4>
<div className="hardware-history-series-toggles">
<Button
type="button"
size="small"
label="CPU"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, usageSeriesVisible.cpu)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
/>
<Button
type="button"
size="small"
label="RAM"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(RAM_SERIES_COLOR, usageSeriesVisible.ram)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, ram: !prev.ram }))}
/>
<Button
type="button"
size="small"
label="GPU"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, usageSeriesVisible.gpu)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
/>
</div>
<div className="hardware-history-chart">
<Chart
key={`usage-${historyViewportBucket}`}
className="hardware-history-chart-canvas"
type="line"
data={historyUsageChartData}
options={historyChartOptions}
/>
</div>
<ChartLegendRow
items={[
{ label: 'CPU', color: CPU_SERIES_COLOR },
{ label: 'RAM', color: RAM_SERIES_COLOR },
{ label: 'GPU', color: GPU_SERIES_COLOR }
]}
/>
</div>
<div className="hardware-history-chart-wrap">
<h4>Temperatur (°C)</h4>
<div className="hardware-history-series-toggles">
<Button
type="button"
size="small"
label="CPU °C"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, tempSeriesVisible.cpu)}
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
/>
<Button
type="button"
size="small"
label="GPU °C"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, tempSeriesVisible.gpu)}
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
/>
</div>
<div className="hardware-history-chart">
<Chart
key={`temp-${historyViewportBucket}`}
className="hardware-history-chart-canvas"
type="line"
data={historyTempChartData}
options={historyChartOptions}
/>
</div>
<ChartLegendRow
items={[
{ label: 'CPU', color: CPU_SERIES_COLOR },
{ label: 'GPU', color: GPU_SERIES_COLOR }
]}
/>
</div>
</div>
)}
</Card>
) : null}
{monitoringState.enabled && sample ? (
<div className="hardware-detail-grid">
<GaugeBlock
title="CPU"
subtitle={cpuTitle}
icon="pi-microchip"
valuePercent={cpu?.overallUsagePercent}
gaugeColor="#b07a24"
bars={[
{ label: 'Gesamtauslastung', valuePercent: cpu?.overallUsagePercent, valueLabel: formatPercent(cpu?.overallUsagePercent) },
{ label: 'Load Avg 1m', valuePercent: clampPercent(Number(cpu?.loadAverage?.[0]) * 100 / 8), valueLabel: Array.isArray(cpu?.loadAverage) ? String(cpu.loadAverage[0] ?? '-') : '-' }
]}
footer={(
<div className="hardware-detail-core-grid">
{cpuPerCore.length === 0 ? <small>Keine Core-Metriken verfügbar.</small> : cpuPerCore.map((core, index) => (
<div key={`cpu-core-${core?.index ?? index}`} className="hardware-detail-core-item">
<div className="hardware-detail-bar-head">
<span>Core {core?.index ?? index}</span>
<span>{formatPercent(core?.usagePercent)}</span>
</div>
<ProgressBar value={clampPercent(core?.usagePercent)} showValue={false} />
</div>
))}
</div>
)}
/>
<GaugeBlock
title="RAM"
subtitle={ramTitle}
icon="pi-server"
valuePercent={memory?.usagePercent}
gaugeColor="#9f6b1d"
bars={[
{ label: 'Verwendet', valuePercent: memory?.usagePercent, valueLabel: formatPercent(memory?.usagePercent) },
{ label: 'Belegt', valuePercent: memory?.usagePercent, valueLabel: formatBytes(memory?.usedBytes) },
{ label: 'Frei', valuePercent: Math.max(0, 100 - clampPercent(memory?.usagePercent)), valueLabel: formatBytes(memory?.freeBytes) }
]}
footer={(
<div className="hardware-detail-meter-wrap">
<div className="hardware-detail-meter-head">
<span>RAM Nutzung</span>
<span>{formatBytes(memory?.usedBytes)} / {formatBytes(memory?.totalBytes)}</span>
</div>
<ProgressBar value={clampPercent(memory?.usagePercent)} showValue={false} />
</div>
)}
/>
<GaugeBlock
title="GPU"
subtitle={gpuTitle}
icon="pi-desktop"
valuePercent={primaryGpu?.utilizationPercent}
gaugeColor="#996521"
bars={[
{ label: '3D / Compute', valuePercent: primaryGpu?.utilizationPercent, valueLabel: formatPercent(primaryGpu?.utilizationPercent) },
{ label: 'Speicher', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: formatPercent(primaryGpu?.memoryUtilizationPercent) },
{ label: 'VRAM', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: `${formatBytes(primaryGpu?.memoryUsedBytes)} / ${formatBytes(primaryGpu?.memoryTotalBytes)}` }
]}
footer={(
<div className="hardware-detail-meter-wrap">
<div className="hardware-detail-meter-head">
<span>GPU Nutzung</span>
<span>
GPU {primaryGpu?.index ?? 0}
{primaryGpu?.name ? ` | ${primaryGpu.name}` : ''}
</span>
</div>
<ProgressBar value={clampPercent(primaryGpu?.utilizationPercent)} showValue={false} />
</div>
)}
/>
</div>
) : null}
</div>
);
}
+35 -13
View File
@@ -59,6 +59,7 @@ const CD_FORMAT_LABELS = {
const MULTIPART_CONTAINER_LIVE_STATUSES = new Set([
'ANALYZING',
'METADATA_LOOKUP',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'READY_TO_START',
@@ -445,7 +446,7 @@ function getQueueActionResult(response) {
function isPipelineMetadataFlowStatus(value) {
const normalized = String(value || '').trim().toUpperCase();
return ['METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
return ['METADATA_LOOKUP', 'METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
}
function normalizeSortText(value) {
@@ -944,7 +945,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
} else if (action === 'restart_encode') {
setActionBusy(true);
try {
const response = await api.restartEncodeWithLastSettings(job.id, options);
const response = await api.restartEncodeWithLastSettings(job.id, {
...(options || {}),
createNewJob: true
});
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
if (result.queued) {
@@ -977,7 +981,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
setActionBusy(true);
try {
const response = await api.restartReviewFromRaw(job.id, options);
const response = await api.restartReviewFromRaw(job.id, {
...(options || {}),
createNewJob: true
});
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
@@ -1307,7 +1314,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
setActionBusy(true);
try {
const response = await api.retryJob(row.id);
const response = await api.retryJob(row.id, { createNewJob: true });
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
@@ -1435,19 +1442,34 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const handleMetadataSearch = async (query, options = {}) => {
const workflow = String(
options?.workflowKind
|| metadataDialogContext?.selectedMetadata?.workflowKind
|| ''
).trim().toLowerCase();
try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = workflow === 'series'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchTmdbMovie(query);
return response.results || [];
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
const includeMovies = filterRaw !== 'series';
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
const [movieResponse, seriesResponse] = await Promise.all([
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
includeSeries ? api.searchTmdbSeries(query, tmdbSeasonHint) : Promise.resolve({ results: [] })
]);
const movieRows = Array.isArray(movieResponse?.results)
? movieResponse.results.map((row) => ({
...row,
workflowKind: 'film',
metadataKind: 'movie',
resultType: 'movie'
}))
: [];
const seriesRows = Array.isArray(seriesResponse?.results)
? seriesResponse.results.map((row) => ({
...row,
workflowKind: 'series',
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
resultType: 'series'
}))
: [];
return [...movieRows, ...seriesRows];
} catch (error) {
toastRef.current?.show({
severity: 'error',
File diff suppressed because it is too large Load Diff
+104 -45
View File
@@ -322,7 +322,7 @@ export default function SettingsPage() {
const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
const [runningChainIds, setRunningChainIds] = useState([]);
const [lastChainTestResult, setLastChainTestResult] = useState(null);
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', description: '', steps: [] });
const [chainEditorErrors, setChainEditorErrors] = useState({});
const [chainDragSource, setChainDragSource] = useState(null);
@@ -1019,6 +1019,32 @@ export default function SettingsPage() {
}
};
const handleToggleScriptFavorite = async (script) => {
const scriptId = Number(script?.id);
if (!Number.isFinite(scriptId) || scriptId <= 0) {
return;
}
setScriptActionBusyId(scriptId);
try {
const nextFavorite = !(script?.isFavorite === true);
await api.setScriptFavorite(scriptId, nextFavorite);
await loadScripts({ silent: true });
toastRef.current?.show({
severity: 'success',
summary: 'Scripte',
detail: nextFavorite ? 'Favorit aktiviert.' : 'Favorit entfernt.'
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Favorit konnte nicht geändert werden',
detail: error.message
});
} finally {
setScriptActionBusyId(null);
}
};
const handleScriptListDragStart = (event, scriptId) => {
if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) {
event.preventDefault();
@@ -1120,18 +1146,51 @@ export default function SettingsPage() {
}
};
const handleToggleChainFavorite = async (chain) => {
const chainId = Number(chain?.id);
if (!Number.isFinite(chainId) || chainId <= 0) {
return;
}
const normalizedChainId = Math.trunc(chainId);
setChainActionBusy(normalizedChainId, true);
try {
const nextFavorite = !(chain?.isFavorite === true);
await api.setScriptChainFavorite(normalizedChainId, nextFavorite);
await loadChains({ silent: true });
toastRef.current?.show({
severity: 'success',
summary: 'Skriptketten',
detail: nextFavorite ? 'Favorit aktiviert.' : 'Favorit entfernt.'
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Favorit konnte nicht geändert werden',
detail: error.message
});
} finally {
setChainActionBusy(normalizedChainId, false);
}
};
// 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()}` })) });
setChainEditor({
open: true,
id: chain.id,
name: chain.name,
description: chain.description || '',
steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` }))
});
} else {
setChainEditor({ open: true, id: null, name: '', steps: [] });
setChainEditor({ open: true, id: null, name: '', description: '', steps: [] });
}
setChainEditorErrors({});
};
const closeChainEditor = () => {
setChainEditor({ open: false, id: null, name: '', steps: [] });
setChainEditor({ open: false, id: null, name: '', description: '', steps: [] });
setChainEditorErrors({});
};
@@ -1182,6 +1241,7 @@ export default function SettingsPage() {
}
const payload = {
name,
description: String(chainEditor.description || '').trim(),
steps: chainEditor.steps.map((s) => ({
stepType: s.stepType,
scriptId: s.stepType === 'script' ? s.scriptId : null,
@@ -1414,6 +1474,7 @@ export default function SettingsPage() {
dirtyKeys={dirtyKeys}
onChange={handleFieldChange}
effectivePaths={effectivePaths}
activationBytes={activationBytes}
onNotify={(msg) => toastRef.current?.show(msg)}
/>
)}
@@ -1506,6 +1567,7 @@ export default function SettingsPage() {
<div className="script-order-list">
{scripts.map((script, index) => {
const isDragging = Number(scriptListDragSourceId) === Number(script.id);
const isFavorite = script?.isFavorite === true;
return (
<div key={script.id} className="script-order-wrapper">
<div
@@ -1530,6 +1592,15 @@ export default function SettingsPage() {
</div>
<div className="script-list-actions">
<Button
icon={isFavorite ? 'pi pi-star-fill' : 'pi pi-star'}
label="Favorit"
severity={isFavorite ? 'warning' : 'secondary'}
outlined={!isFavorite}
onClick={() => handleToggleScriptFavorite(script)}
loading={scriptActionBusyId === script.id}
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
/>
<Button
icon="pi pi-pencil"
label="Bearbeiten"
@@ -1678,6 +1749,7 @@ export default function SettingsPage() {
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
const isFavorite = chain?.isFavorite === true;
return (
<div key={chain.id} className="script-order-wrapper">
<div
@@ -1699,20 +1771,20 @@ export default function SettingsPage() {
</div>
<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 className="chain-description-line" title={String(chain.description || '').trim()}>
{String(chain.description || '').trim() || '—'}
</small>
</div>
<div className="script-list-actions">
<Button
icon={isFavorite ? 'pi pi-star-fill' : 'pi pi-star'}
label="Favorit"
severity={isFavorite ? 'warning' : 'secondary'}
outlined={!isFavorite}
onClick={() => handleToggleChainFavorite(chain)}
loading={isChainActionBusy}
disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
/>
<Button
icon="pi pi-pencil"
label="Bearbeiten"
@@ -1801,6 +1873,20 @@ export default function SettingsPage() {
/>
{chainEditorErrors.name ? <small className="error-text">{chainEditorErrors.name}</small> : null}
</div>
<div className="chain-editor-name-row" style={{ marginTop: '0.7rem' }}>
<label htmlFor="chain-description">Kurzbeschreibung (max. 50 Zeichen)</label>
<InputText
id="chain-description"
value={chainEditor.description}
maxLength={50}
onChange={(e) => {
setChainEditor((prev) => ({ ...prev, description: e.target.value }));
setChainEditorErrors((prev) => ({ ...prev, description: null }));
}}
placeholder="Kurzer Hinweis zur Kette"
/>
{chainEditorErrors.description ? <small className="error-text">{chainEditorErrors.description}</small> : null}
</div>
<div className="chain-editor-body">
{/* Palette */}
@@ -2016,9 +2102,9 @@ export default function SettingsPage() {
) : userPresets.length === 0 ? (
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
) : (
<div className="script-list script-list--reorderable" style={{ marginTop: '1rem' }}>
<div className="preset-list" style={{ marginTop: '1rem' }}>
{userPresets.map((preset) => (
<div key={preset.id} className="script-list-item">
<div key={preset.id} className="script-list-item preset-list-item">
<div className="script-list-main">
<div className="script-title-line">
<strong className="script-id-title">#{preset.id} - {preset.name}</strong>
@@ -2032,7 +2118,7 @@ export default function SettingsPage() {
{preset.description || '-'}
</small>
</div>
<div className="script-list-actions script-list-actions--two">
<div className="script-list-actions script-list-actions--two preset-list-actions">
<Button
icon="pi pi-pencil"
label="Bearbeiten"
@@ -2153,33 +2239,6 @@ export default function SettingsPage() {
</TabView>
</Card>
{expertModeEnabled && activationBytes.length > 0 && (
<Card
title="Activation Bytes Cache"
subTitle="Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt."
style={{ marginTop: '1.5rem' }}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'monospace', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--surface-border)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Checksum</th>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Activation Bytes</th>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Gespeichert am</th>
</tr>
</thead>
<tbody>
{activationBytes.map((entry) => (
<tr key={entry.checksum} style={{ borderBottom: '1px solid var(--surface-border)' }}>
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{entry.checksum}</td>
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 'bold' }}>{entry.activation_bytes}</td>
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{new Date(entry.created_at).toLocaleString('de-DE')}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
);
}