/** * Component: Category Tree View with Toggle Switches * Documentation: documentation/frontend/components.md */ 'use client'; import React, { useState, useMemo } from 'react'; import { TORRENT_CATEGORIES, getChildIds, areAllChildrenSelected, isParentCategory, getAllStandardCategoryIds, } from '@/lib/utils/torrent-categories'; interface CategoryTreeViewProps { selectedCategories: number[]; onChange: (categories: number[]) => void; defaultCategories?: number[]; // Categories to show "Default" badge for (e.g., [3030] for audiobook, [7020] for ebook) } export function CategoryTreeView({ selectedCategories, 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); 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 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); }; const isChildSelected = (childId: number) => { return selectedCategories.includes(childId); }; return (
{/* Standard Categories */} {TORRENT_CATEGORIES.map((category) => (
{/* Parent Category Header */}
{category.name} [{category.id}] {isDefaultCategory(category.id) && ( Default )}
{ if (isParentCategory(category.id)) { handleParentToggle(category.id); } else { handleChildToggle(category.id); } }} disabled={false} />
{/* Child Categories */} {category.children && category.children.length > 0 && (
{category.children.map((child) => (
{child.name} [{child.id}] {isDefaultCategory(child.id) && ( Default )}
handleChildToggle(child.id)} disabled={isParentSelected(category.id)} />
))}
)}
))} {/* Custom Categories Section */}
Custom Add custom Newznab/Torznab category IDs
{/* Existing custom categories */} {customCategories.length > 0 && (
{customCategories.map((catId) => (
Custom [{catId}]
))}
)} {/* Add custom category input */}
{ 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' } `} />
{customError && (

{customError}

)}
); } interface ToggleSwitchProps { checked: boolean; onChange: () => void; disabled: boolean; } function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) { return ( ); }