From 6c71115514ff7ef2db029469e6d4b20915a02751 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Fri, 17 Apr 2026 09:11:14 -0400 Subject: [PATCH 1/3] Enhancement: Add additional fields to plugin manifest including maintainers, deprecated status, repo URL, Discord thread, and latest size. --- Plugin_repo.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/Plugin_repo.md b/Plugin_repo.md index d846526d..6df4dec5 100644 --- a/Plugin_repo.md +++ b/Plugin_repo.md @@ -55,12 +55,17 @@ This is the simplest valid repo manifest - one plugin with enough info to show i "name": "Weather Display", "description": "Shows weather info on the dashboard", "author": "Acme Labs", + "maintainers": ["alice", "bob"], "license": "MIT", + "deprecated": false, + "repo_url": "https://github.com/acmelabs/dispatcharr-weather", + "discord_thread": "https://discord.com/channels/123456/789012", "latest_version": "1.2.5", "last_updated": "2025-01-20T15:30:00Z", "manifest_url": "plugins/weather_display/manifest.json", "latest_url": "plugins/weather_display/releases/weather_display-1.2.5.zip", "latest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "latest_size": 142, "icon_url": "plugins/weather_display/logo.png", "min_dispatcharr_version": "2.5.0", "max_dispatcharr_version": null @@ -128,13 +133,18 @@ If the name contains any of these, the repo will be rejected on add and skipped | `name` | **Yes** | Human-readable display name. | | `description` | No | Short description shown on the plugin card. | | `author` | No | Author or organization name. | +| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. | | `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. | +| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. | +| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). | +| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. | | `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. | | `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. | | `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). | | `latest_url` | No | Direct download URL (or relative path) to the latest release zip. | | `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). | | `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. | +| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. | | `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). | | `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. | | `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. | @@ -216,11 +226,14 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. "author": "Acme Labs", "license": "MIT", "latest_version": "1.2.5", + "registry_name": "Acme Labs Plugins", + "registry_url": "https://github.com/acmelabs/dispatcharr-plugins", "versions": [ { "version": "1.2.5", "url": "releases/weather_display-1.2.5.zip", "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 142, "build_timestamp": "2025-01-20T15:30:00Z", "commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648", "commit_sha_short": "4e8f1b1", @@ -231,6 +244,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. "version": "1.2.5-rc.1", "url": "releases/weather_display-1.2.5-rc.1.zip", "checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "size": 141, "prerelease": true, "build_timestamp": "2025-01-18T09:00:00Z", "min_dispatcharr_version": "2.5.0" @@ -239,6 +253,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. "version": "1.2.4", "url": "releases/weather_display-1.2.4.zip", "checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6", + "size": 138, "build_timestamp": "2025-01-15T10:00:00Z", "min_dispatcharr_version": "2.4.0" } @@ -246,7 +261,9 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. "latest": { "version": "1.2.5", "url": "releases/weather_display-1.2.5.zip", + "latest_url": "releases/weather_display-latest.zip", "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 142, "build_timestamp": "2025-01-20T15:30:00Z", "min_dispatcharr_version": "2.5.0" } @@ -263,8 +280,10 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. | `author` | No | Author/org name shown in the detail modal. | | `license` | No | SPDX license identifier. | | `latest_version` | No | Latest version string. | +| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. | +| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. | | `versions` | No | Array of version objects (newest first recommended). | -| `latest` | No | Object mirroring the latest version entry for quick access. | +| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. | ### Version Object Fields @@ -277,6 +296,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. | `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. | | `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. | | `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. | +| `size` | No | Size of this version's zip in kilobytes. Informational only. | | `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. | | `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. | @@ -536,12 +556,18 @@ You can host release zips as GitHub Release assets and reference them with absol "name": "string (required)", "description": "string", "author": "string", + "maintainers": ["string"], "license": "string (SPDX)", + "deprecated": "boolean", + "repo_url": "string (URL)", + "discord_thread": "string (URL)", "latest_version": "string (semver)", "last_updated": "string (ISO 8601)", "manifest_url": "string (URL or relative path)", "latest_url": "string (URL or relative path)", "latest_sha256": "string (64-char hex)", + "latest_md5": "string", + "latest_size": "number (KB)", "icon_url": "string (URL or relative path)", "min_dispatcharr_version": "string (semver)", "max_dispatcharr_version": "string (semver) or null" @@ -562,11 +588,15 @@ You can host release zips as GitHub Release assets and reference them with absol "author": "string", "license": "string (SPDX)", "latest_version": "string (semver)", + "registry_name": "string", + "registry_url": "string (URL)", "versions": [ { "version": "string (required)", "url": "string (required, URL or relative path)", "checksum_sha256": "string (64-char hex)", + "size": "number (KB)", + "prerelease": "boolean", "build_timestamp": "string (ISO 8601)", "commit_sha": "string", "commit_sha_short": "string", @@ -577,7 +607,9 @@ You can host release zips as GitHub Release assets and reference them with absol "latest": { "version": "string", "url": "string", + "latest_url": "string (stable symlink URL)", "checksum_sha256": "string", + "size": "number (KB)", "build_timestamp": "string", "min_dispatcharr_version": "string", "max_dispatcharr_version": "string or null" From 110f51f711cfd702097e551358092dd295a7bb21 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Fri, 17 Apr 2026 09:44:46 -0400 Subject: [PATCH 2/3] Add file size display to plugin details and install buttons and information popups --- frontend/src/components/PluginDetailPanel.jsx | 9 ++- .../components/cards/AvailablePluginCard.jsx | 74 +++++++++++++++++-- frontend/src/components/cards/PluginCard.jsx | 4 +- frontend/src/pages/PluginBrowse.jsx | 13 ++++ frontend/src/utils/networkUtils.js | 10 +++ 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/PluginDetailPanel.jsx b/frontend/src/components/PluginDetailPanel.jsx index 8a7fc0c0..bbce3430 100644 --- a/frontend/src/components/PluginDetailPanel.jsx +++ b/frontend/src/components/PluginDetailPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { ActionIcon, Alert, @@ -14,6 +14,7 @@ import { } from '@mantine/core'; import { AlertTriangle, Ban, Check, Download, RefreshCw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'; import { compareVersions } from './pluginUtils.js'; +import { formatKB } from '../utils/networkUtils.js'; export const GitHubIcon = ({ size = 16 }) => ( @@ -367,6 +368,12 @@ const PluginDetailPanel = ({ {new Date(selectedVersionData.build_timestamp).toLocaleString()} )} + {Number.isFinite(selectedVersionData.size) && ( + + File Size + {formatKB(selectedVersionData.size)} + + )} {selectedVersionData.min_dispatcharr_version && ( Min Version diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx index af6657d7..d4377b35 100644 --- a/frontend/src/components/cards/AvailablePluginCard.jsx +++ b/frontend/src/components/cards/AvailablePluginCard.jsx @@ -20,6 +20,7 @@ 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'; const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { if (isOfficial) { @@ -112,6 +113,56 @@ 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; @@ -236,6 +287,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe 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, }, @@ -290,6 +342,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe radius="sm" size={48} alt={`${plugin.name} logo`} + imageProps={{ draggable: false }} > {plugin.name?.[0]?.toUpperCase()} @@ -402,30 +455,32 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe {(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') && ( @@ -440,10 +495,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe )} {(plugin.install_status === 'update_available') && ( - : isLatestDowngrade ? : } disabled={!meetsVersion || installing} onClick={() => doInstall(latestInstallParams)} @@ -451,18 +507,19 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe {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'} - + )} @@ -478,6 +535,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe radius="sm" size={28} alt={`${plugin.name} logo`} + imageProps={{ draggable: false }} > {plugin.name?.[0]?.toUpperCase()} diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index 59dd9250..9cecc966 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -177,6 +177,7 @@ const PluginCard = ({ min_dispatcharr_version: avail.min_dispatcharr_version, max_dispatcharr_version: avail.max_dispatcharr_version, build_timestamp: avail.last_updated, + size: avail.latest_size, }] : [], latest: avail.latest_version ? { version: avail.latest_version } : null, }, @@ -363,6 +364,7 @@ const PluginCard = ({ alt={`${plugin.name} logo`} onClick={isManaged ? () => openModal('details') : undefined} style={isManaged ? { cursor: 'pointer' } : undefined} + imageProps={{ draggable: false }} > {plugin.name?.[0]?.toUpperCase()} @@ -529,7 +531,7 @@ const PluginCard = ({ onClose={() => setModalOpen(false)} title={ - + {plugin.name?.[0]?.toUpperCase()} {plugin.name} diff --git a/frontend/src/pages/PluginBrowse.jsx b/frontend/src/pages/PluginBrowse.jsx index 35ecc1ec..ccc81b28 100644 --- a/frontend/src/pages/PluginBrowse.jsx +++ b/frontend/src/pages/PluginBrowse.jsx @@ -26,6 +26,7 @@ import { KeyRound, ShieldCheck, ShieldAlert, + Package, } from 'lucide-react'; import { usePluginStore } from '../store/plugins.jsx'; import useSettingsStore from '../store/settings.jsx'; @@ -364,6 +365,18 @@ export default function PluginBrowsePage() { )} + } + > + Publish Your Plugin + - - {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;