diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..22d3bf1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# Profilarr Development Guide + +## Commands +- **Frontend**: `cd frontend && npm run dev` - Start React dev server +- **Backend**: `cd backend && gunicorn -b 0.0.0.0:5000 app.main:app` - Run Flask server +- **Docker**: `docker compose up` - Start both frontend/backend in dev mode +- **Lint**: `cd frontend && npx eslint 'src/**/*.{js,jsx}'` - Check frontend code style +- **Build**: `cd frontend && npm run build` - Build for production + +## Code Style +### Frontend (React) +- **Imports**: React first, third-party libs next, components, then utils +- **Components**: Functional components with hooks, PascalCase naming +- **Props**: PropTypes for validation, destructure props in component signature +- **State**: Group related state, useCallback for memoized handlers +- **JSX**: 4-space indentation, attributes on new lines for readability +- **Error Handling**: try/catch for async operations, toast notifications + +### Backend (Python) +- **Imports**: Standard lib first, third-party next, local modules last +- **Naming**: snake_case for functions/vars/files, PascalCase for classes +- **Functions**: Single responsibility, descriptive docstrings +- **Error Handling**: Specific exception catches, return (success, message) tuples +- **Indentation**: 4 spaces consistently +- **Modularity**: Related functionality grouped in directories \ No newline at end of file diff --git a/frontend/src/components/format/FormatCard.jsx b/frontend/src/components/format/FormatCard.jsx index 0f1b9ba..506cb43 100644 --- a/frontend/src/components/format/FormatCard.jsx +++ b/frontend/src/components/format/FormatCard.jsx @@ -168,7 +168,7 @@ function FormatCard({ : 'translate-x-0' }`}> {/* Conditions */} -
+
{content.conditions?.map((condition, index) => ( {/* Description */} -
+
{content.description ? (
diff --git a/frontend/src/components/format/FormatTestingTab.jsx b/frontend/src/components/format/FormatTestingTab.jsx index 7cf2f3f..3a5a346 100644 --- a/frontend/src/components/format/FormatTestingTab.jsx +++ b/frontend/src/components/format/FormatTestingTab.jsx @@ -120,7 +120,7 @@ const FormatTestingTab = ({ ))}
) : ( -
+

No tests added yet

diff --git a/frontend/src/components/format/conditions/ConditionCard.jsx b/frontend/src/components/format/conditions/ConditionCard.jsx index f720f47..78ef1c3 100644 --- a/frontend/src/components/format/conditions/ConditionCard.jsx +++ b/frontend/src/components/format/conditions/ConditionCard.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import {CONDITION_TYPES, createCondition} from './conditionTypes'; import {ArrowUp, ArrowDown, X, ChevronsUp, ChevronsDown} from 'lucide-react'; -import BrowserSelect from '@ui/BrowserSelect'; +import SearchDropdown from '@ui/SearchDropdown'; const ConditionCard = ({ condition, @@ -21,7 +21,8 @@ const ConditionCard = ({ const typeOptions = Object.values(CONDITION_TYPES).map(type => ({ value: type.id, - label: type.name + label: type.name, + description: type.description || '' })); const handleTypeChange = e => { @@ -57,14 +58,13 @@ const ConditionCard = ({
{/* Type Selection */} - {/* Render the specific condition component */} diff --git a/frontend/src/components/format/conditions/EditionCondition.jsx b/frontend/src/components/format/conditions/EditionCondition.jsx index 6cc2448..8bc0243 100644 --- a/frontend/src/components/format/conditions/EditionCondition.jsx +++ b/frontend/src/components/format/conditions/EditionCondition.jsx @@ -1,25 +1,31 @@ import React from 'react'; import PropTypes from 'prop-types'; -import BrowserSelect from '@ui/BrowserSelect'; +import SearchDropdown from '@ui/SearchDropdown'; const EditionCondition = ({condition, onChange, patterns}) => { - // Convert patterns to options format + // Format patterns for the dropdown with descriptions if available const patternOptions = patterns.map(pattern => ({ value: pattern.name, - label: pattern.name + label: pattern.name, + description: pattern.description || 'No description available', + priority: pattern.priority })); + const handlePatternChange = e => { + onChange({...condition, pattern: e.target.value}); + }; + return ( -
- + - onChange({...condition, pattern: e.target.value}) - } + onChange={handlePatternChange} options={patternOptions} placeholder='Select edition pattern...' - className='w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 - rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' + searchableFields={['label', 'description']} + className='min-w-[200px]' + width='w-auto' + dropdownWidth='100%' />
); diff --git a/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx b/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx index aefccec..7a60953 100644 --- a/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx +++ b/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx @@ -1,23 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; +import SearchDropdown from '@ui/SearchDropdown'; const ReleaseGroupCondition = ({condition, onChange, patterns}) => { - const sortedPatterns = [...patterns].sort((a, b) => - a.name.localeCompare(b.name) - ); + // Format patterns for the dropdown with descriptions if available + const patternOptions = patterns.map(pattern => ({ + value: pattern.name, + label: pattern.name, + description: pattern.description || 'No description available', + priority: pattern.priority + })); + + const handlePatternChange = e => { + onChange({...condition, pattern: e.target.value}); + }; return ( - +
+ +
); }; diff --git a/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx b/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx index dad2ae7..7231791 100644 --- a/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx +++ b/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx @@ -1,23 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; +import SearchDropdown from '@ui/SearchDropdown'; const ReleaseTitleCondition = ({condition, onChange, patterns}) => { - const sortedPatterns = [...patterns].sort((a, b) => - a.name.localeCompare(b.name) - ); + // Format patterns for the dropdown with enhanced descriptions + const patternOptions = patterns.map(pattern => ({ + value: pattern.name, + label: pattern.name, + description: pattern.description || 'No description available', + priority: pattern.priority + })); + + const handlePatternChange = e => { + onChange({...condition, pattern: e.target.value}); + }; return ( - +
+ +
); }; diff --git a/frontend/src/components/regex/RegexCard.jsx b/frontend/src/components/regex/RegexCard.jsx index b36efeb..1fde0ac 100644 --- a/frontend/src/components/regex/RegexCard.jsx +++ b/frontend/src/components/regex/RegexCard.jsx @@ -142,7 +142,7 @@ const RegexCard = ({ [&>ul]:list-disc [&>ul]:ml-4 [&>ul]:mt-2 [&>ul]:mb-4 [&>ol]:list-decimal [&>ol]:ml-4 [&>ol]:mt-2 [&>ol]:mb-4 [&>ul>li]:mt-0.5 [&>ol>li]:mt-0.5 - [&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50'> + [&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50 scrollable'> {pattern.description}
)} diff --git a/frontend/src/components/regex/RegexTestingTab.jsx b/frontend/src/components/regex/RegexTestingTab.jsx index cad5c62..8b64dc4 100644 --- a/frontend/src/components/regex/RegexTestingTab.jsx +++ b/frontend/src/components/regex/RegexTestingTab.jsx @@ -148,7 +148,7 @@ const RegexTestingTab = ({ ))}
) : ( -
+

No tests added yet

diff --git a/frontend/src/components/ui/DataBar/SearchBar.jsx b/frontend/src/components/ui/DataBar/SearchBar.jsx index 2d4a76d..78abd30 100644 --- a/frontend/src/components/ui/DataBar/SearchBar.jsx +++ b/frontend/src/components/ui/DataBar/SearchBar.jsx @@ -12,7 +12,11 @@ const SearchBar = ({ onInputChange, onAddTerm, onRemoveTerm, - onClearTerms + onClearTerms, + textSize = 'text-sm', // Default text size + badgeTextSize = 'text-sm', // Default badge text size + iconSize = 'h-4 w-4', // Default icon size + minHeight = 'min-h-10' // Default min height }) => { const [isFocused, setIsFocused] = useState(false); @@ -47,7 +51,7 @@ const SearchBar = ({
(
- + className={` + flex items-center gap-1.5 px-2 + ${ + minHeight && minHeight.startsWith('h-') + ? 'py-0.5' + : 'py-1' + } + bg-blue-500/10 dark:bg-blue-500/20 + border border-blue-500/20 dark:border-blue-400/20 + text-blue-600 dark:text-blue-400 + rounded-md shadow-sm + hover:bg-blue-500/15 dark:hover:bg-blue-500/25 + hover:border-blue-500/30 dark:hover:border-blue-400/30 + group/badge flex-shrink-0 + transition-all duration-200 + `}> + {term}
@@ -122,7 +138,7 @@ const SearchBar = ({ hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 group/clear'> - + )}
diff --git a/frontend/src/components/ui/SearchDropdown.jsx b/frontend/src/components/ui/SearchDropdown.jsx new file mode 100644 index 0000000..336091c --- /dev/null +++ b/frontend/src/components/ui/SearchDropdown.jsx @@ -0,0 +1,223 @@ +import React, {useEffect, useRef, useState, useCallback} from 'react'; +import PropTypes from 'prop-types'; +import {ChevronDown, Check, Search} from 'lucide-react'; +import SearchBar from './DataBar/SearchBar'; +import SortDropdown from './SortDropdown'; +import useSearch from '@hooks/useSearch'; +import {useSorting} from '@hooks/useSorting'; + +const SearchDropdown = ({ + value, + onChange, + options, + placeholder, + className, + width = 'w-full', + dropdownWidth, + labelKey = 'label', + valueKey = 'value', + searchableFields = ['label', 'description'] +}) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedOption, setSelectedOption] = useState( + options.find(opt => opt[valueKey] === value) || null + ); + + const dropdownRef = useRef(null); + const menuRef = useRef(null); + + const { + searchTerms, + currentInput, + setCurrentInput, + addSearchTerm, + removeSearchTerm, + clearSearchTerms, + items: filteredOptions + } = useSearch(options, { + searchableFields, + initialSortBy: 'label' + }); + + const {sortConfig, updateSort, sortData} = useSorting({ + field: 'label', + direction: 'asc' + }); + + // Sort options configuration for the dropdown (name only) + const sortOptions = [{value: 'label', label: 'Name (A-Z)'}]; + + // Update selected option when value changes externally + useEffect(() => { + setSelectedOption(options.find(opt => opt[valueKey] === value) || null); + }, [value, options, valueKey]); + + // Handle dropdown visibility + useEffect(() => { + // Handle clicks outside dropdown (close the dropdown) + const handleClickOutside = event => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Apply final sorting to the filtered results + const sortedOptions = useCallback(() => { + return sortData(filteredOptions); + }, [sortData, filteredOptions]); + + // Handle selection + const handleSelect = useCallback( + option => { + setSelectedOption(option); + onChange({target: {value: option[valueKey]}}); + setIsOpen(false); + }, + [onChange, valueKey] + ); + + return ( +
+ {/* Selected Value Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+
+
+
+
+ +
+ +
+
+ + {/* Options List */} +
+ {sortedOptions().length > 0 ? ( +
+ {sortedOptions().map(option => ( +
handleSelect(option)} + className={`px-2.5 py-1.5 text-xs cursor-pointer rounded + ${ + selectedOption?.[valueKey] === + option[valueKey] + ? 'bg-blue-600 text-white' + : 'text-gray-100 hover:bg-gray-700' + }`}> +
+
+ {option[labelKey]} +
+ {selectedOption?.[valueKey] === + option[valueKey] && ( + + )} +
+
+ ))} +
+ ) : ( +
+
+ +

+ No options match your search +

+ +
+
+ )} +
+
+ )} +
+ ); +}; + +SearchDropdown.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + description: PropTypes.string + }) + ).isRequired, + placeholder: PropTypes.string, + className: PropTypes.string, + width: PropTypes.string, + dropdownWidth: PropTypes.string, + labelKey: PropTypes.string, + valueKey: PropTypes.string, + searchableFields: PropTypes.arrayOf(PropTypes.string) +}; + +export default SearchDropdown; diff --git a/frontend/src/components/ui/SortDropdown.jsx b/frontend/src/components/ui/SortDropdown.jsx index 8169142..c10eac8 100644 --- a/frontend/src/components/ui/SortDropdown.jsx +++ b/frontend/src/components/ui/SortDropdown.jsx @@ -7,7 +7,10 @@ const SortDropdown = ({ sortOptions, currentSort, onSortChange, - className = '' + className = '', + textSize = 'text-sm', // Default text size + menuTextSize = 'text-xs', // Default menu text size + iconSize = 16 // Default icon size }) => { const [isOpen, setIsOpen] = useState(false); @@ -30,7 +33,7 @@ const SortDropdown = ({ onClick={toggleDropdown} className={` inline-flex items-center justify-between - min-h-[40px] px-4 py-2 text-sm + px-4 py-2 ${textSize} bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-gray-100 @@ -42,9 +45,9 @@ const SortDropdown = ({ {getCurrentSortLabel()} {currentSort.direction === 'asc' ? ( - + ) : ( - + )} @@ -63,17 +66,17 @@ const SortDropdown = ({ key={option.value} type='button' onClick={() => handleSortClick(option.value)} - className=' + className={` flex items-center justify-between w-full px-4 py-2 - text-xs text-gray-700 dark:text-gray-200 + ${menuTextSize} text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 - '> + `}> {option.label} {currentSort.field === option.value && (currentSort.direction === 'asc' ? ( - + ) : ( - + ))} ))}