diff --git a/frontend/src/components/AboutModal.jsx b/frontend/src/components/AboutModal.jsx
index 55342cb3..ce4bdc8f 100644
--- a/frontend/src/components/AboutModal.jsx
+++ b/frontend/src/components/AboutModal.jsx
@@ -10,8 +10,8 @@ import {
Text,
Tooltip,
} from '@mantine/core';
-import { BookOpen, Github, Heart, Users } from 'lucide-react';
-import { DiscordIcon } from './icons.jsx';
+import { BookOpen, Heart, Users } from 'lucide-react';
+import { DiscordIcon, GitHubIcon } from './icons.jsx';
import logo from '../images/logo.png';
import useSettingsStore from '../store/settings';
@@ -76,7 +76,7 @@ const AboutModal = ({ isOpen, onClose }) => {
target="_blank"
rel="noopener noreferrer"
variant="default"
- leftSection={}
+ leftSection={}
fullWidth
>
GitHub
diff --git a/frontend/src/components/PluginDetailPanel.jsx b/frontend/src/components/PluginDetailPanel.jsx
index 27d553a9..f7a5593f 100644
--- a/frontend/src/components/PluginDetailPanel.jsx
+++ b/frontend/src/components/PluginDetailPanel.jsx
@@ -9,6 +9,9 @@ import {
Select,
Stack,
Table,
+ TableTbody,
+ TableTd,
+ TableTr,
Text,
Tooltip,
} from '@mantine/core';
@@ -21,7 +24,11 @@ import {
ShieldCheck,
Trash2,
} from 'lucide-react';
-import { compareVersions } from './pluginUtils.js';
+import {
+ buildCompatibilityTooltip,
+ buildVersionSelectItems,
+ compareVersions,
+} from '../utils/components/pluginUtils.js';
import { formatKB } from '../utils/networkUtils.js';
import { DiscordIcon, GitHubIcon } from './icons.jsx';
@@ -294,42 +301,12 @@ const PluginDetailPanel = ({
{manifest.versions?.length > 0 &&
(() => {
- const installedMissing =
- installedVersion &&
- !manifest.versions.some(
- (v) => compareVersions(v.version, installedVersion) === 0
- );
- const buildLabel = (v) =>
- `v${v.version}${v.prerelease ? ' (prerelease)' : ''}${v.version === manifest.latest?.version ? ' (latest)' : ''}${installedVersion && compareVersions(v.version, installedVersion) === 0 ? ' (installed)' : ''}`;
-
- let versions = [...manifest.versions];
- if (installedVersionIsPrerelease) {
- const prereleases = versions.filter((v) => v.prerelease);
- const stable = versions.filter((v) => !v.prerelease);
- versions = [...prereleases, ...stable];
- }
-
- const versionItems = versions.map((v) => ({
- value: v.version,
- label: buildLabel(v),
- disabled: false,
- }));
- if (installedMissing) {
- const ghostItem = {
- value: installedVersion,
- label: `v${installedVersion} (installed)`,
- disabled: true,
- };
- // Insert in sorted position (newest first, matching manifest order convention)
- const idx = versionItems.findIndex(
- (item) => compareVersions(installedVersion, item.value) > 0
- );
- if (idx === -1) {
- versionItems.push(ghostItem);
- } else {
- versionItems.splice(idx, 0, ghostItem);
- }
- }
+ const versionItems = buildVersionSelectItems(
+ manifest.versions,
+ manifest.latest?.version,
+ installedVersion,
+ installedVersionIsPrerelease
+ );
return (
<>
@@ -372,21 +349,17 @@ const PluginDetailPanel = ({
selectedVersionData &&
!isSelSame &&
(() => {
- const parts = [];
- if (!selMeetsMin)
- parts.push(
- `${selectedVersionData.min_dispatcharr_version} or newer`
- );
- if (!selMeetsMax)
- parts.push(
- `${selectedVersionData.max_dispatcharr_version} or older`
- );
+ const tooltip = buildCompatibilityTooltip(
+ selMeetsMin,
+ selectedVersionData,
+ selMeetsMax
+ );
const label = !selMeetsMin
? `Min ${selectedVersionData.min_dispatcharr_version}`
: `Max ${selectedVersionData.max_dispatcharr_version}`;
return (
-
+
{selectedVersionData.build_timestamp && (
-
-
+
+
Built
-
-
+
+
{new Date(
selectedVersionData.build_timestamp
).toLocaleString()}
-
-
+
+
)}
{Number.isFinite(selectedVersionData.size) &&
selectedVersionData.size > 0 && (
-
-
+
+
File Size
-
-
+
+
{formatKB(selectedVersionData.size)}
-
-
+
+
)}
{selectedVersionData.min_dispatcharr_version && (
-
-
+
+
Min Version
-
-
+
+
{selectedVersionData.min_dispatcharr_version}
-
-
+
+
)}
{selectedVersionData.max_dispatcharr_version && (
-
-
+
+
Max Version
-
-
+
+
{selectedVersionData.max_dispatcharr_version}
-
-
+
+
)}
{selectedVersionData.commit_sha_short && (
-
-
+
+
Commit
-
-
+
+
{manifest.registry_url ? (
-
+
+
)}
{selectedVersionData.url && (
-
-
+
+
Download
-
-
+
+
{selectedVersionData.url.split('/').pop()}
-
-
+
+
)}
-
+
)}
>
diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx
index c58755b2..b1c0ffe3 100644
--- a/frontend/src/components/backups/BackupManager.jsx
+++ b/frontend/src/components/backups/BackupManager.jsx
@@ -14,7 +14,6 @@ import {
Stack,
Switch,
Text,
- TextInput,
Tooltip,
} from '@mantine/core';
import {
@@ -25,16 +24,32 @@ import {
SquarePlus,
UploadCloud,
} from 'lucide-react';
-import { notifications } from '@mantine/notifications';
-import dayjs from 'dayjs';
-
-import API from '../../api';
import ConfirmationDialog from '../ConfirmationDialog';
import useLocalStorage from '../../hooks/useLocalStorage';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from '../tables/CustomTable';
import { validateCronExpression } from '../../utils/cronUtils';
import ScheduleInput from '../forms/ScheduleInput';
+import { showNotification } from '../../utils/notificationUtils.js';
+import { formatBytes } from '../../utils/networkUtils.js';
+import {
+ format,
+ getDefaultTimeZone,
+ useDateTimeFormat,
+} from '../../utils/dateTimeUtils.js';
+import {
+ createBackup,
+ DAYS_OF_WEEK,
+ deleteBackup,
+ downloadBackup,
+ getBackupSchedule,
+ listBackups,
+ restoreBackup,
+ to12Hour,
+ to24Hour,
+ updateBackupSchedule,
+ uploadBackup,
+} from '../../utils/components/backups/BackupManagerUtils.js';
const RowActions = ({
row,
@@ -81,58 +96,6 @@ const RowActions = ({
);
};
-// Convert 24h time string to 12h format with period
-function to12Hour(time24) {
- if (!time24) return { time: '12:00', period: 'AM' };
- const [hours, minutes] = time24.split(':').map(Number);
- const period = hours >= 12 ? 'PM' : 'AM';
- const hours12 = hours % 12 || 12;
- return {
- time: `${hours12}:${String(minutes).padStart(2, '0')}`,
- period,
- };
-}
-
-// Convert 12h time + period to 24h format
-function to24Hour(time12, period) {
- if (!time12) return '00:00';
- const [hours, minutes] = time12.split(':').map(Number);
- let hours24 = hours;
- if (period === 'PM' && hours !== 12) {
- hours24 = hours + 12;
- } else if (period === 'AM' && hours === 12) {
- hours24 = 0;
- }
- return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
-}
-
-// Get default timezone (same as Settings page)
-function getDefaultTimeZone() {
- try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
- } catch {
- return 'UTC';
- }
-}
-
-const DAYS_OF_WEEK = [
- { value: '0', label: 'Sunday' },
- { value: '1', label: 'Monday' },
- { value: '2', label: 'Tuesday' },
- { value: '3', label: 'Wednesday' },
- { value: '4', label: 'Thursday' },
- { value: '5', label: 'Friday' },
- { value: '6', label: 'Saturday' },
-];
-
-function formatBytes(bytes) {
- if (bytes === 0) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
-}
-
export default function BackupManager() {
const [backups, setBackups] = useState([]);
const [loading, setLoading] = useState(false);
@@ -147,18 +110,9 @@ export default function BackupManager() {
const [deleting, setDeleting] = useState(false);
// Read user's preferences from settings
- const [timeFormat] = useLocalStorage('time-format', '12h');
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
+ const { fullDateTimeFormat, timeFormatSetting } = useDateTimeFormat();
const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone());
- const is12Hour = timeFormat === '12h';
-
- // Format date according to user preferences
- const formatDate = (dateString) => {
- const date = dayjs(dateString);
- const datePart = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
- const timePart = is12Hour ? 'h:mm:ss A' : 'HH:mm:ss';
- return date.format(`${datePart}, ${timePart}`);
- };
+ const is12Hour = timeFormatSetting === '12h';
// Warning suppression for confirmation dialogs
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@@ -213,7 +167,7 @@ export default function BackupManager() {
minSize: 180,
cell: ({ cell }) => (
- {formatDate(cell.getValue())}
+ {format(cell.getValue(), fullDateTimeFormat)}
),
},
@@ -267,10 +221,10 @@ export default function BackupManager() {
const loadBackups = async () => {
setLoading(true);
try {
- const backupList = await API.listBackups();
+ const backupList = await listBackups();
setBackups(backupList);
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to load backups',
color: 'red',
@@ -283,7 +237,7 @@ export default function BackupManager() {
const loadSchedule = async () => {
setScheduleLoading(true);
try {
- const settings = await API.getBackupSchedule();
+ const settings = await getBackupSchedule();
setSchedule(settings);
setScheduleType(settings.cron_expression ? 'cron' : 'interval');
@@ -294,7 +248,7 @@ export default function BackupManager() {
setTimePeriod(period);
setScheduleChanged(false);
- } catch (error) {
+ } catch {
// Ignore errors on initial load - settings may not exist yet
} finally {
setScheduleLoading(false);
@@ -340,17 +294,17 @@ export default function BackupManager() {
? schedule
: { ...schedule, cron_expression: '' };
- const updated = await API.updateBackupSchedule(scheduleToSave);
+ const updated = await updateBackupSchedule(scheduleToSave);
setSchedule(updated);
setScheduleChanged(false);
- notifications.show({
+ showNotification({
title: 'Success',
message: 'Backup schedule saved',
color: 'green',
});
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to save schedule',
color: 'red',
@@ -363,15 +317,15 @@ export default function BackupManager() {
const handleCreateBackup = async () => {
setCreating(true);
try {
- await API.createBackup();
- notifications.show({
+ await createBackup();
+ showNotification({
title: 'Success',
message: 'Backup created successfully',
color: 'green',
});
await loadBackups();
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to create backup',
color: 'red',
@@ -384,14 +338,14 @@ export default function BackupManager() {
const handleDownload = async (filename) => {
setDownloading(filename);
try {
- await API.downloadBackup(filename);
- notifications.show({
+ await downloadBackup(filename);
+ showNotification({
title: 'Download Started',
message: `Downloading ${filename}...`,
color: 'blue',
});
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to download backup',
color: 'red',
@@ -409,15 +363,15 @@ export default function BackupManager() {
const handleDeleteConfirm = async () => {
setDeleting(true);
try {
- await API.deleteBackup(selectedBackup.name);
- notifications.show({
+ await deleteBackup(selectedBackup.name);
+ showNotification({
title: 'Success',
message: 'Backup deleted successfully',
color: 'green',
});
await loadBackups();
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to delete backup',
color: 'red',
@@ -437,8 +391,8 @@ export default function BackupManager() {
const handleRestoreConfirm = async () => {
setRestoring(true);
try {
- await API.restoreBackup(selectedBackup.name);
- notifications.show({
+ await restoreBackup(selectedBackup.name);
+ showNotification({
title: 'Restore Complete',
message:
'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.',
@@ -446,7 +400,7 @@ export default function BackupManager() {
});
setTimeout(() => window.location.reload(), 4000);
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to restore backup',
color: 'red',
@@ -462,8 +416,8 @@ export default function BackupManager() {
if (!uploadFile) return;
try {
- await API.uploadBackup(uploadFile);
- notifications.show({
+ await uploadBackup(uploadFile);
+ showNotification({
title: 'Success',
message: 'Backup uploaded successfully',
color: 'green',
@@ -472,7 +426,7 @@ export default function BackupManager() {
setUploadFile(null);
await loadBackups();
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to upload backup',
color: 'red',
diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx
index 71b5ff1e..497a5435 100644
--- a/frontend/src/components/cards/AvailablePluginCard.jsx
+++ b/frontend/src/components/cards/AvailablePluginCard.jsx
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import {
- ActionIcon,
Avatar,
Badge,
Box,
@@ -33,8 +32,7 @@ import {
PluginSecurityWarning,
PluginSupportDisclaimer,
} from '../PluginWarnings.jsx';
-import { useNavigate, useLocation } from 'react-router-dom';
-import API from '../../api';
+import { useLocation, useNavigate } from 'react-router-dom';
import { usePluginStore } from '../../store/plugins';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
import {
@@ -43,6 +41,11 @@ import {
getInstallInfo,
} from '../../utils/components/pluginUtils.js';
import SizedInstallButton from '../theme/SizedInstallButton.jsx';
+import {
+ deletePluginByKey,
+ getPluginDetailManifest,
+ setPluginEnabled,
+} from '../../utils/pages/PluginsUtils.js';
const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
if (isOfficial) {
@@ -319,7 +322,7 @@ const AvailablePluginCard = ({
if (enableNow && installedKey) {
setEnabling(true);
try {
- await API.setPluginEnabled(installedKey, true);
+ await setPluginEnabled(installedKey, true);
} finally {
setEnabling(false);
}
@@ -333,7 +336,7 @@ const AvailablePluginCard = ({
if (!key) return;
setUninstalling(true);
try {
- const resp = await API.deletePlugin(key);
+ const resp = await deletePluginByKey(key);
if (resp?.success) {
onUninstalled?.(plugin.slug);
usePluginStore.getState().invalidatePlugins();
@@ -379,7 +382,7 @@ const AvailablePluginCard = ({
return;
}
setDetailLoading(true);
- const result = await API.getPluginDetailManifest(
+ const result = await getPluginDetailManifest(
plugin.repo_id,
plugin.manifest_url
);
@@ -534,17 +537,17 @@ const AvailablePluginCard = ({
{!meetsVersion &&
(() => {
- const parts = [];
- if (!meetsMinVersion)
- parts.push(`${plugin.min_dispatcharr_version} or newer`);
- if (!meetsMaxVersion)
- parts.push(`${plugin.max_dispatcharr_version} or older`);
+ const tooltip = buildCompatibilityTooltip(
+ meetsMinVersion,
+ plugin,
+ meetsMaxVersion
+ );
const label = !meetsMinVersion
? `Min ${plugin.min_dispatcharr_version}`
: `Max ${plugin.max_dispatcharr_version}`;
return (
{
- const isDowngrade =
- pendingInstall &&
- plugin.installed_version &&
- compareVersions(pendingInstall.version, plugin.installed_version) < 0;
- const isUpdate =
- pendingInstall &&
- plugin.installed_version &&
- !isDowngrade &&
- compareVersions(pendingInstall.version, plugin.installed_version) > 0;
- const isBadSig = plugin.signature_verified === false;
+ const { isDowngrade, isUpdate, isBadSig } = getInstallInfo(
+ pendingInstall,
+ plugin
+ );
const actionLabel = isDowngrade
? 'Downgrade'
: isUpdate
diff --git a/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx b/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx
index 9d6016d4..5797b6ef 100644
--- a/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx
+++ b/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx
@@ -51,12 +51,11 @@ const RedisStatus = ({ tls }) => {
: 'The connection is encrypted but the server identity is not verified';
}
- let mtlsColor = 'gray';
- let mtlsTooltip = 'Mutual authentication is not active';
- if (enabled && mtls) {
- mtlsColor = 'green';
- mtlsTooltip = 'Both client and server verify each other with certificates';
- }
+ const mtlsColor = enabled && mtls ? 'green' : 'gray';
+ const mtlsTooltip =
+ enabled && mtls
+ ? 'Both client and server verify each other with certificates'
+ : 'Mutual authentication is not active';
return (
@@ -94,11 +93,10 @@ const PostgresStatus = ({ tls }) => {
const sslMode = tls?.ssl_mode;
const mtls = tls?.mtls ?? false;
+ const modeBadge = enabled && sslMode ? sslMode : 'Off';
let modeColor = 'gray';
let modeTooltip = 'Verification mode is not active — TLS is disabled';
- let modeBadge = 'Off';
if (enabled && sslMode) {
- modeBadge = sslMode;
if (sslMode === 'verify-full') {
modeColor = 'green';
modeTooltip = 'Server certificate and hostname are both verified';
@@ -112,12 +110,11 @@ const PostgresStatus = ({ tls }) => {
}
}
- let mtlsColor = 'gray';
- let mtlsTooltip = 'Mutual authentication is not active';
- if (enabled && mtls) {
- mtlsColor = 'green';
- mtlsTooltip = 'Both client and server verify each other with certificates';
- }
+ const mtlsColor = enabled && mtls ? 'green' : 'gray';
+ const mtlsTooltip =
+ enabled && mtls
+ ? 'Both client and server verify each other with certificates'
+ : 'Mutual authentication is not active';
return (
diff --git a/frontend/src/components/forms/settings/UserLimitsForm.jsx b/frontend/src/components/forms/settings/UserLimitsForm.jsx
index b6a9ed5e..ebb2d87f 100644
--- a/frontend/src/components/forms/settings/UserLimitsForm.jsx
+++ b/frontend/src/components/forms/settings/UserLimitsForm.jsx
@@ -2,15 +2,7 @@ import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import { updateSetting } from '../../../utils/pages/SettingsUtils.js';
-import {
- Alert,
- Button,
- Flex,
- NumberInput,
- Stack,
- TextInput,
- Checkbox,
-} from '@mantine/core';
+import { Alert, Button, Flex, Stack, Checkbox } from '@mantine/core';
import { USER_LIMITS_OPTIONS } from '../../../constants.js';
const USER_LIMIT_DEFAULTS = Object.keys(USER_LIMITS_OPTIONS).reduce(
@@ -77,20 +69,14 @@ const UserLimitsForm = React.memo(({ active }) => {
>
)}
- {Object.keys(USER_LIMITS_OPTIONS).reduce((acc, key) => {
- const option = USER_LIMITS_OPTIONS[key];
- acc.push(
-
- );
- return acc;
- }, [])}
+ {Object.entries(USER_LIMITS_OPTIONS).map(([key, option]) => (
+
+ ))}