mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge pull request #1199 from sv-dispatcharr/feat/plugin-hub-sizes
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
[Feature]: Add filesizes to plugin hub
This commit is contained in:
commit
4c19eb0dc6
8 changed files with 868 additions and 361 deletions
|
|
@ -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)
|
||||
|
||||
### Performance
|
||||
|
||||
- Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
|
|
@ -12,8 +12,17 @@ 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';
|
||||
|
||||
export const GitHubIcon = ({ size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
|
|
@ -68,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;
|
||||
|
|
@ -82,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) {
|
||||
|
|
@ -122,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) {
|
||||
|
|
@ -168,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 && (
|
||||
|
|
@ -197,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"
|
||||
|
|
@ -217,8 +242,7 @@ const PluginDetailPanel = ({
|
|||
Unverified
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
{manifest.repo_url && (
|
||||
<Tooltip label="Source Repository">
|
||||
<ActionIcon
|
||||
|
|
@ -234,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 && (
|
||||
|
|
@ -262,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}
|
||||
|
|
@ -327,102 +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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,12 +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 SizedInstallButton from '../theme/SizedInstallButton.jsx';
|
||||
|
||||
const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
|
||||
if (isOfficial) {
|
||||
|
|
@ -27,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 = (
|
||||
|
|
@ -43,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>
|
||||
|
|
@ -103,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>
|
||||
|
|
@ -112,9 +209,22 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst
|
|||
return null;
|
||||
};
|
||||
|
||||
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);
|
||||
|
|
@ -133,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) => {
|
||||
|
|
@ -169,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);
|
||||
|
|
@ -177,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);
|
||||
|
|
@ -229,15 +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,
|
||||
}] : [],
|
||||
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,
|
||||
});
|
||||
|
|
@ -245,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) {
|
||||
|
|
@ -257,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 = {
|
||||
|
|
@ -280,16 +407,24 @@ 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"
|
||||
size={48}
|
||||
alt={`${plugin.name} logo`}
|
||||
imageProps={{ draggable: false }}
|
||||
>
|
||||
{plugin.name?.[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
|
|
@ -299,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>
|
||||
)}
|
||||
|
|
@ -322,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}
|
||||
|
|
@ -330,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 && (
|
||||
|
|
@ -374,103 +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">
|
||||
<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)}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
{plugin.install_status === 'update_available' && (
|
||||
<SizedInstallButton
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="orange"
|
||||
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
|
||||
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 ? 'Installing...' : 'Overwrite'}
|
||||
</Button>
|
||||
</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`}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="orange"
|
||||
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
|
||||
disabled={!meetsVersion || installing}
|
||||
onClick={() => doInstall(latestInstallParams)}
|
||||
>
|
||||
{installing ? 'Installing...' : 'Overwrite'}
|
||||
</Button>
|
||||
</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') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color={isLatestDowngrade ? 'orange' : 'yellow'}
|
||||
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')}
|
||||
</Button>
|
||||
)}
|
||||
{(!plugin.install_status || plugin.install_status === 'not_installed') && plugin.latest_url && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
|
||||
disabled={!meetsVersion || installing}
|
||||
onClick={() => doInstall(latestInstallParams)}
|
||||
>
|
||||
{installing ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
{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
|
||||
|
|
@ -478,6 +667,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
|
|||
radius="sm"
|
||||
size={28}
|
||||
alt={`${plugin.name} logo`}
|
||||
imageProps={{ draggable: false }}
|
||||
>
|
||||
{plugin.name?.[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
|
|
@ -485,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>
|
||||
}
|
||||
|
|
@ -497,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}
|
||||
|
|
@ -513,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">
|
||||
|
|
@ -525,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>
|
||||
|
|
@ -554,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>
|
||||
}
|
||||
|
|
@ -581,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>
|
||||
|
|
@ -697,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>
|
||||
|
|
@ -713,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>
|
||||
}
|
||||
|
|
@ -721,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>
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
</Avatar>
|
||||
|
|
@ -529,7 +531,7 @@ const PluginCard = ({
|
|||
onClose={() => setModalOpen(false)}
|
||||
title={
|
||||
<Group gap="xs" align="center">
|
||||
<Avatar src={plugin.logo_url} radius="sm" size={28} alt={`${plugin.name} logo`}>
|
||||
<Avatar src={plugin.logo_url} radius="sm" size={28} alt={`${plugin.name} logo`} imageProps={{ draggable: false }}>
|
||||
{plugin.name?.[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Text fw={600}>{plugin.name}</Text>
|
||||
|
|
|
|||
97
frontend/src/components/theme/SizedInstallButton.jsx
Normal file
97
frontend/src/components/theme/SizedInstallButton.jsx
Normal 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;
|
||||
|
|
@ -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() {
|
|||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
component="a"
|
||||
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftSection={<Package size={14} />}
|
||||
>
|
||||
Publish Your Plugin
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ export function formatBytes(bytes) {
|
|||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Converts a size in KB (as used in plugin manifests) to a human-readable string.
|
||||
// Trailing zeros are stripped (e.g. "1 KB" not "1.00 KB").
|
||||
export function formatKB(kb) {
|
||||
const bytes = kb * 1024;
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function formatSpeed(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue