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
This commit is contained in:
Samuel Chau
2025-03-08 01:51:31 +10:30
committed by GitHub
parent 923ab1ebd8
commit f9989ee0cd
20 changed files with 1572 additions and 554 deletions

View File

@@ -106,7 +106,7 @@ function FormatPage() {
lastSelectedIndex
} = useMassSelection();
useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
const {
name,

View File

@@ -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;
}
}

View File

@@ -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 (
<div className='h-full flex flex-col'>
<div className='bg-white dark:bg-gray-800 pb-4'>
<div className='grid grid-cols-[auto_1fr] gap-4 items-center'>
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100 leading-tight'>
Language Requirements
</h2>
<p className='text-xs text-gray-600 dark:text-gray-400 leading-relaxed'>
Configure language requirements for media content.
</p>
</div>
</div>
<div className='mt-4 space-y-4'>
<div className='flex gap-2 p-3 text-xs bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
<InfoIcon className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
<p className='text-blue-700 dark:text-blue-300'>
Configure how languages should be handled for your media
content. Select "Any" to accept all languages, or
configure specific language requirements.
</p>
</div>
<div className='p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'>
<div className='space-y-3'>
<div className='space-y-1'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
Language Settings
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Configure language requirements for releases
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
<select
value={currentBehavior}
onChange={e =>
handleLanguageChange(
'behavior',
e.target.value
)
}
className='block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'>
<option value='any'>Any</option>
<option value='must'>Must Include</option>
<option value='only'>Must Only Be</option>
<option value='mustnot'>
Must Not Include
</option>
</select>
{currentBehavior !== 'any' && (
<select
value={currentLanguage || 'english'}
onChange={e =>
handleLanguageChange(
'language',
e.target.value
)
}
className='block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'>
{LANGUAGES.map(language => (
<option
key={language.id}
value={language.id}>
{language.name}
</option>
))}
</select>
)}
</div>
{currentBehavior === 'only' && (
<div className='flex items-center gap-1.5 mt-2'>
<AlertTriangle className='h-3 w-3 text-amber-500' />
<p className='text-[10px] text-amber-600 dark:text-amber-400'>
"Must Only Be" will reject releases with
multiple languages
</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
ProfileLanguagesTab.propTypes = {
language: PropTypes.string.isRequired,
onLanguageChange: PropTypes.func.isRequired
};
export default ProfileLanguagesTab;

View File

@@ -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 && (
<button
onClick={handleDelete}
className={`bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors ${
isDeleting ? 'bg-red-600' : ''
}`}>
{isDeleting ? 'Confirm Delete' : 'Delete'}
className='inline-flex items-center gap-2 px-4 py-2 rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors'>
{isDeleting ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Trash2 className="w-4 h-4 text-red-500" />
)}
<span>Delete</span>
</button>
)}
<button
onClick={handleSave}
className='bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors'>
Save
className='inline-flex items-center gap-2 px-4 py-2 rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors'>
{isSaving ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Save className="w-4 h-4 text-blue-500" />
)}
<span>Save</span>
</button>
</div>
}>

View File

@@ -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);

View File

@@ -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 (
<div
className={`
relative p-2.5 rounded-lg select-none cursor-grab active:cursor-grabbing
border ${
quality.enabled
? 'border-blue-200 dark:border-blue-800'
: 'border-gray-200 dark:border-gray-700'
}
transition-colors duration-200
${
quality.enabled
? 'bg-blue-50 dark:bg-blue-900/20'
: 'bg-white dark:bg-gray-800'
}
hover:border-blue-500 dark:hover:border-blue-400
${isDragging ? 'opacity-50' : ''}
group
`}
style={style}
{...attributes}
{...listeners}>
{/* Header Section */}
<div className='flex items-start justify-between gap-3'>
{/* Title and Description */}
<div className='flex-1 min-w-0'>
<h3 className='text-xs font-medium text-gray-900 dark:text-gray-100'>
{quality.name}
</h3>
{isGroup && quality.description && (
<p className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
{quality.description}
</p>
)}
</div>
{/* Actions and Icons */}
<div className='flex items-center gap-2'>
{/* App Icons */}
<div className='flex items-center gap-1.5'>
{quality.radarr && (
<img
src={RadarrLogo}
className='w-3.5 h-3.5'
alt='Radarr'
/>
)}
{quality.sonarr && (
<img
src={SonarrLogo}
className='w-3.5 h-3.5'
alt='Sonarr'
/>
)}
</div>
{/* Edit/Delete Actions */}
{isGroup && (
<div className='flex items-center gap-1 ml-1'>
{onEdit && (
<button
onClick={e => {
e.stopPropagation();
onEdit(quality);
}}
className='hidden group-hover:flex items-center justify-center h-6 w-6 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 transition-all'>
<Pencil className='w-3 h-3' />
</button>
)}
{onDelete && (
<button
onClick={e => {
e.stopPropagation();
onDelete(quality);
}}
className='hidden group-hover:flex items-center justify-center h-6 w-6 rounded-md text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 transition-all'>
<Trash2 className='w-3 h-3' />
</button>
)}
</div>
)}
</div>
</div>
{/* Quality Tags Section */}
{isGroup && (
<div className='mt-2 flex flex-wrap items-center gap-1'>
{quality.qualities.map(q => (
<span
key={q.id}
className='inline-flex px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'>
{q.name}
</span>
))}
</div>
)}
{/* Non-group Description */}
{!isGroup && quality.description && (
<p className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
{quality.description}
</p>
)}
</div>
);
};
export default QualityItem;

View File

@@ -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 */}
<select
value={currentBehavior}
onChange={e =>
handleLanguageChange('behavior', e.target.value)
}
className='flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'>
<option value='any'>Any</option>
<option value='must'>Must Include</option>
<option value='only'>Must Only Be</option>
<option value='mustnot'>Must Not Include</option>
</select>
{/* Language Dropdown */}
{currentBehavior !== 'any' && (
<select
value={currentLanguage || 'english'}
onChange={e =>
handleLanguageChange(
'language',
e.target.value
)
}
className='flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'>
{LANGUAGES.map(language => (
<option
key={language.id}
value={language.id}>
{language.name}
</option>
))}
</select>
)}
{/* Help text below the controls is in the parent component */}
<div>
{currentBehavior === 'any' && (
<div className='flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>Accept content in any language.</p>
</div>
)}
{currentBehavior === 'must' && (
<div className='flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
Content must include{' '}
{currentLanguage
? LANGUAGES.find(
l => l.id === currentLanguage
)?.name || currentLanguage
: 'English'}
, but can include other languages as well.
</p>
</div>
)}
{currentBehavior === 'only' && (
<div className='flex items-center gap-1.5 text-xs'>
<AlertTriangle className='h-3.5 w-3.5 text-amber-500 flex-shrink-0' />
<p className='text-amber-600 dark:text-amber-400'>
Content must ONLY be in{' '}
{currentLanguage
? LANGUAGES.find(
l => l.id === currentLanguage
)?.name || currentLanguage
: 'English'}
. This will reject releases containing
multiple languages.
</p>
</div>
)}
{currentBehavior === 'mustnot' && (
<div className='flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
Content must NOT include{' '}
{currentLanguage
? LANGUAGES.find(
l => l.id === currentLanguage
)?.name || currentLanguage
: 'English'}
. Any other language is acceptable.
</p>
</div>
)}
</div>
</div>
</div>
);
};
AdvancedView.propTypes = {
language: PropTypes.string.isRequired,
onLanguageChange: PropTypes.func.isRequired
};
export default AdvancedView;

View File

@@ -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 (
<div className='w-full space-y-6'>
<div className='space-y-4'>
{/* Simple header with title and description */}
<div className='mb-4'>
<h2 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Language Settings
</h2>
<p className='text-xs text-gray-500 dark:text-gray-400'>
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.'}
</p>
</div>
{/* Controls row - display mode dropdown with other controls */}
<div className='flex gap-3'>
{/* Mode Selector (always visible) */}
<div className='w-[144px] relative'>
<button
onClick={() => setIsDropdownOpen(prev => !prev)}
className='inline-flex items-center justify-between w-full px-3 py-2 rounded-md border border-gray-600 bg-gray-800 hover:border-gray-500 transition-colors text-gray-100'
aria-expanded={isDropdownOpen}
aria-haspopup='true'>
<span className='flex items-center gap-2'>
{isAdvancedView ? (
<>
<Settings
size={16}
className='text-gray-400'
/>
<span className='text-sm font-medium'>
Advanced
</span>
</>
) : (
<>
<List
size={16}
className='text-gray-400'
/>
<span className='text-sm font-medium'>
Simple
</span>
</>
)}
</span>
<ChevronDown
size={16}
className={`text-gray-400 transition-transform ${
isDropdownOpen ? 'transform rotate-180' : ''
}`}
/>
</button>
{isDropdownOpen && (
<>
<div
className='fixed inset-0'
onClick={() => setIsDropdownOpen(false)}
/>
<div className='absolute left-0 mt-1 w-full rounded-md shadow-lg bg-gray-800 border border-gray-600 z-10'>
<div>
<button
onClick={() => {
setIsDropdownOpen(false);
// When switching from advanced to simple mode, convert to simple format
if (
isAdvancedView &&
language
) {
if (language === 'any') {
// Keep 'any' as is
} else if (
language.includes('_')
) {
// Extract the language part from format like "must_english"
const langPart =
language.split(
'_'
)[1];
// If no language part or if it's not a valid simple language, use 'any'
if (!langPart) {
onLanguageChange(
'any'
);
} else {
onLanguageChange(
langPart
);
}
}
}
}}
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>Simple</span>
</div>
</button>
<button
onClick={() => {
// When switching from simple to advanced mode, convert basic language
// to proper advanced format if necessary
if (
!isAdvancedView &&
language &&
!language.includes('_')
) {
// Default to "must include original" if language is "any"
if (language === 'any') {
onLanguageChange(
'must_original'
);
} else {
// For other languages, use must_[language]
onLanguageChange(
`must_${language}`
);
}
}
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>
{/* SIMPLE MODE: just one language dropdown */}
{!isAdvancedView && (
<div className='flex-1'>
<SearchDropdown
value={language}
onChange={e => onLanguageChange(e.target.value)}
options={languageOptions}
placeholder='Select language...'
dropdownWidth='100%'
className='bg-gray-800 dark:border-gray-600 text-gray-100'
/>
</div>
)}
{/* ADVANCED MODE: two dropdowns (type and language) */}
{isAdvancedView && (
<>
{/* Type Dropdown - Custom styled */}
<div className='w-[144px] relative'>
<select
value={currentBehavior}
onChange={e =>
handleAdvancedLanguageChange(
'behavior',
e.target.value
)
}
className='w-full appearance-none rounded-md border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 pr-8'>
<option value='any'>Any</option>
<option value='must'>Must Include</option>
<option value='only'>Must Only Be</option>
<option value='mustnot'>
Must Not Include
</option>
</select>
<div className='pointer-events-none absolute inset-y-0 right-0 flex items-center px-2'>
<ChevronDown
size={16}
className='text-gray-400'
/>
</div>
</div>
{/* Language Dropdown */}
{currentBehavior !== 'any' && (
<div className='flex-1'>
<SearchDropdown
value={currentLanguage || 'english'}
onChange={e =>
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'
/>
</div>
)}
</>
)}
</div>
{/* Help text section - display the appropriate help text based on view mode and selection */}
<div className='border border-gray-600 rounded-md p-4 bg-gray-800'>
{/* Simple mode help */}
{!isAdvancedView && (
<div className='flex items-center gap-1.5 text-xs text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
{language === 'any' ? (
<>
Attempts to set{' '}
<span className='font-medium text-gray-200'>
Any Language
</span>{' '}
in Radarr profiles. For Sonarr, language
will default to "Original" since it
lacks native language settings.
</>
) : language === 'original' ? (
<>
Attempts to set{' '}
<span className='font-medium text-gray-200'>
Original
</span>{' '}
language in Radarr profiles. For Sonarr,
language will default to "Original"
since it lacks native language settings.
</>
) : (
<>
Attempts to set{' '}
<span className='font-medium text-gray-200'>
{LANGUAGES.find(
l => l.id === language
)?.name || language}
</span>{' '}
language in Radarr profiles. For Sonarr,
language will default to "Original"
since it lacks native language settings.
</>
)}
</p>
</div>
)}
{/* Advanced mode help based on selections */}
{isAdvancedView && (
<>
{language === 'any' && (
<div className='flex items-center gap-1.5 text-xs text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>Accept content in any language.</p>
</div>
)}
{language && language.startsWith('must_') && (
<div className='flex items-center gap-1.5 text-xs text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
Content must include{' '}
<span className='font-medium text-gray-200'>
{language.split('_')[1]
? LANGUAGES.find(
l =>
l.id ===
language.split('_')[1]
)?.name ||
language.split('_')[1]
: 'English'}
</span>
, but can include other languages as
well.
</p>
</div>
)}
{language && language.startsWith('only_') && (
<div className='flex items-center gap-1.5 text-xs'>
<AlertTriangle className='h-3.5 w-3.5 text-amber-500 flex-shrink-0' />
<p className='text-amber-400'>
Content must ONLY be in{' '}
<span className='font-medium text-amber-300'>
{language.split('_')[1]
? LANGUAGES.find(
l =>
l.id ===
language.split('_')[1]
)?.name ||
language.split('_')[1]
: 'English'}
</span>
. This will reject releases containing
multiple languages.
</p>
</div>
)}
{language && language.startsWith('mustnot_') && (
<div className='flex items-center gap-1.5 text-xs text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
Content must NOT include{' '}
<span className='font-medium text-gray-200'>
{language.split('_')[1]
? LANGUAGES.find(
l =>
l.id ===
language.split('_')[1]
)?.name ||
language.split('_')[1]
: 'English'}
</span>
. Any other language is acceptable.
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};
ProfileLanguagesTab.propTypes = {
language: PropTypes.string.isRequired,
onLanguageChange: PropTypes.func.isRequired
};
export default ProfileLanguagesTab;

View File

@@ -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 (
<SearchDropdown
value={language}
onChange={e => onLanguageChange(e.target.value)}
options={languageOptions}
placeholder="Select language..."
dropdownWidth="100%"
/>
);
};
SimpleView.propTypes = {
language: PropTypes.string.isRequired,
onLanguageChange: PropTypes.func.isRequired
};
export default SimpleView;

View File

@@ -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';

View File

@@ -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 (
<div className='bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 mb-4'>
<div className='grid grid-cols-[auto_1fr_auto] gap-4 items-center'>
<h3 className='text-base font-semibold text-gray-900 dark:text-gray-100'>
Upgrade Until
</h3>
<p className='text-xs text-gray-600 dark:text-gray-400'>
Downloads will be upgraded until this quality is reached.
Lower qualities will be upgraded, while higher qualities
will be left unchanged.
</p>
<select
className='w-48 p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm'
value={selectedUpgradeQuality?.id || ''}
onChange={e => {
const quality = enabledQualities.find(
q => q.id === parseInt(e.target.value)
);
onUpgradeQualityChange(quality);
}}>
{enabledQualities.map(quality => (
<option key={quality.id} value={quality.id}>
{quality.name}
</option>
))}
</select>
</div>
</div>
);
};
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
}
/>
</div>
);
@@ -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 (
<div className='h-full flex flex-col'>
<div className='bg-white dark:bg-gray-800 pb-4'>
<div className='grid grid-cols-[auto_1fr_auto] gap-4 items-center'>
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100 leading-tight'>
<div className='mb-4 flex justify-between items-center'>
<div className='flex items-center'>
<h2 className='text-sm font-medium text-gray-700 dark:text-gray-300 mr-4'>
Quality Rankings
</h2>
<p className='text-xs text-gray-600 dark:text-gray-400 leading-relaxed'>
Qualities higher in the list are more preferred even if
not checked. Qualities within the same group are equal.
Only checked qualities are wanted.
</p>
<button
onClick={() => setIsCreateGroupModalOpen(true)}
className='h-10 px-6 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 flex items-center gap-2'>
<InfoIcon className='w-4 h-4' />
Create Group
</button>
</div>
</div>
{upgradesAllowed && (
<UpgradeSection
enabledQualities={sortedQualities.filter(q => q.enabled)}
selectedUpgradeQuality={selectedUpgradeQuality}
onUpgradeQualityChange={onSelectedUpgradeQualityChange}
/>
)}
<div className='flex-1 overflow-auto'>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
modifiers={[
restrictToVerticalAxis,
restrictToParentElement
]}>
<div className=''>
<div className='space-y-2'>
<SortableContext
items={sortedQualities.map(q => q.id)}
strategy={verticalListSortingStrategy}>
{sortedQualities.map(quality => (
<SortableItem
key={quality.id}
quality={quality}
onToggle={handleQualityToggle}
onDelete={
'qualities' in quality
? handleDeleteClick
: undefined
}
onEdit={
'qualities' in quality
? handleEditClick
: undefined
}
/>
))}
</SortableContext>
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 flex items-center space-x-2'>
<span className='inline-flex items-center'>
<svg
className='h-3 w-3 mr-1'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M7 16V4m0 0L3 8m4-4l4 4'
/>
</svg>
Drag to reorder
</span>
<span></span>
<span className='inline-flex items-center'>
<svg
className='h-3 w-3 mr-1'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M5 13l4 4L19 7'
/>
</svg>
Click to toggle
</span>
{upgradesAllowed && (
<>
<span></span>
<span className='inline-flex items-center'>
<svg
className='h-3 w-3 mr-1'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth='2'>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M5 10l7-7m0 0l7 7m-7-7v18'
/>
</svg>
Set upgrade target
</span>
</>
)}
</div>
</DndContext>
</div>
<button
onClick={() => setIsCreateGroupModalOpen(true)}
className='h-8 flex items-center space-x-1 text-sm font-medium bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-md'>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-4 w-4'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'>
<path d='M12 5v14M5 12h14' />
</svg>
<span>Create Group</span>
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}>
<div>
<div className='space-y-2 mb-4'>
<SortableContext
items={sortedQualities.map(q => q.id)}
strategy={verticalListSortingStrategy}>
{sortedQualities.map(quality => (
<SortableItem
key={quality.id}
quality={quality}
onToggle={handleQualityToggle}
onDelete={
'qualities' in quality
? handleDeleteClick
: undefined
}
onEdit={
'qualities' in quality
? handleEditClick
: undefined
}
isUpgradeUntil={isUpgradeUntilQuality(
quality
)}
onUpgradeUntilClick={
upgradesAllowed
? handleUpgradeUntilClick
: undefined
}
/>
))}
</SortableContext>
</div>
</div>
</DndContext>
<CreateGroupModal
isOpen={isCreateGroupModalOpen}
onClose={() => {

View File

@@ -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 <QualityItemGroup {...enhancedProps} />;
} else {
return <QualityItemSingle {...enhancedProps} />;
}
};
export default QualityItem;

View File

@@ -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 (
<div
className={`
relative p-2.5 rounded-lg select-none cursor-grab active:cursor-grabbing
border border-gray-200 dark:border-gray-700
transition-colors duration-200
bg-white dark:bg-gray-800
hover:border-gray-300 dark:hover:border-gray-600
${isDragging ? 'opacity-50' : ''}
group
`}
style={style}
{...attributes}
{...listeners}
onMouseEnter={() => onMouseEnter?.(quality.id)}
onMouseLeave={onMouseLeave}>
{/* Header Row */}
<div className='flex items-center justify-between'>
{/* Title and Description */}
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3 flex-wrap'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
{quality.name}
</h3>
{/* Quality tags inline with name */}
<div className='flex flex-wrap items-center gap-1.5'>
{quality.qualities.map(q => (
<span
key={q.id}
className='inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'>
{q.name}
</span>
))}
</div>
</div>
{quality.description && (
<p className='mt-1.5 text-xs text-gray-600 dark:text-gray-400'>
{quality.description}
</p>
)}
</div>
{/* Right Section */}
<div className='flex items-center gap-2'>
{/* App Icons */}
<div className='flex items-center gap-1.5'>
{quality.radarr && (
<div className='flex items-center bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-200 rounded px-1.5 py-0.5'>
<img
src={RadarrLogo}
className='w-3 h-3 mr-1'
alt='Radarr'
/>
<span className='text-[10px] font-medium'>
Radarr
</span>
</div>
)}
{quality.sonarr && (
<div className='flex items-center bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200 rounded px-1.5 py-0.5'>
<img
src={SonarrLogo}
className='w-3 h-3 mr-1'
alt='Sonarr'
/>
<span className='text-[10px] font-medium'>
Sonarr
</span>
</div>
)}
</div>
{/* Edit/Delete Actions */}
<div className='flex items-center gap-2'>
{onEdit && (
<button
onClick={e => {
e.stopPropagation();
onEdit(quality);
}}
className='flex items-center justify-center h-6 w-6 rounded text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600 transition-colors'>
<Pencil className='w-3 h-3' />
</button>
)}
{onDelete && (
<button
onClick={e => {
e.stopPropagation();
onDelete(quality);
}}
className='flex items-center justify-center h-6 w-6 rounded text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 border border-red-200 hover:border-red-300 dark:border-red-800/40 dark:hover:border-red-700/40 transition-colors'>
<Trash2 className='w-3 h-3' />
</button>
)}
</div>
{/* Upgrade Until button - only shown when enabled and upgrade is allowed */}
{quality.enabled && onUpgradeUntilClick && (
<Tooltip
content={
isUpgradeUntil
? 'This quality is set as upgrade until'
: 'Set as upgrade until quality'
}>
<button
onClick={handleUpgradeClick}
className={`
w-5 h-5 rounded-full flex items-center justify-center
${
isUpgradeUntil
? 'bg-green-500 dark:bg-green-600 text-white'
: 'border border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500 hover:border-green-400 dark:hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/10'
}
`}>
<ArrowUp size={12} />
</button>
</Tooltip>
)}
{/* Selected indicator - shows all three states */}
<div
className={`
w-5 h-5 rounded-full flex items-center justify-center
${
quality.enabled
? 'bg-blue-500 dark:bg-blue-600'
: 'border border-gray-300 dark:border-gray-600'
}
${
!quality.enabled && willBeSelected
? 'bg-blue-100 dark:bg-blue-900/30'
: ''
}
`}>
{quality.enabled && (
<Check size={14} className='text-white' />
)}
{willBeSelected && !quality.enabled && (
<div className='w-2 h-2 rounded-full bg-blue-400' />
)}
</div>
</div>
</div>
</div>
);
};
export default QualityItemGroup;

View File

@@ -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 = () => (
<div className='flex items-center gap-3'>
{quality.radarr && (
<div className='flex items-center text-white'>
<img
src={RadarrLogo}
className='w-3.5 h-3.5 mr-1.5'
alt='Radarr'
/>
<span className='text-xs'>Radarr</span>
</div>
)}
{quality.sonarr && (
<div className='flex items-center text-white'>
<img
src={SonarrLogo}
className='w-3.5 h-3.5 mr-1.5'
alt='Sonarr'
/>
<span className='text-xs'>Sonarr</span>
</div>
)}
</div>
);
const handleUpgradeClick = e => {
e.stopPropagation();
onUpgradeUntilClick?.(quality);
};
return (
<div
className={`
relative p-2.5 rounded-lg select-none cursor-grab active:cursor-grabbing
border border-gray-200 dark:border-gray-700
transition-colors duration-200
bg-white dark:bg-gray-800
hover:border-gray-300 dark:hover:border-gray-600
${isDragging ? 'opacity-50' : ''}
group
`}
style={style}
{...attributes}
{...listeners}
onMouseEnter={() => onMouseEnter?.(quality.id)}
onMouseLeave={onMouseLeave}>
{/* Content Row */}
<div className='flex items-center justify-between'>
{/* Left Section with Title and Info */}
<div className='flex-1 min-w-0'>
{/* Title Row */}
<div className='flex items-center flex-wrap'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
{quality.name}
</h3>
</div>
{/* Description Row */}
{quality.description && (
<p className='mt-1.5 text-xs text-gray-600 dark:text-gray-400'>
{quality.description}
</p>
)}
</div>
{/* Right Section - Info Icon and Selection indicators */}
<div className='flex items-center gap-2'>
{/* Info Badge with Tooltip */}
{(quality.radarr || quality.sonarr) && (
<Tooltip content={<AppTooltipContent />}>
<div className='flex items-center text-blue-500 dark:text-blue-400 cursor-help'>
<Info className='w-3.5 h-3.5' />
</div>
</Tooltip>
)}
{/* Upgrade Until button - only shown when enabled and upgrade is allowed */}
{quality.enabled && onUpgradeUntilClick && (
<Tooltip
content={
isUpgradeUntil
? 'This quality is set as upgrade until'
: 'Set as upgrade until quality'
}>
<button
onClick={handleUpgradeClick}
className={`
w-5 h-5 rounded-full flex items-center justify-center
${
isUpgradeUntil
? 'bg-green-500 dark:bg-green-600 text-white'
: 'border border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500 hover:border-green-400 dark:hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/10'
}
`}>
<ArrowUp size={12} />
</button>
</Tooltip>
)}
{/* Selection indicator */}
<div
className={`
w-5 h-5 rounded-full flex items-center justify-center
${
quality.enabled
? 'bg-blue-500 dark:bg-blue-600'
: 'border border-gray-300 dark:border-gray-600'
}
${
!quality.enabled && willBeSelected
? 'bg-blue-100 dark:bg-blue-900/30'
: ''
}
`}>
{quality.enabled && (
<Check size={14} className='text-white' />
)}
{willBeSelected && !quality.enabled && (
<div className='w-2 h-2 rounded-full bg-blue-400' />
)}
</div>
</div>
</div>
</div>
);
};
export default QualityItemSingle;

View File

@@ -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(() => {

View File

@@ -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 (
<div
className={`fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center transition-opacity duration-300 ease-out scrollable ${
className={`fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center transition-opacity duration-200 scrollable ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
style={{zIndex: 1000 + level * 10}}
onClick={handleClickOutside}>
<div
className={`fixed inset-0 bg-black transition-opacity duration-300 ease-out ${
className={`fixed inset-0 bg-black transition-opacity duration-200 ${
isOpen ? 'bg-opacity-50' : 'bg-opacity-0'
}`}
style={{zIndex: 1000 + level * 10}}
@@ -105,14 +121,19 @@ const Modal = ({
min-w-[320px] min-h-[200px] ${widthClasses[width]} ${
heightClasses[height]
}
transition-all duration-300 ease-out transform
${isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
${isClosing
? 'animate-slide-up'
: isOpen
? 'animate-slide-down'
: 'opacity-0'
}
flex flex-col overflow-hidden`}
style={{
zIndex: 1001 + level * 10,
maxHeight: maxHeight || '80vh'
maxHeight: maxHeight || '80vh',
}}
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className='flex items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-200'>
@@ -128,7 +149,7 @@ const Modal = ({
</div>
)}
<button
onClick={onClose}
onClick={handleClose}
className='ml-auto text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors'>
<svg
className='w-6 h-6'

View File

@@ -73,8 +73,28 @@ const SearchDropdown = ({
// Apply final sorting to the filtered results
const sortedOptions = useCallback(() => {
return sortData(filteredOptions);
}, [sortData, filteredOptions]);
// Separate special and regular items
const specialItems = filteredOptions.filter(item => item.isSpecial);
const regularItems = filteredOptions.filter(item => !item.isSpecial);
// Sort each group separately
const sortedSpecialItems = [...specialItems].sort((a, b) =>
sortConfig.direction === 'asc'
? a[sortConfig.field].localeCompare(b[sortConfig.field])
: b[sortConfig.field].localeCompare(a[sortConfig.field])
);
const sortedRegularItems = [...regularItems].sort((a, b) =>
sortConfig.direction === 'asc'
? a[sortConfig.field].localeCompare(b[sortConfig.field])
: b[sortConfig.field].localeCompare(a[sortConfig.field])
);
// We're adding a divider dynamically in the render based on the transition from special to regular items
// Combine the two sorted arrays
return [...sortedSpecialItems, ...sortedRegularItems];
}, [filteredOptions, sortConfig]);
// Handle selection
const handleSelect = useCallback(
@@ -156,17 +176,27 @@ const SearchDropdown = ({
<div className='flex-1 p-2 pt-3 overflow-auto'>
{sortedOptions().length > 0 ? (
<div className='flex flex-col'>
{sortedOptions().map(option => (
<div
key={option[valueKey]}
onClick={() => 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'
}`}>
{sortedOptions().map((option, index, array) => (
<React.Fragment key={option[valueKey]}>
{/* Add a divider after the last special item */}
{index > 0 &&
!option.isSpecial &&
array[index-1].isSpecial && (
<div className="h-px bg-gray-600/80 mx-2 my-2"></div>
)}
<div
onClick={() => handleSelect(option)}
className={`px-2.5 py-1.5 text-xs cursor-pointer rounded
${
selectedOption?.[valueKey] ===
option[valueKey]
? 'bg-blue-600 text-white'
: option.isSpecial
? 'text-blue-300 hover:bg-gray-700/70 font-medium'
: 'text-gray-100 hover:bg-gray-700'
}
`}>
<div className='flex items-center'>
<div className='flex-grow truncate'>
{option[labelKey]}
@@ -177,6 +207,7 @@ const SearchDropdown = ({
)}
</div>
</div>
</React.Fragment>
))}
</div>
) : (

View File

@@ -20,11 +20,21 @@ module.exports = {
'slide-down': {
'0%': {
opacity: '0',
transform: 'translate3d(0, -100%, 0)'
transform: 'translateY(-80px)'
},
'100%': {
opacity: '1',
transform: 'translate3d(0, 0, 0)'
transform: 'translateY(0)'
}
},
'slide-up': {
'0%': {
opacity: '1',
transform: 'translateY(0)'
},
'100%': {
opacity: '0',
transform: 'translateY(80px)'
}
},
wiggle: {
@@ -40,15 +50,42 @@ module.exports = {
'eye-blink': {
'0%, 100%': {transform: 'scale(1)', opacity: 1},
'50%': {transform: 'scale(1.2)', opacity: 0.8}
},
'modal-in': {
'0%': {
opacity: '0',
transform: 'translateY(20px) scale(0.97)'
},
'60%': {
opacity: '1',
transform: 'translateY(-3px) scale(1.01)'
},
'100%': {
opacity: '1',
transform: 'translateY(0) scale(1)'
}
},
'modal-out': {
'0%': {
opacity: '1',
transform: 'translateY(0) scale(1)'
},
'100%': {
opacity: '0',
transform: 'translateY(20px) scale(0.97)'
}
}
},
animation: {
'modal-open': 'modal-open 0.3s ease-out forwards',
'fade-in': 'fade-in 0.5s ease-in-out forwards',
'slide-down': 'slide-down 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
'slide-down': 'slide-down 0.2s ease-out',
'slide-up': 'slide-up 0.2s ease-in forwards',
wiggle: 'wiggle 0.3s ease-in-out',
'check-bounce': 'check-bounce 0.3s ease-in-out',
'eye-blink': 'eye-blink 0.5s ease-in-out'
'eye-blink': 'eye-blink 0.5s ease-in-out',
'modal-in': 'modal-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards',
'modal-out': 'modal-out 0.15s ease-in-out forwards'
},
colors: {
'dark-bg': '#1a1c23',