mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 13:20:11 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -5,12 +5,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
TORRENT_CATEGORIES,
|
||||
getChildIds,
|
||||
areAllChildrenSelected,
|
||||
isParentCategory,
|
||||
getAllStandardCategoryIds,
|
||||
} from '@/lib/utils/torrent-categories';
|
||||
|
||||
interface CategoryTreeViewProps {
|
||||
@@ -24,7 +25,19 @@ export function CategoryTreeView({
|
||||
onChange,
|
||||
defaultCategories = [3030], // Default to audiobook category for backwards compatibility
|
||||
}: CategoryTreeViewProps) {
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
const [customError, setCustomError] = useState('');
|
||||
|
||||
const standardIds = useMemo(() => getAllStandardCategoryIds(), []);
|
||||
|
||||
// Derive custom categories from selected categories that aren't in the standard tree
|
||||
const customCategories = useMemo(
|
||||
() => selectedCategories.filter((id) => !standardIds.has(id)).sort((a, b) => a - b),
|
||||
[selectedCategories, standardIds]
|
||||
);
|
||||
|
||||
const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId);
|
||||
|
||||
const handleParentToggle = (parentId: number) => {
|
||||
const childIds = getChildIds(parentId);
|
||||
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
|
||||
@@ -57,6 +70,52 @@ export function CategoryTreeView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustom = (categoryId: number) => {
|
||||
onChange(selectedCategories.filter((id) => id !== categoryId));
|
||||
};
|
||||
|
||||
const handleAddCustom = () => {
|
||||
setCustomError('');
|
||||
const trimmed = customInput.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
setCustomError('Enter a category ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
|
||||
if (isNaN(parsed) || !Number.isInteger(Number(trimmed)) || String(parsed) !== trimmed) {
|
||||
setCustomError('Must be a whole number');
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed <= 0) {
|
||||
setCustomError('Must be a positive number');
|
||||
return;
|
||||
}
|
||||
|
||||
if (standardIds.has(parsed)) {
|
||||
setCustomError('This is a standard category — use the toggles above');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCategories.includes(parsed)) {
|
||||
setCustomError('Already added');
|
||||
return;
|
||||
}
|
||||
|
||||
onChange([...selectedCategories, parsed]);
|
||||
setCustomInput('');
|
||||
};
|
||||
|
||||
const handleCustomKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCustom();
|
||||
}
|
||||
};
|
||||
|
||||
const isParentSelected = (parentId: number) => {
|
||||
return areAllChildrenSelected(parentId, selectedCategories);
|
||||
};
|
||||
@@ -67,6 +126,7 @@ export function CategoryTreeView({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Standard Categories */}
|
||||
{TORRENT_CATEGORIES.map((category) => (
|
||||
<div key={category.id} className="space-y-2">
|
||||
{/* Parent Category Header */}
|
||||
@@ -129,6 +189,85 @@ export function CategoryTreeView({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom Categories Section */}
|
||||
<div className="space-y-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 px-2 py-1">
|
||||
<span className="text-base font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
|
||||
Custom
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Add custom Newznab/Torznab category IDs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Existing custom categories */}
|
||||
{customCategories.length > 0 && (
|
||||
<div className="ml-4 space-y-2">
|
||||
{customCategories.map((catId) => (
|
||||
<div
|
||||
key={catId}
|
||||
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">
|
||||
Custom
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
[{catId}]
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCustom(catId)}
|
||||
className="text-xs px-2.5 py-1 rounded-md text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 border border-red-200 dark:border-red-800 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add custom category input */}
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={customInput}
|
||||
onChange={(e) => {
|
||||
setCustomInput(e.target.value);
|
||||
setCustomError('');
|
||||
}}
|
||||
onKeyDown={handleCustomKeyDown}
|
||||
placeholder="Category ID"
|
||||
className={`
|
||||
w-32 px-3 py-1.5 text-sm rounded-lg border bg-white dark:bg-gray-800
|
||||
text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900
|
||||
${customError
|
||||
? 'border-red-300 dark:border-red-700'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustom}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{customError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1.5">
|
||||
{customError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user