Moved SizedInstallButton to components\theme. Skip showing bytes if bytes is somehow 0 (probably not possible but just for consistency).

Ran Prettier formatting.
This commit is contained in:
SergeantPanda 2026-04-17 14:49:01 -05:00
parent 110f51f711
commit f035319e5d
4 changed files with 807 additions and 422 deletions

View file

@ -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

View file

@ -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 (
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading plugin details</Text>
<Text size="sm" c="dimmed">
Loading plugin details
</Text>
</Stack>
);
}
if (!detail?.manifest) {
return <Text size="sm" c="dimmed">Failed to load plugin details.</Text>;
return (
<Text size="sm" c="dimmed">
Failed to load plugin details.
</Text>
);
}
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 ? <Loader size={14} /> : <Download size={14} />,
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 (
<Stack gap="md">
{manifest.description && (
<Text size="sm">{manifest.description}</Text>
)}
{manifest.description && <Text size="sm">{manifest.description}</Text>}
<Group gap="xs" wrap="wrap">
{manifest.author && (
@ -198,8 +222,8 @@ const PluginDetailPanel = ({
{manifest.license}
</Badge>
)}
{detail.signature_verified != null && (
detail.signature_verified ? (
{detail.signature_verified != null &&
(detail.signature_verified ? (
<Badge
size="sm"
variant="default"
@ -218,8 +242,7 @@ const PluginDetailPanel = ({
Unverified
</Badge>
</Tooltip>
)
)}
))}
{manifest.repo_url && (
<Tooltip label="Source Repository">
<ActionIcon
@ -235,25 +258,36 @@ const PluginDetailPanel = ({
</ActionIcon>
</Tooltip>
)}
{manifest.discord_thread && (() => {
const isDiscordChannel = /^https:\/\/discord\.com\/channels\//.test(manifest.discord_thread);
return (
<Tooltip label="Discord Discussion">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
component="a"
href={isDiscordChannel
? manifest.discord_thread.replace('https://', 'discord://')
: manifest.discord_thread}
{...(!isDiscordChannel && { target: '_blank', rel: 'noopener noreferrer' })}
>
<DiscordIcon size={16} />
</ActionIcon>
</Tooltip>
);
})()}
{manifest.discord_thread &&
(() => {
const isDiscordChannel = /^https:\/\/discord\.com\/channels\//.test(
manifest.discord_thread
);
return (
<Tooltip label="Discord Discussion">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
component="a"
href={
isDiscordChannel
? manifest.discord_thread.replace(
'https://',
'discord://'
)
: manifest.discord_thread
}
{...(!isDiscordChannel && {
target: '_blank',
rel: 'noopener noreferrer',
})}
>
<DiscordIcon size={16} />
</ActionIcon>
</Tooltip>
);
})()}
</Group>
{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.
</Alert>
)}
{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 (
<>
<Group gap="xs" align="flex-end">
<Select
label="Version"
size="xs"
allowDeselect={false}
value={selectedVersion}
onChange={onVersionChange}
data={versionItems}
style={{ maxWidth: 240 }}
/>
<Group gap="xs" align="center">
{btnProps.tooltip ? (
<Tooltip label={btnProps.tooltip}>
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);
}
}
return (
<>
<Group gap="xs" align="flex-end">
<Select
label="Version"
size="xs"
allowDeselect={false}
value={selectedVersion}
onChange={onVersionChange}
data={versionItems}
style={{ maxWidth: 240 }}
/>
<Group gap="xs" align="center">
{btnProps.tooltip ? (
<Tooltip label={btnProps.tooltip}>
<Button
size="xs"
variant={btnProps.variant}
color={btnProps.color}
leftSection={btnProps.icon}
disabled={btnDisabled}
onClick={handleInstallClick}
>
{btnProps.label}
</Button>
</Tooltip>
) : (
<Button
size="xs"
variant={btnProps.variant}
@ -328,108 +378,140 @@ const PluginDetailPanel = ({
>
{btnProps.label}
</Button>
</Tooltip>
) : (
<Button
size="xs"
variant={btnProps.variant}
color={btnProps.color}
leftSection={btnProps.icon}
disabled={btnDisabled}
onClick={handleInstallClick}
>
{btnProps.label}
</Button>
)}
{!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 (
<Tooltip label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle size={14} color="var(--mantine-color-yellow-6)" />
<Text size="xs" c="yellow">{label}</Text>
</Group>
</Tooltip>
);
})()}
</Group>
</Group>
{selectedVersionData && (
<Table fontSize="xs" striped highlightOnHover style={{ tableLayout: 'auto' }}>
<Table.Tbody>
{selectedVersionData.build_timestamp && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Built</Table.Td>
<Table.Td>{new Date(selectedVersionData.build_timestamp).toLocaleString()}</Table.Td>
</Table.Tr>
)}
{Number.isFinite(selectedVersionData.size) && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>File Size</Table.Td>
<Table.Td>{formatKB(selectedVersionData.size)}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.min_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Min Version</Table.Td>
<Table.Td>{selectedVersionData.min_dispatcharr_version}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.max_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Max Version</Table.Td>
<Table.Td>{selectedVersionData.max_dispatcharr_version}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.commit_sha_short && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Commit</Table.Td>
<Table.Td>
{manifest.registry_url ? (
<Text
size="xs"
component="a"
href={`${manifest.registry_url}/commit/${selectedVersionData.commit_sha}`}
target="_blank"
rel="noopener noreferrer"
c="blue"
)}
{!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 (
<Tooltip
label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}
>
{selectedVersionData.commit_sha_short}
</Text>
) : (
selectedVersionData.commit_sha_short
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle
size={14}
color="var(--mantine-color-yellow-6)"
/>
<Text size="xs" c="yellow">
{label}
</Text>
</Group>
</Tooltip>
);
})()}
</Group>
</Group>
{selectedVersionData && (
<Table
fontSize="xs"
striped
highlightOnHover
style={{ tableLayout: 'auto' }}
>
<Table.Tbody>
{selectedVersionData.build_timestamp && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Built
</Table.Td>
<Table.Td>
{new Date(
selectedVersionData.build_timestamp
).toLocaleString()}
</Table.Td>
</Table.Tr>
)}
{Number.isFinite(selectedVersionData.size) &&
selectedVersionData.size > 0 && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
File Size
</Table.Td>
<Table.Td>
{formatKB(selectedVersionData.size)}
</Table.Td>
</Table.Tr>
)}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.url && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Download</Table.Td>
<Table.Td>
<Text
size="xs"
component="a"
href={selectedVersionData.url}
target="_blank"
rel="noopener noreferrer"
c="blue"
>
{selectedVersionData.url.split('/').pop()}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
)}
</>
);
})()}
{selectedVersionData.min_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Min Version
</Table.Td>
<Table.Td>
{selectedVersionData.min_dispatcharr_version}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.max_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Max Version
</Table.Td>
<Table.Td>
{selectedVersionData.max_dispatcharr_version}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.commit_sha_short && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Commit
</Table.Td>
<Table.Td>
{manifest.registry_url ? (
<Text
size="xs"
component="a"
href={`${manifest.registry_url}/commit/${selectedVersionData.commit_sha}`}
target="_blank"
rel="noopener noreferrer"
c="blue"
>
{selectedVersionData.commit_sha_short}
</Text>
) : (
selectedVersionData.commit_sha_short
)}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.url && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
Download
</Table.Td>
<Table.Td>
<Text
size="xs"
component="a"
href={selectedVersionData.url}
target="_blank"
rel="noopener noreferrer"
c="blue"
>
{selectedVersionData.url.split('/').pop()}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
)}
</>
);
})()}
</Stack>
);
};

View file

@ -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 }) => {
<Badge
size="xs"
variant="filled"
style={{ backgroundColor: signatureVerified === false ? 'var(--mantine-color-red-9)' : '#14917E' }}
leftSection={signatureVerified != null ? (signatureVerified ? <ShieldCheck size={10} /> : <ShieldAlert size={10} />) : undefined}
style={{
backgroundColor:
signatureVerified === false
? 'var(--mantine-color-red-9)'
: '#14917E',
}}
leftSection={
signatureVerified != null ? (
signatureVerified ? (
<ShieldCheck size={10} />
) : (
<ShieldAlert size={10} />
)
) : undefined
}
>
Official Repo
</Badge>
);
return signatureVerified != null ? (
<Tooltip label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}>{badge}</Tooltip>
) : badge;
<Tooltip
label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}
>
{badge}
</Tooltip>
) : (
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 ? <ShieldCheck size={10} /> : <ShieldAlert size={10} />) : undefined}
leftSection={
signatureVerified != null ? (
signatureVerified ? (
<ShieldCheck size={10} />
) : (
<ShieldAlert size={10} />
)
) : undefined
}
>
{repoName}
</Badge>
);
return signatureVerified != null ? (
<Tooltip label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}>{badge}</Tooltip>
) : badge;
<Tooltip
label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}
>
{badge}
</Tooltip>
) : (
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 (
<Badge size="xs" variant="light" color={isPrerelease ? 'violet' : 'green'} leftSection={isPrerelease ? <FlaskConical size={8} /> : <Check size={8} />}>
<Badge
size="xs"
variant="light"
color={isPrerelease ? 'violet' : 'green'}
leftSection={
isPrerelease ? <FlaskConical size={8} /> : <Check size={8} />
}
>
{baseLabel}
</Badge>
);
}
return (
<Tooltip label={`${isPrerelease ? 'Prerelease installed' : 'Installed'}, but this plugin has been deprecated by its maintainer`}>
<Badge size="xs" variant="light" color={isPrerelease ? 'red' : 'orange'} leftSection={<Ban size={8} />}>
<Tooltip
label={`${isPrerelease ? 'Prerelease installed' : 'Installed'}, but this plugin has been deprecated by its maintainer`}
>
<Badge
size="xs"
variant="light"
color={isPrerelease ? 'red' : 'orange'}
leftSection={<Ban size={8} />}
>
{baseLabel} · Deprecated
</Badge>
</Tooltip>
);
}
if (status === 'update_available') {
const baseLabel = isLatestDowngrade ? 'Newer Installed' : 'Update Available';
const baseLabel = isLatestDowngrade
? 'Newer Installed'
: 'Update Available';
if (!deprecated) {
return (
<Badge size="xs" variant="light" color={isLatestDowngrade ? 'orange' : 'yellow'} leftSection={isLatestDowngrade ? <AlertTriangle size={8} /> : <RefreshCw size={8} />}>
<Badge
size="xs"
variant="light"
color={isLatestDowngrade ? 'orange' : 'yellow'}
leftSection={
isLatestDowngrade ? (
<AlertTriangle size={8} />
) : (
<RefreshCw size={8} />
)
}
>
{baseLabel}
</Badge>
);
}
return (
<Tooltip label="Update available, but this plugin has been deprecated by its maintainer">
<Badge size="xs" variant="light" color="red" leftSection={<Ban size={8} />}>
<Badge
size="xs"
variant="light"
color="red"
leftSection={<Ban size={8} />}
>
{baseLabel} · Deprecated
</Badge>
</Tooltip>
);
}
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 (
<Tooltip label={tooltip}>
<Badge size="xs" variant="light" color={deprecated ? 'red' : 'orange'} leftSection={deprecated ? <Ban size={8} /> : <Check size={8} />}>
<Badge
size="xs"
variant="light"
color={deprecated ? 'red' : 'orange'}
leftSection={deprecated ? <Ban size={8} /> : <Check size={8} />}
>
{deprecated ? 'Installed · Deprecated' : 'Installed'}
</Badge>
</Tooltip>
@ -104,7 +195,12 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst
if (deprecated) {
return (
<Tooltip label="This plugin has been marked as deprecated by its maintainer">
<Badge size="xs" variant="light" color="red" leftSection={<Ban size={8} />}>
<Badge
size="xs"
variant="light"
color="red"
leftSection={<Ban size={8} />}
>
Deprecated
</Badge>
</Tooltip>
@ -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 <Button color={color} loading={loading} disabled={disabled} onClick={onClick} {...buttonProps}>{children}</Button>;
}
const isDisabled = disabled || loading;
const colorVar = color ? `var(--mantine-color-${color}-filled)` : 'var(--mantine-primary-color-filled)';
return (
<Group
gap={0}
align="stretch"
wrap="nowrap"
onMouseEnter={() => { if (!isDisabled) setHovered(true); }}
onMouseLeave={() => setHovered(false)}
>
<Button
color={color}
loading={loading}
disabled={disabled}
onClick={onClick}
styles={hovered && !isDisabled ? { root: { background: color ? `var(--mantine-color-${color}-filled-hover)` : 'var(--mantine-primary-color-filled-hover)' } } : undefined}
{...buttonProps}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
{children}
</Button>
<Box
onClick={!isDisabled ? onClick : undefined}
style={{
background: isDisabled ? colorVar : hovered ? (color ? `var(--mantine-color-${color}-filled-hover)` : 'var(--mantine-primary-color-filled-hover)') : colorVar,
filter: isDisabled ? 'brightness(0.65) saturate(0.7)' : hovered ? 'brightness(0.86)' : 'brightness(0.82)',
borderLeft: '1px solid rgba(0,0,0,0.2)',
borderTopRightRadius: 'var(--mantine-radius-sm)',
borderBottomRightRadius: 'var(--mantine-radius-sm)',
display: 'flex',
alignItems: 'center',
padding: '0 9px',
fontSize: 11,
color: '#fff',
cursor: isDisabled ? 'default' : 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
{formatKB(latest_size)}
</Box>
</Group>
);
};
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' }
: {}),
}}
>
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<Avatar
src={plugin.icon_url}
radius="sm"
@ -352,7 +434,12 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</Text>
<Group gap={6} align="center" wrap="nowrap">
{plugin.author && (
<Text size="xs" c="dimmed" truncate style={{ minWidth: 0, maxWidth: '100%' }}>
<Text
size="xs"
c="dimmed"
truncate
style={{ minWidth: 0, maxWidth: '100%' }}
>
{plugin.author}
</Text>
)}
@ -375,7 +462,15 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</Group>
</Group>
<Box style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Box
style={{
flex: 1,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ overflow: 'hidden' }}>
<Text size="sm" c="dimmed" lineClamp={3} mb={0}>
{plugin.description}
@ -383,11 +478,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</div>
<Stack gap={2} mt="auto" pt={4} style={{ flexShrink: 0 }}>
<Group gap="xs" wrap="wrap">
<Group gap="xs" wrap="wrap">
{plugin.latest_version && (
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>LATEST</span>
v{plugin.latest_version}
<span style={{ opacity: 0.5, marginRight: 4 }}>LATEST</span>v
{plugin.latest_version}
</Badge>
)}
{plugin.license && (
@ -427,107 +522,144 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</Box>
<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 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})`}>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle size={14} color="var(--mantine-color-yellow-6)" />
<Text size="xs" c="yellow">{label}</Text>
</Group>
</Tooltip>
);
})()}
{!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 (
<Tooltip
label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}
>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle
size={14}
color="var(--mantine-color-yellow-6)"
/>
<Text size="xs" c="yellow">
{label}
</Text>
</Group>
</Tooltip>
);
})()}
{meetsVersion && <span />}
<Group gap="xs" wrap="nowrap">
<Button
size="xs"
variant="default"
leftSection={<Info size={14} />}
onClick={handleMoreInfo}
>
More Info
</Button>
{(plugin.install_status === 'unmanaged') && plugin.latest_version && plugin.latest_url && (
<Tooltip label="Installed manually - installing from this repo will take over management">
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
latest_size={plugin.latest_size}
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</SizedInstallButton>
</Tooltip>
)}
{(plugin.install_status === 'different_repo') && plugin.latest_url && (
<Tooltip label={`Managed by ${plugin.installed_source_repo_name || 'another repo'} - installing will transfer management to this repo`}>
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
latest_size={plugin.latest_size}
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</SizedInstallButton>
</Tooltip>
)}
{(plugin.install_status === 'installed') && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setUninstallConfirmOpen(true)}
variant="default"
leftSection={<Info size={14} />}
onClick={handleMoreInfo}
>
Uninstall
More Info
</Button>
)}
{(plugin.install_status === 'update_available') && (
<SizedInstallButton
size="xs"
variant="filled"
color={isLatestDowngrade ? 'orange' : 'yellow'}
latest_size={plugin.latest_size}
leftSection={installing ? <Loader size={14} /> : isLatestDowngrade ? <AlertTriangle size={14} /> : <RefreshCw size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing
? (isLatestDowngrade ? 'Downgrading...' : 'Updating...')
: (isLatestDowngrade ? 'Downgrade' : 'Update')}
</SizedInstallButton>
)}
{(!plugin.install_status || plugin.install_status === 'not_installed') && plugin.latest_url && (
<SizedInstallButton
size="xs"
variant="filled"
latest_size={plugin.latest_size}
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Install'}
</SizedInstallButton>
)}
{plugin.install_status === 'unmanaged' &&
plugin.latest_version &&
plugin.latest_url && (
<Tooltip label="Installed manually - installing from this repo will take over management">
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</SizedInstallButton>
</Tooltip>
)}
{plugin.install_status === 'different_repo' && plugin.latest_url && (
<Tooltip
label={`Managed by ${plugin.installed_source_repo_name || 'another repo'} - installing will transfer management to this repo`}
>
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</SizedInstallButton>
</Tooltip>
)}
{plugin.install_status === 'installed' && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setUninstallConfirmOpen(true)}
>
Uninstall
</Button>
)}
{plugin.install_status === 'update_available' && (
<SizedInstallButton
size="xs"
variant="filled"
color={isLatestDowngrade ? 'orange' : 'yellow'}
latest_size={plugin.latest_size}
leftSection={
installing ? (
<Loader size={14} />
) : isLatestDowngrade ? (
<AlertTriangle size={14} />
) : (
<RefreshCw size={14} />
)
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing
? isLatestDowngrade
? 'Downgrading...'
: 'Updating...'
: isLatestDowngrade
? 'Downgrade'
: 'Update'}
</SizedInstallButton>
)}
{(!plugin.install_status ||
plugin.install_status === 'not_installed') &&
plugin.latest_url && (
<SizedInstallButton
size="xs"
variant="filled"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Install'}
</SizedInstallButton>
)}
</Group>
</Group>
{/* Detail Modal */}
<Modal
opened={detailOpen}
onClose={() => { setDetailOpen(false); onDetailClose?.(); }}
onClose={() => {
setDetailOpen(false);
onDetailClose?.();
}}
title={
<Group gap="xs" align="center">
<Avatar
@ -543,7 +675,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
<RepoBadge
isOfficial={plugin.is_official_repo}
repoName={plugin.repo_name}
signatureVerified={detail?.signature_verified ?? plugin.signature_verified}
signatureVerified={
detail?.signature_verified ?? plugin.signature_verified
}
/>
</Group>
}
@ -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 */}
<Modal
opened={deprecationWarnOpen}
onClose={() => { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }}
onClose={() => {
setDeprecationWarnOpen(false);
setPendingDeprecatedInstall(null);
}}
zIndex={300}
title={
<Group gap="xs" align="center">
@ -583,18 +722,25 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been marked as <b>deprecated</b> by its maintainer.
<b>{plugin.name}</b> has been marked as <b>deprecated</b> by its
maintainer.
</Text>
<Text size="sm" c="dimmed">
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.
</Text>
<Text size="sm" fw={500}>
Do you still want to proceed?
</Text>
<Text size="sm" fw={500}>Do you still want to proceed?</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }}
onClick={() => {
setDeprecationWarnOpen(false);
setPendingDeprecatedInstall(null);
}}
>
Cancel
</Button>
@ -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 (
<Modal
opened={confirmOpen}
onClose={() => { setConfirmOpen(false); setPendingInstall(null); }}
onClose={() => {
setConfirmOpen(false);
setPendingInstall(null);
}}
zIndex={300}
title={
<Group gap="xs" align="center">
{isBadSig
? <ShieldAlert size={18} color="var(--mantine-color-red-6)" />
: isDowngrade
? <AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
: <Download size={18} />}
{isBadSig ? (
<ShieldAlert size={18} color="var(--mantine-color-red-6)" />
) : isDowngrade ? (
<AlertTriangle
size={18}
color="var(--mantine-color-orange-6)"
/>
) : (
<Download size={18} />
)}
<Text fw={600}>Confirm {actionLabel}</Text>
</Group>
}
@ -639,55 +808,76 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<Stack gap="md">
<Text size="sm">
You are about to {actionLabel.toLowerCase()} <b>{plugin.name}</b>{' '}
{isUpdate || isDowngrade
? <>from <b>v{plugin.installed_version}</b> to <b>v{pendingInstall?.version}</b></>
: <><b>v{pendingInstall?.version}</b></>}
{plugin.repo_name ? <> from <b>{plugin.repo_name}</b></> : ''}.
You are about to {actionLabel.toLowerCase()}{' '}
<b>{plugin.name}</b>{' '}
{isUpdate || isDowngrade ? (
<>
from <b>v{plugin.installed_version}</b> to{' '}
<b>v{pendingInstall?.version}</b>
</>
) : (
<>
<b>v{pendingInstall?.version}</b>
</>
)}
{plugin.repo_name ? (
<>
{' '}
from <b>{plugin.repo_name}</b>
</>
) : (
''
)}
.
</Text>
<Text size="sm" c="dimmed">
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.
</Text>
{isDowngrade && (
<Text size="sm" c="orange">
<b>Warning:</b> Downgrading may cause issues with saved settings or data.
<b>Warning:</b> Downgrading may cause issues with saved
settings or data.
</Text>
)}
{isBadSig && (
<Text size="sm" c="red">
<b>Warning:</b> This repository has an invalid or unverified signature.
Installing plugins from unverified sources may be risky.
<b>Warning:</b> This repository has an invalid or unverified
signature. Installing plugins from unverified sources may be
risky.
</Text>
)}
{plugin.install_status === 'unmanaged' && (
<Text size="sm" c="orange">
<b>Note:</b> This plugin was installed manually. Installing from this repo
will bring it under repo management and enable future update checks.
<b>Note:</b> This plugin was installed manually. Installing
from this repo will bring it under repo management and enable
future update checks.
</Text>
)}
{plugin.install_status === 'different_repo' && (
<Text size="sm" c="orange">
<b>Note:</b> This plugin is currently managed
by <b>{plugin.installed_source_repo_name || 'another repo'}</b>.
<b>Note:</b> This plugin is currently managed by{' '}
<b>{plugin.installed_source_repo_name || 'another repo'}</b>.
Installing will transfer management to this repo.
</Text>
)}
<Text size="sm" fw={500}>Are you sure you want to proceed?</Text>
<Text size="sm" fw={500}>
Are you sure you want to proceed?
</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => { setConfirmOpen(false); setPendingInstall(null); }}
onClick={() => {
setConfirmOpen(false);
setPendingInstall(null);
}}
>
Cancel
</Button>
<Button
size="xs"
color={btnColor}
onClick={confirmAndInstall}
>
<Button size="xs" color={btnColor} onClick={confirmAndInstall}>
{actionLabel}
</Button>
</Group>
@ -755,7 +945,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
A restart of Dispatcharr may be required to fully unload the plugin.
</Text>
<Group justify="flex-end">
<Button size="xs" variant="default" onClick={() => setUninstallDoneOpen(false)}>
<Button
size="xs"
variant="default"
onClick={() => setUninstallDoneOpen(false)}
>
Done
</Button>
</Group>
@ -771,7 +965,12 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
<Group gap="xs" align="center">
<RotateCcw size={18} color="var(--mantine-color-blue-6)" />
<Text fw={600}>
Plugin {installAction === 'installed' ? 'Installed' : installAction === 'downgraded' ? 'Downgraded' : 'Updated'}
Plugin{' '}
{installAction === 'installed'
? 'Installed'
: installAction === 'downgraded'
? 'Downgraded'
: 'Updated'}
</Text>
</Group>
}
@ -779,15 +978,18 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been {installAction || 'installed'} successfully.
<b>{plugin.name}</b> has been {installAction || 'installed'}{' '}
successfully.
</Text>
<Text size="sm">
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.
</Text>
{pluginIsDisabled && (
<>
<Text size="xs" c="dimmed">
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.
</Text>
<Group justify="space-between" align="center">
<Text size="sm">Enable plugin</Text>

View file

@ -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 (
<Button
color={color}
loading={loading}
disabled={disabled}
onClick={onClick}
{...buttonProps}
>
{children}
</Button>
);
}
const isDisabled = disabled || loading;
const colorVar = color
? `var(--mantine-color-${color}-filled)`
: 'var(--mantine-primary-color-filled)';
return (
<Group
gap={0}
align="stretch"
wrap="nowrap"
onMouseEnter={() => {
if (!isDisabled) setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
>
<Button
color={color}
loading={loading}
disabled={disabled}
onClick={onClick}
styles={
hovered && !isDisabled
? {
root: {
background: color
? `var(--mantine-color-${color}-filled-hover)`
: 'var(--mantine-primary-color-filled-hover)',
},
}
: undefined
}
{...buttonProps}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
{children}
</Button>
<Box
onClick={!isDisabled ? onClick : undefined}
style={{
background: isDisabled
? colorVar
: hovered
? color
? `var(--mantine-color-${color}-filled-hover)`
: 'var(--mantine-primary-color-filled-hover)'
: colorVar,
filter: isDisabled
? 'brightness(0.65) saturate(0.7)'
: hovered
? 'brightness(0.86)'
: 'brightness(0.82)',
borderLeft: '1px solid rgba(0,0,0,0.2)',
borderTopRightRadius: 'var(--mantine-radius-sm)',
borderBottomRightRadius: 'var(--mantine-radius-sm)',
display: 'flex',
alignItems: 'center',
padding: '0 9px',
fontSize: 11,
color: '#fff',
cursor: isDisabled ? 'default' : 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
{formatKB(latest_size)}
</Box>
</Group>
);
};
export default SizedInstallButton;