mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
@@ -106,7 +106,7 @@ function FormatPage() {
|
||||
lastSelectedIndex
|
||||
} = useMassSelection();
|
||||
|
||||
useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
|
||||
useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
|
||||
|
||||
const {
|
||||
name,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
133
frontend/src/components/profile/language/AdvancedView.jsx
Normal file
133
frontend/src/components/profile/language/AdvancedView.jsx
Normal 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;
|
||||
447
frontend/src/components/profile/language/ProfileLangaugesTab.jsx
Normal file
447
frontend/src/components/profile/language/ProfileLangaugesTab.jsx
Normal 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;
|
||||
51
frontend/src/components/profile/language/SimpleView.jsx
Normal file
51
frontend/src/components/profile/language/SimpleView.jsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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={() => {
|
||||
32
frontend/src/components/profile/quality/QualityItem.jsx
Normal file
32
frontend/src/components/profile/quality/QualityItem.jsx
Normal 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;
|
||||
174
frontend/src/components/profile/quality/QualityItemGroup.jsx
Normal file
174
frontend/src/components/profile/quality/QualityItemGroup.jsx
Normal 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;
|
||||
147
frontend/src/components/profile/quality/QualityItemSingle.jsx
Normal file
147
frontend/src/components/profile/quality/QualityItemSingle.jsx
Normal 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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user