feat: media management (#205)

- implemented new data page for media management
- renaming options to set movie / episode / folder formats
- misc options for propers/repacks/video analysis
- quality definitions
- syncing with instances
- improved mobile view for tabs / navbar
This commit is contained in:
Samuel Chau
2025-06-12 15:42:29 +09:30
committed by GitHub
parent b21216b667
commit 22d4029e20
41 changed files with 2788 additions and 102 deletions

5
.gitignore vendored
View File

@@ -16,4 +16,7 @@ __pycache__/
.DS_Store
# build files
backend/app/static/
backend/app/static/
# Config data
config/

View File

@@ -11,6 +11,7 @@ class Config:
REGEX_DIR = os.path.join(DB_DIR, 'regex_patterns')
FORMAT_DIR = os.path.join(DB_DIR, 'custom_formats')
PROFILE_DIR = os.path.join(DB_DIR, 'profiles')
MEDIA_MANAGEMENT_DIR = os.path.join(DB_DIR, 'media_management')
# Logging
LOG_DIR = os.path.join(CONFIG_DIR, 'log')
@@ -40,7 +41,7 @@ class Config:
"""Create all required directories if they don't exist."""
directories = [
Config.CONFIG_DIR, Config.DB_DIR, Config.REGEX_DIR,
Config.FORMAT_DIR, Config.PROFILE_DIR, Config.LOG_DIR
Config.FORMAT_DIR, Config.PROFILE_DIR, Config.MEDIA_MANAGEMENT_DIR, Config.LOG_DIR
]
logger = logging.getLogger(__name__)
for directory in directories:

View File

@@ -3,19 +3,13 @@ import yaml
import logging
from git import GitCommandError
from .comparison import create_change_summary
from .utils import determine_type, parse_commit_message
from .utils import determine_type, parse_commit_message, extract_name_from_path
logger = logging.getLogger(__name__)
def extract_name(file_path):
"""Extract name from file path by removing type prefix and extension"""
# Remove the file extension
name = os.path.splitext(file_path)[0]
# Remove the type prefix (everything before the first '/')
if '/' in name:
name = name.split('/', 1)[1]
return name
# Use the centralized extract_name_from_path function from utils
extract_name = extract_name_from_path
def check_merge_conflict(repo, branch, file_path):

View File

@@ -3,19 +3,13 @@ import yaml
import logging
from git import GitCommandError
from .comparison import create_change_summary
from .utils import determine_type
from .utils import determine_type, extract_name_from_path
logger = logging.getLogger(__name__)
def extract_name(file_path):
"""Extract name from file path by removing type prefix and extension"""
# Remove the file extension
name = os.path.splitext(file_path)[0]
# Remove the type prefix (everything before the first '/')
if '/' in name:
name = name.split('/', 1)[1]
return name
# Use the centralized extract_name_from_path function from utils
extract_name = extract_name_from_path
def get_outgoing_changes(repo):

View File

@@ -38,9 +38,36 @@ def determine_type(file_path):
return 'Custom Format'
elif 'profiles' in file_path:
return 'Quality Profile'
elif 'media_management' in file_path:
return 'Media Management'
return 'Unknown'
def format_media_management_name(name):
"""Format media management category names for display"""
name_mapping = {
'misc': 'Miscellaneous',
'naming': 'Naming',
'quality_definitions': 'Quality Definitions'
}
return name_mapping.get(name, name)
def extract_name_from_path(file_path):
"""Extract and format name from file path"""
# Remove the file extension
name = os.path.splitext(file_path)[0]
# Remove the type prefix (everything before the first '/')
if '/' in name:
name = name.split('/', 1)[1]
# Format media management names
if 'media_management' in file_path:
return format_media_management_name(name)
return name
def interpret_git_status(x, y):
if x == 'D' or y == 'D':
return 'Deleted'

View File

@@ -14,6 +14,7 @@ from .db import run_migrations, get_settings
from .auth import bp as auth_bp
from .settings import bp as settings_bp
from .logs import bp as logs_bp
from .media_management import media_management_bp
from .middleware import init_middleware
from .init import setup_logging, init_app_config, init_git_user
@@ -71,6 +72,7 @@ def create_app():
app.register_blueprint(importarr_bp, url_prefix='/api/import')
app.register_blueprint(arr_bp, url_prefix='/api/arr')
app.register_blueprint(tasks_bp, url_prefix='/api/tasks')
app.register_blueprint(media_management_bp)
# Initialize middleware
logger.info("Initializing middleware")

View File

@@ -0,0 +1,141 @@
from flask import Blueprint, jsonify, request
import logging
from .utils import (
get_media_management_data,
save_media_management_data,
update_media_management_data,
get_all_media_management_data,
MEDIA_MANAGEMENT_CATEGORIES
)
from .sync import (
sync_naming_config,
sync_media_management_config,
sync_quality_definitions
)
from ..arr.manager import get_arr_config
logger = logging.getLogger(__name__)
media_management_bp = Blueprint('media_management', __name__)
@media_management_bp.route('/api/media-management', methods=['GET'])
def get_all_media_management():
"""Get all media management data for all categories"""
try:
data = get_all_media_management_data()
return jsonify(data), 200
except Exception as e:
logger.error(f"Error retrieving media management data: {e}")
return jsonify({'error': str(e)}), 500
@media_management_bp.route('/api/media-management/<category>', methods=['GET'])
def get_media_management(category):
"""Get media management data for a specific category"""
if category not in MEDIA_MANAGEMENT_CATEGORIES:
return jsonify({'error': f'Invalid category: {category}'}), 400
try:
data = get_media_management_data(category)
return jsonify(data), 200
except Exception as e:
logger.error(f"Error retrieving {category}: {e}")
return jsonify({'error': str(e)}), 500
@media_management_bp.route('/api/media-management/<category>', methods=['PUT'])
def update_media_management(category):
"""Update media management data for a specific category"""
if category not in MEDIA_MANAGEMENT_CATEGORIES:
return jsonify({'error': f'Invalid category: {category}'}), 400
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
updated_data = update_media_management_data(category, data)
return jsonify(updated_data), 200
except Exception as e:
logger.error(f"Error updating {category}: {e}")
return jsonify({'error': str(e)}), 500
@media_management_bp.route('/api/media-management/sync', methods=['POST'])
def sync_media_management():
"""Sync media management data to arr instance"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
arr_id = data.get('arr_id')
categories = data.get('categories', [])
if not arr_id:
return jsonify({'error': 'arr_id is required'}), 400
if not categories:
return jsonify({'error': 'categories list is required'}), 400
# Validate categories
invalid_categories = [cat for cat in categories if cat not in MEDIA_MANAGEMENT_CATEGORIES]
if invalid_categories:
return jsonify({'error': f'Invalid categories: {invalid_categories}'}), 400
# Get arr config
arr_result = get_arr_config(arr_id)
if not arr_result.get('success'):
return jsonify({'error': 'Arr configuration not found'}), 404
arr_config = arr_result.get('data')
base_url = arr_config['arrServer']
api_key = arr_config['apiKey']
arr_type = arr_config['type']
results = {}
# Sync each requested category
for category in categories:
try:
# Get the current media management data for this category
category_data = get_media_management_data(category)
logger.info(f"Raw category_data for {category}: {category_data}")
arr_type_data = category_data.get(arr_type, {})
logger.info(f"Extracted arr_type_data for {arr_type}: {arr_type_data}")
if category == 'naming':
success, message = sync_naming_config(base_url, api_key, arr_type, arr_type_data)
elif category == 'misc':
success, message = sync_media_management_config(base_url, api_key, arr_type, arr_type_data)
elif category == 'quality_definitions':
# Quality definitions has a nested structure: qualityDefinitions -> arr_type -> qualities
quality_defs = category_data.get('qualityDefinitions', {}).get(arr_type, {})
success, message = sync_quality_definitions(base_url, api_key, arr_type, quality_defs)
else:
success, message = False, f"Unknown category: {category}"
results[category] = {
'success': success,
'message': message
}
except Exception as e:
logger.error(f"Error syncing {category}: {e}")
results[category] = {
'success': False,
'message': str(e)
}
# Determine overall success
overall_success = all(result['success'] for result in results.values())
return jsonify({
'success': overall_success,
'results': results
}), 200
except Exception as e:
logger.error(f"Error in media management sync: {e}")
return jsonify({'error': str(e)}), 500

View File

@@ -0,0 +1,258 @@
import logging
import requests
from typing import Dict, Any, Tuple
logger = logging.getLogger(__name__)
def sync_naming_config(base_url: str, api_key: str, arr_type: str, naming_data: Dict[str, Any]) -> Tuple[bool, str]:
"""
Sync naming configuration to arr instance.
First GET current config, update with our data, then PUT back.
Args:
base_url: The arr instance base URL
api_key: The arr instance API key
arr_type: Either 'radarr' or 'sonarr'
naming_data: The naming configuration from our YML file
Returns:
Tuple of (success, message)
"""
try:
# Construct the endpoint URL
endpoint = f"{base_url}/api/v3/config/naming"
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
# GET current naming config
logger.info(f"Fetching current naming config from {arr_type} at {base_url}")
response = requests.get(endpoint, headers=headers, timeout=10)
response.raise_for_status()
current_config = response.json()
logger.info(f"Current naming config for {arr_type}:")
logger.info(current_config)
# Update current_config with fields from naming_data
if arr_type == 'radarr':
# Map our YML fields to Radarr API fields
if 'rename' in naming_data:
current_config['renameMovies'] = naming_data['rename']
if 'replaceIllegalCharacters' in naming_data:
current_config['replaceIllegalCharacters'] = naming_data['replaceIllegalCharacters']
if 'colonReplacementFormat' in naming_data:
current_config['colonReplacementFormat'] = naming_data['colonReplacementFormat']
if 'movieFormat' in naming_data:
current_config['standardMovieFormat'] = naming_data['movieFormat']
if 'movieFolderFormat' in naming_data:
current_config['movieFolderFormat'] = naming_data['movieFolderFormat']
else: # sonarr
# Map our YML fields to Sonarr API fields
if 'rename' in naming_data:
current_config['renameEpisodes'] = naming_data['rename']
if 'replaceIllegalCharacters' in naming_data:
current_config['replaceIllegalCharacters'] = naming_data['replaceIllegalCharacters']
if 'colonReplacementFormat' in naming_data:
current_config['colonReplacementFormat'] = naming_data['colonReplacementFormat']
if 'customColonReplacementFormat' in naming_data:
current_config['customColonReplacementFormat'] = naming_data['customColonReplacementFormat']
if 'multiEpisodeStyle' in naming_data:
current_config['multiEpisodeStyle'] = naming_data['multiEpisodeStyle']
if 'standardEpisodeFormat' in naming_data:
current_config['standardEpisodeFormat'] = naming_data['standardEpisodeFormat']
if 'dailyEpisodeFormat' in naming_data:
current_config['dailyEpisodeFormat'] = naming_data['dailyEpisodeFormat']
if 'animeEpisodeFormat' in naming_data:
current_config['animeEpisodeFormat'] = naming_data['animeEpisodeFormat']
if 'seriesFolderFormat' in naming_data:
current_config['seriesFolderFormat'] = naming_data['seriesFolderFormat']
if 'seasonFolderFormat' in naming_data:
current_config['seasonFolderFormat'] = naming_data['seasonFolderFormat']
if 'specialsFolderFormat' in naming_data:
current_config['specialsFolderFormat'] = naming_data['specialsFolderFormat']
# PUT the updated config back
logger.info(f"Updating naming config for {arr_type}")
logger.info(f"Request body for naming sync:")
logger.info(current_config)
put_response = requests.put(endpoint, json=current_config, headers=headers, timeout=10)
put_response.raise_for_status()
logger.info(f"Successfully synced naming config for {arr_type}")
return True, "Naming config sync successful"
except requests.exceptions.RequestException as e:
error_msg = f"Failed to sync naming config: {str(e)}"
logger.error(error_msg)
return False, error_msg
except Exception as e:
error_msg = f"Unexpected error syncing naming config: {str(e)}"
logger.error(error_msg)
return False, error_msg
def sync_media_management_config(base_url: str, api_key: str, arr_type: str, misc_data: Dict[str, Any]) -> Tuple[bool, str]:
"""
Sync media management (misc) configuration to arr instance.
First GET current config, update with our data, then PUT back.
Args:
base_url: The arr instance base URL
api_key: The arr instance API key
arr_type: Either 'radarr' or 'sonarr'
misc_data: The misc configuration from our YML file
Returns:
Tuple of (success, message)
"""
try:
# Construct the endpoint URL
endpoint = f"{base_url}/api/v3/config/mediamanagement"
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
# GET current media management config
logger.info(f"Fetching current media management config from {arr_type} at {base_url}")
response = requests.get(endpoint, headers=headers, timeout=10)
response.raise_for_status()
current_config = response.json()
logger.info(f"Current media management config for {arr_type}:")
logger.info(current_config)
# Update current_config with fields from misc_data
# We only manage two fields: propersRepacks and enableMediaInfo
if 'propersRepacks' in misc_data:
current_config['downloadPropersAndRepacks'] = misc_data['propersRepacks']
if 'enableMediaInfo' in misc_data:
current_config['enableMediaInfo'] = misc_data['enableMediaInfo']
# PUT the updated config back
logger.info(f"Updating media management config for {arr_type}")
logger.info(f"Request body for media management sync:")
logger.info(current_config)
put_response = requests.put(endpoint, json=current_config, headers=headers, timeout=10)
put_response.raise_for_status()
logger.info(f"Successfully synced media management config for {arr_type}")
return True, "Media management config sync successful"
except requests.exceptions.RequestException as e:
error_msg = f"Failed to sync media management config: {str(e)}"
logger.error(error_msg)
return False, error_msg
except Exception as e:
error_msg = f"Unexpected error syncing media management config: {str(e)}"
logger.error(error_msg)
return False, error_msg
def sync_quality_definitions(base_url: str, api_key: str, arr_type: str, quality_data: Dict[str, Any]) -> Tuple[bool, str]:
"""
Sync quality definitions to arr instance.
Quality definitions contain all required data, so we can directly PUT.
Args:
base_url: The arr instance base URL
api_key: The arr instance API key
arr_type: Either 'radarr' or 'sonarr'
quality_data: The quality definitions from our YML file
Returns:
Tuple of (success, message)
"""
try:
# Construct the endpoint URL
endpoint = f"{base_url}/api/v3/qualitydefinition"
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
# GET current quality definitions (for logging/comparison)
logger.info(f"Fetching current quality definitions from {arr_type} at {base_url}")
response = requests.get(endpoint, headers=headers, timeout=10)
response.raise_for_status()
current_definitions = response.json()
logger.info(f"Current quality definitions for {arr_type}:")
logger.info(current_definitions)
if arr_type == 'sonarr':
# Log the quality data we received from YML
logger.info(f"Quality data from YML:")
logger.info(quality_data)
# Create a mapping of quality names to current definitions for easier lookup
quality_map = {def_['quality']['name']: def_ for def_ in current_definitions}
# Update each quality definition with our values
for quality_name, settings in quality_data.items():
if quality_name in quality_map:
definition = quality_map[quality_name]
# Update size limits from our YML data
if 'min' in settings:
definition['minSize'] = settings['min']
if 'preferred' in settings:
definition['preferredSize'] = settings['preferred']
if 'max' in settings:
definition['maxSize'] = settings['max']
# PUT the updated definitions back
logger.info(f"Updating quality definitions for {arr_type}")
logger.info(f"Request body for quality definitions sync:")
logger.info(current_definitions)
# Sonarr expects the full array of definitions at the update endpoint
update_endpoint = f"{base_url}/api/v3/qualitydefinition/update"
put_response = requests.put(update_endpoint, json=current_definitions, headers=headers, timeout=10)
put_response.raise_for_status()
logger.info(f"Successfully synced quality definitions for {arr_type}")
return True, "Quality definitions sync successful"
else: # radarr
# Log the quality data we received from YML
logger.info(f"Quality data from YML:")
logger.info(quality_data)
# Create a mapping of quality names to current definitions for easier lookup
quality_map = {def_['quality']['name']: def_ for def_ in current_definitions}
# Update each quality definition with our values
for quality_name, settings in quality_data.items():
if quality_name in quality_map:
definition = quality_map[quality_name]
# Update size limits from our YML data
if 'min' in settings:
definition['minSize'] = settings['min']
if 'preferred' in settings:
definition['preferredSize'] = settings['preferred']
if 'max' in settings:
definition['maxSize'] = settings['max']
# PUT the updated definitions back
logger.info(f"Updating quality definitions for {arr_type}")
logger.info(f"Request body for quality definitions sync:")
logger.info(current_definitions)
# Radarr expects the full array of definitions at the update endpoint
update_endpoint = f"{base_url}/api/v3/qualitydefinition/update"
put_response = requests.put(update_endpoint, json=current_definitions, headers=headers, timeout=10)
put_response.raise_for_status()
logger.info(f"Successfully synced quality definitions for {arr_type}")
return True, "Quality definitions sync successful"
except requests.exceptions.RequestException as e:
error_msg = f"Failed to sync quality definitions: {str(e)}"
logger.error(error_msg)
return False, error_msg
except Exception as e:
error_msg = f"Unexpected error syncing quality definitions: {str(e)}"
logger.error(error_msg)
return False, error_msg

View File

@@ -0,0 +1,211 @@
import os
import yaml
import logging
from typing import Dict, Any
from datetime import datetime
from ..config.config import config
logger = logging.getLogger(__name__)
# Media management directory
MEDIA_MANAGEMENT_DIR = config.MEDIA_MANAGEMENT_DIR
# Media management categories
MEDIA_MANAGEMENT_CATEGORIES = ["misc", "naming", "quality_definitions"]
def _preserve_order(data: Dict[str, Any], category: str) -> Dict[str, Any]:
"""Preserve the desired key order based on category"""
if category == "misc":
# Order: radarr, sonarr
ordered = {}
for arr_type in ["radarr", "sonarr"]:
if arr_type in data:
arr_data = data[arr_type]
# Order within each: propersRepacks, enableMediaInfo
ordered_arr = {}
for key in ["propersRepacks", "enableMediaInfo"]:
if key in arr_data:
ordered_arr[key] = arr_data[key]
# Add any remaining keys
for key, value in arr_data.items():
if key not in ordered_arr:
ordered_arr[key] = value
ordered[arr_type] = ordered_arr
# Add any remaining top-level keys
for key, value in data.items():
if key not in ordered:
ordered[key] = value
return ordered
elif category == "naming":
# Order: radarr, sonarr
ordered = {}
for arr_type in ["radarr", "sonarr"]:
if arr_type in data:
arr_data = data[arr_type]
ordered_arr = {}
if arr_type == "radarr":
# Radarr order: rename, movieFormat, movieFolderFormat, replaceIllegalCharacters, colonReplacementFormat
for key in ["rename", "movieFormat", "movieFolderFormat", "replaceIllegalCharacters", "colonReplacementFormat"]:
if key in arr_data:
ordered_arr[key] = arr_data[key]
elif arr_type == "sonarr":
# Sonarr order: rename, standardEpisodeFormat, dailyEpisodeFormat, animeEpisodeFormat, seriesFolderFormat, seasonFolderFormat, replaceIllegalCharacters, colonReplacementFormat, customColonReplacementFormat, multiEpisodeStyle
for key in ["rename", "standardEpisodeFormat", "dailyEpisodeFormat", "animeEpisodeFormat", "seriesFolderFormat", "seasonFolderFormat", "replaceIllegalCharacters", "colonReplacementFormat", "customColonReplacementFormat", "multiEpisodeStyle"]:
if key in arr_data:
ordered_arr[key] = arr_data[key]
# Add any remaining keys
for key, value in arr_data.items():
if key not in ordered_arr:
ordered_arr[key] = value
ordered[arr_type] = ordered_arr
# Add any remaining top-level keys
for key, value in data.items():
if key not in ordered:
ordered[key] = value
return ordered
elif category == "quality_definitions":
# For quality_definitions, preserve the structure: qualityDefinitions -> radarr/sonarr -> qualities
return data
return data
def _get_file_path(category: str) -> str:
"""Get the file path for a media management category"""
return os.path.join(MEDIA_MANAGEMENT_DIR, f"{category}.yml")
def _load_yaml_file(file_path: str) -> Dict[str, Any]:
"""Load YAML file and return contents"""
if not os.path.exists(file_path):
logger.error(f"File not found: {file_path}")
raise FileNotFoundError(f"File not found: {file_path}")
try:
with open(file_path, 'r') as f:
return yaml.safe_load(f) or {}
except Exception as e:
logger.error(f"Error loading {file_path}: {e}")
raise
def _save_yaml_file(file_path: str, data: Dict[str, Any], category: str = None) -> None:
"""Save data to YAML file"""
try:
# Preserve key order if category is specified
if category:
data = _preserve_order(data, category)
with open(file_path, 'w') as f:
yaml.safe_dump(
data,
f,
sort_keys=False,
default_flow_style=False,
width=1000, # Prevent line wrapping
allow_unicode=True
)
except Exception as e:
logger.error(f"Error saving {file_path}: {e}")
raise
def get_media_management_data(category: str) -> Dict[str, Any]:
"""Get media management data for a specific category"""
if category not in MEDIA_MANAGEMENT_CATEGORIES:
raise ValueError(f"Invalid category: {category}")
file_path = _get_file_path(category)
# If file doesn't exist, return empty dict
if not os.path.exists(file_path):
logger.info(f"Media management file not found: {file_path}")
return {}
try:
data = _load_yaml_file(file_path)
return data
except Exception as e:
logger.error(f"Error reading {category}: {e}")
# Return empty dict on error
return {}
def save_media_management_data(category: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Save media management data for a specific category"""
if category not in MEDIA_MANAGEMENT_CATEGORIES:
raise ValueError(f"Invalid category: {category}")
file_path = _get_file_path(category)
try:
_save_yaml_file(file_path, data, category)
logger.info(f"Saved {category} data")
return get_media_management_data(category)
except Exception as e:
logger.error(f"Error saving {category}: {e}")
raise
def update_media_management_data(category: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Update media management data for a specific category"""
if category not in MEDIA_MANAGEMENT_CATEGORIES:
raise ValueError(f"Invalid category: {category}")
# For media management, update is the same as save
# since these files can't be deleted
return save_media_management_data(category, data)
def get_all_media_management_data() -> Dict[str, Any]:
"""Get all media management data for all categories, transformed to have arr type at top level"""
# First get all data in original structure
original_data = {}
for category in MEDIA_MANAGEMENT_CATEGORIES:
try:
data = get_media_management_data(category)
# Only include if data exists
if data:
original_data[category] = data
except Exception as e:
logger.error(f"Error getting {category} data: {e}")
# Transform to have radarr/sonarr at top level
result = {
"radarr": {},
"sonarr": {}
}
for category, data in original_data.items():
if category == "misc":
# misc has radarr/sonarr subdivisions
if "radarr" in data and data["radarr"]:
result["radarr"]["misc"] = data["radarr"]
if "sonarr" in data and data["sonarr"]:
result["sonarr"]["misc"] = data["sonarr"]
elif category == "naming":
# naming has radarr/sonarr subdivisions
if "radarr" in data and data["radarr"]:
result["radarr"]["naming"] = data["radarr"]
if "sonarr" in data and data["sonarr"]:
result["sonarr"]["naming"] = data["sonarr"]
elif category == "quality_definitions":
# quality_definitions has qualityDefinitions.radarr/sonarr
quality_defs = data.get("qualityDefinitions", {})
if "radarr" in quality_defs and quality_defs["radarr"]:
result["radarr"]["quality_definitions"] = quality_defs["radarr"]
if "sonarr" in quality_defs and quality_defs["sonarr"]:
result["sonarr"]["quality_definitions"] = quality_defs["sonarr"]
# Remove empty arr types
if not result["radarr"]:
del result["radarr"]
if not result["sonarr"]:
del result["sonarr"]
return result

View File

@@ -17,11 +17,8 @@ services:
- '5000:5000'
volumes:
- ./backend:/app
- config_data:/config
- ./config:/config
env_file:
- .env
restart: always
volumes:
config_data:
name: regexerr_config

View File

@@ -17,6 +17,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.428.0",
"prop-types": "^15.8.1",
"rc-slider": "^11.1.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.3",
@@ -1351,6 +1352,11 @@
"node": ">=6"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3315,6 +3321,41 @@
}
]
},
"node_modules/rc-slider": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.8.tgz",
"integrity": "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==",
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.5",
"rc-util": "^5.36.0"
},
"engines": {
"node": ">=8.x"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/rc-util": {
"version": "5.44.4",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz",
"integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==",
"dependencies": {
"@babel/runtime": "^7.18.3",
"react-is": "^18.2.0"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/rc-util/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",

View File

@@ -18,6 +18,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.428.0",
"prop-types": "^15.8.1",
"rc-slider": "^11.1.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.3",

View File

@@ -9,6 +9,7 @@ import RegexPage from './components/regex/RegexPage';
import FormatPage from './components/format/FormatPage';
import ProfilePage from './components/profile/ProfilePage';
import SettingsPage from './components/settings/SettingsPage';
import MediaManagementPage from './components/media-management/MediaManagementPage';
import SetupPage from './components/auth/SetupPage';
import LoginPage from './components/auth/LoginPage';
import Navbar from '@ui/Navbar';
@@ -154,6 +155,10 @@ function App() {
path='/profile'
element={<ProfilePage />}
/>
<Route
path='/media-management'
element={<MediaManagementPage />}
/>
<Route
path='/settings'
element={<SettingsPage />}

View File

@@ -0,0 +1,87 @@
import axios from 'axios';
const BASE_URL = '/api/media-management';
/**
* Get all media management data for all categories
* @returns {Promise<Object>} Media management data organized by arr type
*/
export const getMediaManagementData = async () => {
try {
const response = await axios.get(BASE_URL);
return response.data;
} catch (error) {
console.error('Error fetching media management data:', error);
throw error;
}
};
/**
* Get media management data for a specific category
* @param {string} category - The category to fetch (misc, naming, quality_definitions)
* @returns {Promise<Object>} Category data
*/
export const getMediaManagementCategory = async (category) => {
try {
const response = await axios.get(`${BASE_URL}/${category}`);
return response.data;
} catch (error) {
console.error(`Error fetching ${category} data:`, error);
throw error;
}
};
/**
* Update media management data for a specific category
* @param {string} category - The category to update (misc, naming, quality_definitions)
* @param {Object} data - The data to save
* @returns {Promise<Object>} Updated category data
*/
export const updateMediaManagementCategory = async (category, data) => {
try {
const response = await axios.put(`${BASE_URL}/${category}`, data);
return response.data;
} catch (error) {
console.error(`Error updating ${category}:`, error);
throw error;
}
};
/**
* Sync media management data to arr instance
* @param {number} arrId - The arr instance ID to sync to
* @param {string[]} categories - Array of categories to sync
* @returns {Promise<Object>} Sync results
*/
export const syncMediaManagement = async (arrId, categories) => {
try {
const response = await axios.post(`${BASE_URL}/sync`, {
arr_id: arrId,
categories: categories
});
return response.data;
} catch (error) {
console.error('Error syncing media management:', error);
throw error;
}
};
// Organized export pattern for convenience
export const MediaManagement = {
// Main operations
getAll: getMediaManagementData,
getCategory: getMediaManagementCategory,
updateCategory: updateMediaManagementCategory,
// Category-specific helpers
getMisc: () => getMediaManagementCategory('misc'),
getNaming: () => getMediaManagementCategory('naming'),
getQualityDefinitions: () => getMediaManagementCategory('quality_definitions'),
updateMisc: (data) => updateMediaManagementCategory('misc', data),
updateNaming: (data) => updateMediaManagementCategory('naming', data),
updateQualityDefinitions: (data) => updateMediaManagementCategory('quality_definitions', data),
// Sync functionality
sync: syncMediaManagement
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Save, RefreshCw, Check } from 'lucide-react';
const CategoryActions = ({ onSync, onSave, isSaving = false, isSyncing = false }) => {
return (
<div className="flex gap-2">
<button
onClick={onSync}
disabled={isSyncing || isSaving}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-gray-700/50 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isSyncing ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<RefreshCw className="w-4 h-4 text-blue-500" />
)}
<span className="hidden sm:inline">Sync</span>
</button>
<button
onClick={onSave}
disabled={isSaving || isSyncing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-gray-700/50 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isSaving ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Save className="w-4 h-4 text-blue-500" />
)}
<span className="hidden sm:inline">Save</span>
</button>
</div>
);
};
CategoryActions.propTypes = {
onSync: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
isSaving: PropTypes.bool,
isSyncing: PropTypes.bool
};
export default CategoryActions;

View File

@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { ChevronDown, ChevronRight } from 'lucide-react';
import CategoryActions from './CategoryActions';
const CategoryContainer = ({
title,
children,
onSync,
onSave,
isSaving = false,
isSyncing = false,
isExpanded = true
}) => {
const [expanded, setExpanded] = useState(isExpanded);
return (
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg mb-6 border border-gray-700">
{/* Header Section */}
<button
onClick={() => setExpanded(!expanded)}
className={`w-full bg-gray-800/50 px-6 py-3.5 hover:bg-gray-700/50 transition-[background-color] ${
expanded ? 'border-b border-gray-700' : ''
}`}
>
<div className="flex justify-between items-center">
<div className="flex items-center space-x-3">
<h1 className="text-sm sm:text-base font-semibold text-gray-100">
{title}
</h1>
{expanded ?
<ChevronDown size={16} className="text-gray-400" /> :
<ChevronRight size={16} className="text-gray-400" />
}
</div>
<div onClick={(e) => e.stopPropagation()}>
<CategoryActions
onSync={onSync}
onSave={onSave}
isSaving={isSaving}
isSyncing={isSyncing}
/>
</div>
</div>
</button>
{/* Body Section */}
{expanded && (
<div className="p-6">
{children}
</div>
)}
</div>
);
};
CategoryContainer.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
onSync: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
isSaving: PropTypes.bool,
isSyncing: PropTypes.bool,
isExpanded: PropTypes.bool
};
export default CategoryContainer;

View File

@@ -0,0 +1,259 @@
import React, { useState, useEffect } from 'react';
import { MediaManagement } from '@api/mediaManagement';
import Alert from '@ui/Alert';
import { Loader, RefreshCw } from 'lucide-react';
import MiscSettings from './MiscSettings';
import NamingSettings from './NamingSettings';
import QualityDefinitions from './QualityDefinitions';
import SyncModal from './SyncModal';
const loadingMessages = [
'Configuring media management...',
'Organizing file naming rules...',
'Setting up quality definitions...',
'Preparing media settings...',
'Loading configuration options...',
'Initializing management tools...'
];
const LoadingState = () => (
<div className='w-full min-h-[70vh] flex flex-col items-center justify-center'>
<Loader className='w-8 h-8 animate-spin text-blue-500 mb-4' />
<p className='text-lg font-medium text-gray-300'>
{
loadingMessages[
Math.floor(Math.random() * loadingMessages.length)
]
}
</p>
</div>
);
const MediaManagementPage = () => {
const [activeTab, setActiveTab] = useState('radarr');
const [mediaData, setMediaData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [savingStates, setSavingStates] = useState({
misc: false,
naming: false,
quality_definitions: false
});
const [syncModal, setSyncModal] = useState({
isOpen: false,
category: null
});
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const data = await MediaManagement.getAll();
setMediaData(data);
// Set activeTab to first available arr type if current one has no data
if (data) {
if ((!data[activeTab] || Object.keys(data[activeTab]).length === 0)) {
if (data.radarr && Object.keys(data.radarr).length > 0) {
setActiveTab('radarr');
} else if (data.sonarr && Object.keys(data.sonarr).length > 0) {
setActiveTab('sonarr');
}
}
}
} catch (err) {
setError(err.message);
console.error('Error fetching media management data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleTabChange = (tab) => {
setActiveTab(tab);
};
const transformDataForSave = (category, data) => {
// Transform the data back to the format expected by the backend
if (category === 'misc' || category === 'naming') {
return {
radarr: activeTab === 'radarr' ? data : (mediaData.radarr?.[category] || {}),
sonarr: activeTab === 'sonarr' ? data : (mediaData.sonarr?.[category] || {})
};
} else if (category === 'quality_definitions') {
return {
qualityDefinitions: {
radarr: activeTab === 'radarr' ? data : (mediaData.radarr?.quality_definitions || {}),
sonarr: activeTab === 'sonarr' ? data : (mediaData.sonarr?.quality_definitions || {})
}
};
}
return data;
};
const handleSave = async (category, data) => {
setSavingStates(prev => ({ ...prev, [category]: true }));
try {
// Transform data to match backend expectations
const transformedData = transformDataForSave(category, data);
await MediaManagement.updateCategory(category, transformedData);
// Update local state with new data
setMediaData(prev => ({
...prev,
[activeTab]: {
...prev[activeTab],
[category]: data
}
}));
Alert.success(`${category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())} settings saved successfully`);
} catch (err) {
console.error(`Error saving ${category}:`, err);
Alert.error(`Failed to save ${category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())} settings`);
} finally {
setSavingStates(prev => ({ ...prev, [category]: false }));
}
};
const handleSync = (category) => {
setSyncModal({
isOpen: true,
category: category
});
};
const handleSyncAll = () => {
setSyncModal({
isOpen: true,
category: null // null means sync all categories
});
};
const closeSyncModal = () => {
setSyncModal({
isOpen: false,
category: null
});
};
// Check if there's any data at all
const hasData = mediaData && (
(mediaData.radarr && Object.keys(mediaData.radarr).length > 0) ||
(mediaData.sonarr && Object.keys(mediaData.sonarr).length > 0)
);
return (
<div>
{/* Tab Navigation - only show if there's data */}
{hasData && (
<nav className='flex justify-between items-center my-4'>
<div className='flex space-x-2'>
{mediaData.radarr && Object.keys(mediaData.radarr).length > 0 && (
<div
onClick={() => handleTabChange('radarr')}
className={`cursor-pointer px-3 py-2 rounded-md text-sm font-medium ${
activeTab === 'radarr'
? 'bg-gray-600 border border-gray-600 text-white'
: 'bg-gray-800 border border-gray-700 text-white'
}`}>
Radarr
</div>
)}
{mediaData.sonarr && Object.keys(mediaData.sonarr).length > 0 && (
<div
onClick={() => handleTabChange('sonarr')}
className={`cursor-pointer px-3 py-2 rounded-md text-sm font-medium ${
activeTab === 'sonarr'
? 'bg-gray-600 border border-gray-600 text-white'
: 'bg-gray-800 border border-gray-700 text-white'
}`}>
Sonarr
</div>
)}
</div>
<button
onClick={handleSyncAll}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-gray-700/50 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors text-sm"
>
<RefreshCw className="w-4 h-4 text-blue-500" />
<span>Sync All</span>
</button>
</nav>
)}
{/* Loading State */}
{loading && <LoadingState />}
{error && (
<div className="bg-red-900/20 border border-red-600 rounded-lg p-4 mb-6">
<p className="text-red-400">Error loading settings: {error}</p>
</div>
)}
{/* Content */}
{!loading && !error && (
<div className="space-y-6">
{/* Check if no data exists at all */}
{!hasData ? (
<div className="bg-gray-800/50 rounded-lg p-8 text-center mt-6">
<p className="text-gray-400 mb-2">No media management settings found.</p>
<p className="text-sm text-gray-500">Connect to a database to sync media management settings.</p>
</div>
) : (!mediaData[activeTab] || Object.keys(mediaData[activeTab]).length === 0) ? (
<div className="bg-gray-800/50 rounded-lg p-8 text-center mt-6">
<p className="text-gray-400 mb-2">No media management settings found.</p>
<p className="text-sm text-gray-500">Connect to a database to sync {activeTab} media management settings.</p>
</div>
) : (
<>
{/* Only show categories that have data */}
{mediaData[activeTab]?.naming && Object.keys(mediaData[activeTab].naming).length > 0 && (
<NamingSettings
data={mediaData[activeTab].naming}
arrType={activeTab}
onSave={(data) => handleSave('naming', data)}
onSync={() => handleSync('naming')}
isSaving={savingStates.naming}
/>
)}
{mediaData[activeTab]?.misc && Object.keys(mediaData[activeTab].misc).length > 0 && (
<MiscSettings
data={mediaData[activeTab].misc}
arrType={activeTab}
onSave={(data) => handleSave('misc', data)}
onSync={() => handleSync('misc')}
isSaving={savingStates.misc}
/>
)}
{mediaData[activeTab]?.quality_definitions && Object.keys(mediaData[activeTab].quality_definitions).length > 0 && (
<QualityDefinitions
data={mediaData[activeTab].quality_definitions}
arrType={activeTab}
onSave={(data) => handleSave('quality_definitions', data)}
onSync={() => handleSync('quality_definitions')}
isSaving={savingStates.quality_definitions}
/>
)}
</>
)}
</div>
)}
{/* Sync Modal */}
<SyncModal
isOpen={syncModal.isOpen}
onClose={closeSyncModal}
arrType={activeTab}
category={syncModal.category}
/>
</div>
);
};
export default MediaManagementPage;

View File

@@ -0,0 +1,92 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import CategoryContainer from './CategoryContainer';
import Dropdown from '../ui/Dropdown';
const MiscSettings = ({ data, arrType, onSave, onSync, isSaving }) => {
const [localData, setLocalData] = useState({
propersRepacks: 'doNotPrefer',
enableMediaInfo: true
});
useEffect(() => {
if (data) {
setLocalData(data);
}
}, [data]);
const handleChange = (field, value) => {
setLocalData(prev => ({
...prev,
[field]: value
}));
};
const handleSave = () => {
onSave && onSave(localData);
};
const handleSync = () => {
onSync && onSync();
};
return (
<CategoryContainer
title="Miscellaneous"
onSync={handleSync}
onSave={handleSave}
isSaving={isSaving}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
Propers and Repacks
</label>
<Dropdown
value={localData.propersRepacks}
onChange={(e) => handleChange('propersRepacks', e.target.value)}
options={[
{ value: 'preferAndUpgrade', label: 'Prefer and Upgrade' },
{ value: 'doNotUpgrade', label: 'Do Not Upgrade' },
{ value: 'doNotPrefer', label: 'Do Not Prefer' }
]}
placeholder="Select option"
/>
<p className="mt-2 text-xs text-gray-400 dark:text-gray-400">
Choose how to handle proper and repack releases. <span className="font-semibold text-gray-300">Do Not Prefer</span> is needed to allow custom formats to work properly.
</p>
</div>
<div>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={localData.enableMediaInfo}
onChange={(e) => handleChange('enableMediaInfo', e.target.checked)}
className="w-4 h-4 text-blue-400 bg-gray-900/50 border border-gray-700/50 rounded focus:ring-blue-400 focus:ring-1 transition-all duration-200"
/>
<span className="text-sm font-medium text-gray-100 dark:text-gray-100">
Analyze video files
</span>
</label>
<p className="mt-2 text-xs text-gray-400 dark:text-gray-400">
Extract video information such as resolution, runtime and codec information from files.
</p>
</div>
</div>
</CategoryContainer>
);
};
MiscSettings.propTypes = {
data: PropTypes.shape({
propersRepacks: PropTypes.string,
enableMediaInfo: PropTypes.bool
}),
arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired,
onSave: PropTypes.func,
onSync: PropTypes.func,
isSaving: PropTypes.bool
};
export default MiscSettings;

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import CategoryContainer from './CategoryContainer';
import MonospaceInput from '../ui/MonospaceInput';
import Dropdown from '../ui/Dropdown';
import { Info } from 'lucide-react';
const NamingSettings = ({ data, arrType, onSave, onSync, isSaving }) => {
const [localData, setLocalData] = useState({});
useEffect(() => {
if (data) {
setLocalData(data);
}
}, [data]);
const handleChange = (field, value) => {
setLocalData(prev => ({
...prev,
[field]: value
}));
};
const handleSave = () => {
onSave && onSave(localData);
};
const handleSync = () => {
onSync && onSync();
};
const colonReplacementOptions = arrType === 'radarr'
? [
{ value: 'delete', label: 'Delete' },
{ value: 'dash', label: 'Replace with Dash' },
{ value: 'spaceDash', label: 'Replace with Space Dash' },
{ value: 'spaceDashSpace', label: 'Replace with Space Dash Space' },
{ value: 'smart', label: 'Smart Replace' }
]
: [
{ value: 0, label: 'Delete' },
{ value: 1, label: 'Replace with Dash' },
{ value: 2, label: 'Replace with Space Dash' },
{ value: 3, label: 'Replace with Space Dash Space' },
{ value: 4, label: 'Smart Replace' },
{ value: 5, label: 'Custom' }
];
return (
<CategoryContainer
title="Naming"
onSync={handleSync}
onSave={handleSave}
isSaving={isSaving}
>
<div className="space-y-4">
<div>
<label className="flex items-center space-x-3 mb-4">
<input
type="checkbox"
checked={localData.rename || false}
onChange={(e) => handleChange('rename', e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-700 dark:bg-gray-700 border-gray-600 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
<span className="text-sm font-medium text-gray-100 dark:text-gray-100">
Rename {arrType === 'radarr' ? 'Movies' : 'Episodes'}
</span>
</label>
</div>
{arrType === 'radarr' ? (
<>
<div>
<label className={`block text-sm font-medium mb-2 ${!localData.rename ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
Movie Format
</label>
<MonospaceInput
value={localData.movieFormat || ''}
onChange={(e) => handleChange('movieFormat', e.target.value)}
disabled={!localData.rename}
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
Movie Folder Format
</label>
<MonospaceInput
value={localData.movieFolderFormat || ''}
onChange={(e) => handleChange('movieFolderFormat', e.target.value)}
/>
</div>
</>
) : (
<>
<div>
<label className={`block text-sm font-medium mb-2 ${!localData.rename ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
Standard Episode Format
</label>
<MonospaceInput
value={localData.standardEpisodeFormat || ''}
onChange={(e) => handleChange('standardEpisodeFormat', e.target.value)}
disabled={!localData.rename}
rows={2}
/>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${!localData.rename ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
Daily Episode Format
</label>
<MonospaceInput
value={localData.dailyEpisodeFormat || ''}
onChange={(e) => handleChange('dailyEpisodeFormat', e.target.value)}
disabled={!localData.rename}
rows={2}
/>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${!localData.rename ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
Anime Episode Format
</label>
<MonospaceInput
value={localData.animeEpisodeFormat || ''}
onChange={(e) => handleChange('animeEpisodeFormat', e.target.value)}
disabled={!localData.rename}
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
Series Folder Format
</label>
<MonospaceInput
value={localData.seriesFolderFormat || ''}
onChange={(e) => handleChange('seriesFolderFormat', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
Season Folder Format
</label>
<MonospaceInput
value={localData.seasonFolderFormat || ''}
onChange={(e) => handleChange('seasonFolderFormat', e.target.value)}
/>
</div>
</>
)}
<div>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={localData.replaceIllegalCharacters || false}
onChange={(e) => handleChange('replaceIllegalCharacters', e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-700 dark:bg-gray-700 border-gray-600 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
<span className="text-sm font-medium text-gray-100 dark:text-gray-100">
Replace Illegal Characters
</span>
</label>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${!localData.replaceIllegalCharacters ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
Colon Replacement
</label>
<Dropdown
value={localData.colonReplacementFormat ?? (arrType === 'radarr' ? 'smart' : 0)}
onChange={(e) => handleChange('colonReplacementFormat', arrType === 'sonarr' ? parseInt(e.target.value) : e.target.value)}
options={colonReplacementOptions}
disabled={!localData.replaceIllegalCharacters}
placeholder="Select replacement"
/>
</div>
{arrType === 'sonarr' && localData.colonReplacementFormat === 5 && (
<div>
<label className={`block text-sm font-medium mb-2 ${!localData.replaceIllegalCharacters ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
Custom Colon Replacement
</label>
<MonospaceInput
value={localData.customColonReplacementFormat || ''}
onChange={(e) => handleChange('customColonReplacementFormat', e.target.value)}
placeholder="Enter custom replacement"
disabled={!localData.replaceIllegalCharacters}
/>
</div>
)}
{arrType === 'sonarr' && (
<div>
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
Multi-Episode Style
</label>
<Dropdown
value={localData.multiEpisodeStyle ?? 0}
onChange={(e) => handleChange('multiEpisodeStyle', parseInt(e.target.value))}
options={[
{ value: 0, label: 'Extend' },
{ value: 1, label: 'Duplicate' },
{ value: 2, label: 'Repeat' },
{ value: 3, label: 'Scene' },
{ value: 4, label: 'Range' },
{ value: 5, label: 'Prefixed Range' }
]}
placeholder="Select style"
/>
</div>
)}
{/* Disclaimer */}
<div className="flex items-start space-x-2 mt-6 pt-4 border-t border-gray-700">
<Info className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-400">
Please ensure all formats follow {arrType === 'radarr' ? 'Radarr' : 'Sonarr'}'s naming requirements.
Profilarr does not validate formats before syncing.
</p>
</div>
</div>
</CategoryContainer>
);
};
NamingSettings.propTypes = {
data: PropTypes.object,
arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired,
onSave: PropTypes.func,
onSync: PropTypes.func,
isSaving: PropTypes.bool
};
export default NamingSettings;

View File

@@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import CategoryContainer from './CategoryContainer';
import QualityGroup from './QualityGroup';
import QualityItem from './QualityItem';
import Dropdown from '../ui/Dropdown';
import { Info } from 'lucide-react';
const QualityDefinitions = ({ data, arrType, onSave, onSync, isSaving }) => {
const [expandedGroups, setExpandedGroups] = useState({});
const [localData, setLocalData] = useState({});
const [viewMode, setViewMode] = useState('mbPerMin');
useEffect(() => {
if (data) {
setLocalData(data);
}
}, [data]);
const handleQualityChange = (qualityName, newSettings) => {
setLocalData(prev => ({
...prev,
[qualityName]: newSettings
}));
};
const handleSave = () => {
onSave && onSave(localData);
};
const handleSync = () => {
onSync && onSync();
};
const toggleGroup = (group) => {
setExpandedGroups(prev => ({
...prev,
[group]: !prev[group]
}));
};
const viewModeOptions = [
{ value: 'mbPerMin', label: 'Megabytes per minute' },
{ value: 'gbPer20min', label: 'Gigabytes per 20min' },
{ value: 'gbPer30min', label: 'Gigabytes per 30min' },
{ value: 'gbPer40min', label: 'Gigabytes per 40min' },
{ value: 'gbPerHour', label: 'Gigabytes per hour' },
{ value: 'gbPer90min', label: 'Gigabytes per 1h 30min' },
{ value: 'gbPer2hr', label: 'Gigabytes per 2 hours' },
{ value: 'gbPer150min', label: 'Gigabytes per 2h 30min' },
{ value: 'gbPer3hr', label: 'Gigabytes per 3 hours' },
{ value: 'mbps', label: 'Megabits per second' }
];
// Convert MB/min to display value based on view mode
const convertValue = (mbPerMin) => {
if (!mbPerMin && mbPerMin !== 0) return 0;
switch (viewMode) {
case 'gbPer20min':
return (mbPerMin * 20 / 1024).toFixed(2);
case 'gbPer30min':
return (mbPerMin * 30 / 1024).toFixed(2);
case 'gbPer40min':
return (mbPerMin * 40 / 1024).toFixed(2);
case 'gbPerHour':
return (mbPerMin * 60 / 1024).toFixed(2);
case 'gbPer90min':
return (mbPerMin * 90 / 1024).toFixed(2);
case 'gbPer2hr':
return (mbPerMin * 120 / 1024).toFixed(2);
case 'gbPer150min':
return (mbPerMin * 150 / 1024).toFixed(2);
case 'gbPer3hr':
return (mbPerMin * 180 / 1024).toFixed(2);
case 'mbps':
return (mbPerMin * 8 / 60).toFixed(1);
default: // mbPerMin
return mbPerMin;
}
};
// Convert display value back to MB/min for storage
const convertBack = (displayValue) => {
const value = parseFloat(displayValue) || 0;
switch (viewMode) {
case 'gbPer20min':
return Math.round(value * 1024 / 20);
case 'gbPer30min':
return Math.round(value * 1024 / 30);
case 'gbPer40min':
return Math.round(value * 1024 / 40);
case 'gbPerHour':
return Math.round(value * 1024 / 60);
case 'gbPer90min':
return Math.round(value * 1024 / 90);
case 'gbPer2hr':
return Math.round(value * 1024 / 120);
case 'gbPer150min':
return Math.round(value * 1024 / 150);
case 'gbPer3hr':
return Math.round(value * 1024 / 180);
case 'mbps':
return Math.round(value * 60 / 8);
default: // mbPerMin
return Math.round(value);
}
};
// Get unit label for current view mode
const getUnitLabel = () => {
switch (viewMode) {
case 'mbPerMin':
return 'MB/min';
case 'gbPer20min':
return 'GB/20min';
case 'gbPer30min':
return 'GB/30min';
case 'gbPer40min':
return 'GB/40min';
case 'gbPerHour':
return 'GB/hr';
case 'gbPer90min':
return 'GB/90min';
case 'gbPer2hr':
return 'GB/2hr';
case 'gbPer150min':
return 'GB/2.5hr';
case 'gbPer3hr':
return 'GB/3hr';
case 'mbps':
return 'Mbps';
default:
return '';
}
};
// Group qualities by resolution
const groupQualities = (qualities) => {
const groups = {
'Prereleases': ['WORKPRINT', 'CAM', 'TELESYNC', 'TELECINE', 'DVDSCR'],
'SD': ['DVD', 'DVD-R', 'WEBDL-480p', 'WEBRip-480p', 'Bluray-480p', 'Bluray-576p', 'SDTV'],
'HD-720p': ['HDTV-720p', 'WEBDL-720p', 'WEBRip-720p', 'Bluray-720p'],
'HD-1080p': ['HDTV-1080p', 'WEBDL-1080p', 'WEBRip-1080p', 'Bluray-1080p', 'Remux-1080p', 'Bluray-1080p Remux'],
'UHD-2160p': ['HDTV-2160p', 'WEBDL-2160p', 'WEBRip-2160p', 'Bluray-2160p', 'Remux-2160p', 'Bluray-2160p Remux'],
'Other': ['BR-DISK', 'Raw-HD', 'Unknown', 'REGIONAL']
};
const grouped = {};
Object.entries(groups).forEach(([groupName, qualityNames]) => {
const groupQualities = {};
qualityNames.forEach(name => {
if (qualities && qualities[name]) {
groupQualities[name] = qualities[name];
}
});
if (Object.keys(groupQualities).length > 0) {
grouped[groupName] = groupQualities;
}
});
return grouped;
};
const qualityGroups = groupQualities(localData);
return (
<CategoryContainer
title="Quality Definitions"
onSync={handleSync}
onSave={handleSave}
isSaving={isSaving}
>
{/* View Mode Selector and Disclaimer */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start space-x-2 flex-1">
<Info className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-xs text-gray-400">
<p>Preferred size is rarely used when combining quality profiles with custom formats.</p>
<p className="mt-1">Min/max values are useful for setting absolute limits on what can be grabbed.</p>
</div>
</div>
<div className="w-full sm:w-56">
<Dropdown
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
options={viewModeOptions}
/>
</div>
</div>
<div className="space-y-4">
{Object.entries(qualityGroups).map(([groupName, qualities]) => (
<QualityGroup
key={groupName}
title={groupName}
isExpanded={expandedGroups[groupName]}
onToggle={() => toggleGroup(groupName)}
unitLabel={getUnitLabel()}
>
{Object.entries(qualities).map(([qualityName, settings]) => (
<QualityItem
key={qualityName}
name={qualityName}
settings={settings}
arrType={arrType}
viewMode={viewMode}
convertValue={convertValue}
convertBack={convertBack}
onChange={(newSettings) => handleQualityChange(qualityName, newSettings)}
/>
))}
</QualityGroup>
))}
</div>
</CategoryContainer>
);
};
QualityDefinitions.propTypes = {
data: PropTypes.object,
arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired,
onSave: PropTypes.func,
onSync: PropTypes.func,
isSaving: PropTypes.bool
};
export default QualityDefinitions;

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { ChevronDown, ChevronRight } from 'lucide-react';
const QualityGroup = ({ title, children, isExpanded = false, onToggle, unitLabel }) => {
return (
<div className="border border-gray-700 dark:border-gray-700 rounded-lg overflow-hidden mb-4">
{/* Header Section */}
<button
onClick={onToggle}
className={`w-full bg-gray-800/30 px-4 py-2.5 hover:bg-gray-700/30 flex items-center justify-between transition-[background-color] ${
isExpanded ? 'border-b-2 border-gray-700/50' : ''
}`}
>
<h3 className="text-sm font-medium text-gray-200">
{title}
</h3>
{isExpanded ?
<ChevronDown size={14} className="text-gray-400" /> :
<ChevronRight size={14} className="text-gray-400" />
}
</button>
{/* Body Section */}
{isExpanded && (
<div className="overflow-hidden">
<table className="w-full">
<thead className="hidden sm:table-header-group">
<tr>
<th className="text-left pl-6 px-4 pt-4 pb-2 text-xs font-medium text-gray-300 w-40">Quality</th>
<th className="text-left px-2 pt-4 pb-2 text-xs font-medium text-gray-300">Range <span className="text-gray-400 font-normal">({unitLabel})</span></th>
<th className="text-center px-1 pt-4 pb-2 text-xs font-medium text-gray-300 w-16">Min</th>
<th className="text-center px-1 pt-4 pb-2 text-xs font-medium text-gray-300 w-20">Preferred</th>
<th className="text-center px-1 pt-4 pb-2 text-xs font-medium text-gray-300 w-16">Max</th>
</tr>
</thead>
<tbody>
{children}
</tbody>
</table>
</div>
)}
</div>
);
};
QualityGroup.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
isExpanded: PropTypes.bool,
onToggle: PropTypes.func.isRequired,
unitLabel: PropTypes.string.isRequired
};
export default QualityGroup;

View File

@@ -0,0 +1,257 @@
import React from 'react';
import PropTypes from 'prop-types';
import NumberInput from '../ui/NumberInput';
import Slider from '../ui/Slider';
const QualityItem = ({ name, settings, arrType, viewMode, convertValue, convertBack, onChange, disabled = false }) => {
const handleInputChange = (field, value) => {
// Convert display value back to MB/min
let numValue = convertBack(value);
// Validate and clamp values based on field
if (field === 'min') {
// Min must be >= 0
numValue = Math.max(0, numValue);
// Min cannot exceed max - 20 (to leave room for preferred)
if (settings.max) {
numValue = Math.min(numValue, settings.max - 20);
}
} else if (field === 'preferred') {
// Preferred must be at least min + 10
if (settings.min !== undefined) {
numValue = Math.max(settings.min + 10, numValue);
}
// Preferred must be at most max - 10
if (settings.max !== undefined) {
numValue = Math.min(settings.max - 10, numValue);
}
} else if (field === 'max') {
// Max cannot exceed the arr type limit
numValue = Math.min(numValue, maxValue);
// Max must be at least min + 20 (to leave room for preferred)
if (settings.min !== undefined) {
numValue = Math.max(settings.min + 20, numValue);
}
}
// Create updated settings
const updatedSettings = {
...settings,
[field]: numValue
};
// If we changed min or max, we might need to adjust preferred
if (field === 'min' || field === 'max') {
const currentPreferred = updatedSettings.preferred || 0;
const minPreferred = (updatedSettings.min || 0) + 10;
const maxPreferred = (updatedSettings.max || maxValue) - 10;
if (currentPreferred < minPreferred) {
updatedSettings.preferred = minPreferred;
} else if (currentPreferred > maxPreferred) {
updatedSettings.preferred = maxPreferred;
}
}
onChange(updatedSettings);
};
const handleSliderChange = (values) => {
// Apply the same validation as input changes
let { min, preferred, max } = values;
// Clamp min to valid range
min = Math.max(0, Math.min(min, maxValue - 20));
// Clamp max to valid range
max = Math.max(min + 20, Math.min(max, maxValue));
// Clamp preferred to valid range
preferred = Math.max(min + 10, Math.min(preferred, max - 10));
onChange({
min,
preferred,
max
});
};
// Set max value based on arr type
const maxValue = arrType === 'sonarr' ? 1000 : 2000;
// Convert values for display
const displayMin = convertValue(settings.min || 0);
const displayPreferred = convertValue(settings.preferred || 0);
const displayMax = convertValue(settings.max || 0);
const displayMaxLimit = convertValue(maxValue);
// Mobile layout
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 640);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
if (isMobile) {
return (
<tr className="border-b border-gray-700/50 last:border-b-0">
<td colSpan="5" className="px-4 py-3">
<div className="space-y-2">
{/* Quality name */}
<div>
<span
className="inline-block px-2 py-1 bg-gray-800/60 border border-gray-700/50 rounded-full text-xs font-medium text-gray-200 font-mono"
style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace' }}
>
{name}
</span>
</div>
{/* Slider */}
<div className="w-full">
<Slider
min={0}
max={maxValue}
minValue={settings.min || 0}
preferredValue={settings.preferred || 0}
maxValue={settings.max || 0}
onChange={handleSliderChange}
disabled={disabled}
/>
</div>
{/* Number inputs row */}
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-xs text-gray-400 block mb-1">Min</label>
<NumberInput
value={displayMin}
onChange={(value) => handleInputChange('min', value)}
min={0}
max={displayMaxLimit}
disabled={disabled}
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
className="text-xs"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Preferred</label>
<NumberInput
value={displayPreferred}
onChange={(value) => handleInputChange('preferred', value)}
min={0}
max={displayMaxLimit}
disabled={disabled}
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
className="text-xs"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Max</label>
<NumberInput
value={displayMax}
onChange={(value) => handleInputChange('max', value)}
min={0}
max={displayMaxLimit}
disabled={disabled}
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
className="text-xs"
/>
</div>
</div>
</div>
</td>
</tr>
);
}
// Desktop layout
return (
<tr className="border-b border-gray-700/50 last:border-b-0 hover:bg-gray-800/20 transition-colors">
{/* Quality Name */}
<td className="pl-4 pr-6 py-3">
<span
className="inline-block px-3 py-1.5 bg-gray-800/60 border border-gray-700/50 rounded-full text-xs font-medium text-gray-200 font-mono"
style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace' }}
>
{name}
</span>
</td>
{/* Slider Section */}
<td className="px-4 py-3">
<Slider
min={0}
max={maxValue}
minValue={settings.min || 0}
preferredValue={settings.preferred || 0}
maxValue={settings.max || 0}
onChange={handleSliderChange}
disabled={disabled}
/>
</td>
{/* Min Input */}
<td className="px-2 py-3">
<div className="w-20">
<NumberInput
value={displayMin}
onChange={(value) => handleInputChange('min', value)}
min={0}
max={displayMaxLimit}
disabled={disabled}
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
/>
</div>
</td>
{/* Preferred Input */}
<td className="px-2 py-3">
<div className="w-20">
<NumberInput
value={displayPreferred}
onChange={(value) => handleInputChange('preferred', value)}
min={0}
max={displayMaxLimit}
disabled={disabled}
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
/>
</div>
</td>
{/* Max Input */}
<td className="px-2 py-3">
<div className="w-20">
<NumberInput
value={displayMax}
onChange={(value) => handleInputChange('max', value)}
min={0}
max={displayMaxLimit}
disabled={disabled}
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
/>
</div>
</td>
</tr>
);
};
QualityItem.propTypes = {
name: PropTypes.string.isRequired,
settings: PropTypes.shape({
min: PropTypes.number,
preferred: PropTypes.number,
max: PropTypes.number
}).isRequired,
arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired,
viewMode: PropTypes.string.isRequired,
convertValue: PropTypes.func.isRequired,
convertBack: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool
};
export default QualityItem;

View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Modal from '../ui/Modal';
import { getArrConfigs } from '@api/arr';
import { syncMediaManagement } from '@api/mediaManagement';
import Alert from '@ui/Alert';
import { Loader, RefreshCw, Server, Check } from 'lucide-react';
const SyncModal = ({ isOpen, onClose, arrType, category = null }) => {
const [arrConfigs, setArrConfigs] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedConfigs, setSelectedConfigs] = useState([]);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
if (isOpen) {
setSelectedConfigs([]); // Reset selection when modal opens
fetchArrConfigs();
}
}, [isOpen, arrType]);
const fetchArrConfigs = async () => {
setLoading(true);
try {
const response = await getArrConfigs();
// Backend returns { success: true, data: [...configs...] }
if (response.success && response.data) {
// Filter configs by the current arr type (radarr or sonarr)
const filteredConfigs = response.data.filter(config =>
config.type.toLowerCase() === arrType.toLowerCase()
);
setArrConfigs(filteredConfigs);
} else {
console.error('API returned unsuccessful response:', response);
Alert.error(response.error || 'Failed to load arr configurations');
setArrConfigs([]);
}
} catch (error) {
console.error('Error fetching arr configs:', error);
Alert.error('Failed to load arr configurations');
setArrConfigs([]);
} finally {
setLoading(false);
}
};
const handleConfigToggle = (configId) => {
setSelectedConfigs(prev =>
prev.includes(configId)
? prev.filter(id => id !== configId)
: [...prev, configId]
);
};
const handleSelectAll = () => {
if (selectedConfigs.length === arrConfigs.length) {
setSelectedConfigs([]);
} else {
setSelectedConfigs(arrConfigs.map(config => config.id));
}
};
const handleSync = async () => {
if (selectedConfigs.length === 0) {
Alert.warning('Please select at least one arr instance to sync with');
return;
}
const syncCategories = category ? [category] : ['misc', 'naming', 'quality_definitions'];
const syncType = category ? `${category} settings` : 'all settings';
setSyncing(true);
try {
// Sync to each selected arr instance
const syncPromises = selectedConfigs.map(async (configId) => {
try {
const result = await syncMediaManagement(configId, syncCategories);
return { configId, success: result.success, results: result.results };
} catch (error) {
return { configId, success: false, error: error.message };
}
});
const results = await Promise.all(syncPromises);
// Show results
const successCount = results.filter(r => r.success).length;
const totalCount = results.length;
if (successCount === totalCount) {
Alert.success(`Successfully synced ${syncType} to ${successCount} arr instance(s)`);
} else if (successCount > 0) {
Alert.warning(`Synced ${syncType} to ${successCount}/${totalCount} arr instances. Check logs for details.`);
} else {
Alert.error(`Failed to sync ${syncType} to any arr instances. Check logs for details.`);
}
console.log('Sync results:', results);
onClose();
} catch (error) {
console.error('Error during sync:', error);
Alert.error('An error occurred during sync');
} finally {
setSyncing(false);
}
};
const modalTitle = category
? `Sync ${category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())} - ${arrType.charAt(0).toUpperCase() + arrType.slice(1)}`
: `Sync All Settings - ${arrType.charAt(0).toUpperCase() + arrType.slice(1)}`;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={modalTitle}
width="xl"
footer={
<div className="flex justify-end">
<button
onClick={handleSync}
disabled={selectedConfigs.length === 0 || syncing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-gray-700/50 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{syncing ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<RefreshCw className="w-4 h-4 text-blue-500" />
)}
<span>Sync</span>
</button>
</div>
}
>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader className="w-6 h-6 animate-spin text-blue-500" />
<span className="ml-2">Loading...</span>
</div>
) : arrConfigs.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-600">No {arrType} instances configured</p>
</div>
) : (
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-gray-800/50 border-b border-gray-700">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-300">Name</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-300">Tags</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-300">Select</th>
</tr>
</thead>
<tbody>
{arrConfigs.map((config, index) => (
<tr
key={config.id}
className={`cursor-pointer select-none transition-colors hover:bg-gray-700/50 ${index !== arrConfigs.length - 1 ? 'border-b border-gray-700/50' : ''}`}
onClick={() => handleConfigToggle(config.id)}
>
<td className="py-3 px-4">
<span className="font-medium text-gray-200">{config.name}</span>
</td>
<td className="py-3 px-4">
{config.tags && config.tags.length > 0 ? (
<div className="flex gap-2 flex-wrap">
{config.tags.map(tag => (
<span
key={tag}
className="inline-block px-3 py-1.5 bg-gray-800/60 border border-gray-700/50 rounded-full text-xs font-medium text-gray-200"
>
{tag}
</span>
))}
</div>
) : (
<span className="text-sm text-gray-500"></span>
)}
</td>
<td className="py-3 px-4">
<div className="flex justify-end">
<div className={`w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 ${
selectedConfigs.includes(config.id)
? 'bg-blue-500'
: 'bg-gray-700 hover:bg-gray-600'
}`}>
{selectedConfigs.includes(config.id) && (
<Check size={14} className="text-white" />
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Modal>
);
};
SyncModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired,
category: PropTypes.string // null for sync all, or specific category name
};
export default SyncModal;

View File

@@ -10,7 +10,8 @@ import {
FileText,
Settings,
File,
Check
Check,
Cog
} from 'lucide-react';
import Tooltip from '@ui/Tooltip';
import ViewChanges from './ViewChanges';
@@ -56,6 +57,8 @@ const ChangeRow = ({
return <FileText className='text-green-400' size={16} />;
case 'Quality Profile':
return <Settings className='text-purple-400' size={16} />;
case 'Media Management':
return <Cog className='text-orange-400' size={16} />;
default:
return <File className='text-gray-400' size={16} />;
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import {GitCommit, Code, FileText, Settings, File} from 'lucide-react';
import {GitCommit, Code, FileText, Settings, File, Cog} from 'lucide-react';
const PushRow = ({change}) => {
const getTypeIcon = type => {
@@ -10,6 +10,8 @@ const PushRow = ({change}) => {
return <FileText className='text-green-400' size={16} />;
case 'Quality Profile':
return <Settings className='text-purple-400' size={16} />;
case 'Media Management':
return <Cog className='text-orange-400' size={16} />;
default:
return <File className='text-gray-400' size={16} />;
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Modal from '@ui/Modal';
import DiffCommit from './DiffCommit';
import {FileText} from 'lucide-react';
import {FileText, Info} from 'lucide-react';
import useChangeParser from '@hooks/useChangeParser';
import {COMMIT_TYPES, FILE_TYPES, COMMIT_SCOPES} from '@constants/commits';
import Tooltip from '@ui/Tooltip';
@@ -67,52 +67,65 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => {
<DiffCommit commitMessage={change.commit_message} />
)}
<div className='overflow-x-auto rounded-lg border border-gray-700'>
<table className='min-w-full'>
<thead className='bg-gray-800 border-b border-gray-700'>
<tr>
<th className='py-3 px-4 text-left text-gray-400 font-medium w-1/8'>
Change
</th>
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/8'>
Key
</th>
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/6'>
Previous
</th>
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/6'>
Current
</th>
</tr>
</thead>
<tbody>
{parsedChanges.map(item => (
<tr
key={item.id}
className='bg-gray-900 border-b border-gray-700'>
<td className='py-4 px-4 text-gray-300'>
{item.changeType}
</td>
<td className='py-4 px-4'>
<span className='font-medium text-gray-100'>
{item.key}
</span>
</td>
<td className='py-4 px-4 font-mono text-sm text-gray-300'>
<div className='whitespace-pre-wrap'>
{item.from}
</div>
</td>
<td className='py-4 px-4 font-mono text-sm text-gray-300'>
<div className='whitespace-pre-wrap'>
{item.to}
</div>
</td>
{parsedChanges.length > 0 ? (
<div className='overflow-x-auto rounded-lg border border-gray-700'>
<table className='min-w-full'>
<thead className='bg-gray-800 border-b border-gray-700'>
<tr>
<th className='py-3 px-4 text-left text-gray-400 font-medium w-1/8'>
Change
</th>
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/8'>
Key
</th>
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/6'>
Previous
</th>
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/6'>
Current
</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{parsedChanges.map(item => (
<tr
key={item.id}
className='bg-gray-900 border-b border-gray-700'>
<td className='py-4 px-4 text-gray-300'>
{item.changeType}
</td>
<td className='py-4 px-4'>
<span className='font-medium text-gray-100'>
{item.key}
</span>
</td>
<td className='py-4 px-4 font-mono text-sm text-gray-300'>
<div className='whitespace-pre-wrap'>
{item.from}
</div>
</td>
<td className='py-4 px-4 font-mono text-sm text-gray-300'>
<div className='whitespace-pre-wrap'>
{item.to}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className='bg-gray-900 rounded-lg border border-gray-700 p-6'>
<div className='flex items-center space-x-3 text-blue-400'>
<Info size={20} />
<span className='font-medium'>Formatting Changes Only</span>
<span className='text-gray-400'></span>
<span className='text-sm text-gray-400'>
Only whitespace, indentation, or syntax changes detected. No content values were changed.
</span>
</div>
</div>
)}
</div>
</Modal>
);

View File

@@ -5,7 +5,7 @@ const AddButton = ({onClick, label = 'Add New'}) => {
return (
<button
onClick={onClick}
className='flex items-center gap-2 px-3 py-2 rounded-md
className='flex items-center gap-2 px-3 py-2 min-h-10 rounded-md
border border-gray-200 dark:border-gray-700
bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-750
@@ -19,7 +19,7 @@ const AddButton = ({onClick, label = 'Add New'}) => {
group-hover:rotate-90 group-hover:scale-110
group-hover:text-blue-500 dark:group-hover:text-blue-400'
/>
<span className='text-sm font-medium'>{label}</span>
<span className='text-sm font-medium hidden sm:inline'>{label}</span>
</button>
);
};

View File

@@ -9,7 +9,7 @@ const FloatingBar = ({children}) => (
<>
<div className='fixed top-0 left-0 right-0 z-50 bg-gradient-to-br from-gray-800 to-gray-900 border-b border-gray-700 shadow-xl backdrop-blur-sm'>
<div className='max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8'>
<div className='flex items-center gap-4 h-16'>{children}</div>
<div className='flex items-center gap-1 sm:gap-4 h-16'>{children}</div>
</div>
</div>
<div className='h-16' />
@@ -39,13 +39,30 @@ const DataBar = ({
className
}) => {
const [isFloating, setIsFloating] = useState(false);
const [isMobile, setIsMobile] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth < 768;
}
return false;
});
const [isSearchFocused, setIsSearchFocused] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsFloating(window.scrollY > 64);
};
const handleResize = () => {
setIsMobile(window.innerWidth < 768);
};
handleResize();
window.addEventListener('scroll', handleScroll, {passive: true});
return () => window.removeEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
};
}, []);
const controls = (
@@ -57,11 +74,14 @@ const DataBar = ({
onAddTerm={onAddTerm}
onRemoveTerm={onRemoveTerm}
onClearTerms={onClearTerms}
placeholder={searchPlaceholder}
className='flex-1'
placeholder={isMobile ? 'Search' : searchPlaceholder}
className={`${isMobile && isSearchFocused ? 'w-full' : 'flex-1'}`}
requireEnter
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
/>
<div className='flex items-center gap-3'>
{(!isMobile || !isSearchFocused) && (
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-3'} ${isMobile && isSearchFocused ? 'opacity-0' : 'opacity-100'}`}>
<SortDropdown
options={[
{key: 'name', label: 'Sort by Name'},
@@ -86,6 +106,7 @@ const DataBar = ({
onClick={toggleSelectionMode}
/>
</div>
)}
</>
);
@@ -95,7 +116,7 @@ const DataBar = ({
return (
<div className={className}>
<div className='flex items-center h-16 gap-4'>{controls}</div>
<div className={`flex items-center h-16 ${isMobile ? 'gap-1' : 'gap-4'}`}>{controls}</div>
</div>
);
};

View File

@@ -38,7 +38,7 @@ function FilterMenu({
<button
type='button'
className={`
flex items-center gap-2 px-3 py-2 rounded-md
flex items-center gap-2 px-3 py-2 min-h-10 rounded-md
border border-gray-200 dark:border-gray-700
transition-all duration-150 ease-in-out
group
@@ -66,7 +66,7 @@ function FilterMenu({
}
}}
/>
<span className='text-sm font-medium'>
<span className='text-sm font-medium hidden sm:inline'>
{filterType === 'none'
? 'Filter'
: options.find(option => option.value === filterType)

View File

@@ -13,6 +13,8 @@ const SearchBar = ({
onAddTerm,
onRemoveTerm,
onClearTerms,
onFocus,
onBlur,
textSize = 'text-sm', // Default text size
badgeTextSize = 'text-sm', // Default badge text size
iconSize = 'h-4 w-4', // Default icon size
@@ -113,8 +115,14 @@ const SearchBar = ({
<input
type='text'
value={currentInput}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onFocus={() => {
setIsFocused(true);
onFocus && onFocus();
}}
onBlur={() => {
setIsFocused(false);
onBlur && onBlur();
}}
onChange={e => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={

View File

@@ -22,7 +22,7 @@ export const SortDropdown = ({
<div className='relative'>
<button
onClick={() => setIsOpen(!isOpen)}
className='flex items-center gap-2 px-3 py-2 rounded-md
className='flex items-center gap-2 px-3 py-2 min-h-10 rounded-md
border border-gray-200 dark:border-gray-700
bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-750
@@ -36,7 +36,7 @@ export const SortDropdown = ({
group-hover:[transform:rotateX(180deg)]
group-hover:text-blue-500 dark:group-hover:text-blue-400'
/>
<span className='text-sm font-medium'>Sort</span>
<span className='text-sm font-medium hidden sm:inline'>Sort</span>
</button>
{isOpen && (

View File

@@ -6,7 +6,7 @@ const ToggleSelectButton = ({isSelectionMode, onClick, shortcutKey = 'A'}) => {
<button
onClick={onClick}
className={`
flex items-center gap-2 px-3 py-2 rounded-md
flex items-center gap-2 px-3 py-2 min-h-10 rounded-md
border border-gray-200 dark:border-gray-700
transition-all duration-150 ease-in-out
group
@@ -26,7 +26,7 @@ const ToggleSelectButton = ({isSelectionMode, onClick, shortcutKey = 'A'}) => {
}
`}
/>
<span className='text-sm font-medium'>Select</span>
<span className='text-sm font-medium hidden sm:inline'>Select</span>
</button>
);
};

View File

@@ -0,0 +1,110 @@
import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { ChevronDown } from 'lucide-react';
const Dropdown = ({
value,
onChange,
options = [],
placeholder = 'Select an option',
disabled = false,
className = ''
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const selectedOption = options.find(opt => opt.value === value);
const handleSelect = (option) => {
onChange({ target: { value: option.value } });
setIsOpen(false);
};
const baseClasses = `
relative w-full px-4 py-2.5
bg-gray-900/50
border border-gray-700/50
rounded
text-[13px] text-left
transition-all duration-200
flex items-center justify-between
cursor-pointer
outline-none
`;
const stateClasses = disabled
? 'text-gray-500 cursor-not-allowed opacity-60'
: `text-gray-200
hover:border-gray-600 hover:bg-gray-900/70
focus:bg-gray-900 focus:border-blue-400 focus:outline-none
placeholder:text-gray-600`;
return (
<div className="relative w-full" ref={dropdownRef}>
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
className={`${baseClasses} ${stateClasses} ${className}`}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace' }}
disabled={disabled}
>
<span className={!selectedOption ? 'text-gray-400' : ''}>
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronDown
className={`w-4 h-4 transition-transform duration-200 ${
isOpen ? 'transform rotate-180' : ''
} ${disabled ? 'text-gray-600' : 'text-gray-400'}`}
/>
</button>
{isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-gray-800 dark:bg-gray-800 border border-gray-600 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-auto">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option)}
className={`
w-full px-3 py-2 text-left text-sm
transition-colors duration-150
${option.value === value
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-100 dark:text-gray-100 hover:bg-gray-700 dark:hover:bg-gray-700'
}
`}
>
{option.label}
</button>
))}
</div>
)}
</div>
);
};
Dropdown.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
onChange: PropTypes.func.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
label: PropTypes.string.isRequired
})).isRequired,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
className: PropTypes.string
};
export default Dropdown;

View File

@@ -140,7 +140,7 @@ const Modal = ({
{title}
</h3>
{tabs && (
<div className='ml-3'>
<div className='ml-auto mr-3'>
<TabViewer
tabs={tabs}
activeTab={activeTab}
@@ -150,7 +150,7 @@ const Modal = ({
)}
<button
onClick={handleClose}
className='ml-auto text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors'>
className={`${tabs ? '' : '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'
fill='none'

View File

@@ -0,0 +1,77 @@
import React from 'react';
import PropTypes from 'prop-types';
const MonospaceInput = ({
value,
onChange,
placeholder,
disabled = false,
rows = 1,
className = '',
...props
}) => {
const baseClasses = `
w-full px-4 py-2.5
bg-gray-900/50
border border-gray-700/50
rounded
font-mono text-[13px]
transition-all duration-200
outline-none
`;
const stateClasses = disabled
? 'text-gray-500 cursor-not-allowed opacity-60'
: `text-gray-200
hover:border-gray-600 hover:bg-gray-900/70
focus:bg-gray-900 focus:border-blue-400 focus:outline-none focus-visible:outline-none
placeholder:text-gray-600`;
const combinedClasses = `${baseClasses} ${stateClasses} ${className}`;
if (rows > 1) {
return (
<textarea
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
spellCheck={false}
className={`${combinedClasses} resize-none whitespace-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide`}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
height: `${rows * 1.5}em`,
minHeight: 'unset',
maxHeight: `${rows * 1.5}em`
}}
wrap="off"
{...props}
/>
);
}
return (
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
spellCheck={false}
className={combinedClasses}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace' }}
{...props}
/>
);
};
MonospaceInput.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
rows: PropTypes.number,
className: PropTypes.string
};
export default MonospaceInput;

View File

@@ -37,6 +37,7 @@ ToggleSwitch.propTypes = {
function Navbar({darkMode, setDarkMode}) {
const [tabOffset, setTabOffset] = useState(0);
const [tabWidth, setTabWidth] = useState(0);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const tabsRef = useRef({});
const location = useLocation();
const [isInitialized, setIsInitialized] = useState(false);
@@ -46,6 +47,7 @@ function Navbar({darkMode, setDarkMode}) {
if (pathname.startsWith('/regex')) return 'regex';
if (pathname.startsWith('/format')) return 'format';
if (pathname.startsWith('/profile')) return 'profile';
if (pathname.startsWith('/media-management')) return 'media-management';
if (pathname.startsWith('/settings')) return 'settings';
return 'settings';
};
@@ -75,18 +77,22 @@ function Navbar({darkMode, setDarkMode}) {
return () => resizeObserver.disconnect();
}, [activeTab]);
useEffect(() => {
setIsMobileMenuOpen(false);
}, [location]);
return (
<nav className='bg-gradient-to-br from-gray-800 to-gray-900 border-b border-gray-700 shadow-xl backdrop-blur-sm'>
<nav className='bg-gradient-to-br from-gray-800 to-gray-900 border-b border-gray-700 shadow-xl backdrop-blur-sm relative z-[60]'>
<div className='max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 relative'>
<div className='flex items-center justify-between h-16'>
<div className='flex items-center space-x-8'>
<div className='flex items-center flex-1'>
<div className='flex items-center gap-3'>
<Logo className='h-10 w-10' />
<h1 className='text-2xl font-bold text-white'>
profilarr
</h1>
</div>
<div className='relative flex space-x-2'>
<div className='hidden lg:flex relative space-x-2 ml-8'>
{isInitialized && (
<div
className='absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300'
@@ -125,6 +131,16 @@ function Navbar({darkMode, setDarkMode}) {
}`}>
Quality Profiles
</Link>
<Link
to='/media-management'
ref={el => (tabsRef.current['media-management'] = el)}
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
activeTab === 'media-management'
? 'text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}>
Media Management
</Link>
<Link
to='/settings'
ref={el => (tabsRef.current['settings'] = el)}
@@ -137,11 +153,84 @@ function Navbar({darkMode, setDarkMode}) {
</Link>
</div>
</div>
<ToggleSwitch
checked={darkMode}
onChange={() => setDarkMode(!darkMode)}
/>
<div className='hidden lg:block'>
<ToggleSwitch
checked={darkMode}
onChange={() => setDarkMode(!darkMode)}
/>
</div>
<button
className='lg:hidden p-2 rounded-md text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white'
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}>
<span className='sr-only'>Open main menu</span>
{isMobileMenuOpen ? (
<svg className='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
) : (
<svg className='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M4 6h16M4 12h16M4 18h16' />
</svg>
)}
</button>
</div>
{isMobileMenuOpen && (
<div className='lg:hidden absolute top-16 left-0 right-0 bg-gray-800 border-b border-gray-700 shadow-lg z-[60]'>
<div className='px-2 pt-2 pb-3 space-y-1'>
<Link
to='/regex'
className={`block px-3 py-2 rounded-md text-base font-medium ${
activeTab === 'regex'
? 'text-white bg-gray-900'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}>
Regex Patterns
</Link>
<Link
to='/format'
className={`block px-3 py-2 rounded-md text-base font-medium ${
activeTab === 'format'
? 'text-white bg-gray-900'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}>
Custom Formats
</Link>
<Link
to='/profile'
className={`block px-3 py-2 rounded-md text-base font-medium ${
activeTab === 'profile'
? 'text-white bg-gray-900'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}>
Quality Profiles
</Link>
<Link
to='/media-management'
className={`block px-3 py-2 rounded-md text-base font-medium ${
activeTab === 'media-management'
? 'text-white bg-gray-900'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}>
Media Management
</Link>
<Link
to='/settings'
className={`block px-3 py-2 rounded-md text-base font-medium ${
activeTab === 'settings'
? 'text-white bg-gray-900'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}>
Settings
</Link>
</div>
<div className='px-4 py-3 border-t border-gray-700'>
<ToggleSwitch
checked={darkMode}
onChange={() => setDarkMode(!darkMode)}
/>
</div>
</div>
)}
</div>
</nav>
);

View File

@@ -71,13 +71,12 @@ const NumberInput = ({
};
const inputClasses = [
'w-20 h-8 px-2 py-1 text-sm border border-gray-700',
'w-full h-8 px-2 py-1 text-sm border border-gray-700',
'rounded-l focus:outline-none text-left',
'bg-gray-800',
isFocused ? 'text-blue-400' : 'text-gray-300',
'[appearance:textfield]',
disabled && 'opacity-50 cursor-not-allowed',
className
disabled && 'opacity-50 cursor-not-allowed'
]
.filter(Boolean)
.join(' ');

View File

@@ -0,0 +1,71 @@
import React from 'react';
import PropTypes from 'prop-types';
import RcSlider from 'rc-slider';
import 'rc-slider/assets/index.css';
const CustomSlider = ({ min = 0, max = 2000, minValue, preferredValue, maxValue, onChange, disabled = false }) => {
const handleChange = (values) => {
const minGap = 10;
let [newMin, newPreferred, newMax] = values;
// Ensure minimum gaps between handles
if (newPreferred - newMin < minGap) {
newPreferred = newMin + minGap;
}
if (newMax - newPreferred < minGap) {
newMax = newPreferred + minGap;
}
// Ensure we don't exceed bounds
if (newMax > max) {
newMax = max;
newPreferred = Math.min(newPreferred, newMax - minGap);
newMin = Math.min(newMin, newPreferred - minGap);
}
onChange({
min: newMin,
preferred: newPreferred,
max: newMax
});
};
return (
<div className={`w-full ${disabled ? 'opacity-60 pointer-events-none' : ''}`}>
<RcSlider
range
min={min}
max={max}
value={[minValue || 0, preferredValue || 0, maxValue || 0]}
onChange={handleChange}
disabled={disabled}
allowCross={false}
pushable={10}
trackStyle={[
{ backgroundColor: '#3b82f6', opacity: 0.3 },
{ backgroundColor: '#3b82f6', opacity: 0.6 }
]}
handleStyle={[
{ backgroundColor: '#3b82f6', border: 'none', opacity: disabled ? 0.5 : 1 },
{ backgroundColor: '#10b981', border: 'none', opacity: disabled ? 0.5 : 1 },
{ backgroundColor: '#ef4444', border: 'none', opacity: disabled ? 0.5 : 1 }
]}
railStyle={{ backgroundColor: 'oklch(13% 0.028 261.692 / 0.5)' }}
dotStyle={{ backgroundColor: '#6b7280', borderColor: '#9ca3af' }}
activeDotStyle={{ backgroundColor: '#3b82f6', borderColor: '#60a5fa' }}
/>
</div>
);
};
CustomSlider.propTypes = {
min: PropTypes.number,
max: PropTypes.number,
minValue: PropTypes.number.isRequired,
preferredValue: PropTypes.number.isRequired,
maxValue: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool
};
export default CustomSlider;

View File

@@ -5,6 +5,13 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => {
const [tabWidth, setTabWidth] = useState(0);
const tabsRef = useRef({});
const [isInitialized, setIsInitialized] = useState(false);
const [isMobile, setIsMobile] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth < 768;
}
return false;
});
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const updateTabPosition = () => {
if (tabsRef.current[activeTab]) {
@@ -29,8 +36,58 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => {
return () => resizeObserver.disconnect();
}, [activeTab]);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
setIsDropdownOpen(false);
}, [activeTab]);
if (!tabs?.length) return null;
const activeTabLabel = tabs.find(tab => tab.id === activeTab)?.label || '';
if (isMobile) {
return (
<div className='relative'>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className='flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors'>
{activeTabLabel}
<svg className={`w-4 h-4 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`} fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M19 9l-7 7-7-7' />
</svg>
</button>
{isDropdownOpen && (
<div className='absolute top-full right-0 mt-1 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50'>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => {
onTabChange(tab.id);
setIsDropdownOpen(false);
}}
className={`block w-full text-left px-4 py-2 text-sm font-medium transition-colors
${
activeTab === tab.id
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}>
{tab.label}
</button>
))}
</div>
)}
</div>
);
}
return (
<div className='relative flex items-center'>
{isInitialized && (
@@ -60,4 +117,4 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => {
);
};
export default TabViewer;
export default TabViewer;

View File

@@ -6,7 +6,8 @@ import {
Bug, // for fix
Code, // for regex
FileJson, // for format
Settings // for profile
Settings, // for profile
Cog // for media management
} from 'lucide-react';
export const COMMIT_TYPES = [
@@ -67,6 +68,11 @@ export const FILE_TYPES = {
bg: 'bg-amber-500/10',
text: 'text-amber-400',
icon: Settings
},
'Media Management': {
bg: 'bg-orange-500/10',
text: 'text-orange-400',
icon: Cog
}
};
@@ -85,5 +91,10 @@ export const COMMIT_SCOPES = [
value: 'profile',
label: 'Quality Profile',
description: 'Changes related to quality profiles'
},
{
value: 'media',
label: 'Media Management',
description: 'Changes related to media management settings'
}
];

View File

@@ -16,6 +16,17 @@ pre,
font-family: 'Fira Code', monospace;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Custom Scrollbar for Light Mode */
.scrollable::-webkit-scrollbar {
width: 8px;