mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user