mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 02:41:11 +01:00
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:
@@ -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 => {
|
||||
|
||||
@@ -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]) => {
|
||||
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 (
|
||||
<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>
|
||||
<NumberInput
|
||||
value={format.score}
|
||||
onChange={value =>
|
||||
onScoreChange(format.id, value)
|
||||
}
|
||||
/>
|
||||
<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;
|
||||
|
||||
@@ -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>
|
||||
<NumberInput
|
||||
value={format.score}
|
||||
onChange={value =>
|
||||
onScoreChange(format.id, value)
|
||||
}
|
||||
/>
|
||||
<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;
|
||||
|
||||
75
frontend/src/components/profile/scoring/FormatSelector.jsx
Normal file
75
frontend/src/components/profile/scoring/FormatSelector.jsx
Normal 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;
|
||||
235
frontend/src/components/profile/scoring/FormatSelectorModal.jsx
Normal file
235
frontend/src/components/profile/scoring/FormatSelectorModal.jsx
Normal 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;
|
||||
@@ -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 (
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex gap-3'>
|
||||
<SearchBar
|
||||
className='flex-1'
|
||||
@@ -48,96 +162,158 @@ const FormatSettings = ({formats, onScoreChange}) => {
|
||||
onClearTerms={clearSearchTerms}
|
||||
/>
|
||||
|
||||
<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'>
|
||||
<span className='flex items-center gap-2'>
|
||||
{isAdvancedView ? (
|
||||
<>
|
||||
<Settings
|
||||
size={16}
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span className='text-sm font-medium'>
|
||||
Advanced
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
size={16}
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span className='text-sm font-medium'>
|
||||
Basic
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`text-gray-500 dark:text-gray-400 transition-transform ${
|
||||
isDropdownOpen ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<>
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
<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'
|
||||
>
|
||||
<span className='flex items-center gap-2'>
|
||||
{isAdvancedView ? (
|
||||
<>
|
||||
<Settings
|
||||
size={16}
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span className='text-sm font-medium'>
|
||||
Advanced
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
size={16}
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span className='text-sm font-medium'>
|
||||
Basic
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`text-gray-500 dark:text-gray-400 transition-transform ${
|
||||
isDropdownOpen ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
<div className='absolute right-0 mt-12 w-36 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 z-10'>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
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'
|
||||
}`}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<List size={16} />
|
||||
<span>Basic</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
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'
|
||||
}`}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Settings size={16} />
|
||||
<span>Advanced</span>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<>
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
/>
|
||||
<div className='absolute right-0 mt-12 w-36 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 z-10'>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
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'
|
||||
}`}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<List size={16} />
|
||||
<span>Basic</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
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'
|
||||
}`}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Settings size={16} />
|
||||
<span>Advanced</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
@@ -156,4 +332,4 @@ FormatSettings.propTypes = {
|
||||
onScoreChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FormatSettings;
|
||||
export default FormatSettings;
|
||||
@@ -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>
|
||||
|
||||
|
||||
219
frontend/src/components/profile/scoring/SelectiveView.jsx
Normal file
219
frontend/src/components/profile/scoring/SelectiveView.jsx
Normal 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;
|
||||
167
frontend/src/constants/formatGroups.js
Normal file
167
frontend/src/constants/formatGroups.js
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user