From f035319e5da6d5264eef0e03ea3bdabb4f03e59b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 17 Apr 2026 14:49:01 -0500 Subject: [PATCH] Moved `SizedInstallButton` to `components\theme`. Skip showing bytes if bytes is somehow 0 (probably not possible but just for consistency). Ran Prettier formatting. --- CHANGELOG.md | 4 + frontend/src/components/PluginDetailPanel.jsx | 462 +++++++----- .../components/cards/AvailablePluginCard.jsx | 666 ++++++++++++------ .../components/theme/SizedInstallButton.jsx | 97 +++ 4 files changed, 807 insertions(+), 422 deletions(-) create mode 100644 frontend/src/components/theme/SizedInstallButton.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1f493f..4bf4d64d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv) + ## [0.23.0] - 2026-04-17 ### Security diff --git a/frontend/src/components/PluginDetailPanel.jsx b/frontend/src/components/PluginDetailPanel.jsx index bbce3430..602e0852 100644 --- a/frontend/src/components/PluginDetailPanel.jsx +++ b/frontend/src/components/PluginDetailPanel.jsx @@ -12,7 +12,15 @@ import { Text, Tooltip, } from '@mantine/core'; -import { AlertTriangle, Ban, Check, Download, RefreshCw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'; +import { + AlertTriangle, + Ban, + Download, + RefreshCw, + ShieldAlert, + ShieldCheck, + Trash2, +} from 'lucide-react'; import { compareVersions } from './pluginUtils.js'; import { formatKB } from '../utils/networkUtils.js'; @@ -69,13 +77,19 @@ const PluginDetailPanel = ({ return ( - Loading plugin details… + + Loading plugin details… + ); } if (!detail?.manifest) { - return Failed to load plugin details.; + return ( + + Failed to load plugin details. + + ); } const manifest = detail.manifest; @@ -83,19 +97,28 @@ const PluginDetailPanel = ({ (v) => v.version === selectedVersion ); - const isSelSame = installedVersion && selectedVersion && + const isSelSame = + installedVersion && + selectedVersion && compareVersions(selectedVersion, installedVersion) === 0; - const isSelDowngrade = installedVersion && selectedVersion && + const isSelDowngrade = + installedVersion && + selectedVersion && compareVersions(selectedVersion, installedVersion) < 0; const isInstalled = !!installedVersion; - const selMeetsMin = !selectedVersionData?.min_dispatcharr_version || - compareVersions(appVersion, selectedVersionData.min_dispatcharr_version) >= 0; - const selMeetsMax = !selectedVersionData?.max_dispatcharr_version || - compareVersions(appVersion, selectedVersionData.max_dispatcharr_version) <= 0; + const selMeetsMin = + !selectedVersionData?.min_dispatcharr_version || + compareVersions(appVersion, selectedVersionData.min_dispatcharr_version) >= + 0; + const selMeetsMax = + !selectedVersionData?.max_dispatcharr_version || + compareVersions(appVersion, selectedVersionData.max_dispatcharr_version) <= + 0; const selCompatible = selMeetsMin && selMeetsMax; - const isOverwrite = installStatus === 'unmanaged' || installStatus === 'different_repo'; + const isOverwrite = + installStatus === 'unmanaged' || installStatus === 'different_repo'; const handleInstallClick = () => { if (isSelSame && onUninstall) { @@ -123,9 +146,10 @@ const PluginDetailPanel = ({ color: 'orange', icon: installing ? : , variant: 'filled', - tooltip: installStatus === 'unmanaged' - ? 'Installed manually – installing will take over management' - : `Managed by ${installedSourceRepoName || 'another repo'} – installing will transfer management to this repo`, + tooltip: + installStatus === 'unmanaged' + ? 'Installed manually – installing will take over management' + : `Managed by ${installedSourceRepoName || 'another repo'} – installing will transfer management to this repo`, }; } if (isSelSame) { @@ -169,13 +193,13 @@ const PluginDetailPanel = ({ }; const btnProps = getButtonProps(); - const btnDisabled = (isSelSame ? uninstalling : (!selCompatible || installing || !selectedVersionData?.url)); + const btnDisabled = isSelSame + ? uninstalling + : !selCompatible || installing || !selectedVersionData?.url; return ( - {manifest.description && ( - {manifest.description} - )} + {manifest.description && {manifest.description}} {manifest.author && ( @@ -198,8 +222,8 @@ const PluginDetailPanel = ({ {manifest.license} )} - {detail.signature_verified != null && ( - detail.signature_verified ? ( + {detail.signature_verified != null && + (detail.signature_verified ? ( - ) - )} + ))} {manifest.repo_url && ( )} - {manifest.discord_thread && (() => { - const isDiscordChannel = /^https:\/\/discord\.com\/channels\//.test(manifest.discord_thread); - return ( - - - - - - ); - })()} + {manifest.discord_thread && + (() => { + const isDiscordChannel = /^https:\/\/discord\.com\/channels\//.test( + manifest.discord_thread + ); + return ( + + + + + + ); + })()} {manifest.deprecated && ( @@ -263,61 +297,77 @@ const PluginDetailPanel = ({ variant="light" title="Deprecated Plugin" > - This plugin has been marked as deprecated by its maintainer. It may no longer receive - updates or fixes, and could stop working with future versions of Dispatcharr. - Consider looking for an alternative. + This plugin has been marked as deprecated by its maintainer. It may no + longer receive updates or fixes, and could stop working with future + versions of Dispatcharr. Consider looking for an alternative. )} - {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)' : ''}`; + {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); + let versions = [...manifest.versions]; + if (installedVersionIsPrerelease) { + const prereleases = versions.filter((v) => v.prerelease); + const stable = versions.filter((v) => !v.prerelease); + versions = [...prereleases, ...stable]; } - } - return ( - <> - - + + {btnProps.tooltip ? ( + + + + ) : ( - - ) : ( - - )} - {!selCompatible && 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 label = !selMeetsMin - ? `Min ${selectedVersionData.min_dispatcharr_version}` - : `Max ${selectedVersionData.max_dispatcharr_version}`; - return ( - - - - {label} - - - ); - })()} - - - {selectedVersionData && ( - - - {selectedVersionData.build_timestamp && ( - - Built - {new Date(selectedVersionData.build_timestamp).toLocaleString()} - - )} - {Number.isFinite(selectedVersionData.size) && ( - - 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 ? ( - { + const parts = []; + if (!selMeetsMin) + parts.push( + `${selectedVersionData.min_dispatcharr_version} or newer` + ); + if (!selMeetsMax) + parts.push( + `${selectedVersionData.max_dispatcharr_version} or older` + ); + const label = !selMeetsMin + ? `Min ${selectedVersionData.min_dispatcharr_version}` + : `Max ${selectedVersionData.max_dispatcharr_version}`; + return ( + - {selectedVersionData.commit_sha_short} - - ) : ( - selectedVersionData.commit_sha_short + + + + {label} + + + + ); + })()} + + + {selectedVersionData && ( +
+ + {selectedVersionData.build_timestamp && ( + + + Built + + + {new Date( + selectedVersionData.build_timestamp + ).toLocaleString()} + + + )} + {Number.isFinite(selectedVersionData.size) && + selectedVersionData.size > 0 && ( + + + File Size + + + {formatKB(selectedVersionData.size)} + + )} - - - )} - {selectedVersionData.url && ( - - Download - - - {selectedVersionData.url.split('/').pop()} - - - - )} - -
- )} - - ); - })()} + {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.commit_sha_short} + + ) : ( + selectedVersionData.commit_sha_short + )} + + + )} + {selectedVersionData.url && ( + + + Download + + + + {selectedVersionData.url.split('/').pop()} + + + + )} + + + )} + + ); + })()}
); }; diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx index d4377b35..5866f51a 100644 --- a/frontend/src/components/cards/AvailablePluginCard.jsx +++ b/frontend/src/components/cards/AvailablePluginCard.jsx @@ -14,13 +14,25 @@ import { Text, Tooltip, } from '@mantine/core'; -import { AlertTriangle, Ban, Check, Download, FlaskConical, Info, RefreshCw, RotateCcw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'; +import { + AlertTriangle, + Ban, + Check, + Download, + FlaskConical, + Info, + RefreshCw, + RotateCcw, + ShieldAlert, + ShieldCheck, + Trash2, +} from 'lucide-react'; import { useNavigate, useLocation } from 'react-router-dom'; import API from '../../api'; import { usePluginStore } from '../../store/plugins'; import PluginDetailPanel from '../PluginDetailPanel.jsx'; import { compareVersions } from '../pluginUtils.js'; -import { formatKB } from '../../utils/networkUtils.js'; +import SizedInstallButton from '../theme/SizedInstallButton.jsx'; const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { if (isOfficial) { @@ -28,15 +40,34 @@ const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { : ) : undefined} + style={{ + backgroundColor: + signatureVerified === false + ? 'var(--mantine-color-red-9)' + : '#14917E', + }} + leftSection={ + signatureVerified != null ? ( + signatureVerified ? ( + + ) : ( + + ) + ) : undefined + } > Official Repo ); return signatureVerified != null ? ( - {badge} - ) : badge; + + {badge} + + ) : ( + badge + ); } if (!repoName) return null; const badge = ( @@ -44,58 +75,118 @@ const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { size="xs" variant="filled" color={signatureVerified === false ? 'red.9' : 'gray'} - leftSection={signatureVerified != null ? (signatureVerified ? : ) : undefined} + leftSection={ + signatureVerified != null ? ( + signatureVerified ? ( + + ) : ( + + ) + ) : undefined + } > {repoName} ); return signatureVerified != null ? ( - {badge} - ) : badge; + + {badge} + + ) : ( + badge + ); }; -const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, installedSourceRepoName }) => { +const StatusBadge = ({ + status, + deprecated, + isPrerelease, + isLatestDowngrade, + installedSourceRepoName, +}) => { if (status === 'installed') { const baseLabel = isPrerelease ? 'Prerelease' : 'Installed'; if (!deprecated) { return ( - : }> + : + } + > {baseLabel} ); } return ( - - }> + + } + > {baseLabel} · Deprecated ); } if (status === 'update_available') { - const baseLabel = isLatestDowngrade ? 'Newer Installed' : 'Update Available'; + const baseLabel = isLatestDowngrade + ? 'Newer Installed' + : 'Update Available'; if (!deprecated) { return ( - : }> + + ) : ( + + ) + } + > {baseLabel} ); } return ( - }> + } + > {baseLabel} · Deprecated ); } if (status === 'unmanaged' || status === 'different_repo') { - const tooltip = status === 'unmanaged' - ? (deprecated ? 'Installed manually (deprecated) - installing from this repo will take over management' : 'Installed manually - installing from this repo will take over management') - : `Managed by ${installedSourceRepoName || 'another repo'}${deprecated ? ' (deprecated)' : ''}`; + const tooltip = + status === 'unmanaged' + ? deprecated + ? 'Installed manually (deprecated) - installing from this repo will take over management' + : 'Installed manually - installing from this repo will take over management' + : `Managed by ${installedSourceRepoName || 'another repo'}${deprecated ? ' (deprecated)' : ''}`; return ( - : }> + : } + > {deprecated ? 'Installed · Deprecated' : 'Installed'} @@ -104,7 +195,12 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst if (deprecated) { return ( - }> + } + > Deprecated @@ -113,59 +209,22 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst return null; }; -const SizedInstallButton = ({ latest_size, children, color, loading, disabled, onClick, ...buttonProps }) => { - const [hovered, setHovered] = useState(false); - if (!Number.isFinite(latest_size)) { - return ; - } - const isDisabled = disabled || loading; - const colorVar = color ? `var(--mantine-color-${color}-filled)` : 'var(--mantine-primary-color-filled)'; - return ( - { if (!isDisabled) setHovered(true); }} - onMouseLeave={() => setHovered(false)} - > - - - {formatKB(latest_size)} - - - ); -}; - -const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDetail = false, onDetailClose, onInstalled, onUninstalled, onBeforeInstall }) => { - const meetsMinVersion = !plugin.min_dispatcharr_version || compareVersions(appVersion, plugin.min_dispatcharr_version) >= 0; - const meetsMaxVersion = !plugin.max_dispatcharr_version || compareVersions(appVersion, plugin.max_dispatcharr_version) <= 0; +const AvailablePluginCard = ({ + plugin, + appVersion, + multiRepo = false, + autoOpenDetail = false, + onDetailClose, + onInstalled, + onUninstalled, + onBeforeInstall, +}) => { + const meetsMinVersion = + !plugin.min_dispatcharr_version || + compareVersions(appVersion, plugin.min_dispatcharr_version) >= 0; + const meetsMaxVersion = + !plugin.max_dispatcharr_version || + compareVersions(appVersion, plugin.max_dispatcharr_version) <= 0; const meetsVersion = meetsMinVersion && meetsMaxVersion; const [detailOpen, setDetailOpen] = useState(false); const [detail, setDetail] = useState(null); @@ -184,14 +243,17 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe const [uninstallConfirmOpen, setUninstallConfirmOpen] = useState(false); const [uninstalling, setUninstalling] = useState(false); const [deprecationWarnOpen, setDeprecationWarnOpen] = useState(false); - const [pendingDeprecatedInstall, setPendingDeprecatedInstall] = useState(null); + const [pendingDeprecatedInstall, setPendingDeprecatedInstall] = + useState(null); const installPlugin = usePluginStore((s) => s.installPlugin); const navigate = useNavigate(); const { pathname } = useLocation(); const onMyPlugins = pathname === '/plugins'; - const isLatestDowngrade = plugin.install_status === 'update_available' && - plugin.latest_version && plugin.installed_version && + const isLatestDowngrade = + plugin.install_status === 'update_available' && + plugin.latest_version && + plugin.installed_version && compareVersions(plugin.latest_version, plugin.installed_version) < 0; const doInstall = (params) => { @@ -220,7 +282,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe const executeInstall = async (params) => { const wasInstalled = plugin.installed; - const wasDowngrade = plugin.installed_version && params.version && + const wasDowngrade = + plugin.installed_version && + params.version && compareVersions(params.version, plugin.installed_version) < 0; onBeforeInstall?.(plugin.slug); setInstalling(true); @@ -228,7 +292,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe setInstalling(false); setPendingInstall(null); if (result?.success) { - setInstallAction(wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed'); + setInstallAction( + wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed' + ); setInstalledKey(result.plugin?.key || params.slug); setPluginIsDisabled(result.plugin?.enabled === false); setEnableNow(false); @@ -280,16 +346,22 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe description: plugin.description, author: plugin.author, license: plugin.license, - versions: plugin.latest_version ? [{ - version: plugin.latest_version, - url: plugin.latest_url, - checksum_sha256: plugin.latest_sha256, - min_dispatcharr_version: plugin.min_dispatcharr_version, - max_dispatcharr_version: plugin.max_dispatcharr_version, - build_timestamp: plugin.last_updated, - size: plugin.latest_size, - }] : [], - latest: plugin.latest_version ? { version: plugin.latest_version } : null, + versions: plugin.latest_version + ? [ + { + version: plugin.latest_version, + url: plugin.latest_url, + checksum_sha256: plugin.latest_sha256, + min_dispatcharr_version: plugin.min_dispatcharr_version, + max_dispatcharr_version: plugin.max_dispatcharr_version, + build_timestamp: plugin.last_updated, + size: plugin.latest_size, + }, + ] + : [], + latest: plugin.latest_version + ? { version: plugin.latest_version } + : null, }, signature_verified: plugin.signature_verified ?? null, }); @@ -297,7 +369,10 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe return; } setDetailLoading(true); - const result = await API.getPluginDetailManifest(plugin.repo_id, plugin.manifest_url); + const result = await API.getPluginDetailManifest( + plugin.repo_id, + plugin.manifest_url + ); if (result) { setDetail(result); if (result.manifest?.versions?.length) { @@ -309,7 +384,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe React.useEffect(() => { if (autoOpenDetail) handleMoreInfo(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const latestInstallParams = { @@ -332,11 +407,18 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe flexDirection: 'column', minHeight: 220, backgroundColor: '#27272A', - ...(multiRepo && plugin.is_official_repo ? { borderColor: '#0e6459' } : {}), + ...(multiRepo && plugin.is_official_repo + ? { borderColor: '#0e6459' } + : {}), }} > - + {plugin.author && ( - + {plugin.author} )} @@ -375,7 +462,15 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe - +
{plugin.description} @@ -383,11 +478,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
- + {plugin.latest_version && ( - LATEST - v{plugin.latest_version} + LATESTv + {plugin.latest_version} )} {plugin.license && ( @@ -427,107 +522,144 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
- {!meetsVersion && (() => { - const parts = []; - if (!meetsMinVersion) parts.push(`${plugin.min_dispatcharr_version} or newer`); - if (!meetsMaxVersion) parts.push(`${plugin.max_dispatcharr_version} or older`); - const label = !meetsMinVersion - ? `Min ${plugin.min_dispatcharr_version}` - : `Max ${plugin.max_dispatcharr_version}`; - return ( - - - - {label} - - - ); - })()} + {!meetsVersion && + (() => { + const parts = []; + if (!meetsMinVersion) + parts.push(`${plugin.min_dispatcharr_version} or newer`); + if (!meetsMaxVersion) + parts.push(`${plugin.max_dispatcharr_version} or older`); + const label = !meetsMinVersion + ? `Min ${plugin.min_dispatcharr_version}` + : `Max ${plugin.max_dispatcharr_version}`; + return ( + + + + + {label} + + + + ); + })()} {meetsVersion && } - - {(plugin.install_status === 'unmanaged') && plugin.latest_version && plugin.latest_url && ( - - : } - disabled={!meetsVersion || installing} - onClick={() => doInstall(latestInstallParams)} - > - {installing ? 'Installing...' : 'Overwrite'} - - - )} - {(plugin.install_status === 'different_repo') && plugin.latest_url && ( - - : } - disabled={!meetsVersion || installing} - onClick={() => doInstall(latestInstallParams)} - > - {installing ? 'Installing...' : 'Overwrite'} - - - )} - {(plugin.install_status === 'installed') && ( - )} - {(plugin.install_status === 'update_available') && ( - : isLatestDowngrade ? : } - disabled={!meetsVersion || installing} - onClick={() => doInstall(latestInstallParams)} - > - {installing - ? (isLatestDowngrade ? 'Downgrading...' : 'Updating...') - : (isLatestDowngrade ? 'Downgrade' : 'Update')} - - )} - {(!plugin.install_status || plugin.install_status === 'not_installed') && plugin.latest_url && ( - : } - disabled={!meetsVersion || installing} - onClick={() => doInstall(latestInstallParams)} - > - {installing ? 'Installing...' : 'Install'} - - )} + {plugin.install_status === 'unmanaged' && + plugin.latest_version && + plugin.latest_url && ( + + : + } + disabled={!meetsVersion || installing} + onClick={() => doInstall(latestInstallParams)} + > + {installing ? 'Installing...' : 'Overwrite'} + + + )} + {plugin.install_status === 'different_repo' && plugin.latest_url && ( + + : + } + disabled={!meetsVersion || installing} + onClick={() => doInstall(latestInstallParams)} + > + {installing ? 'Installing...' : 'Overwrite'} + + + )} + {plugin.install_status === 'installed' && ( + + )} + {plugin.install_status === 'update_available' && ( + + ) : isLatestDowngrade ? ( + + ) : ( + + ) + } + disabled={!meetsVersion || installing} + onClick={() => doInstall(latestInstallParams)} + > + {installing + ? isLatestDowngrade + ? 'Downgrading...' + : 'Updating...' + : isLatestDowngrade + ? 'Downgrade' + : 'Update'} + + )} + {(!plugin.install_status || + plugin.install_status === 'not_installed') && + plugin.latest_url && ( + : + } + disabled={!meetsVersion || installing} + onClick={() => doInstall(latestInstallParams)} + > + {installing ? 'Installing...' : 'Install'} + + )} {/* Detail Modal */} { setDetailOpen(false); onDetailClose?.(); }} + onClose={() => { + setDetailOpen(false); + onDetailClose?.(); + }} title={ } @@ -555,7 +689,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe selectedVersion={selectedVersion} onVersionChange={setSelectedVersion} installedVersion={plugin.installed_version} - installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease} + installedVersionIsPrerelease={ + !!plugin.installed_version_is_prerelease + } appVersion={appVersion} installing={installing} uninstalling={uninstalling} @@ -571,7 +707,10 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe {/* Deprecation warning modal */} { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }} + onClose={() => { + setDeprecationWarnOpen(false); + setPendingDeprecatedInstall(null); + }} zIndex={300} title={ @@ -583,18 +722,25 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe > - {plugin.name} has been marked as deprecated by its maintainer. + {plugin.name} has been marked as deprecated by its + maintainer. - Deprecated plugins may no longer receive updates or fixes, and could stop working with future - versions of Dispatcharr. It is recommended to look for an alternative. + Deprecated plugins may no longer receive updates or fixes, and could + stop working with future versions of Dispatcharr. It is recommended + to look for an alternative. + + + Do you still want to proceed? - Do you still want to proceed? @@ -612,26 +758,49 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe {/* Unified install confirmation modal */} {(() => { - const isDowngrade = pendingInstall && plugin.installed_version && + const isDowngrade = + pendingInstall && + plugin.installed_version && compareVersions(pendingInstall.version, plugin.installed_version) < 0; - const isUpdate = pendingInstall && plugin.installed_version && + const isUpdate = + pendingInstall && + plugin.installed_version && !isDowngrade && compareVersions(pendingInstall.version, plugin.installed_version) > 0; const isBadSig = plugin.signature_verified === false; - const actionLabel = isDowngrade ? 'Downgrade' : isUpdate ? 'Update' : 'Install'; - const btnColor = (isDowngrade && isBadSig) ? 'red' : isDowngrade ? 'orange' : isBadSig ? 'red' : undefined; + const actionLabel = isDowngrade + ? 'Downgrade' + : isUpdate + ? 'Update' + : 'Install'; + const btnColor = + isDowngrade && isBadSig + ? 'red' + : isDowngrade + ? 'orange' + : isBadSig + ? 'red' + : undefined; return ( { setConfirmOpen(false); setPendingInstall(null); }} + onClose={() => { + setConfirmOpen(false); + setPendingInstall(null); + }} zIndex={300} title={ - {isBadSig - ? - : isDowngrade - ? - : } + {isBadSig ? ( + + ) : isDowngrade ? ( + + ) : ( + + )} Confirm {actionLabel} } @@ -639,55 +808,76 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe > - You are about to {actionLabel.toLowerCase()} {plugin.name}{' '} - {isUpdate || isDowngrade - ? <>from v{plugin.installed_version} to v{pendingInstall?.version} - : <>v{pendingInstall?.version}} - {plugin.repo_name ? <> from {plugin.repo_name} : ''}. + You are about to {actionLabel.toLowerCase()}{' '} + {plugin.name}{' '} + {isUpdate || isDowngrade ? ( + <> + from v{plugin.installed_version} to{' '} + v{pendingInstall?.version} + + ) : ( + <> + v{pendingInstall?.version} + + )} + {plugin.repo_name ? ( + <> + {' '} + from {plugin.repo_name} + + ) : ( + '' + )} + . - Plugins run server-side code with full access to your Dispatcharr instance and its - data. Only install plugins from developers you trust. Malicious plugins could read - or modify data, call internal APIs, or perform unwanted actions. + Plugins run server-side code with full access to your + Dispatcharr instance and its data. Only install plugins from + developers you trust. Malicious plugins could read or modify + data, call internal APIs, or perform unwanted actions. {isDowngrade && ( - Warning: Downgrading may cause issues with saved settings or data. + Warning: Downgrading may cause issues with saved + settings or data. )} {isBadSig && ( - Warning: This repository has an invalid or unverified signature. - Installing plugins from unverified sources may be risky. + Warning: This repository has an invalid or unverified + signature. Installing plugins from unverified sources may be + risky. )} {plugin.install_status === 'unmanaged' && ( - Note: This plugin was installed manually. Installing from this repo - will bring it under repo management and enable future update checks. + Note: This plugin was installed manually. Installing + from this repo will bring it under repo management and enable + future update checks. )} {plugin.install_status === 'different_repo' && ( - Note: This plugin is currently managed - by {plugin.installed_source_repo_name || 'another repo'}. + Note: This plugin is currently managed by{' '} + {plugin.installed_source_repo_name || 'another repo'}. Installing will transfer management to this repo. )} - Are you sure you want to proceed? + + Are you sure you want to proceed? + - @@ -755,7 +945,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe A restart of Dispatcharr may be required to fully unload the plugin. - @@ -771,7 +965,12 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe - Plugin {installAction === 'installed' ? 'Installed' : installAction === 'downgraded' ? 'Downgraded' : 'Updated'} + Plugin{' '} + {installAction === 'installed' + ? 'Installed' + : installAction === 'downgraded' + ? 'Downgraded' + : 'Updated'} } @@ -779,15 +978,18 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe > - {plugin.name} has been {installAction || 'installed'} successfully. + {plugin.name} has been {installAction || 'installed'}{' '} + successfully. - A restart of Dispatcharr may be required for the plugin to be fully loaded. + A restart of Dispatcharr may be required for the plugin to be fully + loaded. {pluginIsDisabled && ( <> - This plugin is currently disabled. You can enable it now or at any time from My Plugins. + This plugin is currently disabled. You can enable it now or at + any time from My Plugins. Enable plugin diff --git a/frontend/src/components/theme/SizedInstallButton.jsx b/frontend/src/components/theme/SizedInstallButton.jsx new file mode 100644 index 00000000..780aedb5 --- /dev/null +++ b/frontend/src/components/theme/SizedInstallButton.jsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { Box, Button, Group } from '@mantine/core'; +import { formatKB } from '../../utils/networkUtils.js'; + +const SizedInstallButton = ({ + latest_size, + children, + color, + loading, + disabled, + onClick, + ...buttonProps +}) => { + const [hovered, setHovered] = useState(false); + if (!Number.isFinite(latest_size) || latest_size <= 0) { + return ( + + ); + } + const isDisabled = disabled || loading; + const colorVar = color + ? `var(--mantine-color-${color}-filled)` + : 'var(--mantine-primary-color-filled)'; + return ( + { + if (!isDisabled) setHovered(true); + }} + onMouseLeave={() => setHovered(false)} + > + + + {formatKB(latest_size)} + + + ); +}; + +export default SizedInstallButton;