Slight component refactoring

This commit is contained in:
Nick Sandstrom 2026-07-08 14:54:57 -07:00
parent 18f7e8584a
commit 71f1c7d5e5
7 changed files with 146 additions and 239 deletions

View file

@ -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={<Github size={15} />}
leftSection={<GitHubIcon size={15} />}
fullWidth
>
GitHub

View file

@ -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 (
<>
<Group gap="xs" align="flex-end">
@ -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 (
<Tooltip
label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}
label={`Incompatible: requires Dispatcharr ${tooltip} (you have v${appVersion})`}
>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle
@ -409,56 +382,56 @@ const PluginDetailPanel = ({
highlightOnHover
style={{ tableLayout: 'auto' }}
>
<Table.Tbody>
<TableTbody>
{selectedVersionData.build_timestamp && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Built
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{new Date(
selectedVersionData.build_timestamp
).toLocaleString()}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{Number.isFinite(selectedVersionData.size) &&
selectedVersionData.size > 0 && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
File Size
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{formatKB(selectedVersionData.size)}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{selectedVersionData.min_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Min Version
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{selectedVersionData.min_dispatcharr_version}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{selectedVersionData.max_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Max Version
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{selectedVersionData.max_dispatcharr_version}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{selectedVersionData.commit_sha_short && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Commit
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{manifest.registry_url ? (
<Text
size="xs"
@ -473,15 +446,15 @@ const PluginDetailPanel = ({
) : (
selectedVersionData.commit_sha_short
)}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{selectedVersionData.url && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Download
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
<Text
size="xs"
component="a"
@ -492,10 +465,10 @@ const PluginDetailPanel = ({
>
{selectedVersionData.url.split('/').pop()}
</Text>
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
</Table.Tbody>
</TableTbody>
</Table>
)}
</>

View file

@ -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 }) => (
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>
{formatDate(cell.getValue())}
{format(cell.getValue(), fullDateTimeFormat)}
</Text>
),
},
@ -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',

View file

@ -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 = ({
<Group justify="space-between" mt="sm" align="center" wrap="nowrap">
{!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 (
<Tooltip
label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}
label={`Incompatible: requires Dispatcharr ${tooltip} (you have v${appVersion})`}
>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle
@ -775,16 +778,10 @@ const AvailablePluginCard = ({
{/* Unified install confirmation modal */}
{(() => {
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

View file

@ -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 (
<TlsServiceCard serviceName="Redis">
@ -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 (
<TlsServiceCard serviceName="PostgreSQL">

View file

@ -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 }) => {
></Alert>
)}
{Object.keys(USER_LIMITS_OPTIONS).reduce((acc, key) => {
const option = USER_LIMITS_OPTIONS[key];
acc.push(
<Checkbox
key={key}
label={option.label}
description={option.description}
{...userLimitSettingsForm.getInputProps(key, {
type: 'checkbox',
})}
/>
);
return acc;
}, [])}
{Object.entries(USER_LIMITS_OPTIONS).map(([key, option]) => (
<Checkbox
key={key}
label={option.label}
description={option.description}
{...userLimitSettingsForm.getInputProps(key, { type: 'checkbox' })}
/>
))}
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
<Button

View file

@ -34,7 +34,7 @@ import useSettingsStore from '../store/settings.jsx';
import AvailablePluginCard from '../components/cards/AvailablePluginCard.jsx';
import { showNotification } from '../utils/notificationUtils.js';
import { reloadPlugins } from '../utils/pages/PluginsUtils.js';
import { compareVersions } from '../components/pluginUtils.js';
import { compareVersions } from '../utils/components/pluginUtils.js';
export default function PluginBrowsePage() {
const repos = usePluginStore((s) => s.repos);