mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Extracted ManageReposModal from PluginBrowse
This commit is contained in:
parent
99fc71de99
commit
07d17a5ffc
2 changed files with 702 additions and 680 deletions
567
frontend/src/components/modals/ManageReposModal.jsx
Normal file
567
frontend/src/components/modals/ManageReposModal.jsx
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { KeyRound, Plus, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import ConfirmationDialog from '../ConfirmationDialog.jsx';
|
||||
import { usePluginStore } from '../../store/plugins.jsx';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
getPluginRepoSettings,
|
||||
previewPluginRepo,
|
||||
updatePluginRepoSettings,
|
||||
} from '../../utils/pages/PluginsUtils.js';
|
||||
|
||||
export default function ManageReposModal({ opened, onClose }) {
|
||||
const repos = usePluginStore((s) => s.repos);
|
||||
const reposLoading = usePluginStore((s) => s.reposLoading);
|
||||
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
|
||||
const refreshRepo = usePluginStore((s) => s.refreshRepo);
|
||||
const addRepo = usePluginStore((s) => s.addRepo);
|
||||
const removeRepo = usePluginStore((s) => s.removeRepo);
|
||||
const updateRepo = usePluginStore((s) => s.updateRepo);
|
||||
|
||||
const [refreshInterval, setRefreshInterval] = useState(6);
|
||||
const [savingInterval, setSavingInterval] = useState(false);
|
||||
const saveIntervalTimer = useRef(null);
|
||||
|
||||
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
|
||||
const [editKeyValue, setEditKeyValue] = useState('');
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
|
||||
const [showAddRepo, setShowAddRepo] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState('');
|
||||
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
|
||||
const [addingRepo, setAddingRepo] = useState(false);
|
||||
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
|
||||
const [repoPreview, setRepoPreview] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const previewTimer = useRef(null);
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
|
||||
const loadRepoSettings = useCallback(async () => {
|
||||
const data = await getPluginRepoSettings();
|
||||
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
|
||||
}, []);
|
||||
|
||||
const handleSaveInterval = useCallback((val) => {
|
||||
const hours = val ?? 0;
|
||||
setRefreshInterval(hours);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
saveIntervalTimer.current = setTimeout(async () => {
|
||||
setSavingInterval(true);
|
||||
try {
|
||||
await updatePluginRepoSettings({ refresh_interval_hours: hours });
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setSavingInterval(false);
|
||||
}
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
// Debounced manifest preview
|
||||
const fetchPreview = useCallback((url, publicKey) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
|
||||
setRepoPreview(null);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
previewTimer.current = setTimeout(async () => {
|
||||
const result = await previewPluginRepo(url.trim(), publicKey?.trim());
|
||||
setRepoPreview(result);
|
||||
setPreviewLoading(false);
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
const handleAddRepo = useCallback(async () => {
|
||||
if (!newRepoUrl.trim()) return;
|
||||
setAddingRepo(true);
|
||||
try {
|
||||
await addRepo({
|
||||
url: newRepoUrl.trim(),
|
||||
public_key: newRepoPublicKey.trim(),
|
||||
});
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
setShowAddRepo(false);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Added',
|
||||
message: 'Plugin repo added',
|
||||
color: 'green',
|
||||
});
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setAddingRepo(false);
|
||||
}
|
||||
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleDeleteRepo = useCallback(
|
||||
async (id) => {
|
||||
await removeRepo(id);
|
||||
setDeleteConfirmId(null);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Removed',
|
||||
message: 'Plugin repo removed',
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
[removeRepo, fetchAvailablePlugins]
|
||||
);
|
||||
|
||||
const handleEditKey = useCallback((repo) => {
|
||||
setEditingKeyRepoId(repo.id);
|
||||
setEditKeyValue(repo.public_key || '');
|
||||
}, []);
|
||||
|
||||
const handleSaveKey = useCallback(async () => {
|
||||
if (editingKeyRepoId == null) return;
|
||||
setSavingKey(true);
|
||||
try {
|
||||
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
|
||||
await refreshRepo(editingKeyRepoId);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Updated',
|
||||
message: 'Public key updated',
|
||||
color: 'green',
|
||||
});
|
||||
setEditingKeyRepoId(null);
|
||||
setEditKeyValue('');
|
||||
} catch {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: 'Failed to update key',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
}, [
|
||||
editingKeyRepoId,
|
||||
editKeyValue,
|
||||
updateRepo,
|
||||
refreshRepo,
|
||||
fetchAvailablePlugins,
|
||||
]);
|
||||
|
||||
// Load settings when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) loadRepoSettings();
|
||||
}, [opened, loadRepoSettings]);
|
||||
|
||||
// Cleanup any pending timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600}>Plugin Repositories</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Add third-party plugin repositories or manage existing ones.
|
||||
Manifests are fetched automatically at the configured interval.
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flexShrink: 0 }}>
|
||||
<Text size="sm" fw={500} mb={2}>
|
||||
Refresh Interval
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={refreshInterval}
|
||||
onChange={handleSaveInterval}
|
||||
min={0}
|
||||
max={168}
|
||||
size="xs"
|
||||
disabled={savingInterval}
|
||||
w={115}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Hours, 0 to disable
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
styles={{
|
||||
title: { width: '100%' },
|
||||
header: { alignItems: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{reposLoading && repos.length === 0 && <Loader size="sm" />}
|
||||
|
||||
{repos.map((repo) => (
|
||||
<React.Fragment key={repo.id}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{repo.name}
|
||||
</Text>
|
||||
{repo.is_official && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="filled"
|
||||
style={{ backgroundColor: '#14917E' }}
|
||||
>
|
||||
Official Repo
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === false && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<ShieldAlert size={10} />}
|
||||
>
|
||||
Invalid Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repo.registry_url ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
<a
|
||||
href={repo.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repo.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{repo.url}
|
||||
</Text>
|
||||
{repo.last_fetched && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? 'red'
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
Last fetched:{' '}
|
||||
{new Date(repo.last_fetched).toLocaleString()}
|
||||
{repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? ` · ${repo.last_fetch_status}`
|
||||
: repo.plugin_count != null
|
||||
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
|
||||
: ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!repo.is_official && (
|
||||
<Stack gap={4} align="center">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit public key"
|
||||
onClick={() => handleEditKey(repo)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Remove repo"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
{editingKeyRepoId === repo.id && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Textarea
|
||||
placeholder={
|
||||
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
}
|
||||
value={editKeyValue}
|
||||
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
|
||||
size="xs"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleSaveKey}
|
||||
loading={savingKey}
|
||||
>
|
||||
Save Key
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setEditingKeyRepoId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{!showAddRepo ? (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
onClick={() => setShowAddRepo(true)}
|
||||
>
|
||||
Add Repository
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Text fw={500} size="sm" mt="sm">
|
||||
Add Repository
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
minHeight: 90,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
border:
|
||||
repoPreview && !previewLoading && !repoPreview.valid
|
||||
? '1px solid var(--mantine-color-red-7)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Group gap="xs" align="center">
|
||||
<Loader size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Checking manifest...
|
||||
</Text>
|
||||
</Group>
|
||||
) : repoPreview ? (
|
||||
repoPreview.valid ? (
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm">
|
||||
{repoPreview.registry_name}
|
||||
</Text>
|
||||
{repoPreview.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repoPreview.signature_verified === false && (
|
||||
<>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Signed Manifest
|
||||
</Badge>
|
||||
<Text
|
||||
size="xs"
|
||||
c="var(--mantine-color-yellow-6)"
|
||||
fs="italic"
|
||||
>
|
||||
Public key required for verification
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{repoPreview.signature_verified == null && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
No Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repoPreview.registry_url ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
<a
|
||||
href={repoPreview.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repoPreview.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{newRepoUrl.trim()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{repoPreview.plugin_count} plugin
|
||||
{repoPreview.plugin_count !== 1 ? 's' : ''} available
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text size="xs" c="yellow">
|
||||
Third-party repositories are not reviewed by the Dispatcharr
|
||||
team.
|
||||
<br />
|
||||
Adding sources and installing plugins is done at your own
|
||||
risk.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<TextInput
|
||||
placeholder="Repository Manifest URL (ending in .json)"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => {
|
||||
setNewRepoUrl(e.currentTarget.value);
|
||||
fetchPreview(e.currentTarget.value, newRepoPublicKey);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder={
|
||||
gpgKeyFocused
|
||||
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
: 'Optional: Paste public GPG key here'
|
||||
}
|
||||
value={newRepoPublicKey}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setNewRepoPublicKey(value);
|
||||
fetchPreview(newRepoUrl, value);
|
||||
}}
|
||||
size="sm"
|
||||
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
|
||||
maxRows={8}
|
||||
autosize
|
||||
onFocus={() => setGpgKeyFocused(true)}
|
||||
onBlur={() => {
|
||||
if (!newRepoPublicKey) setGpgKeyFocused(false);
|
||||
}}
|
||||
styles={
|
||||
repoPreview?.valid &&
|
||||
repoPreview?.signature_verified === false &&
|
||||
!newRepoPublicKey.trim()
|
||||
? {
|
||||
input: { borderColor: 'var(--mantine-color-yellow-6)' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddRepo(false);
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRepo}
|
||||
loading={addingRepo}
|
||||
disabled={!newRepoUrl.trim()}
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={deleteConfirmId != null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
onConfirm={() => handleDeleteRepo(deleteConfirmId)}
|
||||
title="Remove Repository"
|
||||
message={
|
||||
<>
|
||||
<Text size="sm">
|
||||
Are you sure you want to remove this repository?
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Plugins installed from this repo will remain installed but become
|
||||
unmanaged.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,66 +7,34 @@ import {
|
|||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NativeSelect,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import API from '../api.js';
|
||||
import {
|
||||
RefreshCcw,
|
||||
Trash2,
|
||||
Plus,
|
||||
Search,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { Package, RefreshCcw, Search } from 'lucide-react';
|
||||
import { usePluginStore } from '../store/plugins.jsx';
|
||||
import useSettingsStore from '../store/settings.jsx';
|
||||
import AvailablePluginCard from '../components/cards/AvailablePluginCard.jsx';
|
||||
import ManageReposModal from '../components/modals/ManageReposModal.jsx';
|
||||
import { showNotification } from '../utils/notificationUtils.js';
|
||||
import { reloadPlugins } from '../utils/pages/PluginsUtils.js';
|
||||
import { compareVersions } from '../utils/components/pluginUtils.js';
|
||||
|
||||
export default function PluginBrowsePage() {
|
||||
const repos = usePluginStore((s) => s.repos);
|
||||
const reposLoading = usePluginStore((s) => s.reposLoading);
|
||||
const availablePlugins = usePluginStore((s) => s.availablePlugins);
|
||||
const availableLoading = usePluginStore((s) => s.availableLoading);
|
||||
const fetchRepos = usePluginStore((s) => s.fetchRepos);
|
||||
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
|
||||
const refreshRepo = usePluginStore((s) => s.refreshRepo);
|
||||
const addRepo = usePluginStore((s) => s.addRepo);
|
||||
const removeRepo = usePluginStore((s) => s.removeRepo);
|
||||
const updateRepo = usePluginStore((s) => s.updateRepo);
|
||||
|
||||
const appVersion = useSettingsStore((s) => s.version?.version || '');
|
||||
|
||||
const [repoModalOpen, setRepoModalOpen] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState('');
|
||||
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
|
||||
const [addingRepo, setAddingRepo] = useState(false);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
|
||||
const [editKeyValue, setEditKeyValue] = useState('');
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
const [showAddRepo, setShowAddRepo] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
|
||||
const [repoPreview, setRepoPreview] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const previewTimer = useRef(null);
|
||||
const [refreshInterval, setRefreshInterval] = useState(6);
|
||||
const [savingInterval, setSavingInterval] = useState(false);
|
||||
const saveIntervalTimer = useRef(null);
|
||||
|
||||
const recentlyInstalledSlugs = useRef(new Set());
|
||||
const recentlyUpdatedSlugs = useRef(new Set());
|
||||
|
|
@ -121,130 +89,6 @@ export default function PluginBrowsePage() {
|
|||
}
|
||||
}, [refreshRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleAddRepo = useCallback(async () => {
|
||||
if (!newRepoUrl.trim()) return;
|
||||
setAddingRepo(true);
|
||||
try {
|
||||
await addRepo({
|
||||
url: newRepoUrl.trim(),
|
||||
public_key: newRepoPublicKey.trim(),
|
||||
});
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
setShowAddRepo(false);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Added',
|
||||
message: 'Plugin repo added',
|
||||
color: 'green',
|
||||
});
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setAddingRepo(false);
|
||||
}
|
||||
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleDeleteRepo = useCallback(
|
||||
async (id) => {
|
||||
await removeRepo(id);
|
||||
setDeleteConfirmId(null);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Removed',
|
||||
message: 'Plugin repo removed',
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
[removeRepo, fetchAvailablePlugins]
|
||||
);
|
||||
|
||||
const handleEditKey = useCallback((repo) => {
|
||||
setEditingKeyRepoId(repo.id);
|
||||
setEditKeyValue(repo.public_key || '');
|
||||
}, []);
|
||||
|
||||
const handleSaveKey = useCallback(async () => {
|
||||
if (editingKeyRepoId == null) return;
|
||||
setSavingKey(true);
|
||||
try {
|
||||
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
|
||||
await refreshRepo(editingKeyRepoId);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Updated',
|
||||
message: 'Public key updated',
|
||||
color: 'green',
|
||||
});
|
||||
setEditingKeyRepoId(null);
|
||||
setEditKeyValue('');
|
||||
} catch {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: 'Failed to update key',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
}, [
|
||||
editingKeyRepoId,
|
||||
editKeyValue,
|
||||
updateRepo,
|
||||
refreshRepo,
|
||||
fetchAvailablePlugins,
|
||||
]);
|
||||
|
||||
const loadRepoSettings = useCallback(async () => {
|
||||
const data = await API.getPluginRepoSettings();
|
||||
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
|
||||
}, []);
|
||||
|
||||
const handleSaveInterval = useCallback((val) => {
|
||||
const hours = val ?? 0;
|
||||
setRefreshInterval(hours);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
saveIntervalTimer.current = setTimeout(async () => {
|
||||
setSavingInterval(true);
|
||||
try {
|
||||
await API.updatePluginRepoSettings({ refresh_interval_hours: hours });
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setSavingInterval(false);
|
||||
}
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
// Debounced manifest preview
|
||||
const fetchPreview = useCallback((url, publicKey) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
|
||||
setRepoPreview(null);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
previewTimer.current = setTimeout(async () => {
|
||||
const result = await API.previewPluginRepo(url.trim(), publicKey?.trim());
|
||||
setRepoPreview(result);
|
||||
setPreviewLoading(false);
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
// Cleanup any pending timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
};
|
||||
}, []);
|
||||
// Load settings when modal opens
|
||||
useEffect(() => {
|
||||
if (repoModalOpen) loadRepoSettings();
|
||||
}, [repoModalOpen, loadRepoSettings]);
|
||||
|
||||
const loading = availableLoading && availablePlugins.length === 0;
|
||||
|
||||
// Build repo filter options from available plugins
|
||||
|
|
@ -367,153 +211,152 @@ export default function PluginBrowsePage() {
|
|||
}}
|
||||
>
|
||||
<Box p={16} pb={60} style={{ flex: 1 }}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
Find Plugins
|
||||
</Text>
|
||||
{availablePlugins.length > 0 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{availablePlugins.length} Plugins Available
|
||||
</Badge>
|
||||
)}
|
||||
{repos.length > 1 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{repos.length} Repos
|
||||
</Badge>
|
||||
)}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
Find Plugins
|
||||
</Text>
|
||||
{availablePlugins.length > 0 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{availablePlugins.length} Plugins Available
|
||||
</Badge>
|
||||
)}
|
||||
{repos.length > 1 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{repos.length} Repos
|
||||
</Badge>
|
||||
)}
|
||||
</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"
|
||||
onClick={() => setRepoModalOpen(true)}
|
||||
>
|
||||
Manage Repos
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={handleRefreshAll}
|
||||
title="Refresh all repos"
|
||||
loading={refreshingAll}
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</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"
|
||||
onClick={() => setRepoModalOpen(true)}
|
||||
>
|
||||
Manage Repos
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={handleRefreshAll}
|
||||
title="Refresh all repos"
|
||||
loading={refreshingAll}
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{loading && <Loader />}
|
||||
{loading && <Loader />}
|
||||
|
||||
{!loading && (
|
||||
<Group gap="sm" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search plugins..."
|
||||
leftSection={<Search size={14} />}
|
||||
size="xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
data={[
|
||||
{ value: 'name-asc', label: 'Name A-Z' },
|
||||
{ value: 'name-desc', label: 'Name Z-A' },
|
||||
{ value: 'author', label: 'Author' },
|
||||
{ value: 'updated', label: 'Recently Updated' },
|
||||
]}
|
||||
style={{ width: 170 }}
|
||||
/>
|
||||
{repoOptions.length > 2 && (
|
||||
{!loading && (
|
||||
<Group gap="sm" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search plugins..."
|
||||
leftSection={<Search size={14} />}
|
||||
size="xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterRepo}
|
||||
onChange={setFilterRepo}
|
||||
data={repoOptions}
|
||||
style={{ width: 160 }}
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
data={[
|
||||
{ value: 'name-asc', label: 'Name A-Z' },
|
||||
{ value: 'name-desc', label: 'Name Z-A' },
|
||||
{ value: 'author', label: 'Author' },
|
||||
{ value: 'updated', label: 'Recently Updated' },
|
||||
]}
|
||||
style={{ width: 170 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
data={[
|
||||
{ value: 'all', label: 'All Plugins' },
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'not-installed', label: 'Not Installed' },
|
||||
{ value: 'compatible', label: 'Compatible' },
|
||||
]}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{repoOptions.length > 2 && (
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterRepo}
|
||||
onChange={setFilterRepo}
|
||||
data={repoOptions}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
data={[
|
||||
{ value: 'all', label: 'All Plugins' },
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'not-installed', label: 'Not Installed' },
|
||||
{ value: 'compatible', label: 'Compatible' },
|
||||
]}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
filteredPlugins.length === 0 &&
|
||||
availablePlugins.length > 0 && (
|
||||
{!loading &&
|
||||
filteredPlugins.length === 0 &&
|
||||
availablePlugins.length > 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins match your filters. Try adjusting your search or
|
||||
filter criteria.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && availablePlugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins match your filters. Try adjusting your search or filter
|
||||
criteria.
|
||||
No plugins available. Try refreshing repos or adding a new plugin
|
||||
repository.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && availablePlugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins available. Try refreshing repos or adding a new plugin
|
||||
repository.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
|
||||
{paginatedPlugins.map((p) => (
|
||||
<AvailablePluginCard
|
||||
key={`${p.repo_id}-${p.slug}`}
|
||||
plugin={p}
|
||||
appVersion={appVersion}
|
||||
multiRepo={repos.length > 1}
|
||||
onBeforeInstall={(slug) => {
|
||||
if (slug) {
|
||||
if (p.install_status === 'update_available') {
|
||||
recentlyUpdatedSlugs.current.add(slug);
|
||||
} else {
|
||||
recentlyInstalledSlugs.current.add(slug);
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
|
||||
{paginatedPlugins.map((p) => (
|
||||
<AvailablePluginCard
|
||||
key={`${p.repo_id}-${p.slug}`}
|
||||
plugin={p}
|
||||
appVersion={appVersion}
|
||||
multiRepo={repos.length > 1}
|
||||
onBeforeInstall={(slug) => {
|
||||
if (slug) {
|
||||
if (p.install_status === 'update_available') {
|
||||
recentlyUpdatedSlugs.current.add(slug);
|
||||
} else {
|
||||
recentlyInstalledSlugs.current.add(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onInstalled={(slug) => {
|
||||
if (slug) recentlyInstalledSlugs.current.add(slug);
|
||||
fetchAvailablePlugins();
|
||||
}}
|
||||
onUninstalled={(slug) => {
|
||||
if (slug) recentlyUninstalledSlugs.current.add(slug);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
}}
|
||||
onInstalled={(slug) => {
|
||||
if (slug) recentlyInstalledSlugs.current.add(slug);
|
||||
fetchAvailablePlugins();
|
||||
}}
|
||||
onUninstalled={(slug) => {
|
||||
if (slug) recentlyUninstalledSlugs.current.add(slug);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
|
|
@ -551,398 +394,10 @@ export default function PluginBrowsePage() {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{/* Manage Repos Modal */}
|
||||
<Modal
|
||||
<ManageReposModal
|
||||
opened={repoModalOpen}
|
||||
onClose={() => setRepoModalOpen(false)}
|
||||
title={
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600}>Plugin Repositories</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Add third-party plugin repositories or manage existing ones.
|
||||
Manifests are fetched automatically at the configured interval.
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flexShrink: 0 }}>
|
||||
<Text size="sm" fw={500} mb={2}>
|
||||
Refresh Interval
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={refreshInterval}
|
||||
onChange={handleSaveInterval}
|
||||
min={0}
|
||||
max={168}
|
||||
size="xs"
|
||||
disabled={savingInterval}
|
||||
w={115}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Hours, 0 to disable
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
styles={{
|
||||
title: { width: '100%' },
|
||||
header: { alignItems: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{reposLoading && repos.length === 0 && <Loader size="sm" />}
|
||||
|
||||
{repos.map((repo) => (
|
||||
<React.Fragment key={repo.id}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{repo.name}
|
||||
</Text>
|
||||
{repo.is_official && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="filled"
|
||||
style={{ backgroundColor: '#14917E' }}
|
||||
>
|
||||
Official Repo
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === false && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<ShieldAlert size={10} />}
|
||||
>
|
||||
Invalid Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repo.registry_url ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
<a
|
||||
href={repo.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repo.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{repo.url}
|
||||
</Text>
|
||||
{repo.last_fetched && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? 'red'
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
Last fetched:{' '}
|
||||
{new Date(repo.last_fetched).toLocaleString()}
|
||||
{repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? ` · ${repo.last_fetch_status}`
|
||||
: repo.plugin_count != null
|
||||
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
|
||||
: ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!repo.is_official && (
|
||||
<Stack gap={4} align="center">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit public key"
|
||||
onClick={() => handleEditKey(repo)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Remove repo"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
{editingKeyRepoId === repo.id && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Textarea
|
||||
placeholder={
|
||||
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
}
|
||||
value={editKeyValue}
|
||||
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
|
||||
size="xs"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleSaveKey}
|
||||
loading={savingKey}
|
||||
>
|
||||
Save Key
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setEditingKeyRepoId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{!showAddRepo ? (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
onClick={() => setShowAddRepo(true)}
|
||||
>
|
||||
Add Repository
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Text fw={500} size="sm" mt="sm">
|
||||
Add Repository
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
minHeight: 90,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
border:
|
||||
repoPreview && !previewLoading && !repoPreview.valid
|
||||
? '1px solid var(--mantine-color-red-7)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Group gap="xs" align="center">
|
||||
<Loader size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Checking manifest...
|
||||
</Text>
|
||||
</Group>
|
||||
) : repoPreview ? (
|
||||
repoPreview.valid ? (
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm">
|
||||
{repoPreview.registry_name}
|
||||
</Text>
|
||||
{repoPreview.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repoPreview.signature_verified === false && (
|
||||
<>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Signed Manifest
|
||||
</Badge>
|
||||
<Text
|
||||
size="xs"
|
||||
c="var(--mantine-color-yellow-6)"
|
||||
fs="italic"
|
||||
>
|
||||
Public key required for verification
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{repoPreview.signature_verified == null && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
No Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repoPreview.registry_url ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
<a
|
||||
href={repoPreview.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repoPreview.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{newRepoUrl.trim()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{repoPreview.plugin_count} plugin
|
||||
{repoPreview.plugin_count !== 1 ? 's' : ''} available
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text size="xs" c="yellow">
|
||||
Third-party repositories are not reviewed by the Dispatcharr
|
||||
team.
|
||||
<br />
|
||||
Adding sources and installing plugins is done at your own
|
||||
risk.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<TextInput
|
||||
placeholder="Repository Manifest URL (ending in .json)"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => {
|
||||
setNewRepoUrl(e.currentTarget.value);
|
||||
fetchPreview(e.currentTarget.value, newRepoPublicKey);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder={
|
||||
gpgKeyFocused
|
||||
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
: 'Optional: Paste public GPG key here'
|
||||
}
|
||||
value={newRepoPublicKey}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setNewRepoPublicKey(value);
|
||||
fetchPreview(newRepoUrl, value);
|
||||
}}
|
||||
size="sm"
|
||||
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
|
||||
maxRows={8}
|
||||
autosize
|
||||
onFocus={() => setGpgKeyFocused(true)}
|
||||
onBlur={() => {
|
||||
if (!newRepoPublicKey) setGpgKeyFocused(false);
|
||||
}}
|
||||
styles={
|
||||
repoPreview?.valid &&
|
||||
repoPreview?.signature_verified === false &&
|
||||
!newRepoPublicKey.trim()
|
||||
? {
|
||||
input: { borderColor: 'var(--mantine-color-yellow-6)' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddRepo(false);
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRepo}
|
||||
loading={addingRepo}
|
||||
disabled={!newRepoUrl.trim()}
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
opened={deleteConfirmId != null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
title="Remove Repository"
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Text size="sm">Are you sure you want to remove this repository?</Text>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Plugins installed from this repo will remain installed but become
|
||||
unmanaged.
|
||||
</Text>
|
||||
<Group mt="md" justify="flex-end" gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => handleDeleteRepo(deleteConfirmId)}>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
/>
|
||||
</AppShellMain>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue