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]) => ( + + ))}