From f9989ee0cda215c74b48522904aff0cb6ed0232c Mon Sep 17 00:00:00 2001 From: Samuel Chau Date: Sat, 8 Mar 2025 01:51:31 +1030 Subject: [PATCH] feat: various styling improvements + simple language processing (#154) style: improve quality item aesthetic - perma edit / delete buttons for groups - replace radarr/sonarr badges with tooltip - replace selected styling with tick icon refactor: upgrade until logic - remove dropdown selection - let individual quality items be selectable as the upgrade until target - fix: let modal handle scrolling on quality tab - style: improve quality header / create group button - feat: add special choice functionality to search dropdown - style: add bottom margin to qualities container feat: language improvements - simple mode added to change for profile langauge back compatability in radarr - improved styling for language tab style: profile footer improvements - save confirmation - improved styling for save / delete buttons - feat: enhance modal close animations and add closing state management - fix: append [copy] to cloned profiles - fix: change keyboard shortcut from 'a' to 'm' for selection mode --- backend/app/compile/profile_compiler.py | 31 +- backend/app/importarr/profile.py | 16 +- frontend/src/components/format/FormatPage.jsx | 2 +- .../src/components/profile/ProfileCard.jsx | 34 +- .../profile/ProfileLangaugesTab.jsx | 125 ----- .../src/components/profile/ProfileModal.jsx | 298 ++++++------ .../src/components/profile/ProfilePage.jsx | 9 +- .../src/components/profile/QualityItem.jsx | 124 ----- .../profile/language/AdvancedView.jsx | 133 ++++++ .../profile/language/ProfileLangaugesTab.jsx | 447 ++++++++++++++++++ .../profile/language/SimpleView.jsx | 51 ++ .../{ => quality}/CreateGroupModal.jsx | 2 +- .../{ => quality}/ProfileQualitiesTab.jsx | 356 +++++++++----- .../profile/quality/QualityItem.jsx | 32 ++ .../profile/quality/QualityItemGroup.jsx | 174 +++++++ .../profile/quality/QualityItemSingle.jsx | 147 ++++++ frontend/src/components/regex/RegexPage.jsx | 2 +- frontend/src/components/ui/Modal.jsx | 41 +- frontend/src/components/ui/SearchDropdown.jsx | 57 ++- frontend/tailwind.config.js | 45 +- 20 files changed, 1572 insertions(+), 554 deletions(-) delete mode 100644 frontend/src/components/profile/ProfileLangaugesTab.jsx delete mode 100644 frontend/src/components/profile/QualityItem.jsx create mode 100644 frontend/src/components/profile/language/AdvancedView.jsx create mode 100644 frontend/src/components/profile/language/ProfileLangaugesTab.jsx create mode 100644 frontend/src/components/profile/language/SimpleView.jsx rename frontend/src/components/profile/{ => quality}/CreateGroupModal.jsx (99%) rename frontend/src/components/profile/{ => quality}/ProfileQualitiesTab.jsx (57%) create mode 100644 frontend/src/components/profile/quality/QualityItem.jsx create mode 100644 frontend/src/components/profile/quality/QualityItemGroup.jsx create mode 100644 frontend/src/components/profile/quality/QualityItemSingle.jsx diff --git a/backend/app/compile/profile_compiler.py b/backend/app/compile/profile_compiler.py index 71d8330..dc285d0 100644 --- a/backend/app/compile/profile_compiler.py +++ b/backend/app/compile/profile_compiler.py @@ -172,22 +172,39 @@ class ProfileConverter: return converted_group def convert_profile(self, profile: Dict) -> ConvertedProfile: - language = profile.get('language') - if language != 'any': + language = profile.get('language', 'any') + + # Handle language processing for advanced mode (with behavior_language format) + if language != 'any' and '_' in language: language_parts = language.split('_', 1) - behaviour, language = language_parts + behaviour, language_code = language_parts try: language_formats = self._process_language_formats( - behaviour, language) + behaviour, language_code) if 'custom_formats' not in profile: profile['custom_formats'] = [] profile['custom_formats'].extend(language_formats) except Exception as e: logger.error(f"Failed to process language formats: {e}") - selected_language = ValueResolver.get_language('any', - self.target_app, - for_profile=True) + # Simple mode: just use the language directly without custom formats + # This lets the Arr application's built-in language filter handle it + + # Get the appropriate language data for the profile + if language != 'any' and '_' not in language: + # Simple mode - use the language directly + selected_language = ValueResolver.get_language(language, + self.target_app, + for_profile=True) + logger.info(f"Using simple language mode: {language}") + logger.info(f"Selected language data: {selected_language}") + else: + # Advanced mode or 'any' - set language to 'any' as filtering is done via formats + selected_language = ValueResolver.get_language('any', + self.target_app, + for_profile=True) + logger.info( + f"Using advanced mode or 'any', setting language to 'any'") converted_profile = ConvertedProfile( name=profile["name"], diff --git a/backend/app/importarr/profile.py b/backend/app/importarr/profile.py index 128410f..cedc1e8 100644 --- a/backend/app/importarr/profile.py +++ b/backend/app/importarr/profile.py @@ -73,9 +73,19 @@ def import_profiles_to_arr(profile_names: List[str], original_names: List[str], profile_language = profile_data.get('language', 'any') if profile_language != 'any': - logger.info( - f"Profile '{profile_name}' has language override: {profile_language}" - ) + # Detect if we're using simple or advanced mode + is_simple_mode = '_' not in profile_language + if is_simple_mode: + logger.info( + f"Profile '{profile_name}' has simple mode language: {profile_language}" + ) + logger.info( + f"Simple mode will set language filter to: {profile_language}" + ) + else: + logger.info( + f"Profile '{profile_name}' has advanced mode language: {profile_language}" + ) logger.info("Compiling quality profile...") compiled_profiles = compile_quality_profile( diff --git a/frontend/src/components/format/FormatPage.jsx b/frontend/src/components/format/FormatPage.jsx index 55966ec..1c8d2b7 100644 --- a/frontend/src/components/format/FormatPage.jsx +++ b/frontend/src/components/format/FormatPage.jsx @@ -106,7 +106,7 @@ function FormatPage() { lastSelectedIndex } = useMassSelection(); - useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true}); + useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true}); const { name, diff --git a/frontend/src/components/profile/ProfileCard.jsx b/frontend/src/components/profile/ProfileCard.jsx index 56c59e4..bb23d9b 100644 --- a/frontend/src/components/profile/ProfileCard.jsx +++ b/frontend/src/components/profile/ProfileCard.jsx @@ -10,6 +10,7 @@ import { } from 'lucide-react'; import Tooltip from '@ui/Tooltip'; import ReactMarkdown from 'react-markdown'; +import { LANGUAGES } from '@constants/languages'; function unsanitize(text) { if (!text) return ''; @@ -17,19 +18,38 @@ function unsanitize(text) { } function parseLanguage(languageStr) { - if (!languageStr || languageStr === 'any') return 'Any'; - + // Handle empty or "any" case + if (!languageStr || languageStr === 'any') return 'Any Language'; + + // Handle "original" language case + if (languageStr === 'original') return 'Original'; + + // Check if this is a simple language choice (not in format of type_language) + const matchedLanguage = LANGUAGES.find(lang => lang.id === languageStr); + if (matchedLanguage) { + return matchedLanguage.name; + } + + // If we get here, it's an advanced language setting with type_language format const [type, language] = languageStr.split('_'); - const capitalizedLanguage = - language.charAt(0).toUpperCase() + language.slice(1); + + // If language part is missing, just return the type + if (!language) return type; + + // Find language name from constants + const langObj = LANGUAGES.find(lang => lang.id === language); + const languageName = langObj ? langObj.name : language.charAt(0).toUpperCase() + language.slice(1); + // Format based on type switch (type) { case 'only': - return `Must Only Be: ${capitalizedLanguage}`; + return `Must Only Be: ${languageName}`; case 'must': - return `Must Include: ${capitalizedLanguage}`; + return `Must Include: ${languageName}`; + case 'mustnot': + return `Must Not Include: ${languageName}`; default: - return capitalizedLanguage; + return languageName; } } diff --git a/frontend/src/components/profile/ProfileLangaugesTab.jsx b/frontend/src/components/profile/ProfileLangaugesTab.jsx deleted file mode 100644 index a1ef56b..0000000 --- a/frontend/src/components/profile/ProfileLangaugesTab.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {InfoIcon, AlertTriangle} from 'lucide-react'; -import {LANGUAGES} from '@constants/languages'; - -const ProfileLanguagesTab = ({language, onLanguageChange}) => { - const handleLanguageChange = (type, value) => { - // If selecting 'any' behavior, just return 'any' - if (type === 'behavior' && value === 'any') { - onLanguageChange('any'); - return; - } - - // For other cases, split current language setting - const [behavior, lang] = (language || 'must_english').split('_'); - - const newValue = - type === 'behavior' - ? `${value}_${lang || 'english'}` - : `${behavior || 'must'}_${value}`; - - onLanguageChange(newValue); - }; - - // Split current language setting only if it's not 'any' - const [currentBehavior, currentLanguage] = - language === 'any' - ? ['any', ''] - : (language || 'must_english').split('_'); - - return ( -
-
-
-

- Language Requirements -

-

- Configure language requirements for media content. -

-
-
- -
-
- -

- Configure how languages should be handled for your media - content. Select "Any" to accept all languages, or - configure specific language requirements. -

-
- -
-
-
-

- Language Settings -

-

- Configure language requirements for releases -

-
- -
- - - {currentBehavior !== 'any' && ( - - )} -
- - {currentBehavior === 'only' && ( -
- -

- "Must Only Be" will reject releases with - multiple languages -

-
- )} -
-
-
-
- ); -}; - -ProfileLanguagesTab.propTypes = { - language: PropTypes.string.isRequired, - onLanguageChange: PropTypes.func.isRequired -}; - -export default ProfileLanguagesTab; diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx index 9ca4099..0d6c680 100644 --- a/frontend/src/components/profile/ProfileModal.jsx +++ b/frontend/src/components/profile/ProfileModal.jsx @@ -3,11 +3,11 @@ import PropTypes from 'prop-types'; import {Profiles} from '@api/data'; import Modal from '../ui/Modal'; import Alert from '@ui/Alert'; -import {Loader} from 'lucide-react'; +import {Loader, Save, Trash2, Check} from 'lucide-react'; import ProfileGeneralTab from './ProfileGeneralTab'; import ProfileScoringTab from './scoring/ProfileScoringTab'; -import ProfileQualitiesTab from './ProfileQualitiesTab'; -import ProfileLangaugesTab from './ProfileLangaugesTab'; +import ProfileQualitiesTab from './quality/ProfileQualitiesTab'; +import ProfileLangaugesTab from './language/ProfileLangaugesTab'; import QUALITIES from '../../constants/qualities'; function unsanitize(text) { @@ -28,6 +28,7 @@ function ProfileModal({ const [description, setDescription] = useState(''); const [error, setError] = useState(''); const [isDeleting, setIsDeleting] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [loading, setLoading] = useState(true); const [modalTitle, setModalTitle] = useState(''); @@ -116,6 +117,8 @@ function ProfileModal({ useEffect(() => { if (isOpen) { setLoading(true); + setIsDeleting(false); + setIsSaving(false); setModalTitle( isCloning @@ -322,46 +325,93 @@ function ProfileModal({ }, [initialProfile, isOpen, formats, isCloning]); const handleSave = async () => { - if (!name.trim()) { - setError('Name is required.'); - Alert.error('Please enter a profile name'); - return; - } + if (isSaving) { + // This is the confirmation click + if (!name.trim()) { + setError('Name is required.'); + Alert.error('Please enter a profile name'); + setIsSaving(false); + return; + } - try { - const profileData = { - name, - description, - tags, - upgradesAllowed, - minCustomFormatScore, - upgradeUntilScore, - minScoreIncrement, - 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] + try { + const profileData = { + name, + description, + tags, + upgradesAllowed, + minCustomFormatScore, + upgradeUntilScore, + minScoreIncrement, + custom_formats: (() => { + // Check if selective mode is enabled + const selectiveMode = localStorage.getItem( + 'formatSettingsSelectiveMode' + ); + const useSelectiveMode = + selectiveMode !== null && JSON.parse(selectiveMode); + + if (useSelectiveMode) { + // In selective mode, save both: + // 1. Formats with non-zero scores as usual + // 2. Formats with zero score that have been explicitly selected in selectedFormatIds + + try { + // Get the list of explicitly selected format IDs + const selectedFormatIdsStr = + localStorage.getItem('selectedFormatIds'); + const selectedFormatIds = selectedFormatIdsStr + ? JSON.parse(selectedFormatIdsStr) + : []; + + // Get formats with non-zero scores + const nonZeroFormats = customFormats.filter( + format => format.score !== 0 + ); + + // Get formats with zero scores that are explicitly selected + const explicitlySelectedZeroFormats = + customFormats.filter( + format => + format.score === 0 && + selectedFormatIds.includes(format.id) + ); + + // Combine both lists + return [ + ...nonZeroFormats, + ...explicitlySelectedZeroFormats + ] + .sort((a, b) => { + // First sort by score (descending) + if (b.score !== a.score) { + return b.score - a.score; + } + // Then alphabetically for equal scores + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.score + })); + } catch (e) { + // If there's any error parsing the selectedFormatIds, fall back to just non-zero scores + return customFormats + .filter(format => format.score !== 0) + .sort((a, b) => { + if (b.score !== a.score) + return b.score - a.score; + return a.name.localeCompare(b.name); + }) + .map(format => ({ + name: format.name, + score: format.score + })); + } + } else { + // Standard behavior - only include formats with non-zero scores + return customFormats + .filter(format => format.score !== 0) .sort((a, b) => { // First sort by score (descending) if (b.score !== a.score) { @@ -374,93 +424,69 @@ function ProfileModal({ name: format.name, score: format.score })); - } catch (e) { - // If there's any error parsing the selectedFormatIds, fall back to just non-zero scores - return customFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.name.localeCompare(b.name); - }) - .map(format => ({ - name: format.name, - score: format.score - })); } - } else { - // Standard behavior - only include formats with non-zero scores - return customFormats - .filter(format => format.score !== 0) - .sort((a, b) => { - // First sort by score (descending) - 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 => { - if ('qualities' in q) { - return { - id: q.id, - name: q.name, - description: q.description || '', - qualities: q.qualities.map(subQ => ({ - id: subQ.id, - name: subQ.name - })) - }; - } else { - return { - id: q.id, - name: q.name - }; - } - }), - upgrade_until: selectedUpgradeQuality - ? { - id: selectedUpgradeQuality.id, - name: selectedUpgradeQuality.name, - ...(selectedUpgradeQuality.description && { - description: selectedUpgradeQuality.description - }) - } - : null, - language - }; + })(), + qualities: sortedQualities + .filter(q => q.enabled) + .map(q => { + if ('qualities' in q) { + return { + id: q.id, + name: q.name, + description: q.description || '', + qualities: q.qualities.map(subQ => ({ + id: subQ.id, + name: subQ.name + })) + }; + } else { + return { + id: q.id, + name: q.name + }; + } + }), + upgrade_until: selectedUpgradeQuality + ? { + id: selectedUpgradeQuality.id, + name: selectedUpgradeQuality.name, + ...(selectedUpgradeQuality.description && { + description: selectedUpgradeQuality.description + }) + } + : null, + language + }; - if (isCloning || !initialProfile) { - // Creating new profile - await Profiles.create(profileData); - Alert.success('Profile created successfully'); - } else { - // Updating existing profile - const originalName = initialProfile.content.name; - const isNameChanged = originalName !== name; - await Profiles.update( - initialProfile.file_name.replace('.yml', ''), - profileData, - isNameChanged ? name : undefined - ); - Alert.success('Profile updated successfully'); + if (isCloning || !initialProfile) { + // Creating new profile + await Profiles.create(profileData); + Alert.success('Profile created successfully'); + } else { + // Updating existing profile + const originalName = initialProfile.content.name; + const isNameChanged = originalName !== name; + await Profiles.update( + initialProfile.file_name.replace('.yml', ''), + profileData, + isNameChanged ? name : undefined + ); + Alert.success('Profile updated successfully'); + } + + onSave(); + onClose(); + } catch (error) { + console.error('Error saving profile:', error); + const errorMessage = + error.message || 'An unexpected error occurred'; + Alert.error(errorMessage); + setError(errorMessage); + setIsSaving(false); } - - onSave(); - onClose(); - } catch (error) { - console.error('Error saving profile:', error); - const errorMessage = - error.message || 'An unexpected error occurred'; - Alert.error(errorMessage); - setError(errorMessage); + } else { + // First click - show confirmation + setIsSaving(true); } }; @@ -509,16 +535,24 @@ function ProfileModal({ {initialProfile && ( )} }> diff --git a/frontend/src/components/profile/ProfilePage.jsx b/frontend/src/components/profile/ProfilePage.jsx index 1376ef0..8ff6b29 100644 --- a/frontend/src/components/profile/ProfilePage.jsx +++ b/frontend/src/components/profile/ProfilePage.jsx @@ -111,7 +111,7 @@ function ProfilePage() { lastSelectedIndex } = useMassSelection(); - useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true}); + useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true}); useEffect(() => { fetchGitStatus(); @@ -242,9 +242,10 @@ function ProfilePage() { if (isSelectionMode) return; const clonedProfile = { ...profile, - id: 0, - name: `${profile.name} [COPY]`, - custom_formats: profile.custom_formats || [] + content: { + ...profile.content, + name: `${profile.content.name} [COPY]` + } }; setSelectedProfile(clonedProfile); setIsModalOpen(true); diff --git a/frontend/src/components/profile/QualityItem.jsx b/frontend/src/components/profile/QualityItem.jsx deleted file mode 100644 index 078099f..0000000 --- a/frontend/src/components/profile/QualityItem.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react'; -import RadarrLogo from '@logo/Radarr.svg'; -import SonarrLogo from '@logo/Sonarr.svg'; -import {Pencil, Trash2} from 'lucide-react'; - -const QualityItem = ({ - quality, - isDragging, - listeners, - attributes, - style, - onDelete, - onEdit -}) => { - const isGroup = 'qualities' in quality; - - return ( -
- {/* Header Section */} -
- {/* Title and Description */} -
-

- {quality.name} -

- {isGroup && quality.description && ( -

- {quality.description} -

- )} -
- - {/* Actions and Icons */} -
- {/* App Icons */} -
- {quality.radarr && ( - Radarr - )} - {quality.sonarr && ( - Sonarr - )} -
- - {/* Edit/Delete Actions */} - {isGroup && ( -
- {onEdit && ( - - )} - {onDelete && ( - - )} -
- )} -
-
- - {/* Quality Tags Section */} - {isGroup && ( -
- {quality.qualities.map(q => ( - - {q.name} - - ))} -
- )} - - {/* Non-group Description */} - {!isGroup && quality.description && ( -

- {quality.description} -

- )} -
- ); -}; - -export default QualityItem; diff --git a/frontend/src/components/profile/language/AdvancedView.jsx b/frontend/src/components/profile/language/AdvancedView.jsx new file mode 100644 index 0000000..89835fa --- /dev/null +++ b/frontend/src/components/profile/language/AdvancedView.jsx @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {InfoIcon, AlertTriangle} from 'lucide-react'; +import {LANGUAGES} from '@constants/languages'; + +const AdvancedView = ({language, onLanguageChange}) => { + const handleLanguageChange = (type, value) => { + // If selecting 'any' behavior, just return 'any' + if (type === 'behavior' && value === 'any') { + onLanguageChange('any'); + return; + } + + // For other cases, split current language setting + const [behavior, lang] = (language || 'must_english').split('_'); + + const newValue = + type === 'behavior' + ? `${value}_${lang || 'english'}` + : `${behavior || 'must'}_${value}`; + + onLanguageChange(newValue); + }; + + // Split current language setting only if it's not 'any' + const [currentBehavior, currentLanguage] = + language === 'any' + ? ['any', ''] + : (language || 'must_english').split('_'); + + return ( + <> + {/* Type Dropdown */} + + + {/* Language Dropdown */} + {currentBehavior !== 'any' && ( + + )} + + {/* Help text below the controls is in the parent component */} + +
+ {currentBehavior === 'any' && ( +
+ +

Accept content in any language.

+
+ )} + + {currentBehavior === 'must' && ( +
+ +

+ Content must include{' '} + {currentLanguage + ? LANGUAGES.find( + l => l.id === currentLanguage + )?.name || currentLanguage + : 'English'} + , but can include other languages as well. +

+
+ )} + + {currentBehavior === 'only' && ( +
+ +

+ Content must ONLY be in{' '} + {currentLanguage + ? LANGUAGES.find( + l => l.id === currentLanguage + )?.name || currentLanguage + : 'English'} + . This will reject releases containing + multiple languages. +

+
+ )} + + {currentBehavior === 'mustnot' && ( +
+ +

+ Content must NOT include{' '} + {currentLanguage + ? LANGUAGES.find( + l => l.id === currentLanguage + )?.name || currentLanguage + : 'English'} + . Any other language is acceptable. +

+
+ )} +
+ + + ); +}; + +AdvancedView.propTypes = { + language: PropTypes.string.isRequired, + onLanguageChange: PropTypes.func.isRequired +}; + +export default AdvancedView; diff --git a/frontend/src/components/profile/language/ProfileLangaugesTab.jsx b/frontend/src/components/profile/language/ProfileLangaugesTab.jsx new file mode 100644 index 0000000..f0008b3 --- /dev/null +++ b/frontend/src/components/profile/language/ProfileLangaugesTab.jsx @@ -0,0 +1,447 @@ +import React, {useState, useEffect, useMemo} from 'react'; +import PropTypes from 'prop-types'; +import { + Settings, + List, + ChevronDown, + InfoIcon, + AlertTriangle +} from 'lucide-react'; +import {LANGUAGES} from '@constants/languages'; +import SearchDropdown from '@ui/SearchDropdown'; + +const ProfileLanguagesTab = ({language, onLanguageChange}) => { + // Determine advanced view based on language format + const [isAdvancedView, setIsAdvancedView] = useState(() => { + // If language includes an underscore (e.g., must_english) or doesn't exist, it's advanced mode + // If it's a simple language ID without underscore (e.g., english, original, any), it's simple mode + return !language || language.includes('_'); + }); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + // Update mode whenever language changes externally + useEffect(() => { + // If no language provided, set a default value for new profiles + if (!language) { + // Default for new profiles: Advanced mode with "must include original" + onLanguageChange('must_original'); + } else { + // Otherwise determine mode from language format + setIsAdvancedView(!language || language.includes('_')); + } + }, [language, onLanguageChange]); + + // For simple view - language options + const languageOptions = useMemo(() => { + return [ + // Any language at the very top - special item + { + value: 'any', + label: 'Any Language', + description: 'Accepting content in any language', + isSpecial: true + }, + // Original language next - special item + { + value: 'original', + label: 'Original', + description: + 'Content must include Original, but can include other languages as well', + isSpecial: true + }, + // All other languages - sorted alphabetically + ...LANGUAGES.filter(lang => lang.id !== 'original') // Skip original since we added it manually above + .sort((a, b) => a.name.localeCompare(b.name)) + .map(lang => ({ + value: lang.id, + label: lang.name, + description: `Content must include ${lang.name}, but can include other languages as well` + })) + ]; + }, []); + + // For advanced view - handle language changes + const handleAdvancedLanguageChange = (type, value) => { + // If selecting 'any' behavior, just return 'any' + if (type === 'behavior' && value === 'any') { + onLanguageChange('any'); + return; + } + + // For other cases, split current language setting + const [behavior, lang] = (language || 'must_english').split('_'); + + const newValue = + type === 'behavior' + ? `${value}_${lang || 'english'}` + : `${behavior || 'must'}_${value}`; + + onLanguageChange(newValue); + }; + + // Split current language setting only if it's not 'any' + const [currentBehavior, currentLanguage] = + language === 'any' + ? ['any', ''] + : (language || 'must_english').split('_'); + + return ( +
+
+ {/* Simple header with title and description */} +
+

+ Language Settings +

+

+ Configure how language preferences are applied to your + profiles. + {isAdvancedView + ? ' Advanced mode creates custom formats for precise language control in both Radarr and Sonarr. This is required for Sonarr as it lacks built-in language settings.' + : ' Simple mode sets language preferences directly in Radarr without custom formats. For Sonarr, consider using Advanced mode since it has no built-in language filtering.'} +

+
+ + {/* Controls row - display mode dropdown with other controls */} +
+ {/* Mode Selector (always visible) */} +
+ + {isDropdownOpen && ( + <> +
setIsDropdownOpen(false)} + /> +
+
+ + +
+
+ + )} +
+ + {/* SIMPLE MODE: just one language dropdown */} + {!isAdvancedView && ( +
+ onLanguageChange(e.target.value)} + options={languageOptions} + placeholder='Select language...' + dropdownWidth='100%' + className='bg-gray-800 dark:border-gray-600 text-gray-100' + /> +
+ )} + + {/* ADVANCED MODE: two dropdowns (type and language) */} + {isAdvancedView && ( + <> + {/* Type Dropdown - Custom styled */} +
+ +
+ +
+
+ + {/* Language Dropdown */} + {currentBehavior !== 'any' && ( +
+ + handleAdvancedLanguageChange( + 'language', + e.target.value + ) + } + options={[ + // Special items at the top + { + value: 'original', + label: 'Original', + description: + 'Content must include Original, but can include other languages as well', + isSpecial: true + }, + // All other languages - sorted alphabetically + ...LANGUAGES.filter( + lang => lang.id !== 'original' + ) // Skip original since we added it manually above + .sort((a, b) => + a.name.localeCompare(b.name) + ) + .map(lang => ({ + value: lang.id, + label: lang.name, + description: `Content must include ${lang.name}, but can include other languages as well` + })) + ]} + placeholder='Select language...' + dropdownWidth='100%' + className='bg-gray-800 dark:border-gray-600 text-gray-100' + /> +
+ )} + + )} +
+ + {/* Help text section - display the appropriate help text based on view mode and selection */} +
+ {/* Simple mode help */} + {!isAdvancedView && ( +
+ +

+ {language === 'any' ? ( + <> + Attempts to set{' '} + + Any Language + {' '} + in Radarr profiles. For Sonarr, language + will default to "Original" since it + lacks native language settings. + + ) : language === 'original' ? ( + <> + Attempts to set{' '} + + Original + {' '} + language in Radarr profiles. For Sonarr, + language will default to "Original" + since it lacks native language settings. + + ) : ( + <> + Attempts to set{' '} + + {LANGUAGES.find( + l => l.id === language + )?.name || language} + {' '} + language in Radarr profiles. For Sonarr, + language will default to "Original" + since it lacks native language settings. + + )} +

+
+ )} + + {/* Advanced mode help based on selections */} + {isAdvancedView && ( + <> + {language === 'any' && ( +
+ +

Accept content in any language.

+
+ )} + + {language && language.startsWith('must_') && ( +
+ +

+ Content must include{' '} + + {language.split('_')[1] + ? LANGUAGES.find( + l => + l.id === + language.split('_')[1] + )?.name || + language.split('_')[1] + : 'English'} + + , but can include other languages as + well. +

+
+ )} + + {language && language.startsWith('only_') && ( +
+ +

+ Content must ONLY be in{' '} + + {language.split('_')[1] + ? LANGUAGES.find( + l => + l.id === + language.split('_')[1] + )?.name || + language.split('_')[1] + : 'English'} + + . This will reject releases containing + multiple languages. +

+
+ )} + + {language && language.startsWith('mustnot_') && ( +
+ +

+ Content must NOT include{' '} + + {language.split('_')[1] + ? LANGUAGES.find( + l => + l.id === + language.split('_')[1] + )?.name || + language.split('_')[1] + : 'English'} + + . Any other language is acceptable. +

+
+ )} + + )} +
+
+
+ ); +}; + +ProfileLanguagesTab.propTypes = { + language: PropTypes.string.isRequired, + onLanguageChange: PropTypes.func.isRequired +}; + +export default ProfileLanguagesTab; diff --git a/frontend/src/components/profile/language/SimpleView.jsx b/frontend/src/components/profile/language/SimpleView.jsx new file mode 100644 index 0000000..bd222fb --- /dev/null +++ b/frontend/src/components/profile/language/SimpleView.jsx @@ -0,0 +1,51 @@ +import React, {useMemo} from 'react'; +import PropTypes from 'prop-types'; +import {LANGUAGES} from '@constants/languages'; +import SearchDropdown from '@ui/SearchDropdown'; + +const SimpleView = ({language, onLanguageChange}) => { + const languageOptions = useMemo(() => { + return [ + // Any language at the very top - special item + { + value: 'any', + label: 'Any Language', + description: 'Accepting content in any language', + isSpecial: true + }, + // Original language next - special item + { + value: 'original', + label: 'Original', + description: 'Content must include Original, but can include other languages as well', + isSpecial: true + }, + // All other languages - sorted alphabetically + ...LANGUAGES + .filter(lang => lang.id !== 'original') // Skip original since we added it manually above + .sort((a, b) => a.name.localeCompare(b.name)) + .map(lang => ({ + value: lang.id, + label: lang.name, + description: `Content must include ${lang.name}, but can include other languages as well` + })) + ]; + }, []); + + return ( + onLanguageChange(e.target.value)} + options={languageOptions} + placeholder="Select language..." + dropdownWidth="100%" + /> + ); +}; + +SimpleView.propTypes = { + language: PropTypes.string.isRequired, + onLanguageChange: PropTypes.func.isRequired +}; + +export default SimpleView; diff --git a/frontend/src/components/profile/CreateGroupModal.jsx b/frontend/src/components/profile/quality/CreateGroupModal.jsx similarity index 99% rename from frontend/src/components/profile/CreateGroupModal.jsx rename to frontend/src/components/profile/quality/CreateGroupModal.jsx index b5c5de5..af3862c 100644 --- a/frontend/src/components/profile/CreateGroupModal.jsx +++ b/frontend/src/components/profile/quality/CreateGroupModal.jsx @@ -1,5 +1,5 @@ import React, {useState, useEffect} from 'react'; -import Modal from '../ui/Modal'; +import Modal from '@ui/Modal'; import Tooltip from '@ui/Tooltip'; import {InfoIcon} from 'lucide-react'; diff --git a/frontend/src/components/profile/ProfileQualitiesTab.jsx b/frontend/src/components/profile/quality/ProfileQualitiesTab.jsx similarity index 57% rename from frontend/src/components/profile/ProfileQualitiesTab.jsx rename to frontend/src/components/profile/quality/ProfileQualitiesTab.jsx index bb99876..3acf7d7 100644 --- a/frontend/src/components/profile/ProfileQualitiesTab.jsx +++ b/frontend/src/components/profile/quality/ProfileQualitiesTab.jsx @@ -6,8 +6,7 @@ import { KeyboardSensor, PointerSensor, useSensor, - useSensors, - DragOverlay + useSensors } from '@dnd-kit/core'; import { arrayMove, @@ -20,54 +19,20 @@ import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers'; -import {InfoIcon} from 'lucide-react'; -import Modal from '../ui/Modal'; +import Modal from '@ui/Modal'; import CreateGroupModal from './CreateGroupModal'; import QualityItem from './QualityItem'; -import QUALITIES from '../../constants/qualities'; +import QUALITIES from '@constants/qualities'; import Alert from '@ui/Alert'; -const UpgradeSection = ({ - enabledQualities, - selectedUpgradeQuality, - onUpgradeQualityChange +const SortableItem = ({ + quality, + onToggle, + onDelete, + onEdit, + isUpgradeUntil, + onUpgradeUntilClick }) => { - if (enabledQualities.length === 0) { - return null; - } - - return ( -
-
-

- Upgrade Until -

-

- Downloads will be upgraded until this quality is reached. - Lower qualities will be upgraded, while higher qualities - will be left unchanged. -

- -
-
- ); -}; - -const SortableItem = ({quality, onToggle, onDelete, onEdit}) => { const { attributes, listeners, @@ -99,6 +64,10 @@ const SortableItem = ({quality, onToggle, onDelete, onEdit}) => { style={style} onEdit={'qualities' in quality ? onEdit : undefined} onDelete={'qualities' in quality ? onDelete : undefined} + isUpgradeUntil={isUpgradeUntil} + onUpgradeUntilClick={ + quality.enabled ? onUpgradeUntilClick : undefined + } />
); @@ -217,6 +186,19 @@ const ProfileQualitiesTab = ({ const handleQualityToggle = quality => { if (!activeId) { + // Prevent disabling a quality that's set as the upgrade until quality + if ( + quality.enabled && + upgradesAllowed && + selectedUpgradeQuality && + isUpgradeUntilQuality(quality) + ) { + Alert.error( + "You can't disable a quality that's set as 'upgrade until'. Please set another quality as 'upgrade until' first." + ); + return; + } + const currentEnabledCount = sortedQualities.filter( q => q.enabled ).length; @@ -252,34 +234,69 @@ const ProfileQualitiesTab = ({ onQualitiesChange(allEnabledQualities); - // Only update the upgrade quality if we're disabling the current upgrade quality + // We shouldn't reach this point for the upgrade until quality, + // but keeping as a safety measure if ( + upgradesAllowed && selectedUpgradeQuality && quality.enabled === false && // We're disabling a quality - (quality.id === selectedUpgradeQuality.id || // Direct match - ('qualities' in quality && // Group match + (quality.id === selectedUpgradeQuality.id || // Direct match (group or quality) + ('qualities' in quality && // Quality is in a group that's being disabled + !('qualities' in selectedUpgradeQuality) && quality.qualities.some( q => q.id === selectedUpgradeQuality.id ))) ) { + // Find another enabled quality to set as upgrade until const nearestQuality = findNearestEnabledQuality( newQualities, quality.id ); - onSelectedUpgradeQualityChange?.(nearestQuality); + + if (nearestQuality) { + onSelectedUpgradeQualityChange?.(nearestQuality); + Alert.info( + `Upgrade until quality changed to ${nearestQuality.name}` + ); + } } } }; + const handleUpgradeUntilClick = quality => { + // Make sure we're setting the quality object properly + if (quality) { + // For single qualities, pass as is + // For groups, we pass the group itself to maintain the group ID in the selection + onSelectedUpgradeQualityChange?.(quality); + + // Provide user feedback + Alert.success(`${quality.name} set as upgrade until quality`); + } + }; + const handleCreateOrUpdateGroup = groupData => { - if ( - selectedUpgradeQuality && - !('qualities' in selectedUpgradeQuality) - ) { - const qualityMovingToGroup = groupData.qualities.some( - q => q.id === selectedUpgradeQuality.id - ); - if (qualityMovingToGroup) { + // Check if the currently selected upgrade quality is being moved into this group + if (selectedUpgradeQuality) { + // If the selected upgrade quality is a single quality (not a group itself) + if (!('qualities' in selectedUpgradeQuality)) { + const qualityMovingToGroup = groupData.qualities.some( + q => q.id === selectedUpgradeQuality.id + ); + + // If the current upgrade quality is being moved into this group + // Update the upgrade quality to be the group instead + if (qualityMovingToGroup) { + onSelectedUpgradeQualityChange({ + id: groupData.id, + name: groupData.name, + description: groupData.description + }); + } + } + // If the selected upgrade quality is the group we're editing + else if (selectedUpgradeQuality.id === editingGroup?.id) { + // Update the upgrade quality to reflect the new group data onSelectedUpgradeQualityChange({ id: groupData.id, name: groupData.name, @@ -371,15 +388,39 @@ const ProfileQualitiesTab = ({ const handleDeleteGroup = group => { // Check if we're deleting the currently selected upgrade group if (selectedUpgradeQuality && selectedUpgradeQuality.id === group.id) { - const firstQualityFromGroup = group.qualities[0]; - onSelectedUpgradeQualityChange(firstQualityFromGroup); + // Find the first quality from the group and set it as the upgrade until quality + if (group.qualities && group.qualities.length > 0) { + const firstQualityFromGroup = group.qualities[0]; + onSelectedUpgradeQualityChange(firstQualityFromGroup); + Alert.info( + `Upgrade until quality changed to ${firstQualityFromGroup.name}` + ); + } else { + // If somehow the group has no qualities, find the first enabled quality + const firstEnabledQuality = sortedQualities.find( + q => q.enabled && q.id !== group.id + ); + if (firstEnabledQuality) { + onSelectedUpgradeQualityChange(firstEnabledQuality); + Alert.info( + `Upgrade until quality changed to ${firstEnabledQuality.name}` + ); + } + } } onSortedQualitiesChange(prev => { const index = prev.findIndex(q => q.id === group.id); if (index === -1) return prev; const newQualities = [...prev]; - newQualities.splice(index, 1, ...group.qualities); + + // Make sure all qualities from the group are set as enabled + const enabledGroupQualities = group.qualities.map(q => ({ + ...q, + enabled: true + })); + + newQualities.splice(index, 1, ...enabledGroupQualities); return newQualities; }); }; @@ -410,75 +451,146 @@ const ProfileQualitiesTab = ({ setActiveId(null); }; + const isUpgradeUntilQuality = quality => { + if (!selectedUpgradeQuality) return false; + + // Direct ID match (works for both individual qualities and groups) + if (quality.id === selectedUpgradeQuality.id) { + return true; + } + + // Check if the selected upgrade quality is a member of this group + if ( + 'qualities' in quality && + !('qualities' in selectedUpgradeQuality) && + quality.qualities.some(q => q.id === selectedUpgradeQuality.id) + ) { + return true; + } + + return false; + }; + return (
-
-
-

+
+
+

Quality Rankings

- -

- Qualities higher in the list are more preferred even if - not checked. Qualities within the same group are equal. - Only checked qualities are wanted. -

- - -
-
- - {upgradesAllowed && ( - q.enabled)} - selectedUpgradeQuality={selectedUpgradeQuality} - onUpgradeQualityChange={onSelectedUpgradeQualityChange} - /> - )} - -
- -
-
- q.id)} - strategy={verticalListSortingStrategy}> - {sortedQualities.map(quality => ( - - ))} - -
+
+ + + + + Drag to reorder + + + + + + + Click to toggle + + {upgradesAllowed && ( + <> + + + + + + Set upgrade target + + + )}
- +
+
+ +
+
+ q.id)} + strategy={verticalListSortingStrategy}> + {sortedQualities.map(quality => ( + + ))} + +
+
+
+ { diff --git a/frontend/src/components/profile/quality/QualityItem.jsx b/frontend/src/components/profile/quality/QualityItem.jsx new file mode 100644 index 0000000..3697bd3 --- /dev/null +++ b/frontend/src/components/profile/quality/QualityItem.jsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import QualityItemSingle from './QualityItemSingle'; +import QualityItemGroup from './QualityItemGroup'; + +const QualityItem = (props) => { + const [hoveredItem, setHoveredItem] = useState(null); + const isGroup = 'qualities' in props.quality; + + const handleMouseEnter = (id) => { + setHoveredItem(id); + }; + + const handleMouseLeave = () => { + setHoveredItem(null); + }; + + // Add mouseEnter/Leave handlers and hoveredState to props + const enhancedProps = { + ...props, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + willBeSelected: hoveredItem === props.quality.id + }; + + if (isGroup) { + return ; + } else { + return ; + } +}; + +export default QualityItem; \ No newline at end of file diff --git a/frontend/src/components/profile/quality/QualityItemGroup.jsx b/frontend/src/components/profile/quality/QualityItemGroup.jsx new file mode 100644 index 0000000..bb9119b --- /dev/null +++ b/frontend/src/components/profile/quality/QualityItemGroup.jsx @@ -0,0 +1,174 @@ +import React from 'react'; +import RadarrLogo from '@logo/Radarr.svg'; +import SonarrLogo from '@logo/Sonarr.svg'; +import {Pencil, Trash2, Check, ArrowUp} from 'lucide-react'; +import Tooltip from '@ui/Tooltip'; + +const QualityItemGroup = ({ + quality, + isDragging, + listeners, + attributes, + style, + onDelete, + onEdit, + onMouseEnter, + onMouseLeave, + willBeSelected, + isUpgradeUntil, + onUpgradeUntilClick +}) => { + const handleUpgradeClick = e => { + e.stopPropagation(); + onUpgradeUntilClick?.(quality); + }; + + return ( +
onMouseEnter?.(quality.id)} + onMouseLeave={onMouseLeave}> + {/* Header Row */} +
+ {/* Title and Description */} +
+
+

+ {quality.name} +

+ + {/* Quality tags inline with name */} +
+ {quality.qualities.map(q => ( + + {q.name} + + ))} +
+
+ {quality.description && ( +

+ {quality.description} +

+ )} +
+ + {/* Right Section */} +
+ {/* App Icons */} +
+ {quality.radarr && ( +
+ Radarr + + Radarr + +
+ )} + {quality.sonarr && ( +
+ Sonarr + + Sonarr + +
+ )} +
+ + {/* Edit/Delete Actions */} +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+ + {/* Upgrade Until button - only shown when enabled and upgrade is allowed */} + {quality.enabled && onUpgradeUntilClick && ( + + + + )} + + {/* Selected indicator - shows all three states */} +
+ {quality.enabled && ( + + )} + {willBeSelected && !quality.enabled && ( +
+ )} +
+
+
+
+ ); +}; + +export default QualityItemGroup; diff --git a/frontend/src/components/profile/quality/QualityItemSingle.jsx b/frontend/src/components/profile/quality/QualityItemSingle.jsx new file mode 100644 index 0000000..34d2b2f --- /dev/null +++ b/frontend/src/components/profile/quality/QualityItemSingle.jsx @@ -0,0 +1,147 @@ +import React from 'react'; +import {Check, Info, ArrowUp} from 'lucide-react'; +import Tooltip from '@ui/Tooltip'; +import RadarrLogo from '@logo/Radarr.svg'; +import SonarrLogo from '@logo/Sonarr.svg'; + +const QualityItemSingle = ({ + quality, + isDragging, + listeners, + attributes, + style, + onMouseEnter, + onMouseLeave, + willBeSelected, + isUpgradeUntil, + onUpgradeUntilClick +}) => { + // Create tooltip content with just icons and text + const AppTooltipContent = () => ( +
+ {quality.radarr && ( +
+ Radarr + Radarr +
+ )} + {quality.sonarr && ( +
+ Sonarr + Sonarr +
+ )} +
+ ); + + const handleUpgradeClick = e => { + e.stopPropagation(); + onUpgradeUntilClick?.(quality); + }; + + return ( +
onMouseEnter?.(quality.id)} + onMouseLeave={onMouseLeave}> + {/* Content Row */} +
+ {/* Left Section with Title and Info */} +
+ {/* Title Row */} +
+

+ {quality.name} +

+
+ + {/* Description Row */} + {quality.description && ( +

+ {quality.description} +

+ )} +
+ + {/* Right Section - Info Icon and Selection indicators */} +
+ {/* Info Badge with Tooltip */} + {(quality.radarr || quality.sonarr) && ( + }> +
+ +
+
+ )} + + {/* Upgrade Until button - only shown when enabled and upgrade is allowed */} + {quality.enabled && onUpgradeUntilClick && ( + + + + )} + + {/* Selection indicator */} +
+ {quality.enabled && ( + + )} + {willBeSelected && !quality.enabled && ( +
+ )} +
+
+
+
+ ); +}; + +export default QualityItemSingle; diff --git a/frontend/src/components/regex/RegexPage.jsx b/frontend/src/components/regex/RegexPage.jsx index 080cf52..436431d 100644 --- a/frontend/src/components/regex/RegexPage.jsx +++ b/frontend/src/components/regex/RegexPage.jsx @@ -78,7 +78,7 @@ function RegexPage() { } = useMassSelection(); // Keyboard shortcuts - useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true}); + useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true}); // Mouse position tracking for shift-select useEffect(() => { diff --git a/frontend/src/components/ui/Modal.jsx b/frontend/src/components/ui/Modal.jsx index c8885e4..f541275 100644 --- a/frontend/src/components/ui/Modal.jsx +++ b/frontend/src/components/ui/Modal.jsx @@ -18,12 +18,27 @@ const Modal = ({ }) => { const modalRef = useRef(); const [activeTab, setActiveTab] = useState(tabs?.[0]?.id); + const [isClosing, setIsClosing] = useState(false); + + useEffect(() => { + if (isOpen) { + setIsClosing(false); + } + }, [isOpen]); + + const handleClose = () => { + setIsClosing(true); + setTimeout(() => { + onClose(); + setIsClosing(false); + }, 200); // Match animation duration + }; useEffect(() => { if (isOpen && !disableCloseOnEscape) { const handleEscape = event => { if (event.key === 'Escape') { - onClose(); + handleClose(); } }; document.addEventListener('keydown', handleEscape); @@ -31,7 +46,7 @@ const Modal = ({ document.removeEventListener('keydown', handleEscape); }; } - }, [isOpen, onClose, disableCloseOnEscape]); + }, [isOpen, disableCloseOnEscape]); const handleClickOutside = e => { // Get the current selection @@ -42,9 +57,10 @@ const Modal = ({ modalRef.current && !modalRef.current.contains(e.target) && !disableCloseOnOutsideClick && - !hasSelection // Don't close if there's text selected + !hasSelection && // Don't close if there's text selected + !isClosing ) { - onClose(); + handleClose(); } }; @@ -87,13 +103,13 @@ const Modal = ({ return (
e.stopPropagation()}> + {/* Header */}

@@ -128,7 +149,7 @@ const Modal = ({

)}