fix/improve: arr config modal (#146)

- Fix: remove / update dangling references on data delete / rename in arr config. 
- Fix: Show dangling references in data selector so they can be removed after git operation (cascade operations not possible).
- Fix: improve onclick behaviour so that button clicks in arr modal don't incorrectly trigger save / update.
- Refactor: Turn data selector into a non modal component - now exists on the same pane as the arr modal.
- Feat: Add search / sort functionality to data selector. Sorted A-Z by default now.
This commit is contained in:
santiagosayshey
2025-02-23 04:12:10 +10:30
committed by GitHub
parent b9289510eb
commit f91fea113f
8 changed files with 525 additions and 344 deletions

View File

@@ -7,6 +7,7 @@ from typing import Dict, List, Any, Tuple, Union
import git
import regex
import logging
from ..db.queries.arr import update_arr_config_on_rename, update_arr_config_on_delete
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@@ -165,8 +166,20 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
# Update references before performing the rename
try:
# Update regular references
updated_files = update_references(category, old_name, new_name)
logger.info(f"Updated references in: {updated_files}")
# Update arr configs if this is a format or profile
if category in ['custom_format', 'profile']:
arr_category = 'customFormats' if category == 'custom_format' else 'profiles'
updated_configs = update_arr_config_on_rename(
arr_category, old_name, new_name)
if updated_configs:
logger.info(
f"Updated arr configs for {category} rename: {updated_configs}"
)
except Exception as e:
logger.error(f"Failed to update references: {e}")
raise Exception(f"Failed to update references: {str(e)}")
@@ -262,9 +275,9 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]:
format_data = load_yaml_file(format_path)
# Check each condition in the format
for condition in format_data.get('conditions', []):
if (condition['type'] in [
if condition['type'] in [
'release_title', 'release_group', 'edition'
] and condition.get('pattern') == check_name):
] and condition.get('pattern') == check_name:
references.append(
f"custom format: {format_data['name']}")
except Exception as e:
@@ -299,6 +312,14 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]:
f"Error checking profile file {profile_file}: {e}")
continue
# Update arr configs for formats and profiles
if category in ['custom_format', 'profile']:
arr_category = 'customFormats' if category == 'custom_format' else 'profiles'
updated_configs = update_arr_config_on_delete(arr_category, name)
if updated_configs:
logger.info(
f"Removed {name} from arr configs: {updated_configs}")
if references:
error_msg = f"Cannot delete - item is referenced in:\n" + "\n".join(
f"- {ref}" for ref in references)

View File

@@ -1,12 +1,15 @@
# backend/app/db/__init__.py
from .connection import get_db
from .queries.settings import get_settings, get_secret_key, save_settings
from .queries.arr import get_unique_arrs
from .queries.format_renames import add_format_to_renames, remove_format_from_renames, is_format_in_renames
from .queries.arr import (get_unique_arrs, update_arr_config_on_rename,
update_arr_config_on_delete)
from .queries.format_renames import (add_format_to_renames,
remove_format_from_renames,
is_format_in_renames)
from .migrations.runner import run_migrations
__all__ = [
'get_db', 'get_settings', 'get_secret_key', 'save_settings',
'get_unique_arrs', 'run_migrations', 'add_format_to_renames',
'get_unique_arrs', 'update_arr_config_on_rename',
'update_arr_config_on_delete', 'run_migrations', 'add_format_to_renames',
'remove_format_from_renames', 'is_format_in_renames'
]

View File

@@ -1,14 +1,15 @@
# backend/app/db/queries/arr.py
from ..connection import get_db
import json
import logging
logger = logging.getLogger(__name__)
def get_unique_arrs(arr_ids):
"""
Get import_as_unique settings for a list of arr IDs.
Args:
arr_ids (list): List of arr configuration IDs
Returns:
dict: Dictionary mapping arr IDs to their import_as_unique settings and names
"""
@@ -18,12 +19,12 @@ def get_unique_arrs(arr_ids):
with get_db() as conn:
placeholders = ','.join('?' * len(arr_ids))
query = f'''
SELECT id, name, import_as_unique
FROM arr_config
WHERE id IN ({placeholders})
SELECT id, name, import_as_unique
FROM arr_config
WHERE id IN ({placeholders})
'''
results = conn.execute(query, arr_ids).fetchall()
return {
row['id']: {
'import_as_unique': bool(row['import_as_unique']),
@@ -31,3 +32,88 @@ def get_unique_arrs(arr_ids):
}
for row in results
}
def update_arr_config_on_rename(category, old_name, new_name):
"""
Update arr_config data_to_sync when a format or profile is renamed.
Args:
category (str): Either 'customFormats' or 'profiles'
old_name (str): Original name being changed
new_name (str): New name to change to
Returns:
list: IDs of arr_config rows that were updated
"""
updated_ids = []
with get_db() as conn:
# Get all configs that might reference this name
rows = conn.execute(
'SELECT id, data_to_sync FROM arr_config WHERE data_to_sync IS NOT NULL'
).fetchall()
for row in rows:
try:
data = json.loads(row['data_to_sync'])
# Check if this config has the relevant category data
if category in data:
# Update any matching names
if old_name in data[category]:
# Replace old name with new name
data[category] = [
new_name if x == old_name else x
for x in data[category]
]
# Save changes back to database
conn.execute(
'UPDATE arr_config SET data_to_sync = ? WHERE id = ?',
(json.dumps(data), row['id']))
updated_ids.append(row['id'])
except json.JSONDecodeError:
logger.error(f"Invalid JSON in arr_config id={row['id']}")
continue
if updated_ids:
conn.commit()
return updated_ids
def update_arr_config_on_delete(category, name):
"""
Update arr_config data_to_sync when a format or profile is deleted.
Args:
category (str): Either 'customFormats' or 'profiles'
name (str): Name being deleted
Returns:
list: IDs of arr_config rows that were updated
"""
updated_ids = []
with get_db() as conn:
# Get all configs that might reference this name
rows = conn.execute(
'SELECT id, data_to_sync FROM arr_config WHERE data_to_sync IS NOT NULL'
).fetchall()
for row in rows:
try:
data = json.loads(row['data_to_sync'])
# Check if this config has the relevant category data
if category in data:
# Remove any matching names
if name in data[category]:
data[category].remove(name)
# Save changes back to database
conn.execute(
'UPDATE arr_config SET data_to_sync = ? WHERE id = ?',
(json.dumps(data), row['id']))
updated_ids.append(row['id'])
except json.JSONDecodeError:
logger.error(f"Invalid JSON in arr_config id={row['id']}")
continue
if updated_ids:
conn.commit()
return updated_ids

View File

@@ -1,8 +1,10 @@
// ArrModal.jsx
import React from 'react';
import {Plus, TestTube, Loader, Save, X, Trash, Check} from 'lucide-react';
import Modal from '@ui/Modal';
import {useArrModal} from '@hooks/useArrModal';
import DataSelectorModal from './DataSelectorModal';
import DataSelector from './DataSelector';
import SyncModal from './SyncModal';
const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
@@ -44,43 +46,34 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
{value: 'schedule', label: 'Scheduled'}
];
// Ensure data_to_sync always has the required structure
const safeSelectedData = {
profiles: formData.data_to_sync?.profiles || [],
customFormats: formData.data_to_sync?.customFormats || []
};
// Handle sync method change
const handleSyncMethodChange = e => {
const newMethod = e.target.value;
handleInputChange({
target: {
id: 'sync_method',
value: newMethod
}
});
// Reset data_to_sync when switching to manual
if (newMethod === 'manual') {
handleInputChange({
target: {
id: 'data_to_sync',
value: {profiles: [], customFormats: []}
}
});
}
const handleFormSubmit = e => {
e.preventDefault();
e.stopPropagation();
handleSubmit(e);
};
const inputClasses = errorKey =>
`w-full px-3 py-2 text-sm rounded-lg border ${
errors[errorKey]
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 ${
errors[errorKey]
? 'focus:ring-red-500 focus:border-red-500'
: 'focus:ring-blue-500 focus:border-blue-500'
} placeholder-gray-400 dark:placeholder-gray-500 transition-all`;
const inputClasses = errorKey => `
w-full px-3 py-2 text-sm rounded-lg border ${
errors[errorKey]
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'
} bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 ${
errors[errorKey]
? 'focus:ring-red-500 focus:border-red-500'
: 'focus:ring-blue-500 focus:border-blue-500'
} placeholder-gray-400 dark:placeholder-gray-500 transition-all
`;
const handleSyncMethodChange = e => {
e.preventDefault();
e.stopPropagation();
handleInputChange(e);
};
return (
<Modal
@@ -119,7 +112,7 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
!formData.apiKey
}
className='flex items-center px-3 py-2 text-sm rounded-lg bg-emerald-600 hover:bg-emerald-700
disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'>
disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'>
{isTestingConnection ? (
<>
<Loader className='w-3.5 h-3.5 mr-2 animate-spin' />
@@ -142,7 +135,7 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
type='submit'
form='arrForm'
className='flex items-center px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700
text-white font-medium transition-colors'>
text-white font-medium transition-colors'>
{saveConfirm ? (
<>
<Check className='w-3.5 h-3.5 mr-2' />
@@ -162,8 +155,16 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
</button>
</div>
}>
<form id='arrForm' onSubmit={handleSubmit} className='space-y-4'>
{/* Name Field */}
<form
id='arrForm'
onSubmit={handleFormSubmit}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
}
}}
className='space-y-4'>
<div className='space-y-1.5'>
<label
htmlFor='name'
@@ -180,7 +181,6 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
/>
</div>
{/* Type Field */}
<div className='space-y-1.5'>
<label
htmlFor='type'
@@ -201,7 +201,6 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
</select>
</div>
{/* Tags Field */}
<div className='space-y-1.5'>
<label
htmlFor='tags'
@@ -216,7 +215,10 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
{tag}
<button
type='button'
onClick={() => handleRemoveTag(tag)}
onClick={e => {
e.preventDefault();
handleRemoveTag(tag);
}}
className='ml-1 hover:text-blue-900 dark:hover:text-blue-200'>
<X size={12} />
</button>
@@ -236,14 +238,13 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
type='button'
onClick={handleAddTag}
className='px-3 py-2 text-sm rounded-lg bg-blue-100 text-blue-600 hover:bg-blue-200
dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800
font-medium transition-colors'>
dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800
font-medium transition-colors'>
Add
</button>
</div>
</div>
{/* Server URL Field */}
<div className='space-y-1.5'>
<label
htmlFor='arrServer'
@@ -265,7 +266,6 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
)}
</div>
{/* API Key Field */}
<div className='space-y-1.5'>
<label
htmlFor='apiKey'
@@ -283,7 +283,6 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
/>
</div>
{/* Sync Method Field */}
<div className='space-y-1.5'>
<label
htmlFor='sync_method'
@@ -308,28 +307,23 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
{formData.sync_method === 'manual' && (
<p>
Manual sync allows you to selectively import data
when changes occur in the source instance. You'll
need to manually select and import the data you want
to sync.
when changes occur in the source instance.
</p>
)}
{formData.sync_method === 'pull' && (
<p>
On Pull automatically syncs data whenever the
database pulls in new changes. This is a "set and
forget" option - perfect for maintaining consistency
across instances without manual intervention.
database pulls in new changes.
</p>
)}
{formData.sync_method === 'schedule' && (
<p>
Scheduled sync runs at fixed intervals, ensuring
your instances stay in sync at regular times.
your instances stay in sync.
</p>
)}
</div>
{/* Import as Unique - Now always visible */}
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
<label className='flex items-center space-x-2'>
@@ -352,13 +346,11 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
</label>
<span className='text-xs text-gray-500 dark:text-gray-400 max-w-sm text-right'>
Creates a unique hash from the data and target
instance name, allowing the same profile/format to
be imported multiple times
instance name
</span>
</div>
</div>
{/* Conditional Fields for Sync Method */}
{formData.sync_method === 'schedule' && (
<div className='space-y-1.5'>
<label
@@ -384,51 +376,20 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
</div>
)}
{/* Sync Options */}
{formData.sync_method !== 'manual' && (
<>
<button
type='button'
onClick={() => setIsDataDrawerOpen(true)}
className='w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200
bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700
rounded-lg transition-colors
border border-gray-200 dark:border-gray-700'>
<div className='flex flex-col space-y-2'>
<div className='flex items-center justify-between'>
<span>Select Data to Sync</span>
</div>
{(safeSelectedData.profiles.length > 0 ||
safeSelectedData.customFormats.length >
0) && (
<div className='flex flex-wrap gap-2'>
{safeSelectedData.profiles.map(
profile => (
<span
key={profile}
className='inline-flex items-center bg-blue-100 text-blue-800
dark:bg-blue-900 dark:text-blue-300
text-xs rounded px-2 py-1'>
{profile}
</span>
)
)}
{safeSelectedData.customFormats.map(
format => (
<span
key={format}
className='inline-flex items-center bg-green-100 text-green-800
dark:bg-green-900 dark:text-green-300
text-xs rounded px-2 py-1'>
{format}
</span>
)
)}
</div>
)}
</div>
</button>
<div className='border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800'>
<h3 className='text-sm font-medium mb-4'>
Select Data to Sync
</h3>
<DataSelector
isLoading={isLoading}
availableData={availableData}
selectedData={safeSelectedData}
onDataToggle={handleDataToggle}
error={errors.data_to_sync}
/>
</div>
{errors.data_to_sync && (
<p className='text-xs text-red-500 mt-1'>
{errors.data_to_sync}
@@ -436,34 +397,23 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
)}
</>
)}
<DataSelectorModal
isOpen={
formData.sync_method !== 'manual' && isDataDrawerOpen
}
onClose={() => setIsDataDrawerOpen(false)}
isLoading={isLoading}
availableData={availableData}
selectedData={safeSelectedData}
onDataToggle={handleDataToggle}
error={errors.data_to_sync}
/>
{showSyncConfirm && (
<SyncModal
isOpen={showSyncConfirm}
onClose={() => {
setShowSyncConfirm(false);
onSubmit();
}}
onSkip={() => {
setShowSyncConfirm(false);
onSubmit();
}}
onSync={handleManualSync}
isSyncing={isInitialSyncing}
/>
)}
</form>
{showSyncConfirm && (
<SyncModal
isOpen={showSyncConfirm}
onClose={() => {
setShowSyncConfirm(false);
onSubmit();
}}
onSkip={() => {
setShowSyncConfirm(false);
onSubmit();
}}
onSync={handleManualSync}
isSyncing={isInitialSyncing}
/>
)}
</Modal>
);
};

View File

@@ -0,0 +1,250 @@
import React from 'react';
import {Loader, AlertTriangle} from 'lucide-react';
import useSearch from '@hooks/useSearch';
import {useSorting} from '@hooks/useSorting';
import SearchBar from '@ui/DataBar/SearchBar';
import SortDropdown from '@ui/SortDropdown';
const DataSelector = ({
isLoading,
availableData = {profiles: [], customFormats: []},
selectedData = {profiles: [], customFormats: []},
onDataToggle,
error
}) => {
const profiles = selectedData?.profiles || [];
const customFormats = selectedData?.customFormats || [];
const availableProfileNames = new Set(
availableData.profiles.map(p => p.content.name)
);
const availableFormatNames = new Set(
availableData.customFormats.map(f => f.content.name)
);
const missingProfiles = profiles.filter(
name => !availableProfileNames.has(name)
);
const missingFormats = customFormats.filter(
name => !availableFormatNames.has(name)
);
const combinedProfiles = [
...missingProfiles.map(name => ({name, isMissing: true})),
...availableData.profiles.map(profile => ({
name: profile.content.name,
isMissing: false
}))
];
const combinedFormats = [
...missingFormats.map(name => ({name, isMissing: true})),
...availableData.customFormats.map(format => ({
name: format.content.name,
isMissing: false
}))
];
const {
items: filteredProfiles,
searchTerms: searchTermsProfiles,
currentInput: currentInputProfiles,
setCurrentInput: setCurrentInputProfiles,
addSearchTerm: addSearchTermProfiles,
removeSearchTerm: removeSearchTermProfiles,
clearSearchTerms: clearSearchTermsProfiles
} = useSearch(combinedProfiles, {
searchableFields: ['name']
});
const {
items: filteredFormats,
searchTerms: searchTermsFormats,
currentInput: currentInputFormats,
setCurrentInput: setCurrentInputFormats,
addSearchTerm: addSearchTermFormats,
removeSearchTerm: removeSearchTermFormats,
clearSearchTerms: clearSearchTermsFormats
} = useSearch(combinedFormats, {
searchableFields: ['name']
});
const {
sortConfig: profilesSortConfig,
updateSort: updateProfilesSort,
sortData: sortProfiles
} = useSorting({field: 'name', direction: 'asc'});
const {
sortConfig: formatsSortConfig,
updateSort: updateFormatsSort,
sortData: sortFormats
} = useSorting({field: 'name', direction: 'asc'});
const sortedProfiles = sortProfiles(filteredProfiles);
const sortedFormats = sortFormats(filteredFormats);
const sortOptions = [{label: 'Name', value: 'name'}];
const renderItem = (item, type) => (
<label
key={item.name}
className={`flex items-center p-2 bg-white dark:bg-gray-800
hover:bg-gray-50 dark:hover:bg-gray-700
rounded-lg cursor-pointer group transition-colors
border ${
item.isMissing
? 'border-amber-500/50 dark:border-amber-500/30'
: 'border-gray-200 dark:border-gray-700'
}`}>
<div className='flex-1 flex items-center'>
<input
type='checkbox'
checked={
type === 'profiles'
? profiles.includes(item.name)
: customFormats.includes(item.name)
}
onChange={() => onDataToggle(type, item.name)}
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-offset-0'
/>
<span
className='ml-3 text-sm text-gray-700 dark:text-gray-300
group-hover:text-gray-900 dark:group-hover:text-gray-100 flex-1'>
{item.name}
</span>
{item.isMissing && (
<div className='flex items-center text-amber-500 dark:text-amber-400'>
<AlertTriangle className='w-4 h-4 mr-1' />
<span className='text-xs'>File not found</span>
</div>
)}
</div>
</label>
);
return (
<div className='space-y-6'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader className='w-6 h-6 animate-spin text-blue-500' />
</div>
) : (
<>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>
Quality Profiles
</h4>
<div className='flex items-center space-x-2'>
{missingProfiles.length > 0 && (
<span className='text-xs text-amber-500 dark:text-amber-400'>
{missingProfiles.length} missing
</span>
)}
<span className='text-xs text-gray-500 dark:text-gray-400'>
{profiles.length} selected
</span>
</div>
</div>
<div className='flex items-center gap-2 mb-2'>
<SearchBar
placeholder='Search profiles...'
requireEnter={true}
searchTerms={searchTermsProfiles}
currentInput={currentInputProfiles}
onInputChange={setCurrentInputProfiles}
onAddTerm={addSearchTermProfiles}
onRemoveTerm={removeSearchTermProfiles}
onClearTerms={() => {
clearSearchTermsProfiles();
setCurrentInputProfiles('');
}}
className='flex-1'
/>
<div
onClick={e => {
e.stopPropagation();
}}>
<SortDropdown
sortOptions={sortOptions}
currentSort={profilesSortConfig}
onSortChange={updateProfilesSort}
/>
</div>
</div>
<div className='grid grid-cols-1 gap-2'>
{sortedProfiles.map(item =>
renderItem(item, 'profiles')
)}
</div>
</div>
<div className='space-y-3'>
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>
Custom Formats
</h4>
<div className='flex items-center space-x-2'>
{missingFormats.length > 0 && (
<span className='text-xs text-amber-500 dark:text-amber-400'>
{missingFormats.length} missing
</span>
)}
<span className='text-xs text-gray-500 dark:text-gray-400'>
{customFormats.length} selected
</span>
</div>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Note: Custom formats used in selected quality
profiles are automatically imported and don't
need to be selected here.
</p>
</div>
<div className='flex items-center gap-2 mb-2'>
<SearchBar
placeholder='Search custom formats...'
requireEnter={true}
searchTerms={searchTermsFormats}
currentInput={currentInputFormats}
onInputChange={setCurrentInputFormats}
onAddTerm={addSearchTermFormats}
onRemoveTerm={removeSearchTermFormats}
onClearTerms={() => {
clearSearchTermsFormats();
setCurrentInputFormats('');
}}
className='flex-1'
/>
<div
onClick={e => {
e.stopPropagation();
}}>
<SortDropdown
sortOptions={sortOptions}
currentSort={formatsSortConfig}
onSortChange={updateFormatsSort}
/>
</div>
</div>
<div className='grid grid-cols-1 gap-2'>
{sortedFormats.map(item =>
renderItem(item, 'customFormats')
)}
</div>
</div>
{error && (
<div className='pt-2'>
<p className='text-xs text-red-500'>{error}</p>
</div>
)}
</>
)}
</div>
);
};
export default DataSelector;

View File

@@ -1,139 +0,0 @@
import React from 'react';
import {Loader} from 'lucide-react';
import Modal from '@ui/Modal';
const DataSelectorModal = ({
isOpen,
onClose,
isLoading,
availableData = {profiles: [], customFormats: []},
selectedData = {profiles: [], customFormats: []},
onDataToggle,
error
}) => {
// Ensure we have safe defaults for selectedData
const profiles = selectedData?.profiles || [];
const customFormats = selectedData?.customFormats || [];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title='Select Data to Sync'
height='2xl'
width='2xl'>
<div className='space-y-6'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader className='w-6 h-6 animate-spin text-blue-500' />
</div>
) : (
<>
{/* Quality Profiles Section */}
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>
Quality Profiles
</h4>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{profiles.length} selected
</span>
</div>
<div className='grid grid-cols-2 gap-2'>
{(availableData?.profiles || []).map(
profile => (
<label
key={profile.file_name}
className='flex items-center p-2 bg-white dark:bg-gray-800
hover:bg-gray-50 dark:hover:bg-gray-700
rounded-lg cursor-pointer group transition-colors
border border-gray-200 dark:border-gray-700'>
<input
type='checkbox'
checked={profiles.includes(
profile.content.name
)}
onChange={() =>
onDataToggle(
'profiles',
profile.content.name
)
}
className='rounded border-gray-300 text-blue-600
focus:ring-blue-500 focus:ring-offset-0'
/>
<span
className='ml-3 text-sm text-gray-700 dark:text-gray-300
group-hover:text-gray-900 dark:group-hover:text-gray-100'>
{profile.content.name}
</span>
</label>
)
)}
</div>
</div>
{/* Custom Formats Section */}
<div className='space-y-3'>
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>
Custom Formats
</h4>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{customFormats.length} selected
</span>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Note: Custom formats used in selected
quality profiles are automatically imported
and don't need to be selected here.
</p>
</div>
<div className='grid grid-cols-2 gap-2'>
{(availableData?.customFormats || []).map(
format => (
<label
key={format.file_name}
className='flex items-center p-2 bg-white dark:bg-gray-800
hover:bg-gray-50 dark:hover:bg-gray-700
rounded-lg cursor-pointer group transition-colors
border border-gray-200 dark:border-gray-700'>
<input
type='checkbox'
checked={customFormats.includes(
format.content.name
)}
onChange={() =>
onDataToggle(
'customFormats',
format.content.name
)
}
className='rounded border-gray-300 text-blue-600
focus:ring-blue-500 focus:ring-offset-0'
/>
<span
className='ml-3 text-sm text-gray-700 dark:text-gray-300
group-hover:text-gray-900 dark:group-hover:text-gray-100'>
{format.content.name}
</span>
</label>
)
)}
</div>
</div>
{error && (
<div className='pt-2'>
<p className='text-xs text-red-500'>{error}</p>
</div>
)}
</>
)}
</div>
</Modal>
);
};
export default DataSelectorModal;

View File

@@ -1,3 +1,5 @@
// SearchBar.jsx
import React, {useState, useEffect} from 'react';
import {Search, X} from 'lucide-react';
@@ -15,7 +17,7 @@ const SearchBar = ({
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
const handleKeyDown = e => {
const handleKeyDownGlobal = e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
document.querySelector('input[type="text"]')?.focus();
@@ -24,93 +26,90 @@ const SearchBar = ({
onClearTerms();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', handleKeyDownGlobal);
return () =>
document.removeEventListener('keydown', handleKeyDownGlobal);
}, [onClearTerms]);
const handleKeyDown = e => {
// Handle backspace when input is empty and there are search terms
if (requireEnter && e.key === 'Enter' && currentInput.trim()) {
e.preventDefault();
onAddTerm(currentInput);
return;
}
if (e.key === 'Backspace' && !currentInput && searchTerms.length > 0) {
e.preventDefault();
onRemoveTerm(searchTerms[searchTerms.length - 1]);
}
};
const handleKeyPress = e => {
if (requireEnter && e.key === 'Enter' && currentInput.trim()) {
onAddTerm(currentInput);
}
};
return (
<div className={`relative flex-1 min-w-0 group ${className}`}>
<Search
className={`
absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4
transition-colors duration-200
${
isFocused
? 'text-blue-500'
: 'text-gray-400 group-hover:text-gray-500 dark:group-hover:text-gray-300'
}
`}
absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4
transition-colors duration-200
${
isFocused
? 'text-blue-500'
: 'text-gray-400 group-hover:text-gray-500 dark:group-hover:text-gray-300'
}
`}
/>
<div
className={`
w-full min-h-10 pl-9 pr-8 rounded-md
transition-all duration-200 ease-in-out
border shadow-sm flex items-center flex-wrap gap-2 p-2
${
isFocused
? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5'
: 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600'
}
bg-white dark:bg-gray-800
`}>
w-full min-h-10 pl-9 pr-8 rounded-md
transition-all duration-200 ease-in-out
border shadow-sm flex items-center flex-wrap gap-2 p-2
${
isFocused
? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5'
: 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600'
}
bg-white dark:bg-gray-800
`}>
{searchTerms.map((term, index) => (
<div
key={index}
className='flex items-center gap-1.5 px-2 py-1
bg-blue-500/10 dark:bg-blue-500/20
border border-blue-500/20 dark:border-blue-400/20
text-blue-600 dark:text-blue-400
rounded-md shadow-sm
hover:bg-blue-500/15 dark:hover:bg-blue-500/25
hover:border-blue-500/30 dark:hover:border-blue-400/30
group/badge
transition-all duration-200'>
bg-blue-500/10 dark:bg-blue-500/20
border border-blue-500/20 dark:border-blue-400/20
text-blue-600 dark:text-blue-400
rounded-md shadow-sm
hover:bg-blue-500/15 dark:hover:bg-blue-500/25
hover:border-blue-500/30 dark:hover:border-blue-400/30
group/badge
transition-all duration-200
'>
<span className='text-sm font-medium leading-none'>
{term}
</span>
<button
onClick={() => onRemoveTerm(term)}
className='p-0.5 hover:bg-blue-500/20
rounded-sm transition-colors
opacity-70 group-hover/badge:opacity-100'
aria-label={`Remove ${term} filter`}>
rounded-sm transition-colors
opacity-70 group-hover/badge:opacity-100'>
<X className='h-3 w-3' />
</button>
</div>
))}
<input
type='text'
value={currentInput}
onChange={e => onInputChange(e.target.value)}
onKeyPress={handleKeyPress}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={e => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
searchTerms.length
? 'Add another filter...'
: placeholder
}
className='flex-1 min-w-[200px] bg-transparent
text-gray-900 dark:text-gray-100
placeholder:text-gray-500 dark:placeholder:text-gray-400
focus:outline-none'
text-gray-900 dark:text-gray-100
placeholder:text-gray-500 dark:placeholder:text-gray-400
focus:outline-none'
/>
</div>
@@ -118,12 +117,11 @@ const SearchBar = ({
<button
onClick={onClearTerms}
className='absolute right-3 top-1/2 -translate-y-1/2
p-1.5 rounded-full
text-gray-400 hover:text-gray-600
hover:bg-gray-100 dark:hover:bg-gray-700
transition-all duration-200
group/clear'
aria-label='Clear all searches'>
p-1.5 rounded-full
text-gray-400 hover:text-gray-600
hover:bg-gray-100 dark:hover:bg-gray-700
transition-all duration-200
group/clear'>
<X className='h-4 w-4' />
</button>
)}

View File

@@ -1,5 +1,7 @@
// SortDropdown.jsx
import React, {useState} from 'react';
import {ChevronDown, ChevronUp, ArrowDown, ArrowUp} from 'lucide-react';
import {ArrowDown, ArrowUp} from 'lucide-react';
const SortDropdown = ({
sortOptions,
@@ -24,14 +26,19 @@ const SortDropdown = ({
return (
<div className={`relative inline-block text-left ${className}`}>
<button
type='button'
onClick={toggleDropdown}
className='inline-flex items-center justify-between w-full px-4 py-2 text-xs
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
text-gray-900 dark:text-gray-100
rounded-md
hover:bg-gray-50 dark:hover:bg-gray-700
focus:outline-none focus:ring-2 focus:ring-blue-500'>
className={`
inline-flex items-center justify-between
min-h-[40px] px-4 py-2 text-sm
bg-white dark:bg-gray-800
border border-gray-300 dark:border-gray-700
text-gray-900 dark:text-gray-100
rounded-md
hover:bg-gray-50 dark:hover:bg-gray-700
focus:outline-none focus:ring-2 focus:ring-blue-500
transition-all
`}>
<span className='flex items-center gap-2'>
{getCurrentSortLabel()}
{currentSort.direction === 'asc' ? (
@@ -44,18 +51,23 @@ const SortDropdown = ({
{isOpen && (
<div
className='absolute right-0 z-10 w-56 mt-2 origin-top-right
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-md shadow-lg'>
className='
absolute right-0 z-10 w-56 mt-2 origin-top-right
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-md shadow-lg
'>
<div className='py-1'>
{sortOptions.map(option => (
<button
key={option.value}
type='button'
onClick={() => handleSortClick(option.value)}
className='flex items-center justify-between w-full px-4 py-2
text-xs text-gray-700 dark:text-gray-200
hover:bg-gray-50 dark:hover:bg-gray-700'>
className='
flex items-center justify-between w-full px-4 py-2
text-xs text-gray-700 dark:text-gray-200
hover:bg-gray-50 dark:hover:bg-gray-700
'>
<span>{option.label}</span>
{currentSort.field === option.value &&
(currentSort.direction === 'asc' ? (