From 737a2625680ec28bf11d42c8604c431b868620ab Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Thu, 14 Aug 2025 03:11:36 +0930 Subject: [PATCH] refactor(scoring): completely overhauled format settings - selection simplified to radarr/sonarr enabled - removed basic / advanced views, replaced with grouping - added ability to copy sonarr / radarr score into each other --- .../src/components/profile/ProfileModal.jsx | 428 +++++++--------- .../profile/scoring/FormatGroup.jsx | 220 +++++++++ .../profile/scoring/FormatSettings.jsx | 455 +++++++++--------- .../profile/scoring/GroupFilter.jsx | 213 ++++++++ .../profile/scoring/ProfileScoringTab.jsx | 56 +-- .../profile/scoring/UpgradeSettings.jsx | 6 +- frontend/src/components/ui/Modal.jsx | 4 + frontend/src/components/ui/TabViewer.jsx | 28 +- 8 files changed, 874 insertions(+), 536 deletions(-) create mode 100644 frontend/src/components/profile/scoring/FormatGroup.jsx create mode 100644 frontend/src/components/profile/scoring/GroupFilter.jsx diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx index fb2f6a0..15559ca 100644 --- a/frontend/src/components/profile/ProfileModal.jsx +++ b/frontend/src/components/profile/ProfileModal.jsx @@ -35,12 +35,8 @@ function ProfileModal({ // Tags state const [tags, setTags] = useState([]); - // Format scoring state - now app-specific - const [customFormats, setCustomFormats] = useState({ - both: [], - radarr: [], - sonarr: [] - }); + // Format scoring state - single array with radarr/sonarr flags and separate scores + const [customFormats, setCustomFormats] = useState([]); const [formatTags, setFormatTags] = useState([]); const [formatFilter, setFormatFilter] = useState(''); const [formatSortKey, setFormatSortKey] = useState('score'); @@ -80,18 +76,17 @@ function ProfileModal({ setError(''); setTags([]); - // Format scoring state - initialize all apps with all formats at score 0 + // Format scoring state - initialize all formats with score 0 and both apps disabled const safeCustomFormats = formats.map(format => ({ id: format.name, name: format.name, - score: 0, + radarrScore: 0, + sonarrScore: 0, + radarr: false, + sonarr: false, tags: format.tags || [] })); - setCustomFormats({ - both: [...safeCustomFormats], - radarr: [...safeCustomFormats], - sonarr: [...safeCustomFormats] - }); + setCustomFormats(safeCustomFormats); // Reset all other states to defaults setUpgradesAllowed(false); @@ -155,31 +150,73 @@ function ProfileModal({ setUpgradeUntilScore(Number(content.upgradeUntilScore || 0)); setMinScoreIncrement(Number(content.minScoreIncrement || 0)); - // Custom formats setup - handle backwards compatible format - const initialCustomFormats = content.custom_formats || []; - const initialCustomFormatsRadarr = content.custom_formats_radarr || []; - const initialCustomFormatsSonarr = content.custom_formats_sonarr || []; + // Custom formats setup - merge data from all sources + const bothFormats = content.custom_formats || []; // Formats for both with same score + const radarrFormats = content.custom_formats_radarr || []; + const sonarrFormats = content.custom_formats_sonarr || []; - setCustomFormats({ - both: formats.map(format => ({ - id: format.name, - name: format.name, - score: initialCustomFormats.find(cf => cf.name === format.name)?.score || 0, - tags: format.tags || [] - })), - radarr: formats.map(format => ({ - id: format.name, - name: format.name, - score: initialCustomFormatsRadarr.find(cf => cf.name === format.name)?.score || 0, - tags: format.tags || [] - })), - sonarr: formats.map(format => ({ - id: format.name, - name: format.name, - score: initialCustomFormatsSonarr.find(cf => cf.name === format.name)?.score || 0, - tags: format.tags || [] - })) + // Create a map to track which formats are enabled for which apps and their scores + const formatMap = new Map(); + + // Process formats that apply to both with same score + bothFormats.forEach(cf => { + formatMap.set(cf.name, { + radarrScore: cf.score || 0, + sonarrScore: cf.score || 0, + radarr: true, + sonarr: true + }); }); + + // Process radarr-specific formats + radarrFormats.forEach(cf => { + if (!formatMap.has(cf.name)) { + formatMap.set(cf.name, { + radarrScore: cf.score || 0, + sonarrScore: 0, + radarr: true, + sonarr: false + }); + } else { + // Format exists (from both), update radarr score + const existing = formatMap.get(cf.name); + existing.radarr = true; + existing.radarrScore = cf.score || 0; + } + }); + + // Process sonarr-specific formats + sonarrFormats.forEach(cf => { + if (!formatMap.has(cf.name)) { + formatMap.set(cf.name, { + radarrScore: 0, + sonarrScore: cf.score || 0, + radarr: false, + sonarr: true + }); + } else { + // Format exists (from both or radarr), update sonarr score + const existing = formatMap.get(cf.name); + existing.sonarr = true; + existing.sonarrScore = cf.score || 0; + } + }); + + // Build the final formats array + const newFormats = formats.map(format => { + const savedData = formatMap.get(format.name); + return { + id: format.name, + name: format.name, + radarrScore: savedData?.radarrScore || 0, + sonarrScore: savedData?.sonarrScore || 0, + radarr: Boolean(savedData?.radarr), + sonarr: Boolean(savedData?.sonarr), + tags: format.tags || [] + }; + }); + + setCustomFormats(newFormats); // Format tags const allTags = [ @@ -297,14 +334,13 @@ function ProfileModal({ const safeCustomFormats = formats.map(format => ({ id: format.name, name: format.name, - score: 0, + radarrScore: 0, + sonarrScore: 0, + radarr: false, + sonarr: false, tags: format.tags || [] })); - setCustomFormats({ - both: [...safeCustomFormats], - radarr: [...safeCustomFormats], - sonarr: [...safeCustomFormats] - }); + setCustomFormats(safeCustomFormats); // Format tags const allTags = [ @@ -369,136 +405,59 @@ function ProfileModal({ minCustomFormatScore, upgradeUntilScore, minScoreIncrement, - custom_formats: (() => { - // Check if selective mode is enabled - const selectiveMode = localStorage.getItem('formatSettingsSelectiveMode'); - const useSelectiveMode = selectiveMode !== null && JSON.parse(selectiveMode); - - // Helper function to process formats for an app type - const processFormats = (appFormats, appType) => { - if (useSelectiveMode) { - try { - // Get the list of explicitly selected format IDs for this app - const selectedFormatIdsStr = localStorage.getItem(`selectedFormatIds_${appType}`); - const selectedFormatIds = selectedFormatIdsStr ? JSON.parse(selectedFormatIdsStr) : []; - - // Get formats with non-zero scores - const nonZeroFormats = appFormats.filter(format => format.score !== 0); - - // Get formats with zero scores that are explicitly selected - const explicitlySelectedZeroFormats = appFormats.filter( - format => format.score === 0 && selectedFormatIds.includes(format.id) - ); - - // Combine both lists - return [...nonZeroFormats, ...explicitlySelectedZeroFormats] - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); - } catch (e) { - // Fallback to just non-zero scores - return appFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); - } - } else { - // Standard behavior - only include formats with non-zero scores - return appFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); - } - }; - - // Always save "both" formats as the main custom_formats field for backwards compatibility - return processFormats(customFormats.both || [], 'both'); - })(), - ...((() => { - // Check if selective mode is enabled - const selectiveMode = localStorage.getItem('formatSettingsSelectiveMode'); - const useSelectiveMode = selectiveMode !== null && JSON.parse(selectiveMode); - - // Helper function to process formats for an app type - const processFormats = (appFormats, appType) => { - if (useSelectiveMode) { - try { - // Get the list of explicitly selected format IDs for this app - const selectedFormatIdsStr = localStorage.getItem(`selectedFormatIds_${appType}`); - const selectedFormatIds = selectedFormatIdsStr ? JSON.parse(selectedFormatIdsStr) : []; - - // Get formats with non-zero scores - const nonZeroFormats = appFormats.filter(format => format.score !== 0); - - // Get formats with zero scores that are explicitly selected - const explicitlySelectedZeroFormats = appFormats.filter( - format => format.score === 0 && selectedFormatIds.includes(format.id) - ); - - // Combine both lists - return [...nonZeroFormats, ...explicitlySelectedZeroFormats] - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); - } catch (e) { - // Fallback to just non-zero scores - return appFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); - } - } else { - // Standard behavior - only include formats with non-zero scores - return appFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); - } - }; - - // Always include app-specific formats as separate fields (empty arrays if no scores) - const radarrFormats = processFormats(customFormats.radarr || [], 'radarr'); - const sonarrFormats = processFormats(customFormats.sonarr || [], 'sonarr'); - - return { - custom_formats_radarr: radarrFormats, - custom_formats_sonarr: sonarrFormats - }; - })()), + // Intelligently split formats based on enabled apps and scores + // If both apps enabled with same score -> goes to custom_formats + // Otherwise -> goes to app-specific arrays + custom_formats: customFormats + .filter(format => { + const radarrScore = format.radarrScore ?? 0; + const sonarrScore = format.sonarrScore ?? 0; + return format.radarr && format.sonarr && radarrScore === sonarrScore; + }) + .sort((a, b) => { + const aScore = a.radarrScore ?? 0; + const bScore = b.radarrScore ?? 0; + if (bScore !== aScore) return bScore - aScore; + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.radarrScore ?? 0 + })), + custom_formats_radarr: customFormats + .filter(format => { + const radarrScore = format.radarrScore ?? 0; + const sonarrScore = format.sonarrScore ?? 0; + // Include if: radarr-only OR both enabled but different scores + return format.radarr && (!format.sonarr || radarrScore !== sonarrScore); + }) + .sort((a, b) => { + const aScore = a.radarrScore ?? 0; + const bScore = b.radarrScore ?? 0; + if (bScore !== aScore) return bScore - aScore; + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.radarrScore ?? 0 + })), + custom_formats_sonarr: customFormats + .filter(format => { + const radarrScore = format.radarrScore ?? 0; + const sonarrScore = format.sonarrScore ?? 0; + // Include if: sonarr-only OR both enabled but different scores + return format.sonarr && (!format.radarr || radarrScore !== sonarrScore); + }) + .sort((a, b) => { + const aScore = a.sonarrScore ?? 0; + const bScore = b.sonarrScore ?? 0; + if (bScore !== aScore) return bScore - aScore; + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.sonarrScore ?? 0 + })), qualities: sortedQualities .filter(q => q.enabled) .map(q => { @@ -585,6 +544,37 @@ function ProfileModal({ } }; + const handleScoreChange = (formatId, app, score) => { + setCustomFormats(prev => + prev.map(format => { + if (format.id === formatId) { + if (app === 'radarr') { + return {...format, radarrScore: score}; + } else if (app === 'sonarr') { + return {...format, sonarrScore: score}; + } + } + return format; + }) + ); + }; + + const handleFormatToggle = (formatId, app, enabled) => { + setCustomFormats(prev => + prev.map(format => { + if (format.id === formatId) { + // If explicitly passing enabled state + if (enabled !== undefined) { + return {...format, [app]: enabled}; + } + // Otherwise toggle + return {...format, [app]: !format[app]}; + } + return format; + }) + ); + }; + const onFormatSort = (key, direction) => { setFormatSortKey(key); setFormatSortDirection(direction); @@ -657,101 +647,13 @@ function ProfileModal({ {activeTab === 'scoring' && ( { - setCustomFormats(prev => { - const newFormats = {...prev}; - - // If setting a non-zero score, handle conflicts - if (score !== 0) { - if (appType === 'both') { - // Setting in "both" clears radarr AND sonarr - newFormats.radarr = newFormats.radarr.map(f => - f.id === id ? {...f, score: 0} : f - ); - newFormats.sonarr = newFormats.sonarr.map(f => - f.id === id ? {...f, score: 0} : f - ); - } else { - // Setting in radarr/sonarr only clears "both" - newFormats.both = newFormats.both.map(f => - f.id === id ? {...f, score: 0} : f - ); - } - } - - // Update the target app type - newFormats[appType] = newFormats[appType].map(f => - f.id === id ? {...f, score} : f - ); - - return newFormats; - }); - }} - formatSortKey={formatSortKey} - formatSortDirection={formatSortDirection} - onFormatSort={onFormatSort} - tags={formatTags} - tagFilter={tagFilter} - onTagFilterChange={setTagFilter} - tagScores={tagScores} - onTagScoreChange={(appType, tag, score) => { - setTagScores(prev => ({ - ...prev, - [tag]: score - })); - setCustomFormats(prev => { - const newFormats = {...prev}; - - // If setting a non-zero score, handle conflicts for all formats with this tag - if (score !== 0) { - if (appType === 'both') { - // Setting in "both" clears radarr AND sonarr for formats with this tag - newFormats.radarr = newFormats.radarr.map(format => { - if (format.tags?.includes(tag)) { - return {...format, score: 0}; - } - return format; - }); - newFormats.sonarr = newFormats.sonarr.map(format => { - if (format.tags?.includes(tag)) { - return {...format, score: 0}; - } - return format; - }); - } else { - // Setting in radarr/sonarr only clears "both" for formats with this tag - newFormats.both = newFormats.both.map(format => { - if (format.tags?.includes(tag)) { - return {...format, score: 0}; - } - return format; - }); - } - } - - // Update the target app type - newFormats[appType] = newFormats[appType].map(format => { - if (format.tags?.includes(tag)) { - return {...format, score}; - } - return format; - }); - - return newFormats; - }); - }} - tagSortKey={tagSortKey} - tagSortDirection={tagSortDirection} - onTagSort={onTagSort} + onScoreChange={handleScoreChange} + onFormatToggle={handleFormatToggle} minCustomFormatScore={minCustomFormatScore} upgradeUntilScore={upgradeUntilScore} minScoreIncrement={minScoreIncrement} onMinScoreChange={setMinCustomFormatScore} - onUpgradeUntilScoreChange={ - setUpgradeUntilScore - } + onUpgradeUntilScoreChange={setUpgradeUntilScore} onMinIncrementChange={setMinScoreIncrement} upgradesAllowed={upgradesAllowed} onUpgradesAllowedChange={setUpgradesAllowed} diff --git a/frontend/src/components/profile/scoring/FormatGroup.jsx b/frontend/src/components/profile/scoring/FormatGroup.jsx new file mode 100644 index 0000000..d8dcf85 --- /dev/null +++ b/frontend/src/components/profile/scoring/FormatGroup.jsx @@ -0,0 +1,220 @@ +import React, { useState, useCallback, memo } from 'react'; +import PropTypes from 'prop-types'; +import { ChevronDown, Volume2, Monitor, Users, Tv, Code, HardDrive, Tag, Square, Layers, Database, Folder } from 'lucide-react'; +import NumberInput from '@ui/NumberInput'; +import Tooltip from '@ui/Tooltip'; +import { Copy } from 'lucide-react'; + +const FormatGroup = memo(({ groupName, formats, onScoreChange, onFormatToggle, icon }) => { + const [isExpanded, setIsExpanded] = useState(true); + + // Map group names to icons + const groupIcons = { + 'Audio': Volume2, + 'HDR': Monitor, + 'Release Groups': Users, + 'Streaming Services': Tv, + 'Codecs': Code, + 'Storage': HardDrive, + 'Release Group Tiers': Tag, + 'Resolution': Square, + 'Source': Database, + 'Indexer Flags': Tag, + 'Custom Formats': Layers, + 'Uncategorized': Folder + }; + + // Use provided icon or look up based on group name + const GroupIcon = icon || groupIcons[groupName] || Tag; + + const handleAppToggle = useCallback((formatId, app) => { + const format = formats.find(f => f.id === formatId); + onFormatToggle(formatId, app, !format[app]); + }, [formats, onFormatToggle]); + + const handleScoreChange = useCallback((formatId, app, score) => { + onScoreChange(formatId, app, score); + }, [onScoreChange]); + + const handleCopyScore = useCallback((formatId, fromApp, toApp) => { + const format = formats.find(f => f.id === formatId); + if (format) { + const scoreKey = `${fromApp}Score`; + const score = format[scoreKey] || format.score || 0; + onScoreChange(formatId, toApp, score); + } + }, [formats, onScoreChange]); + + return ( +
+ {/* Group Header */} +
+
+ +

{groupName}

+ ({formats.length}) +
+ + +
+ + {/* Formats Table */} + {isExpanded && ( +
+ + + + + + + + + + {formats.map((format) => { + const isActive = Boolean(format.radarr) || Boolean(format.sonarr); + const radarrScore = format.radarrScore ?? format.score ?? 0; + const sonarrScore = format.sonarrScore ?? format.score ?? 0; + + return ( + + + + + + ); + })} + +
+ Format + + Radarr + + Sonarr +
+
+ {format.name} +
+ {format.tags && format.tags.length > 0 && ( +
+ {format.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ + handleScoreChange(format.id, 'radarr', score)} + className="w-24" + step={1000} + disabled={!format.radarr} + /> + {format.radarr && format.sonarr && ( + + + + )} +
+
+
+ + handleScoreChange(format.id, 'sonarr', score)} + className="w-24" + step={1000} + disabled={!format.sonarr} + /> + {format.radarr && format.sonarr && ( + + + + )} +
+
+
+ )} +
+ ); +}); + +FormatGroup.propTypes = { + groupName: PropTypes.string.isRequired, + formats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number, + radarrScore: PropTypes.number, + sonarrScore: PropTypes.number, + radarr: PropTypes.bool, + sonarr: PropTypes.bool, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ).isRequired, + onScoreChange: PropTypes.func.isRequired, + onFormatToggle: PropTypes.func.isRequired, + icon: PropTypes.elementType +}; + +export default FormatGroup; \ No newline at end of file diff --git a/frontend/src/components/profile/scoring/FormatSettings.jsx b/frontend/src/components/profile/scoring/FormatSettings.jsx index a9e94e3..1158350 100644 --- a/frontend/src/components/profile/scoring/FormatSettings.jsx +++ b/frontend/src/components/profile/scoring/FormatSettings.jsx @@ -1,90 +1,170 @@ -import React, {useState, useEffect, useMemo} from 'react'; +import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'; import PropTypes from 'prop-types'; import SearchBar from '@ui/DataBar/SearchBar'; import useSearch from '@hooks/useSearch'; -import AdvancedView from './AdvancedView'; -import BasicView from './BasicView'; -import FormatSelectorModal from './FormatSelectorModal'; -import FormatSettingsModal from './FormatSettingsModal'; -import {Settings, Plus, CheckSquare} from 'lucide-react'; -import Tooltip from '@ui/Tooltip'; +import GroupFilter from './GroupFilter'; +import FormatGroup from './FormatGroup'; +import { Layers } from 'lucide-react'; -const FormatSettings = ({formats, onScoreChange, appType = 'both', activeApp, onAppChange}) => { - // Initialize state from localStorage, falling back to true if no value is stored - const [isAdvancedView, setIsAdvancedView] = useState(() => { - const stored = localStorage.getItem('formatSettingsView'); - return stored === null ? true : JSON.parse(stored); - }); - - // Initialize selectiveMode from localStorage (global setting) - const [showSelectiveMode, setShowSelectiveMode] = useState(() => { - const stored = localStorage.getItem('formatSettingsSelectiveMode'); - return stored === null ? false : JSON.parse(stored); - }); +const FormatSettings = ({ formats, onScoreChange, onFormatToggle, activeApp }) => { + // Track the initial formats to detect profile changes + const initialFormatsRef = useRef(null); + const sortOrderRef = useRef([]); + const [groupFilter, setGroupFilter] = useState({ selectedGroups: ['All Groups'], customTags: [] }); + const [isProcessing, setIsProcessing] = useState(true); + const [sortedFormats, setSortedFormats] = useState([]); - const [availableFormats, setAvailableFormats] = useState([]); - const [selectedFormatIds, setSelectedFormatIds] = useState(() => { - try { - const stored = localStorage.getItem(`selectedFormatIds_${appType}`); - return stored ? JSON.parse(stored) : []; - } catch { - return []; - } - }); + const handleGroupChange = useCallback((filter) => { + setGroupFilter(filter); + }, []); - // Format selector modal state - const [isSelectorModalOpen, setIsSelectorModalOpen] = useState(false); - - // Settings modal state - const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); - - // Calculate which formats to display - const displayFormats = useMemo(() => { - if (showSelectiveMode) { - // In selective mode: - // 1. Display all formats with non-zero scores - // 2. Also display formats with zero scores that are explicitly selected - const nonZeroFormats = formats.filter(f => f.score !== 0); - const selectedZeroFormats = formats.filter(f => - f.score === 0 && selectedFormatIds.includes(f.id) - ); + const processSortedFormats = useCallback(() => { + // Create a unique key for the current format set + const currentFormatKey = formats.map(f => f.id).sort().join(','); + const previousFormatKey = initialFormatsRef.current?.key; + + // Check if this is a new profile (different format set) + const isNewProfile = !previousFormatKey || currentFormatKey !== previousFormatKey; + + let sorted = formats; + + if (isNewProfile && formats.length > 0) { + // New profile - create fresh sort and clear old sort order + sortOrderRef.current = []; + initialFormatsRef.current = { + key: currentFormatKey, + ids: formats.map(f => f.id) + }; - return [...nonZeroFormats, ...selectedZeroFormats]; - } else { - // In regular mode, display all formats as usual - return formats; + // Pre-calculate scores and active status for efficiency + const formatsWithMeta = formats.map(format => { + const isActive = Boolean(format.radarr) || Boolean(format.sonarr); + let maxScore = 0; + + if (isActive) { + const scores = []; + if (format.radarr) scores.push(format.radarrScore ?? format.score ?? 0); + if (format.sonarr) scores.push(format.sonarrScore ?? format.score ?? 0); + maxScore = scores.length > 0 ? Math.max(...scores) : 0; + } + + return { + format, + isActive, + maxScore, + nameLower: format.name.toLowerCase() + }; + }); + + // Sort using pre-calculated values + formatsWithMeta.sort((a, b) => { + // Active formats first + if (a.isActive !== b.isActive) { + return a.isActive ? -1 : 1; + } + + // If both active, sort by score + if (a.isActive && a.maxScore !== b.maxScore) { + return b.maxScore - a.maxScore; + } + + // Same score or both inactive - sort alphabetically + return a.nameLower.localeCompare(b.nameLower); + }); + + sorted = formatsWithMeta.map(item => item.format); + + // Store the sort order + sortOrderRef.current = sorted.map(f => f.id); + } else if (sortOrderRef.current.length > 0) { + // Same profile - maintain existing order + const idToFormat = new Map(formats.map(f => [f.id, f])); + sorted = sortOrderRef.current + .map(id => idToFormat.get(id)) + .filter(Boolean); } - }, [formats, showSelectiveMode, selectedFormatIds]); - - // Save to localStorage whenever view preferences change - useEffect(() => { - localStorage.setItem('formatSettingsView', JSON.stringify(isAdvancedView)); - }, [isAdvancedView]); - - useEffect(() => { - localStorage.setItem('formatSettingsSelectiveMode', JSON.stringify(showSelectiveMode)); - }, [showSelectiveMode]); + + setSortedFormats(sorted); + setIsProcessing(false); + }, [formats]); - // Save selected format IDs to localStorage + // Process formats asynchronously to avoid blocking the UI useEffect(() => { - localStorage.setItem(`selectedFormatIds_${appType}`, JSON.stringify(selectedFormatIds)); - }, [selectedFormatIds, appType]); - - // Calculate available formats for selection (not already in use) - useEffect(() => { - // To be "available", a format must have zero score and not be in selectedFormatIds - const usedFormatIds = formats.filter(f => f.score !== 0).map(f => f.id); - const allUnavailableIds = [...usedFormatIds, ...selectedFormatIds]; + setIsProcessing(true); + setSortedFormats([]); // Clear previous sorted formats immediately - // Available formats are those not already used or selected - const available = formats.filter(format => - !allUnavailableIds.includes(format.id) - ); + // Use requestIdleCallback for better performance, fallback to setTimeout + if ('requestIdleCallback' in window) { + const id = requestIdleCallback(() => { + processSortedFormats(); + }, { timeout: 100 }); + + return () => cancelIdleCallback(id); + } else { + const timeoutId = setTimeout(() => { + processSortedFormats(); + }, 10); + + return () => clearTimeout(timeoutId); + } + }, [formats, processSortedFormats]); + + // Group formats based on selected groups + const groupedFormats = useMemo(() => { + if (groupFilter.selectedGroups.includes('All Groups')) { + // When "All Groups" is selected, show all formats in a single group + return { 'Custom Formats': sortedFormats }; + } - setAvailableFormats(available); - }, [formats, selectedFormatIds]); - - // Search hook for filtering formats + const groups = {}; + const selectedGroupsSet = new Set(groupFilter.selectedGroups); + + // Pre-compile matching patterns for better performance + const matchers = { + 'Audio': (tag) => /audio|atmos|dts|truehd|flac|aac/i.test(tag), + 'HDR': (tag) => /hdr|dv|dolby|vision/i.test(tag), + 'Release Groups': (tag) => /group/i.test(tag) && !/tier/i.test(tag), + 'Streaming Services': (tag) => /streaming|web|netflix|amazon/i.test(tag), + 'Codecs': (tag) => /codec|x264|x265|h264|h265|av1/i.test(tag), + 'Resolution': (tag) => /resolution|1080|2160|720|4k/i.test(tag), + 'Source': (tag) => /source|bluray|remux|web/i.test(tag), + 'Storage': (tag) => /storage|size/i.test(tag), + 'Release Group Tiers': (tag) => /tier/i.test(tag), + 'Indexer Flags': (tag) => /indexer|flag/i.test(tag) + }; + + // Group formats by matching tags + for (const format of sortedFormats) { + if (!format.tags || format.tags.length === 0) continue; + + // Check each selected group + for (const groupName of selectedGroupsSet) { + const matcher = matchers[groupName]; + const hasMatchingTag = matcher + ? format.tags.some(tag => matcher(tag)) + : format.tags.some(tag => { + const tagLower = tag.toLowerCase(); + const groupLower = groupName.toLowerCase(); + return tagLower.includes(groupLower) || groupLower.includes(tagLower); + }); + + if (hasMatchingTag) { + if (!groups[groupName]) { + groups[groupName] = []; + } + groups[groupName].push(format); + } + } + } + + return groups; + }, [sortedFormats, groupFilter.selectedGroups]); + + // Flatten grouped formats for search + const allGroupedFormats = useMemo(() => { + return Object.values(groupedFormats).flat(); + }, [groupedFormats]); + const { searchTerms, currentInput, @@ -92,77 +172,44 @@ const FormatSettings = ({formats, onScoreChange, appType = 'both', activeApp, on addSearchTerm, removeSearchTerm, clearSearchTerms, - items: filteredFormats - } = useSearch(displayFormats, { - searchableFields: ['name'] + items: searchFilteredFormats + } = useSearch(allGroupedFormats, { + searchableFields: ['name'], + initialSortBy: 'custom', + sortOptions: { + custom: (a, b) => { + // Maintain our custom sort order (already sorted in sortedFormats) + // Just return 0 to keep the existing order + return 0; + } + } }); - - // Handle format toggle (add/remove) - const handleFormatToggle = (formatId) => { - const format = formats.find(f => f.id === formatId); - - if (!format) return; - - // Check if this format is already selected (either has a non-zero score or is in selectedFormatIds) - const isSelected = format.score !== 0 || selectedFormatIds.includes(formatId); - - if (isSelected) { - // Remove format - if (format.score !== 0) { - // If format has a non-zero score, set it to 0 (don't remove it completely) - onScoreChange(formatId, 0); - } - // If format was explicitly selected, remove from the selection list - setSelectedFormatIds(prev => prev.filter(id => id !== formatId)); - } else { - // Add format - // Set the format score to 0 initially, just to mark it as "selected" - onScoreChange(formatId, 0); - - // Add to our list of explicitly selected format IDs - setSelectedFormatIds(prev => [...prev, formatId]); - } - }; - - // When a format score changes, we need to update our tracking - const handleScoreChange = (formatId, score) => { - // Pass the score change to parent - onScoreChange(formatId, score); - - const format = formats.find(f => f.id === formatId); - if (!format) return; - - if (score !== 0) { - // If the score is changing from 0 to non-zero, we no longer need to track it - // as an explicitly selected format (it's tracked by virtue of its non-zero score) - if (format.score === 0 && selectedFormatIds.includes(formatId)) { - // Format was previously explicitly selected with zero score, but now has a non-zero score - // We can remove it from our explicit selection tracking - setSelectedFormatIds(prev => prev.filter(id => id !== formatId)); - } - } else { - // If the score is changing to 0, we need to track it as explicitly selected - // so it remains visible in selective mode - if (format.score !== 0 && !selectedFormatIds.includes(formatId)) { - // Format was previously non-zero, but now is 0 - // Add it to our explicit selection tracking - setSelectedFormatIds(prev => [...prev, formatId]); - } - } - }; - - // Open the format selector modal - const openFormatSelector = () => { - setIsSelectorModalOpen(true); - }; + // Filter grouped formats based on search + const filteredGroupedFormats = useMemo(() => { + if (searchTerms.length === 0 && !currentInput) { + return groupedFormats; + } + + const filteredGroups = {}; + const searchIds = new Set(searchFilteredFormats.map(f => f.id)); + + Object.entries(groupedFormats).forEach(([groupName, formats]) => { + const filteredFormats = formats.filter(f => searchIds.has(f.id)); + if (filteredFormats.length > 0) { + filteredGroups[groupName] = filteredFormats; + } + }); + + return filteredGroups; + }, [groupedFormats, searchFilteredFormats, searchTerms, currentInput]); return ( -
-
+
+
- -
- {/* Settings Button */} - - - - - {/* Selective Mode Toggle */} - - - {/* Add Format Button (only show in selective mode) */} - {showSelectiveMode && ( - - - - )} -
+
- {/* Format Selector Modal */} - setIsSelectorModalOpen(false)} - availableFormats={availableFormats} - selectedFormatIds={selectedFormatIds} - allFormats={formats} - onFormatToggle={handleFormatToggle} - /> - - {/* Settings Modal */} - setIsSettingsModalOpen(false)} - activeApp={activeApp} - onAppChange={onAppChange} - isAdvancedView={isAdvancedView} - onViewChange={setIsAdvancedView} - /> - - {/* Format Display */} - {isAdvancedView ? ( - handleFormatToggle(formatId)} - showRemoveButton={showSelectiveMode} - /> - ) : ( - handleFormatToggle(formatId)} - showRemoveButton={showSelectiveMode} - /> - )} +
+ {isProcessing ? ( +
+ {/* Loading skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : Object.keys(filteredGroupedFormats).length === 0 ? ( +
+

No formats match your current filters

+
+ ) : ( + Object.entries(filteredGroupedFormats).map(([groupName, groupFormats]) => ( + + )) + )} +
); }; @@ -257,14 +267,17 @@ FormatSettings.propTypes = { PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, - score: PropTypes.number.isRequired, + score: PropTypes.number, + radarrScore: PropTypes.number, + sonarrScore: PropTypes.number, + radarr: PropTypes.bool, + sonarr: PropTypes.bool, tags: PropTypes.arrayOf(PropTypes.string) }) ).isRequired, onScoreChange: PropTypes.func.isRequired, - appType: PropTypes.oneOf(['both', 'radarr', 'sonarr']), - activeApp: PropTypes.oneOf(['both', 'radarr', 'sonarr']).isRequired, - onAppChange: PropTypes.func.isRequired + onFormatToggle: PropTypes.func.isRequired, + activeApp: PropTypes.oneOf(['both', 'radarr', 'sonarr']) }; export default FormatSettings; \ No newline at end of file diff --git a/frontend/src/components/profile/scoring/GroupFilter.jsx b/frontend/src/components/profile/scoring/GroupFilter.jsx new file mode 100644 index 0000000..b35594a --- /dev/null +++ b/frontend/src/components/profile/scoring/GroupFilter.jsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; +import PropTypes from 'prop-types'; +import { Check, Plus, X, Volume2, Monitor, Users, Tv, Code, HardDrive, Tag, Square, Layers, Database } from 'lucide-react'; + +const GroupFilter = memo(({ onGroupChange }) => { + const [isOpen, setIsOpen] = useState(false); + const [newTagInput, setNewTagInput] = useState(''); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + // Initialize from localStorage immediately + const [selectedGroups, setSelectedGroups] = useState(() => { + const saved = localStorage.getItem('scoringGroupFilters'); + return saved ? JSON.parse(saved) : ['All Groups']; + }); + + const [customTags, setCustomTags] = useState(() => { + const saved = localStorage.getItem('scoringCustomTags'); + return saved ? JSON.parse(saved) : []; + }); + + const allGroupsOption = { name: 'All Groups', icon: Layers }; + + const predefinedGroups = [ + { name: 'Audio', icon: Volume2 }, + { name: 'HDR', icon: Monitor }, + { name: 'Release Groups', icon: Users }, + { name: 'Streaming Services', icon: Tv }, + { name: 'Codecs', icon: Code }, + { name: 'Storage', icon: HardDrive }, + { name: 'Release Group Tiers', icon: Tag }, + { name: 'Resolution', icon: Square }, + { name: 'Source', icon: Database }, + { name: 'Indexer Flags', icon: Tag } + ]; + + + // Handle click outside + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target) && + buttonRef.current && !buttonRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + // Dispatch changes + useEffect(() => { + onGroupChange({ selectedGroups, customTags }); + }, [selectedGroups, customTags, onGroupChange]); + + const toggleGroup = useCallback((groupName) => { + let newGroups; + + if (groupName === 'All Groups') { + newGroups = ['All Groups']; + } else { + // Remove "All Groups" if adding specific group + let filtered = selectedGroups.filter(g => g !== 'All Groups'); + + if (filtered.includes(groupName)) { + filtered = filtered.filter(g => g !== groupName); + // If no groups left, default to "All Groups" + if (filtered.length === 0) { + filtered = ['All Groups']; + } + } else { + filtered = [...filtered, groupName]; + } + newGroups = filtered; + } + + setSelectedGroups(newGroups); + localStorage.setItem('scoringGroupFilters', JSON.stringify(newGroups)); + }, [selectedGroups]); + + const addCustomTag = useCallback(() => { + const trimmedTag = newTagInput.trim(); + if (trimmedTag && !customTags.includes(trimmedTag)) { + const newCustomTags = [...customTags, trimmedTag]; + setCustomTags(newCustomTags); + + // Also select the new custom tag + const newSelectedGroups = selectedGroups.filter(g => g !== 'All Groups'); + newSelectedGroups.push(trimmedTag); + setSelectedGroups(newSelectedGroups); + + setNewTagInput(''); + + localStorage.setItem('scoringCustomTags', JSON.stringify(newCustomTags)); + localStorage.setItem('scoringGroupFilters', JSON.stringify(newSelectedGroups)); + } + }, [newTagInput, customTags, selectedGroups]); + + const removeCustomTag = useCallback((tag) => { + const newCustomTags = customTags.filter(t => t !== tag); + const newSelectedGroups = selectedGroups.filter(g => g !== tag); + + setCustomTags(newCustomTags); + setSelectedGroups(newSelectedGroups.length === 0 ? ['All Groups'] : newSelectedGroups); + + localStorage.setItem('scoringCustomTags', JSON.stringify(newCustomTags)); + localStorage.setItem('scoringGroupFilters', JSON.stringify(newSelectedGroups)); + }, [customTags, selectedGroups]); + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomTag(); + } + }, [addCustomTag]); + + const activeGroupCount = selectedGroups.filter(g => g !== 'All Groups').length; + const groupOptions = [allGroupsOption, ...predefinedGroups, ...customTags.map(tag => ({ name: tag, icon: Tag, isCustom: true }))]; + + return ( +
+ + + {/* Invisible bridge to maintain hover connection */} +
+ + {/* Dropdown - Uses CSS hover instead of JS events */} +
+ {/* Extended invisible bridge inside dropdown */} +
+
+ {groupOptions.map((group) => ( +
!group.isCustom && toggleGroup(group.name)} + className={`flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${ + group.isCustom ? 'group' : '' + }`} + > +
+ + {group.name} + {group.isCustom && ( + + )} +
+ {selectedGroups.includes(group.name) && ( +
+ +
+ )} +
+ ))} +
+ + {/* Custom tag input */} +
+ setNewTagInput(e.target.value)} + onKeyDown={handleKeyDown} + onClick={(e) => e.stopPropagation()} + placeholder="Add custom tag..." + className="flex-1 bg-transparent text-sm text-gray-700 dark:text-gray-300 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none" + /> + +
+
+
+ ); +}); + +GroupFilter.propTypes = { + onGroupChange: PropTypes.func.isRequired +}; + +export default GroupFilter; \ No newline at end of file diff --git a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx index 571e389..fa2d137 100644 --- a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx +++ b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import FormatSettings from './FormatSettings'; import UpgradeSettings from './UpgradeSettings'; @@ -6,6 +6,7 @@ import UpgradeSettings from './UpgradeSettings'; const ProfileScoringTab = ({ customFormats, onScoreChange, + onFormatToggle, minCustomFormatScore, upgradeUntilScore, minScoreIncrement, @@ -15,7 +16,6 @@ const ProfileScoringTab = ({ upgradesAllowed, onUpgradesAllowedChange }) => { - const [activeApp, setActiveApp] = useState('both'); return (
{/* Upgrade Settings Section */} @@ -71,18 +71,15 @@ const ProfileScoringTab = ({ Format Settings

- Customize format scoring to prioritize your preferred downloads. - Selective mode allows you to display and manage - only formats you care about instead of all available formats. + Customize format scoring to prioritize your preferred downloads. + Toggle formats for Radarr and/or Sonarr independently.

onScoreChange(activeApp, id, score)} - appType={activeApp} - activeApp={activeApp} - onAppChange={setActiveApp} + formats={customFormats || []} + onScoreChange={onScoreChange} + onFormatToggle={onFormatToggle} />
@@ -90,33 +87,18 @@ const ProfileScoringTab = ({ }; ProfileScoringTab.propTypes = { - customFormats: PropTypes.shape({ - both: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - score: PropTypes.number.isRequired, - tags: PropTypes.arrayOf(PropTypes.string) - }) - ), - radarr: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - score: PropTypes.number.isRequired, - tags: PropTypes.arrayOf(PropTypes.string) - }) - ), - sonarr: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - score: PropTypes.number.isRequired, - tags: PropTypes.arrayOf(PropTypes.string) - }) - ) - }).isRequired, + customFormats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number, + radarr: PropTypes.bool, + sonarr: PropTypes.bool, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ).isRequired, onScoreChange: PropTypes.func.isRequired, + onFormatToggle: PropTypes.func.isRequired, minCustomFormatScore: PropTypes.number.isRequired, upgradeUntilScore: PropTypes.number.isRequired, minScoreIncrement: PropTypes.number.isRequired, @@ -127,4 +109,4 @@ ProfileScoringTab.propTypes = { onUpgradesAllowedChange: PropTypes.func.isRequired }; -export default ProfileScoringTab; +export default ProfileScoringTab; \ No newline at end of file diff --git a/frontend/src/components/profile/scoring/UpgradeSettings.jsx b/frontend/src/components/profile/scoring/UpgradeSettings.jsx index 8bc2c39..25f82f8 100644 --- a/frontend/src/components/profile/scoring/UpgradeSettings.jsx +++ b/frontend/src/components/profile/scoring/UpgradeSettings.jsx @@ -27,7 +27,7 @@ const UpgradeSettings = ({
@@ -50,7 +50,7 @@ const UpgradeSettings = ({ value={upgradeUntilScore} onChange={onUpgradeUntilScoreChange} min={0} - className="w-20" + className="w-24" />
@@ -70,7 +70,7 @@ const UpgradeSettings = ({ value={minScoreIncrement} onChange={onMinIncrementChange} min={0} - className="w-20" + className="w-24" /> diff --git a/frontend/src/components/ui/Modal.jsx b/frontend/src/components/ui/Modal.jsx index 0504537..848ce8c 100644 --- a/frontend/src/components/ui/Modal.jsx +++ b/frontend/src/components/ui/Modal.jsx @@ -31,6 +31,10 @@ const Modal = ({ setTimeout(() => { onClose(); setIsClosing(false); + // Reset to first tab after modal closes + if (tabs && tabs.length > 0) { + setActiveTab(tabs[0].id); + } }, 200); // Match animation duration }; diff --git a/frontend/src/components/ui/TabViewer.jsx b/frontend/src/components/ui/TabViewer.jsx index 682034e..51ee827 100644 --- a/frontend/src/components/ui/TabViewer.jsx +++ b/frontend/src/components/ui/TabViewer.jsx @@ -1,4 +1,4 @@ -import React, {useRef, useState, useEffect, useLayoutEffect} from 'react'; +import React, {useRef, useState, useEffect, useLayoutEffect, useCallback} from 'react'; const TabViewer = ({tabs, activeTab, onTabChange}) => { const [tabOffset, setTabOffset] = useState(0); @@ -13,20 +13,24 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => { }); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const updateTabPosition = () => { + const updateTabPosition = useCallback(() => { if (tabsRef.current[activeTab]) { const tab = tabsRef.current[activeTab]; - setTabOffset(tab.offsetLeft); - setTabWidth(tab.offsetWidth); - if (!isInitialized) { - setIsInitialized(true); - } + // Use requestAnimationFrame to ensure smooth animation + requestAnimationFrame(() => { + setTabOffset(tab.offsetLeft); + setTabWidth(tab.offsetWidth); + if (!isInitialized) { + setIsInitialized(true); + } + }); } - }; + }, [activeTab, isInitialized]); useLayoutEffect(() => { + // Immediate update for position updateTabPosition(); - }, [activeTab]); + }, [activeTab, updateTabPosition]); useEffect(() => { const resizeObserver = new ResizeObserver(updateTabPosition); @@ -34,7 +38,7 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => { resizeObserver.observe(tabsRef.current[activeTab]); } return () => resizeObserver.disconnect(); - }, [activeTab]); + }, [activeTab, updateTabPosition]); useEffect(() => { const handleResize = () => { @@ -92,9 +96,9 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => {
{isInitialized && (