feat: format selection and scoring customization options (#153)

- new selection resolver allowing users to add formats with any score (including 0)
- basic / advanced view for format selector
- seperate formatGroups into shared constants file
This commit is contained in:
Samuel Chau
2025-03-07 18:47:47 +10:30
committed by GitHub
parent df16d7c52f
commit 923ab1ebd8
9 changed files with 1108 additions and 248 deletions

View File

@@ -337,7 +337,59 @@ function ProfileModal({
minCustomFormatScore,
upgradeUntilScore,
minScoreIncrement,
custom_formats: customFormats
custom_formats: (() => {
// Check if selective mode is enabled
const selectiveMode = localStorage.getItem('formatSettingsSelectiveMode');
const useSelectiveMode = selectiveMode !== null && JSON.parse(selectiveMode);
if (useSelectiveMode) {
// In selective mode, save both:
// 1. Formats with non-zero scores as usual
// 2. Formats with zero score that have been explicitly selected in selectedFormatIds
try {
// Get the list of explicitly selected format IDs
const selectedFormatIdsStr = localStorage.getItem('selectedFormatIds');
const selectedFormatIds = selectedFormatIdsStr ? JSON.parse(selectedFormatIdsStr) : [];
// Get formats with non-zero scores
const nonZeroFormats = customFormats.filter(format => format.score !== 0);
// Get formats with zero scores that are explicitly selected
const explicitlySelectedZeroFormats = customFormats.filter(format =>
format.score === 0 && selectedFormatIds.includes(format.id)
);
// Combine both lists
return [...nonZeroFormats, ...explicitlySelectedZeroFormats]
.sort((a, b) => {
// First sort by score (descending)
if (b.score !== a.score) {
return b.score - a.score;
}
// Then alphabetically for equal scores
return a.name.localeCompare(b.name);
})
.map(format => ({
name: format.name,
score: format.score
}));
} catch (e) {
// If there's any error parsing the selectedFormatIds, fall back to just non-zero scores
return customFormats
.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 customFormats
.filter(format => format.score !== 0)
.sort((a, b) => {
// First sort by score (descending)
@@ -350,7 +402,9 @@ function ProfileModal({
.map(format => ({
name: format.name,
score: format.score
})),
}));
}
})(),
qualities: sortedQualities
.filter(q => q.enabled)
.map(q => {

View File

@@ -1,138 +1,34 @@
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import NumberInput from '@ui/NumberInput';
import {useSorting} from '@hooks/useSorting';
import SortDropdown from '@ui/SortDropdown';
import {
Music,
Tv,
Users,
Cloud,
Film,
HardDrive,
Maximize,
Globe,
Video,
Flag,
Zap,
Package,
List
} from 'lucide-react';
import { X } from 'lucide-react';
import { groupFormatsByTags, getGroupIcon } from '@constants/formatGroups';
const AdvancedView = ({formats, onScoreChange}) => {
const AdvancedView = ({formats, onScoreChange, onFormatRemove, showRemoveButton}) => {
const sortOptions = [
{label: 'Name', value: 'name'},
{label: 'Score', value: 'score'}
];
// Group formats by their tags
const groupedFormats = formats.reduce((acc, format) => {
// Check if format has any tags that match our known categories
const hasKnownTag = format.tags?.some(
tag =>
tag.includes('Audio') ||
tag.includes('Codec') ||
tag.includes('Enhancement') ||
tag.includes('HDR') ||
tag.includes('Flag') ||
tag.includes('Language') ||
(tag.includes('Release Group') && !tag.includes('Tier')) ||
tag.includes('Release Group Tier') ||
tag.includes('Resolution') ||
tag.includes('Source') ||
tag.includes('Storage') ||
tag.includes('Streaming Service')
);
// Use the shared helper function to group formats
const formatGroups = groupFormatsByTags(formats);
if (!hasKnownTag) {
if (!acc['Uncategorized']) acc['Uncategorized'] = [];
acc['Uncategorized'].push(format);
return acc;
}
format.tags.forEach(tag => {
if (!acc[tag]) acc[tag] = [];
acc[tag].push(format);
});
return acc;
}, {});
const formatGroups = {
Audio: Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Audio'))
.flatMap(([_, formats]) => formats),
Codecs: Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Codec'))
.flatMap(([_, formats]) => formats),
Enhancements: Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Enhancement'))
.flatMap(([_, formats]) => formats),
HDR: Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('HDR'))
.flatMap(([_, formats]) => formats),
'Indexer Flags': Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Flag'))
.flatMap(([_, formats]) => formats),
Language: Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Language'))
.flatMap(([_, formats]) => formats),
'Release Groups': Object.entries(groupedFormats)
.filter(
([tag]) =>
tag.includes('Release Group') && !tag.includes('Tier')
)
.flatMap(([_, formats]) => formats),
'Group Tier Lists': Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Release Group Tier'))
.flatMap(([_, formats]) => formats),
Resolution: Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Resolution'))
.flatMap(([_, formats]) => formats),
Source: Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Source'))
.flatMap(([_, formats]) => formats),
Storage: Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Storage'))
.flatMap(([_, formats]) => formats),
'Streaming Services': Object.entries(groupedFormats)
.filter(([tag]) => tag.includes('Streaming Service'))
.flatMap(([_, formats]) => formats),
Uncategorized: groupedFormats['Uncategorized'] || []
};
const getGroupIcon = groupName => {
const icons = {
Audio: <Music size={16} />,
HDR: <Tv size={16} />,
'Release Groups': <Users size={16} />,
'Group Tier Lists': <List size={16} />,
'Streaming Services': <Cloud size={16} />,
Codecs: <Film size={16} />,
Storage: <HardDrive size={16} />,
Resolution: <Maximize size={16} />,
Language: <Globe size={16} />,
Source: <Video size={16} />,
'Indexer Flags': <Flag size={16} />,
Enhancements: <Zap size={16} />,
Uncategorized: <Package size={16} />
};
return icons[groupName] || <Package size={16} />;
};
// Create sort instances for each group
const groupSorts = Object.entries(formatGroups)
.filter(([_, formats]) => formats.length > 0) // Only create sorts for non-empty groups
.reduce((acc, [groupName, formats]) => {
// Create a single sort instance for all formats
const defaultSort = {field: 'name', direction: 'asc'};
const {sortConfig, updateSort, sortData} = useSorting(defaultSort);
const {sortConfig: globalSortConfig, updateSort: globalUpdateSort, sortData: globalSortData} = useSorting(defaultSort);
acc[groupName] = {
sortedData: sortData(formats),
sortConfig,
updateSort
};
return acc;
}, {});
// Pre-sort all groups using the global sort function
const sortedGroups = useMemo(() => {
const result = {};
Object.entries(formatGroups)
.filter(([_, formats]) => formats.length > 0)
.forEach(([groupName, formats]) => {
result[groupName] = globalSortData(formats);
});
return result;
}, [formatGroups, globalSortData]);
return (
<div className='grid grid-cols-1 md:grid-cols-2 gap-3'>
@@ -140,8 +36,8 @@ const AdvancedView = ({formats, onScoreChange}) => {
.filter(([_, formats]) => formats.length > 0) // Only render non-empty groups
.sort(([a], [b]) => a.localeCompare(b))
.map(([groupName, formats]) => {
const {sortedData, sortConfig, updateSort} =
groupSorts[groupName];
// Use pre-sorted data from our useMemo
const sortedData = sortedGroups[groupName] || [];
return (
<div
@@ -154,8 +50,8 @@ const AdvancedView = ({formats, onScoreChange}) => {
</h3>
<SortDropdown
sortOptions={sortOptions}
currentSort={sortConfig}
onSortChange={updateSort}
currentSort={globalSortConfig}
onSortChange={globalUpdateSort}
/>
</div>
@@ -169,12 +65,23 @@ const AdvancedView = ({formats, onScoreChange}) => {
{format.name}
</p>
</div>
<div className="flex items-center gap-2">
<NumberInput
value={format.score}
onChange={value =>
onScoreChange(format.id, value)
}
/>
{showRemoveButton && (
<button
onClick={() => onFormatRemove(format.id)}
className="text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 p-1"
title="Remove format"
>
<X size={16} />
</button>
)}
</div>
</div>
))}
</div>
@@ -194,7 +101,14 @@ AdvancedView.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
onScoreChange: PropTypes.func.isRequired
onScoreChange: PropTypes.func.isRequired,
onFormatRemove: PropTypes.func,
showRemoveButton: PropTypes.bool
};
AdvancedView.defaultProps = {
onFormatRemove: () => {},
showRemoveButton: false
};
export default AdvancedView;

View File

@@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
import NumberInput from '@ui/NumberInput';
import {useSorting} from '@hooks/useSorting';
import SortDropdown from '@ui/SortDropdown';
import { X } from 'lucide-react';
const BasicView = ({formats, onScoreChange}) => {
const BasicView = ({formats, onScoreChange, onFormatRemove, showRemoveButton}) => {
const sortOptions = [
{label: 'Score', value: 'score'},
{label: 'Name', value: 'name'}
@@ -48,12 +49,23 @@ const BasicView = ({formats, onScoreChange}) => {
)}
</div>
</div>
<div className='flex items-center gap-2'>
<NumberInput
value={format.score}
onChange={value =>
onScoreChange(format.id, value)
}
/>
{showRemoveButton && (
<button
onClick={() => onFormatRemove(format.id)}
className="text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 p-1"
title="Remove format"
>
<X size={16} />
</button>
)}
</div>
</div>
))
) : (
@@ -75,7 +87,14 @@ BasicView.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
onScoreChange: PropTypes.func.isRequired
onScoreChange: PropTypes.func.isRequired,
onFormatRemove: PropTypes.func,
showRemoveButton: PropTypes.bool
};
BasicView.defaultProps = {
onFormatRemove: () => {},
showRemoveButton: false
};
export default BasicView;

View File

@@ -0,0 +1,75 @@
import React, {useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import SearchDropdown from '@ui/SearchDropdown';
const FormatSelector = ({availableFormats, onFormatAdd}) => {
const [selectedFormats, setSelectedFormats] = useState([]);
const [dropdownOptions, setDropdownOptions] = useState([]);
// Transform availableFormats into the format expected by SearchDropdown
useEffect(() => {
if (availableFormats && availableFormats.length > 0) {
const options = availableFormats.map(format => ({
value: format.id,
label: format.name,
description: format.tags ? format.tags.join(', ') : '',
tags: format.tags
}));
setDropdownOptions(options);
} else {
setDropdownOptions([]);
}
}, [availableFormats]);
const handleSelectFormat = e => {
const formatId = e.target.value;
if (formatId && !selectedFormats.includes(formatId)) {
setSelectedFormats(prev => [...prev, formatId]);
onFormatAdd(formatId);
}
};
return (
<div className='bg-gray-800 rounded-lg border border-gray-700 overflow-visible mb-4'>
<div className='px-4 py-3 border-b border-gray-700'>
<h3 className='text-sm font-bold text-gray-100 mb-2'>
Available Formats
</h3>
<p className='text-xs text-gray-400 mb-3'>
Select formats to include in your profile. Zero-scored
formats are still saved when selected.
</p>
<SearchDropdown
options={dropdownOptions}
value=''
onChange={handleSelectFormat}
placeholder='Select formats to add...'
searchableFields={['label', 'description']}
dropdownWidth='100%'
width='100%'
/>
</div>
{dropdownOptions.length === 0 && (
<div className='py-4 text-sm text-gray-400 text-center italic'>
No available formats to add
</div>
)}
</div>
);
};
FormatSelector.propTypes = {
availableFormats: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
onFormatAdd: PropTypes.func.isRequired
};
export default FormatSelector;

View File

@@ -0,0 +1,235 @@
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import Modal from '@ui/Modal';
import SearchBar from '@ui/DataBar/SearchBar';
import useSearch from '@hooks/useSearch';
import { Plus, Check, Settings, Grid3X3 } from 'lucide-react';
import { groupFormatsByTags, getGroupIcon, FORMAT_GROUP_NAMES } from '@constants/formatGroups';
const FormatSelectorModal = ({
isOpen,
onClose,
availableFormats,
selectedFormatIds,
allFormats,
onFormatToggle
}) => {
// State to track view mode (basic/advanced)
const [viewMode, setViewMode] = useState(() => {
const stored = localStorage.getItem('formatSelectorViewMode');
return stored === null ? 'basic' : JSON.parse(stored);
});
// Save view mode preference
const toggleViewMode = () => {
const newMode = viewMode === 'basic' ? 'advanced' : 'basic';
setViewMode(newMode);
localStorage.setItem('formatSelectorViewMode', JSON.stringify(newMode));
};
// Group formats for advanced view
const groupedFormats = useMemo(() => {
return groupFormatsByTags(allFormats);
}, [allFormats]);
// Search functionality
const {
searchTerms,
currentInput,
setCurrentInput,
addSearchTerm,
removeSearchTerm,
clearSearchTerms,
items: filteredFormats
} = useSearch(allFormats, {
searchableFields: ['name', 'tags']
});
// Handle format selection/deselection
const handleFormatClick = (formatId) => {
onFormatToggle(formatId);
};
// Handle format card rendering for basic view
const renderFormatCard = (format) => {
const isSelected = selectedFormatIds.includes(format.id) || format.score !== 0;
return (
<div
key={format.id}
className={`p-2 rounded border transition-colors mb-1.5 cursor-pointer
${isSelected
? 'border-green-500 bg-green-50 dark:bg-green-900/30 dark:border-green-700'
: 'border-gray-300 bg-white hover:border-blue-400 dark:bg-gray-800 dark:border-gray-700 dark:hover:border-blue-600'
}`}
onClick={() => handleFormatClick(format.id)}
>
<div className="flex justify-between items-center">
<div className="flex-1 truncate mr-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{format.name}</h3>
{format.tags && format.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{format.tags.slice(0, 2).map(tag => (
<span key={tag} className="px-1.5 py-0.5 text-xs rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{tag}
</span>
))}
{format.tags.length > 2 && (
<span className="px-1.5 py-0.5 text-xs rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
+{format.tags.length - 2}
</span>
)}
</div>
)}
</div>
{isSelected ? (
<Check className="text-green-500 dark:text-green-400 flex-shrink-0" size={16} />
) : (
<Plus className="text-gray-400 dark:text-gray-500 flex-shrink-0" size={16} />
)}
</div>
</div>
);
};
// Render advanced (grouped) view
const renderAdvancedView = () => {
return (
<div className="space-y-4">
{Object.entries(groupedFormats)
.filter(([_, formats]) => formats.length > 0) // Only render non-empty groups
.sort(([a], [b]) => a.localeCompare(b))
.map(([groupName, formats]) => {
// Filter formats to match search
const filteredGroupFormats = formats.filter(format =>
filteredFormats.some(f => f.id === format.id)
);
// Skip empty groups after filtering
if (filteredGroupFormats.length === 0) {
return null;
}
return (
<div key={groupName} className="mb-4">
<h3 className="text-xs font-bold text-gray-900 dark:text-gray-100 flex items-center mb-2">
{getGroupIcon(groupName)}
<span className="ml-1">{groupName}</span>
<span className="ml-1 text-gray-500 dark:text-gray-400">({filteredGroupFormats.length})</span>
</h3>
<div className="grid grid-cols-2 gap-x-3 gap-y-0">
{filteredGroupFormats.map(renderFormatCard)}
</div>
</div>
);
})
}
{filteredFormats.length === 0 && (
<div className="py-8 text-center text-gray-500 dark:text-gray-400 italic">
No formats found matching your search
</div>
)}
</div>
);
};
// Render basic view (simple grid)
const renderBasicView = () => {
return (
<>
{filteredFormats.length > 0 ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-0">
{filteredFormats.map(renderFormatCard)}
</div>
) : (
<div className="py-8 text-center text-gray-500 dark:text-gray-400 italic">
No formats found matching your search
</div>
)}
</>
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Select Formats"
width="2xl"
height="4xl"
>
<div className="h-full flex flex-col">
<div className="mb-2">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Select formats to include in your profile. Click a format to toggle its selection.
</div>
<div className="flex items-center gap-3">
<SearchBar
className='flex-1'
placeholder='Search formats...'
searchTerms={searchTerms}
currentInput={currentInput}
onInputChange={setCurrentInput}
onAddTerm={addSearchTerm}
onRemoveTerm={removeSearchTerm}
onClearTerms={clearSearchTerms}
/>
<button
onClick={toggleViewMode}
className="flex items-center gap-1 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"
title={viewMode === 'basic' ? 'Switch to Advanced View' : 'Switch to Basic View'}
>
{viewMode === 'basic' ? (
<>
<Settings size={16} className="text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium">Advanced</span>
</>
) : (
<>
<Grid3X3 size={16} className="text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium">Basic</span>
</>
)}
</button>
</div>
</div>
<div className="format-count text-xs mb-2">
<span className="text-green-600 dark:text-green-400 font-medium">{selectedFormatIds.length + allFormats.filter(f => f.score !== 0).length}</span> of {allFormats.length} formats selected
</div>
<div className="flex-1 overflow-y-auto pr-1">
{viewMode === 'basic' ? renderBasicView() : renderAdvancedView()}
</div>
</div>
</Modal>
);
};
FormatSelectorModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
availableFormats: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number,
tags: PropTypes.arrayOf(PropTypes.string)
})
),
selectedFormatIds: PropTypes.arrayOf(PropTypes.string).isRequired,
allFormats: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number,
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
onFormatToggle: PropTypes.func.isRequired
};
export default FormatSelectorModal;

View File

@@ -1,10 +1,12 @@
import React, {useState, useEffect} from 'react';
import React, {useState, useEffect, useMemo} 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 {ChevronDown, Settings, List} from 'lucide-react';
import FormatSelectorModal from './FormatSelectorModal';
import {ChevronDown, Settings, List, CheckSquare, Plus} from 'lucide-react';
import Tooltip from '@ui/Tooltip';
const FormatSettings = ({formats, onScoreChange}) => {
// Initialize state from localStorage, falling back to true if no value is stored
@@ -12,16 +14,74 @@ const FormatSettings = ({formats, onScoreChange}) => {
const stored = localStorage.getItem('formatSettingsView');
return stored === null ? true : JSON.parse(stored);
});
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// Save to localStorage whenever isAdvancedView changes
useEffect(() => {
localStorage.setItem(
'formatSettingsView',
JSON.stringify(isAdvancedView)
// Initialize selectiveMode from localStorage
const [showSelectiveMode, setShowSelectiveMode] = useState(() => {
const stored = localStorage.getItem('formatSettingsSelectiveMode');
return stored === null ? false : JSON.parse(stored);
});
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [availableFormats, setAvailableFormats] = useState([]);
const [selectedFormatIds, setSelectedFormatIds] = useState(() => {
try {
const stored = localStorage.getItem('selectedFormatIds');
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
// Format selector modal state
const [isSelectorModalOpen, setIsSelectorModalOpen] = 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)
);
return [...nonZeroFormats, ...selectedZeroFormats];
} else {
// In regular mode, display all formats as usual
return formats;
}
}, [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]);
// Save selected format IDs to localStorage
useEffect(() => {
localStorage.setItem('selectedFormatIds', JSON.stringify(selectedFormatIds));
}, [selectedFormatIds]);
// 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];
// Available formats are those not already used or selected
const available = formats.filter(format =>
!allUnavailableIds.includes(format.id)
);
setAvailableFormats(available);
}, [formats, selectedFormatIds]);
// Search hook for filtering formats
const {
searchTerms,
currentInput,
@@ -30,12 +90,66 @@ const FormatSettings = ({formats, onScoreChange}) => {
removeSearchTerm,
clearSearchTerms,
items: filteredFormats
} = useSearch(formats, {
} = useSearch(displayFormats, {
searchableFields: ['name']
});
// 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);
// 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 (score !== 0) {
const format = formats.find(f => f.id === formatId);
if (format && 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));
}
}
};
// Toggle selective mode on/off
const toggleSelectiveMode = () => {
setShowSelectiveMode(prev => !prev);
};
// Open the format selector modal
const openFormatSelector = () => {
setIsSelectorModalOpen(true);
};
return (
<div className='space-y-3'>
<div className='space-y-4'>
<div className='flex gap-3'>
<SearchBar
className='flex-1'
@@ -48,12 +162,15 @@ const FormatSettings = ({formats, onScoreChange}) => {
onClearTerms={clearSearchTerms}
/>
<div className='flex gap-2'>
{/* View Mode Dropdown */}
<div className='relative flex'>
<button
onClick={() => setIsDropdownOpen(prev => !prev)}
className='inline-flex items-center justify-between w-36 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'
aria-expanded={isDropdownOpen}
aria-haspopup='true'>
aria-haspopup='true'
>
<span className='flex items-center gap-2'>
{isAdvancedView ? (
<>
@@ -127,17 +244,76 @@ const FormatSettings = ({formats, onScoreChange}) => {
</>
)}
</div>
{/* Selective Mode with Format Selector */}
<div className="flex">
<button
onClick={toggleSelectiveMode}
className={`px-3 py-2 rounded-l-md border transition-colors flex items-center gap-1 ${
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>
{showSelectiveMode && (
<Tooltip
content="Select formats to include in your profile"
position="bottom"
>
<button
onClick={openFormatSelector}
className="px-3 py-2 border rounded-r-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-1 h-full -ml-[1px]"
>
<Plus size={16} />
<span className="text-sm font-medium">Add</span>
</button>
</Tooltip>
)}
{!showSelectiveMode && (
<Tooltip
content="Enable selective mode to add formats"
position="bottom"
>
<div className="px-3 py-2 border rounded-r-md bg-gray-100 border-gray-300 text-gray-400 dark:bg-gray-700 dark:border-gray-700 dark:text-gray-500 flex items-center gap-1 cursor-not-allowed h-full -ml-[1px]">
<Plus size={16} />
<span className="text-sm font-medium">Add</span>
</div>
</Tooltip>
)}
</div>
</div>
</div>
{/* Format Selector Modal */}
<FormatSelectorModal
isOpen={isSelectorModalOpen}
onClose={() => setIsSelectorModalOpen(false)}
availableFormats={availableFormats}
selectedFormatIds={selectedFormatIds}
allFormats={formats}
onFormatToggle={handleFormatToggle}
/>
{/* Format Display */}
{isAdvancedView ? (
<AdvancedView
formats={filteredFormats}
onScoreChange={onScoreChange}
onScoreChange={handleScoreChange}
onFormatRemove={formatId => handleFormatToggle(formatId)}
showRemoveButton={showSelectiveMode}
/>
) : (
<BasicView
formats={filteredFormats}
onScoreChange={onScoreChange}
onScoreChange={handleScoreChange}
onFormatRemove={formatId => handleFormatToggle(formatId)}
showRemoveButton={showSelectiveMode}
/>
)}
</div>

View File

@@ -70,9 +70,10 @@ const ProfileScoringTab = ({
Format Settings
</h2>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Assign scores to different formats to control download
preferences. View formats in the traditional arr style,
or in filtered A/V grids.
Customize format scoring to prioritize your preferred downloads.
Use Basic mode for a simple list view with sliders, Advanced mode for
detailed A/V category grids, and Selective mode to display and manage
only formats you care about instead of all available formats.
</p>
</div>

View File

@@ -0,0 +1,219 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import NumberInput from '@ui/NumberInput';
import { useSorting } from '@hooks/useSorting';
import SortDropdown from '@ui/SortDropdown';
import { Plus, X } from 'lucide-react';
const SelectiveView = ({ formats, onScoreChange, allFormats }) => {
const [selectedFormats, setSelectedFormats] = useState([]);
const [availableFormats, setAvailableFormats] = useState([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchInput, setSearchInput] = useState('');
const sortOptions = [
{ label: 'Score', value: 'score' },
{ label: 'Name', value: 'name' }
];
const { sortConfig, updateSort, sortData } = useSorting({
field: 'score',
direction: 'desc'
});
// Initialize selected formats from the formats prop
useEffect(() => {
setSelectedFormats(formats);
// Set available formats (those not already selected)
updateAvailableFormats(formats);
// Save selected format IDs to localStorage
const selectedIds = formats.map(f => f.id);
localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds));
}, [formats, allFormats]);
// Update available formats list (excluding already selected ones)
const updateAvailableFormats = (selectedFormats) => {
const selectedIds = selectedFormats.map(f => f.id);
setAvailableFormats(allFormats.filter(f => !selectedIds.includes(f.id)));
};
// Add a format to the selected list
const addFormat = (format) => {
// Always start with score 0 for newly added formats
const formatWithScore = {...format, score: 0};
const newSelectedFormats = [...selectedFormats, formatWithScore];
setSelectedFormats(newSelectedFormats);
updateAvailableFormats(newSelectedFormats);
setDropdownOpen(false);
setSearchInput('');
// Update the localStorage list of selected formats
const selectedIds = newSelectedFormats.map(f => f.id);
localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds));
// Notify parent component about the new format
onScoreChange(format.id, 0);
};
// Remove a format from the selected list
const removeFormat = (formatId) => {
const newSelectedFormats = selectedFormats.filter(f => f.id !== formatId);
setSelectedFormats(newSelectedFormats);
updateAvailableFormats(newSelectedFormats);
// Update the localStorage list of selected formats
const selectedIds = newSelectedFormats.map(f => f.id);
localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds));
// Also notify parent component that this format is no longer used
onScoreChange(formatId, 0);
};
// Filter available formats based on search input
const filteredAvailableFormats = availableFormats.filter(format =>
format.name.toLowerCase().includes(searchInput.toLowerCase())
);
const sortedFormats = sortData(selectedFormats);
return (
<div className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden'>
<div className='px-4 py-3 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center'>
<h3 className='text-sm font-bold text-gray-900 dark:text-gray-100'>
Selected Formats
</h3>
<div className='flex gap-2'>
<SortDropdown
sortOptions={sortOptions}
currentSort={sortConfig}
onSortChange={updateSort}
/>
</div>
</div>
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
{/* Add new format button */}
<div className='relative'>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className='w-full flex items-center justify-center px-4 py-2 text-blue-500 hover:bg-gray-50 dark:hover:bg-gray-800/50'
>
<Plus size={16} className='mr-2' />
<span>Add Format</span>
</button>
{/* Dropdown for selecting a format to add */}
{dropdownOpen && (
<>
<div
className='fixed inset-0 z-10'
onClick={() => setDropdownOpen(false)}
/>
<div className='absolute left-0 right-0 mt-1 max-h-60 overflow-y-auto z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg'>
<div className='p-2'>
<input
type='text'
placeholder='Search formats...'
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className='w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-700 dark:text-gray-300'
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
{filteredAvailableFormats.length > 0 ? (
filteredAvailableFormats.map(format => (
<button
key={format.id}
className='w-full text-left px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm'
onClick={() => addFormat(format)}
>
<div className='flex items-center gap-2'>
<p className='text-gray-900 dark:text-gray-100 truncate'>
{format.name}
</p>
{format.tags && format.tags.length > 0 && (
<span className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{format.tags.join(', ')}
</span>
)}
</div>
</button>
))
) : (
<div className='px-4 py-3 text-sm text-gray-500 dark:text-gray-400'>
No formats available
</div>
)}
</div>
</div>
</>
)}
</div>
{/* List of selected formats */}
{sortedFormats.length > 0 ? (
sortedFormats.map(format => (
<div
key={format.id}
className='flex items-center justify-between px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 group'
>
<div className='flex-1 min-w-0 mr-4'>
<div className='flex items-center gap-2'>
<p className='text-sm text-gray-900 dark:text-gray-100 truncate'>
{format.name}
</p>
{format.tags && format.tags.length > 0 && (
<span className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{format.tags.join(', ')}
</span>
)}
</div>
</div>
<div className='flex items-center gap-2'>
<NumberInput
value={format.score}
onChange={value => onScoreChange(format.id, value)}
/>
<button
onClick={() => removeFormat(format.id)}
className='text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400'
>
<X size={16} />
</button>
</div>
</div>
))
) : (
<div className='px-4 py-3 text-sm text-gray-500 dark:text-gray-400'>
No formats selected
</div>
)}
</div>
</div>
);
};
SelectiveView.propTypes = {
formats: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
onScoreChange: PropTypes.func.isRequired,
allFormats: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired
};
export default SelectiveView;

View File

@@ -0,0 +1,167 @@
import React from 'react';
import {
Music,
Tv,
Users,
Cloud,
Film,
HardDrive,
Maximize,
Globe,
Video,
Flag,
Zap,
Package,
List,
BookOpen,
X
} from 'lucide-react';
// Format tag categories for grouping
export const FORMAT_TAG_CATEGORIES = {
AUDIO: 'Audio',
CODEC: 'Codec',
EDITION: 'Edition',
ENHANCEMENT: 'Enhancement',
HDR: 'HDR',
FLAG: 'Flag',
LANGUAGE: 'Language',
RELEASE_GROUP: 'Release Group',
RELEASE_GROUP_TIER: 'Release Group Tier',
RESOLUTION: 'Resolution',
SOURCE: 'Source',
STORAGE: 'Storage',
STREAMING_SERVICE: 'Streaming Service'
};
// Format grouping mappings (tag to display group)
export const FORMAT_GROUP_NAMES = {
Audio: 'Audio',
Codecs: 'Codecs',
Edition: 'Edition',
Enhancements: 'Enhancements',
HDR: 'HDR',
'Indexer Flags': 'Indexer Flags',
Language: 'Language',
'Release Groups': 'Release Groups',
'Group Tier Lists': 'Group Tier Lists',
Resolution: 'Resolution',
Source: 'Source',
Storage: 'Storage',
'Streaming Services': 'Streaming Services',
Uncategorized: 'Uncategorized'
};
// Icon components creation function
const createIcon = (IconComponent, size = 16) => {
return React.createElement(IconComponent, { size });
};
// Icons for each format group
export const FORMAT_GROUP_ICONS = {
Audio: createIcon(Music),
HDR: createIcon(Tv),
'Release Groups': createIcon(Users),
'Group Tier Lists': createIcon(List),
'Streaming Services': createIcon(Cloud),
Codecs: createIcon(Film),
Edition: createIcon(BookOpen),
Storage: createIcon(HardDrive),
Resolution: createIcon(Maximize),
Language: createIcon(Globe),
Source: createIcon(Video),
'Indexer Flags': createIcon(Flag),
Enhancements: createIcon(Zap),
Uncategorized: createIcon(Package),
Remove: createIcon(X)
};
// Helper function to group formats by their tags
export const groupFormatsByTags = (formats) => {
// First group by tags
const groupedByTags = formats.reduce((acc, format) => {
// Check if format has any tags that match known categories
const hasKnownTag = format.tags?.some(
tag =>
tag.includes(FORMAT_TAG_CATEGORIES.AUDIO) ||
tag.includes(FORMAT_TAG_CATEGORIES.CODEC) ||
tag.includes(FORMAT_TAG_CATEGORIES.EDITION) ||
tag.includes(FORMAT_TAG_CATEGORIES.ENHANCEMENT) ||
tag.includes(FORMAT_TAG_CATEGORIES.HDR) ||
tag.includes(FORMAT_TAG_CATEGORIES.FLAG) ||
tag.includes(FORMAT_TAG_CATEGORIES.LANGUAGE) ||
(tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP) && !tag.includes('Tier')) ||
tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP_TIER) ||
tag.includes(FORMAT_TAG_CATEGORIES.RESOLUTION) ||
tag.includes(FORMAT_TAG_CATEGORIES.SOURCE) ||
tag.includes(FORMAT_TAG_CATEGORIES.STORAGE) ||
tag.includes(FORMAT_TAG_CATEGORIES.STREAMING_SERVICE)
);
// Place in uncategorized if no known tags
if (!hasKnownTag) {
if (!acc['Uncategorized']) acc['Uncategorized'] = [];
acc['Uncategorized'].push(format);
return acc;
}
// Otherwise, place in each relevant tag category
format.tags.forEach(tag => {
if (!acc[tag]) acc[tag] = [];
acc[tag].push(format);
});
return acc;
}, {});
// Then map to proper format groups
return {
Audio: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.AUDIO))
.flatMap(([_, formats]) => formats),
Codecs: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.CODEC))
.flatMap(([_, formats]) => formats),
Edition: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.EDITION))
.flatMap(([_, formats]) => formats),
Enhancements: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.ENHANCEMENT))
.flatMap(([_, formats]) => formats),
HDR: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.HDR))
.flatMap(([_, formats]) => formats),
'Indexer Flags': Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.FLAG))
.flatMap(([_, formats]) => formats),
Language: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.LANGUAGE))
.flatMap(([_, formats]) => formats),
'Release Groups': Object.entries(groupedByTags)
.filter(
([tag]) =>
tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP) && !tag.includes('Tier')
)
.flatMap(([_, formats]) => formats),
'Group Tier Lists': Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP_TIER))
.flatMap(([_, formats]) => formats),
Resolution: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.RESOLUTION))
.flatMap(([_, formats]) => formats),
Source: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.SOURCE))
.flatMap(([_, formats]) => formats),
Storage: Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.STORAGE))
.flatMap(([_, formats]) => formats),
'Streaming Services': Object.entries(groupedByTags)
.filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.STREAMING_SERVICE))
.flatMap(([_, formats]) => formats),
Uncategorized: groupedByTags['Uncategorized'] || []
};
};
// Get the appropriate icon for a group name
export const getGroupIcon = (groupName) => {
return FORMAT_GROUP_ICONS[groupName] || FORMAT_GROUP_ICONS.Uncategorized;
};