mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 02:41:11 +01:00
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:
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
250
frontend/src/components/settings/arrs/DataSelector.jsx
Normal file
250
frontend/src/components/settings/arrs/DataSelector.jsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
Reference in New Issue
Block a user