mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Refactor indexer management and improve search logic
Refactors admin settings to use a new IndexersTab and card-based indexer management UI, supporting category selection and improved configuration. Updates backend and API routes to handle indexer categories, propagate ASIN for better search scoring, and group indexers by categories to optimize Prowlarr searches. Enhances documentation to clarify non-terminal request matching and auto-completion behavior. Adds new reusable components for indexer management and category selection.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Component: Available Indexer Row
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface AvailableIndexerRowProps {
|
||||
indexer: {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
supportsRss: boolean;
|
||||
};
|
||||
isAdded: boolean;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export function AvailableIndexerRow({
|
||||
indexer,
|
||||
isAdded,
|
||||
onAdd,
|
||||
}: AvailableIndexerRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700 last:border-b-0 ${
|
||||
isAdded ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Indexer Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{indexer.name}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{indexer.protocol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div>
|
||||
{isAdded ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<svg
|
||||
className="w-4 h-4 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">
|
||||
Added
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={onAdd} variant="primary" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Component: Category Tree View with Toggle Switches
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
TORRENT_CATEGORIES,
|
||||
getChildIds,
|
||||
areAllChildrenSelected,
|
||||
isParentCategory,
|
||||
} from '@/lib/utils/torrent-categories';
|
||||
|
||||
interface CategoryTreeViewProps {
|
||||
selectedCategories: number[];
|
||||
onChange: (categories: number[]) => void;
|
||||
}
|
||||
|
||||
export function CategoryTreeView({
|
||||
selectedCategories,
|
||||
onChange,
|
||||
}: CategoryTreeViewProps) {
|
||||
const handleParentToggle = (parentId: number) => {
|
||||
const childIds = getChildIds(parentId);
|
||||
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
|
||||
|
||||
if (allChildrenSelected) {
|
||||
// Deselect parent and all children
|
||||
onChange(
|
||||
selectedCategories.filter(
|
||||
(id) => id !== parentId && !childIds.includes(id)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Select parent and all children
|
||||
const newSelection = new Set(selectedCategories);
|
||||
newSelection.add(parentId);
|
||||
childIds.forEach((id) => newSelection.add(id));
|
||||
onChange(Array.from(newSelection));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChildToggle = (childId: number) => {
|
||||
const isSelected = selectedCategories.includes(childId);
|
||||
|
||||
if (isSelected) {
|
||||
// Deselect child
|
||||
onChange(selectedCategories.filter((id) => id !== childId));
|
||||
} else {
|
||||
// Select child
|
||||
onChange([...selectedCategories, childId]);
|
||||
}
|
||||
};
|
||||
|
||||
const isParentSelected = (parentId: number) => {
|
||||
return areAllChildrenSelected(parentId, selectedCategories);
|
||||
};
|
||||
|
||||
const isChildSelected = (childId: number) => {
|
||||
return selectedCategories.includes(childId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{TORRENT_CATEGORIES.map((category) => (
|
||||
<div key={category.id} className="space-y-2">
|
||||
{/* Parent Category Header */}
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-base font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
|
||||
{category.name}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
[{category.id}]
|
||||
</span>
|
||||
{category.id === 3030 && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={isParentCategory(category.id) ? isParentSelected(category.id) : isChildSelected(category.id)}
|
||||
onChange={() => {
|
||||
if (isParentCategory(category.id)) {
|
||||
handleParentToggle(category.id);
|
||||
} else {
|
||||
handleChildToggle(category.id);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Child Categories */}
|
||||
{category.children && category.children.length > 0 && (
|
||||
<div className="ml-4 space-y-2">
|
||||
{category.children.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center justify-between p-2.5 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{child.name}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
[{child.id}]
|
||||
</span>
|
||||
{child.id === 3030 && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={isChildSelected(child.id)}
|
||||
onChange={() => handleChildToggle(child.id)}
|
||||
disabled={isParentSelected(category.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800
|
||||
${
|
||||
checked
|
||||
? 'bg-blue-600 dark:bg-blue-500'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
aria-checked={checked}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ease-in-out shadow-lg
|
||||
${checked ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Component: Delete Confirmation Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
indexerName: string;
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
indexerName,
|
||||
}: DeleteConfirmModalProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Remove Indexer"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Are you sure you want to remove <span className="font-semibold">{indexerName}</span>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
This indexer will no longer be used for searches. You can add it back later if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button onClick={onClose} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
Remove Indexer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Component: Indexer Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface IndexerCardProps {
|
||||
indexer: {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
};
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function IndexerCard({ indexer, onEdit, onDelete }: IndexerCardProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Indexer Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate mb-1">
|
||||
{indexer.name}
|
||||
</h3>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{indexer.protocol}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Edit Button */}
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
|
||||
title="Edit indexer"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title="Delete indexer"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Component: Indexer Configuration Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { CategoryTreeView } from './CategoryTreeView';
|
||||
import { DEFAULT_CATEGORIES } from '@/lib/utils/torrent-categories';
|
||||
|
||||
interface IndexerConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'add' | 'edit';
|
||||
indexer: {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
supportsRss: boolean;
|
||||
};
|
||||
initialConfig?: {
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
};
|
||||
onSave: (config: {
|
||||
id: number;
|
||||
name: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function IndexerConfigModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
indexer,
|
||||
initialConfig,
|
||||
onSave,
|
||||
}: IndexerConfigModalProps) {
|
||||
// Default values for Add mode
|
||||
const defaults = {
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 0,
|
||||
rssEnabled: indexer.supportsRss,
|
||||
categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030]
|
||||
};
|
||||
|
||||
// Form state
|
||||
const [priority, setPriority] = useState(
|
||||
initialConfig?.priority ?? defaults.priority
|
||||
);
|
||||
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
|
||||
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
|
||||
);
|
||||
const [rssEnabled, setRssEnabled] = useState(
|
||||
initialConfig?.rssEnabled ?? defaults.rssEnabled
|
||||
);
|
||||
const [selectedCategories, setSelectedCategories] = useState<number[]>(
|
||||
initialConfig?.categories ?? defaults.categories
|
||||
);
|
||||
|
||||
// Validation errors
|
||||
const [errors, setErrors] = useState<{
|
||||
priority?: string;
|
||||
seedingTimeMinutes?: string;
|
||||
categories?: string;
|
||||
}>({});
|
||||
|
||||
// Reset form when modal opens or indexer changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'add') {
|
||||
setPriority(defaults.priority);
|
||||
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
|
||||
setRssEnabled(defaults.rssEnabled);
|
||||
setSelectedCategories(defaults.categories);
|
||||
} else {
|
||||
setPriority(initialConfig?.priority ?? defaults.priority);
|
||||
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
|
||||
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
|
||||
setSelectedCategories(initialConfig?.categories ?? defaults.categories);
|
||||
}
|
||||
setErrors({});
|
||||
}
|
||||
}, [isOpen, mode, indexer.id]);
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: typeof errors = {};
|
||||
|
||||
if (priority < 1 || priority > 25) {
|
||||
newErrors.priority = 'Priority must be between 1 and 25';
|
||||
}
|
||||
|
||||
if (seedingTimeMinutes < 0) {
|
||||
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
|
||||
}
|
||||
|
||||
if (selectedCategories.length === 0) {
|
||||
newErrors.categories = 'At least one category must be selected';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
priority,
|
||||
seedingTimeMinutes,
|
||||
rssEnabled: indexer.supportsRss ? rssEnabled : false,
|
||||
categories: selectedCategories,
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePriorityChange = (value: string) => {
|
||||
const parsed = parseInt(value);
|
||||
if (!isNaN(parsed)) {
|
||||
// Clamp value between 1 and 25
|
||||
setPriority(Math.max(1, Math.min(25, parsed)));
|
||||
} else if (value === '') {
|
||||
setPriority(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeedingTimeChange = (value: string) => {
|
||||
if (value === '') {
|
||||
setSeedingTimeMinutes(0);
|
||||
} else {
|
||||
const parsed = parseInt(value);
|
||||
if (!isNaN(parsed)) {
|
||||
setSeedingTimeMinutes(Math.max(0, parsed));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={mode === 'add' ? 'Add Indexer' : 'Edit Indexer'}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Indexer Info (readonly) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Indexer
|
||||
</label>
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<span className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
{indexer.name}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{indexer.protocol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Priority (1-25)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="25"
|
||||
value={priority}
|
||||
onChange={(e) => handlePriorityChange(e.target.value)}
|
||||
className={errors.priority ? 'border-red-500' : ''}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Higher values = preferred in ranking algorithm
|
||||
</p>
|
||||
{errors.priority && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{errors.priority}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Seeding Time */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Seeding Time (minutes)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={seedingTimeMinutes}
|
||||
onChange={(e) => handleSeedingTimeChange(e.target.value)}
|
||||
placeholder="0"
|
||||
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
0 = unlimited seeding (files remain seeded indefinitely)
|
||||
</p>
|
||||
{errors.seedingTimeMinutes && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{errors.seedingTimeMinutes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RSS Monitoring */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
RSS Monitoring
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rssEnabled}
|
||||
onChange={(e) => setRssEnabled(e.target.checked)}
|
||||
disabled={!indexer.supportsRss}
|
||||
className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Auto-check RSS feeds every 15 minutes
|
||||
</span>
|
||||
</div>
|
||||
{!indexer.supportsRss && (
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400 mt-2">
|
||||
This indexer does not support RSS monitoring
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Categories
|
||||
</label>
|
||||
<div className="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<CategoryTreeView
|
||||
selectedCategories={selectedCategories}
|
||||
onChange={setSelectedCategories}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Select categories to search on this indexer. Parent selection locks all children as selected.
|
||||
</p>
|
||||
{errors.categories && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{errors.categories}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button onClick={onClose} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} variant="primary">
|
||||
{mode === 'add' ? 'Add Indexer' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 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;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Sync with parent when configuredIndexers changes
|
||||
useEffect(() => {
|
||||
if (onIndexersChange) {
|
||||
onIndexersChange(configuredIndexers);
|
||||
}
|
||||
}, [configuredIndexers, onIndexersChange]);
|
||||
|
||||
// Sync with initialIndexers prop changes
|
||||
useEffect(() => {
|
||||
setConfiguredIndexers(initialIndexers);
|
||||
}, [initialIndexers]);
|
||||
|
||||
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: 'torrent', // Default fallback
|
||||
supportsRss: config.rssEnabled,
|
||||
},
|
||||
currentConfig: config,
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalState({ isOpen: false, mode: 'add' });
|
||||
};
|
||||
|
||||
const handleSave = (config: SavedIndexerConfig) => {
|
||||
if (modalState.mode === 'add') {
|
||||
// Add new indexer
|
||||
setConfiguredIndexers([...configuredIndexers, config]);
|
||||
} else {
|
||||
// Update existing indexer
|
||||
setConfiguredIndexers(
|
||||
configuredIndexers.map((idx) =>
|
||||
idx.id === config.id ? config : idx
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
setConfiguredIndexers(
|
||||
configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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: 'torrent', // Will be populated correctly from fetched data
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user