mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-17 19:50:09 +00:00
af0eaceb98
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
285 lines
8.8 KiB
TypeScript
285 lines
8.8 KiB
TypeScript
/**
|
|
* Component: Indexer Management Container
|
|
* Documentation: documentation/frontend/components.md
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { IndexerCard } from './IndexerCard';
|
|
import { IndexerConfigModal } from './IndexerConfigModal';
|
|
import { AvailableIndexerRow } from './AvailableIndexerRow';
|
|
import { DeleteConfirmModal } from './DeleteConfirmModal';
|
|
import { fetchWithAuth } from '@/lib/utils/api';
|
|
|
|
interface ProwlarrIndexer {
|
|
id: number;
|
|
name: string;
|
|
protocol: string;
|
|
supportsRss: boolean;
|
|
}
|
|
|
|
interface SavedIndexerConfig {
|
|
id: number;
|
|
name: string;
|
|
protocol: string;
|
|
priority: number;
|
|
seedingTimeMinutes?: number; // Torrents only
|
|
removeAfterProcessing?: boolean; // Usenet only
|
|
rssEnabled: boolean;
|
|
audiobookCategories: number[]; // Categories for audiobook searches
|
|
ebookCategories: number[]; // Categories for ebook searches
|
|
}
|
|
|
|
interface IndexerManagementProps {
|
|
prowlarrUrl: string;
|
|
prowlarrApiKey: string;
|
|
mode: 'wizard' | 'settings';
|
|
initialIndexers?: SavedIndexerConfig[];
|
|
onIndexersChange?: (indexers: SavedIndexerConfig[]) => void;
|
|
}
|
|
|
|
export function IndexerManagement({
|
|
prowlarrUrl,
|
|
prowlarrApiKey,
|
|
mode,
|
|
initialIndexers = [],
|
|
onIndexersChange,
|
|
}: IndexerManagementProps) {
|
|
const [fetchedIndexers, setFetchedIndexers] = useState<ProwlarrIndexer[]>([]);
|
|
const [configuredIndexers, setConfiguredIndexers] = useState<SavedIndexerConfig[]>(initialIndexers);
|
|
const [modalState, setModalState] = useState<{
|
|
isOpen: boolean;
|
|
mode: 'add' | 'edit';
|
|
indexer?: ProwlarrIndexer;
|
|
currentConfig?: SavedIndexerConfig;
|
|
}>({ isOpen: false, mode: 'add' });
|
|
const [deleteModalState, setDeleteModalState] = useState<{
|
|
isOpen: boolean;
|
|
indexerId?: number;
|
|
indexerName?: string;
|
|
}>({ isOpen: false });
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// In settings mode, the parent fetches indexers asynchronously and passes them
|
|
// as initialIndexers after mount. This effect picks up that late-arriving data.
|
|
// Wizard mode doesn't need this — it initializes correctly via useState above.
|
|
useEffect(() => {
|
|
if (mode === 'settings') {
|
|
setConfiguredIndexers(initialIndexers);
|
|
}
|
|
}, [initialIndexers, mode]);
|
|
|
|
const fetchIndexers = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const endpoint = mode === 'wizard'
|
|
? '/api/setup/test-prowlarr'
|
|
: '/api/admin/settings/test-prowlarr';
|
|
|
|
// Use fetchWithAuth for settings mode (requires authentication)
|
|
// Use plain fetch for wizard mode (no auth required)
|
|
const response = mode === 'settings'
|
|
? await fetchWithAuth(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
url: prowlarrUrl,
|
|
apiKey: prowlarrApiKey,
|
|
}),
|
|
})
|
|
: await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
url: prowlarrUrl,
|
|
apiKey: prowlarrApiKey,
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
throw new Error(data.error || 'Failed to fetch indexers');
|
|
}
|
|
|
|
setFetchedIndexers(data.indexers || []);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to fetch indexers');
|
|
setFetchedIndexers([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const openAddModal = (indexer: ProwlarrIndexer) => {
|
|
setModalState({
|
|
isOpen: true,
|
|
mode: 'add',
|
|
indexer,
|
|
});
|
|
};
|
|
|
|
const openEditModal = (config: SavedIndexerConfig) => {
|
|
// Find the full indexer info from fetched list
|
|
const indexer = fetchedIndexers.find((idx) => idx.id === config.id);
|
|
|
|
setModalState({
|
|
isOpen: true,
|
|
mode: 'edit',
|
|
indexer: indexer || {
|
|
id: config.id,
|
|
name: config.name,
|
|
protocol: config.protocol,
|
|
supportsRss: config.rssEnabled,
|
|
},
|
|
currentConfig: config,
|
|
});
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setModalState({ isOpen: false, mode: 'add' });
|
|
};
|
|
|
|
const handleSave = (config: SavedIndexerConfig) => {
|
|
let updated: SavedIndexerConfig[];
|
|
if (modalState.mode === 'add') {
|
|
updated = [...configuredIndexers, config];
|
|
} else {
|
|
updated = configuredIndexers.map((idx) =>
|
|
idx.id === config.id ? config : idx
|
|
);
|
|
}
|
|
setConfiguredIndexers(updated);
|
|
onIndexersChange?.(updated);
|
|
};
|
|
|
|
const handleDelete = (id: number) => {
|
|
const indexer = configuredIndexers.find((idx) => idx.id === id);
|
|
if (!indexer) return;
|
|
|
|
setDeleteModalState({
|
|
isOpen: true,
|
|
indexerId: id,
|
|
indexerName: indexer.name,
|
|
});
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
if (deleteModalState.indexerId) {
|
|
const updated = configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId);
|
|
setConfiguredIndexers(updated);
|
|
onIndexersChange?.(updated);
|
|
}
|
|
};
|
|
|
|
const isIndexerAdded = (id: number) => {
|
|
return configuredIndexers.some((idx) => idx.id === id);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Section 1: Available Indexers */}
|
|
<div className="border-b border-gray-200 dark:border-gray-700 pb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
Available Indexers
|
|
</h3>
|
|
<Button
|
|
onClick={fetchIndexers}
|
|
loading={loading}
|
|
variant="outline"
|
|
disabled={!prowlarrUrl || !prowlarrApiKey}
|
|
>
|
|
{configuredIndexers.length > 0 || fetchedIndexers.length > 0
|
|
? 'Refresh Indexers'
|
|
: 'Fetch Indexers'}
|
|
</Button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-800 dark:text-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{fetchedIndexers.length > 0 && (
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden max-h-64 overflow-y-auto">
|
|
{fetchedIndexers.map((indexer) => (
|
|
<AvailableIndexerRow
|
|
key={indexer.id}
|
|
indexer={indexer}
|
|
isAdded={isIndexerAdded(indexer.id)}
|
|
onAdd={() => openAddModal(indexer)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && fetchedIndexers.length === 0 && !error && (
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 py-6 text-center border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
|
{prowlarrUrl && prowlarrApiKey
|
|
? 'Click "Fetch Indexers" to load available indexers from Prowlarr.'
|
|
: 'Enter Prowlarr URL and API key above, then fetch indexers.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Section 2: Configured Indexers */}
|
|
<div>
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
|
Configured Indexers ({configuredIndexers.length})
|
|
</h3>
|
|
|
|
{configuredIndexers.length === 0 ? (
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 py-8 text-center border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
|
<p className="mb-2">No indexers configured yet</p>
|
|
<p className="text-xs">
|
|
Fetch indexers from Prowlarr and click "Add" to configure them.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{configuredIndexers.map((config) => (
|
|
<IndexerCard
|
|
key={config.id}
|
|
indexer={{
|
|
id: config.id,
|
|
name: config.name,
|
|
protocol: config.protocol,
|
|
}}
|
|
onEdit={() => openEditModal(config)}
|
|
onDelete={() => handleDelete(config.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Config Modal */}
|
|
{modalState.isOpen && modalState.indexer && (
|
|
<IndexerConfigModal
|
|
isOpen={modalState.isOpen}
|
|
onClose={closeModal}
|
|
mode={modalState.mode}
|
|
indexer={modalState.indexer}
|
|
initialConfig={modalState.currentConfig}
|
|
onSave={handleSave}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
<DeleteConfirmModal
|
|
isOpen={deleteModalState.isOpen}
|
|
onClose={() => setDeleteModalState({ isOpen: false })}
|
|
onConfirm={confirmDelete}
|
|
indexerName={deleteModalState.indexerName || ''}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|