mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
@@ -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}
|
||||
|
||||
220
frontend/src/components/profile/scoring/FormatGroup.jsx
Normal file
220
frontend/src/components/profile/scoring/FormatGroup.jsx
Normal 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;
|
||||
@@ -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;
|
||||
213
frontend/src/components/profile/scoring/GroupFilter.jsx
Normal file
213
frontend/src/components/profile/scoring/GroupFilter.jsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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`
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user