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
This commit is contained in:
Sam Chau
2025-08-14 03:11:36 +09:30
parent 17756013b2
commit 737a262568
8 changed files with 874 additions and 536 deletions

View File

@@ -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' && (
<ProfileScoringTab
customFormats={customFormats}
formatFilter={formatFilter}
onFormatFilterChange={setFormatFilter}
onScoreChange={(appType, id, score) => {
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}

View File

@@ -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 (
<div className="mb-6">
{/* Group Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<GroupIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{groupName}</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">({formats.length})</span>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
aria-label={`${isExpanded ? 'Hide' : 'Show'} ${groupName} formats`}
>
<ChevronDown className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isExpanded ? '' : '-rotate-90'}`} />
</button>
</div>
{/* Formats Table */}
{isExpanded && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-1/2">
Format
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Radarr
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Sonarr
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{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 (
<tr
key={format.id}
className={`transition-all ${
!isActive ? 'opacity-40 bg-gray-50 dark:bg-gray-900' : 'bg-white dark:bg-gray-800'
}`}
>
<td className="px-4 py-3">
<div
className={`font-medium text-sm ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{format.name}
</div>
{format.tags && format.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{format.tags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 text-xs rounded"
>
{tag}
</span>
))}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => handleAppToggle(format.id, 'radarr')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
format.radarr
? 'bg-yellow-500 border-yellow-500'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:border-yellow-400'
}`}
>
{format.radarr && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
<NumberInput
value={radarrScore}
onChange={(score) => handleScoreChange(format.id, 'radarr', score)}
className="w-24"
step={1000}
disabled={!format.radarr}
/>
{format.radarr && format.sonarr && (
<Tooltip content="Copy to Sonarr" position="top">
<button
onClick={() => handleCopyScore(format.id, 'radarr', 'sonarr')}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<Copy size={14} />
</button>
</Tooltip>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => handleAppToggle(format.id, 'sonarr')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
format.sonarr
? 'bg-blue-500 border-blue-500'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:border-blue-400'
}`}
>
{format.sonarr && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
<NumberInput
value={sonarrScore}
onChange={(score) => handleScoreChange(format.id, 'sonarr', score)}
className="w-24"
step={1000}
disabled={!format.sonarr}
/>
{format.radarr && format.sonarr && (
<Tooltip content="Copy to Radarr" position="top">
<button
onClick={() => handleCopyScore(format.id, 'sonarr', 'radarr')}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<Copy size={14} />
</button>
</Tooltip>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
});
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;

View File

@@ -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 (
<div className='space-y-4'>
<div className='flex gap-3'>
<div className="space-y-4">
<div className="flex items-center gap-2">
<SearchBar
className='flex-1'
placeholder='Search formats...'
className="flex-1"
placeholder="Search formats..."
searchTerms={searchTerms}
currentInput={currentInput}
onInputChange={setCurrentInput}
@@ -170,84 +217,47 @@ const FormatSettings = ({formats, onScoreChange, appType = 'both', activeApp, on
onRemoveTerm={removeSearchTerm}
onClearTerms={clearSearchTerms}
/>
<div className='flex gap-2'>
{/* Settings Button */}
<Tooltip content="Format settings" position="bottom">
<button
onClick={() => setIsSettingsModalOpen(true)}
className='px-3 py-2 rounded-md border border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600 flex items-center gap-2'
>
<Settings size={16} className='text-gray-500 dark:text-gray-400' />
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>Settings</span>
</button>
</Tooltip>
{/* Selective Mode Toggle */}
<button
onClick={() => setShowSelectiveMode(prev => !prev)}
className={`px-3 py-2 rounded-md border transition-colors flex items-center gap-2 ${
showSelectiveMode
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
: 'border-gray-300 bg-white hover:border-gray-400 dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600'
}`}
title={showSelectiveMode ? 'Hide unused formats' : 'Show all formats'}
>
<CheckSquare size={16} />
<span className='text-sm font-medium'>Selective</span>
</button>
{/* Add Format Button (only show in selective mode) */}
{showSelectiveMode && (
<Tooltip content="Add formats to profile" position="bottom">
<button
onClick={openFormatSelector}
className="px-3 py-2 border rounded-md border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600 flex items-center gap-2"
>
<Plus size={16} className='text-gray-500 dark:text-gray-400' />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Add</span>
</button>
</Tooltip>
)}
</div>
<GroupFilter onGroupChange={handleGroupChange} />
</div>
{/* Format Selector Modal */}
<FormatSelectorModal
isOpen={isSelectorModalOpen}
onClose={() => setIsSelectorModalOpen(false)}
availableFormats={availableFormats}
selectedFormatIds={selectedFormatIds}
allFormats={formats}
onFormatToggle={handleFormatToggle}
/>
{/* Settings Modal */}
<FormatSettingsModal
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
activeApp={activeApp}
onAppChange={onAppChange}
isAdvancedView={isAdvancedView}
onViewChange={setIsAdvancedView}
/>
{/* Format Display */}
{isAdvancedView ? (
<AdvancedView
formats={filteredFormats}
onScoreChange={handleScoreChange}
onFormatRemove={formatId => handleFormatToggle(formatId)}
showRemoveButton={showSelectiveMode}
/>
) : (
<BasicView
formats={filteredFormats}
onScoreChange={handleScoreChange}
onFormatRemove={formatId => handleFormatToggle(formatId)}
showRemoveButton={showSelectiveMode}
/>
)}
<div className="mt-4">
{isProcessing ? (
<div className="space-y-4">
{/* Loading skeleton */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="animate-pulse">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div className="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div className="space-y-2">
<div className="h-10 bg-gray-100 dark:bg-gray-700 rounded"></div>
<div className="h-10 bg-gray-100 dark:bg-gray-700 rounded"></div>
<div className="h-10 bg-gray-100 dark:bg-gray-700 rounded"></div>
</div>
</div>
</div>
</div>
) : Object.keys(filteredGroupedFormats).length === 0 ? (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-500 dark:text-gray-400">No formats match your current filters</p>
</div>
) : (
Object.entries(filteredGroupedFormats).map(([groupName, groupFormats]) => (
<FormatGroup
key={groupName}
groupName={groupName}
formats={groupFormats}
onScoreChange={onScoreChange}
onFormatToggle={onFormatToggle}
icon={groupName === 'Custom Formats' ? Layers : null}
/>
))
)}
</div>
</div>
);
};
@@ -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;

View File

@@ -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 (
<div className="relative group/dropdown">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center px-3 py-2 h-10 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors relative"
>
<Layers className="w-4 h-4" />
{/* Active indicator */}
{activeGroupCount > 0 && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-[10px] font-medium text-white leading-none">{activeGroupCount}</span>
</div>
)}
</button>
{/* Invisible bridge to maintain hover connection */}
<div className={`absolute top-full left-0 right-0 h-2 ${
isOpen ? '' : 'group-hover/dropdown:block hidden'
}`} />
{/* Dropdown - Uses CSS hover instead of JS events */}
<div className={`absolute z-20 top-full mt-2 right-0 w-64 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden transition-all duration-200 ${
isOpen ? 'opacity-100 visible translate-y-0' : 'opacity-0 invisible translate-y-[-10px] pointer-events-none group-hover/dropdown:opacity-100 group-hover/dropdown:visible group-hover/dropdown:translate-y-0 group-hover/dropdown:pointer-events-auto'
}`}>
{/* Extended invisible bridge inside dropdown */}
<div className="absolute -top-2 -left-4 -right-4 h-3" />
<div className="overflow-y-auto">
{groupOptions.map((group) => (
<div
key={group.name}
onClick={() => !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' : ''
}`}
>
<div className="flex items-center gap-2 flex-1">
<group.icon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
<span className="text-sm text-gray-700 dark:text-gray-300">{group.name}</span>
{group.isCustom && (
<button
onClick={(e) => {
e.stopPropagation();
removeCustomTag(group.name);
}}
className="opacity-0 group-hover:opacity-100 ml-1 p-0.5 text-gray-400 hover:text-white hover:bg-red-500 dark:hover:bg-red-600 rounded transition-all"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{selectedGroups.includes(group.name) && (
<div className="w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-white" />
</div>
)}
</div>
))}
</div>
{/* Custom tag input */}
<div className="flex items-center gap-2 px-4 py-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<input
type="text"
value={newTagInput}
onChange={(e) => 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"
/>
<button
onClick={(e) => {
e.stopPropagation();
addCustomTag();
}}
className="w-5 h-5 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 rounded-full flex items-center justify-center transition-colors group"
>
<Plus className="w-3 h-3 text-gray-600 dark:text-gray-400 group-hover:text-white" />
</button>
</div>
</div>
</div>
);
});
GroupFilter.propTypes = {
onGroupChange: PropTypes.func.isRequired
};
export default GroupFilter;

View File

@@ -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 (
<div className='w-full space-y-6'>
{/* Upgrade Settings Section */}
@@ -71,18 +71,15 @@ const ProfileScoringTab = ({
Format Settings
</h2>
<p className='text-xs text-gray-500 dark:text-gray-400'>
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.
</p>
</div>
<FormatSettings
formats={customFormats[activeApp] || []}
onScoreChange={(id, score) => onScoreChange(activeApp, id, score)}
appType={activeApp}
activeApp={activeApp}
onAppChange={setActiveApp}
formats={customFormats || []}
onScoreChange={onScoreChange}
onFormatToggle={onFormatToggle}
/>
</div>
</div>
@@ -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;

View File

@@ -27,7 +27,7 @@ const UpgradeSettings = ({
<NumberInput
value={minCustomFormatScore}
onChange={onMinScoreChange}
className="w-20"
className="w-24"
/>
</div>
@@ -50,7 +50,7 @@ const UpgradeSettings = ({
value={upgradeUntilScore}
onChange={onUpgradeUntilScoreChange}
min={0}
className="w-20"
className="w-24"
/>
</div>
@@ -70,7 +70,7 @@ const UpgradeSettings = ({
value={minScoreIncrement}
onChange={onMinIncrementChange}
min={0}
className="w-20"
className="w-24"
/>
</div>
</>

View File

@@ -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
};

View File

@@ -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}) => {
<div className='relative flex items-center'>
{isInitialized && (
<div
className='absolute top-0 bottom-0 bg-gray-900 dark:bg-gray-900 rounded-md transition-all duration-300'
className='absolute top-0 bottom-0 bg-gray-900 dark:bg-gray-900 rounded-md transition-all duration-300 ease-out will-change-transform'
style={{
left: `${tabOffset}px`,
transform: `translateX(${tabOffset}px)`,
width: `${tabWidth}px`
}}
/>