/** * 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 (
{customError}
)}