diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx
index 584a42d..9ca4099 100644
--- a/frontend/src/components/profile/ProfileModal.jsx
+++ b/frontend/src/components/profile/ProfileModal.jsx
@@ -337,20 +337,74 @@ function ProfileModal({
minCustomFormatScore,
upgradeUntilScore,
minScoreIncrement,
- custom_formats: customFormats
- .filter(format => format.score !== 0)
- .sort((a, b) => {
- // First sort by score (descending)
- if (b.score !== a.score) {
- return b.score - a.score;
+ 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
+ }));
}
- // Then alphabetically for equal scores
- 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)
+ 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
+ }));
+ }
+ })(),
qualities: sortedQualities
.filter(q => q.enabled)
.map(q => {
diff --git a/frontend/src/components/profile/scoring/AdvancedView.jsx b/frontend/src/components/profile/scoring/AdvancedView.jsx
index 51edad8..2cfd114 100644
--- a/frontend/src/components/profile/scoring/AdvancedView.jsx
+++ b/frontend/src/components/profile/scoring/AdvancedView.jsx
@@ -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: ,
- HDR: ,
- 'Release Groups': ,
- 'Group Tier Lists':
,
- 'Streaming Services': ,
- Codecs: ,
- Storage: ,
- Resolution: ,
- Language: ,
- Source: ,
- 'Indexer Flags': ,
- Enhancements: ,
- Uncategorized:
- };
- return icons[groupName] || ;
- };
-
- // 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]) => {
- const defaultSort = {field: 'name', direction: 'asc'};
- const {sortConfig, updateSort, sortData} = useSorting(defaultSort);
-
- acc[groupName] = {
- sortedData: sortData(formats),
- sortConfig,
- updateSort
- };
- return acc;
- }, {});
+ // Create a single sort instance for all formats
+ const defaultSort = {field: 'name', direction: 'asc'};
+ const {sortConfig: globalSortConfig, updateSort: globalUpdateSort, sortData: globalSortData} = useSorting(defaultSort);
+
+ // 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 (
@@ -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 (
{
@@ -169,12 +65,23 @@ const AdvancedView = ({formats, onScoreChange}) => {
{format.name}
-
- onScoreChange(format.id, value)
- }
- />
+
+
+ onScoreChange(format.id, value)
+ }
+ />
+ {showRemoveButton && (
+ 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"
+ >
+
+
+ )}
+
))}
@@ -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;
diff --git a/frontend/src/components/profile/scoring/BasicView.jsx b/frontend/src/components/profile/scoring/BasicView.jsx
index 7bbce91..a72d305 100644
--- a/frontend/src/components/profile/scoring/BasicView.jsx
+++ b/frontend/src/components/profile/scoring/BasicView.jsx
@@ -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}) => {
)}
-
- onScoreChange(format.id, value)
- }
- />
+
+
+ onScoreChange(format.id, value)
+ }
+ />
+ {showRemoveButton && (
+ 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"
+ >
+
+
+ )}
+
))
) : (
@@ -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;
diff --git a/frontend/src/components/profile/scoring/FormatSelector.jsx b/frontend/src/components/profile/scoring/FormatSelector.jsx
new file mode 100644
index 0000000..cf29019
--- /dev/null
+++ b/frontend/src/components/profile/scoring/FormatSelector.jsx
@@ -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 (
+
+
+
+ Available Formats
+
+
+ Select formats to include in your profile. Zero-scored
+ formats are still saved when selected.
+
+
+
+
+
+ {dropdownOptions.length === 0 && (
+
+ No available formats to add
+
+ )}
+
+ );
+};
+
+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;
diff --git a/frontend/src/components/profile/scoring/FormatSelectorModal.jsx b/frontend/src/components/profile/scoring/FormatSelectorModal.jsx
new file mode 100644
index 0000000..2111e40
--- /dev/null
+++ b/frontend/src/components/profile/scoring/FormatSelectorModal.jsx
@@ -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 (
+ handleFormatClick(format.id)}
+ >
+
+
+
{format.name}
+ {format.tags && format.tags.length > 0 && (
+
+ {format.tags.slice(0, 2).map(tag => (
+
+ {tag}
+
+ ))}
+ {format.tags.length > 2 && (
+
+ +{format.tags.length - 2}
+
+ )}
+
+ )}
+
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ };
+
+ // Render advanced (grouped) view
+ const renderAdvancedView = () => {
+ return (
+
+ {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 (
+
+
+ {getGroupIcon(groupName)}
+ {groupName}
+ ({filteredGroupFormats.length})
+
+
+ {filteredGroupFormats.map(renderFormatCard)}
+
+
+ );
+ })
+ }
+
+ {filteredFormats.length === 0 && (
+
+ No formats found matching your search
+
+ )}
+
+ );
+ };
+
+ // Render basic view (simple grid)
+ const renderBasicView = () => {
+ return (
+ <>
+ {filteredFormats.length > 0 ? (
+
+ {filteredFormats.map(renderFormatCard)}
+
+ ) : (
+
+ No formats found matching your search
+
+ )}
+ >
+ );
+ };
+
+ return (
+
+
+
+
+ Select formats to include in your profile. Click a format to toggle its selection.
+
+
+
+
+
+
+ {viewMode === 'basic' ? (
+ <>
+
+ Advanced
+ >
+ ) : (
+ <>
+
+ Basic
+ >
+ )}
+
+
+
+
+
+ {selectedFormatIds.length + allFormats.filter(f => f.score !== 0).length} of {allFormats.length} formats selected
+
+
+
+ {viewMode === 'basic' ? renderBasicView() : renderAdvancedView()}
+
+
+
+ );
+};
+
+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;
\ No newline at end of file
diff --git a/frontend/src/components/profile/scoring/FormatSettings.jsx b/frontend/src/components/profile/scoring/FormatSettings.jsx
index 8e632f1..93ca15f 100644
--- a/frontend/src/components/profile/scoring/FormatSettings.jsx
+++ b/frontend/src/components/profile/scoring/FormatSettings.jsx
@@ -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
+ // 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)
- );
+ 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 (
-
+
{
onClearTerms={clearSearchTerms}
/>
-
-
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'>
-
- {isAdvancedView ? (
- <>
-
-
- Advanced
-
- >
- ) : (
- <>
-
-
- Basic
-
- >
- )}
-
-
-
- {isDropdownOpen && (
- <>
-
setIsDropdownOpen(false)}
+
+ {/* View Mode Dropdown */}
+
+
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'
+ >
+
+ {isAdvancedView ? (
+ <>
+
+
+ Advanced
+
+ >
+ ) : (
+ <>
+
+
+ Basic
+
+ >
+ )}
+
+
-
-
-
{
- setIsAdvancedView(false);
- setIsDropdownOpen(false);
- }}
- className={`w-full text-left px-4 py-2 text-sm ${
- !isAdvancedView
- ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
- : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
- }`}>
-
-
- Basic
-
-
-
{
- setIsAdvancedView(true);
- setIsDropdownOpen(false);
- }}
- className={`w-full text-left px-4 py-2 text-sm ${
- isAdvancedView
- ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
- : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
- }`}>
-
-
- Advanced
-
-
+
+ {isDropdownOpen && (
+ <>
+
setIsDropdownOpen(false)}
+ />
+
+
+
{
+ setIsAdvancedView(false);
+ setIsDropdownOpen(false);
+ }}
+ className={`w-full text-left px-4 py-2 text-sm ${
+ !isAdvancedView
+ ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
+ : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
+ }`}>
+
+
+ Basic
+
+
+
{
+ setIsAdvancedView(true);
+ setIsDropdownOpen(false);
+ }}
+ className={`w-full text-left px-4 py-2 text-sm ${
+ isAdvancedView
+ ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
+ : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
+ }`}>
+
+
+ Advanced
+
+
+
-
- >
- )}
+ >
+ )}
+
+
+ {/* Selective Mode with Format Selector */}
+
+
+
+ Selective
+
+
+ {showSelectiveMode && (
+
+
+
+ Add
+
+
+ )}
+
+ {!showSelectiveMode && (
+
+
+
+ )}
+
+ {/* Format Selector Modal */}
+
setIsSelectorModalOpen(false)}
+ availableFormats={availableFormats}
+ selectedFormatIds={selectedFormatIds}
+ allFormats={formats}
+ onFormatToggle={handleFormatToggle}
+ />
+
+ {/* Format Display */}
{isAdvancedView ? (
handleFormatToggle(formatId)}
+ showRemoveButton={showSelectiveMode}
/>
) : (
handleFormatToggle(formatId)}
+ showRemoveButton={showSelectiveMode}
/>
)}
@@ -156,4 +332,4 @@ FormatSettings.propTypes = {
onScoreChange: PropTypes.func.isRequired
};
-export default FormatSettings;
+export default FormatSettings;
\ No newline at end of file
diff --git a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx
index 1b34fb3..0f73429 100644
--- a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx
+++ b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx
@@ -70,9 +70,10 @@ const ProfileScoringTab = ({
Format Settings
- 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.
diff --git a/frontend/src/components/profile/scoring/SelectiveView.jsx b/frontend/src/components/profile/scoring/SelectiveView.jsx
new file mode 100644
index 0000000..d19ef89
--- /dev/null
+++ b/frontend/src/components/profile/scoring/SelectiveView.jsx
@@ -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 (
+
+
+
+ Selected Formats
+
+
+
+
+
+
+
+ {/* Add new format button */}
+
+
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'
+ >
+
+ Add Format
+
+
+ {/* Dropdown for selecting a format to add */}
+ {dropdownOpen && (
+ <>
+
setDropdownOpen(false)}
+ />
+
+
+ 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()}
+ />
+
+
+ {filteredAvailableFormats.length > 0 ? (
+ filteredAvailableFormats.map(format => (
+
addFormat(format)}
+ >
+
+
+ {format.name}
+
+ {format.tags && format.tags.length > 0 && (
+
+ {format.tags.join(', ')}
+
+ )}
+
+
+ ))
+ ) : (
+
+ No formats available
+
+ )}
+
+
+ >
+ )}
+
+
+ {/* List of selected formats */}
+ {sortedFormats.length > 0 ? (
+ sortedFormats.map(format => (
+
+
+
+
+ {format.name}
+
+ {format.tags && format.tags.length > 0 && (
+
+ {format.tags.join(', ')}
+
+ )}
+
+
+
+ onScoreChange(format.id, value)}
+ />
+ removeFormat(format.id)}
+ className='text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400'
+ >
+
+
+
+
+ ))
+ ) : (
+
+ No formats selected
+
+ )}
+
+
+ );
+};
+
+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;
\ No newline at end of file
diff --git a/frontend/src/constants/formatGroups.js b/frontend/src/constants/formatGroups.js
new file mode 100644
index 0000000..89679f7
--- /dev/null
+++ b/frontend/src/constants/formatGroups.js
@@ -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;
+};
\ No newline at end of file