mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat: Rename / Status Improvements (#142)
- Renames within profilarr now affect all files that reference the renamed file - Outgoing / Incoming status now properly shows renamed files as renamed, rather than new - Overhauled view changes header + commit section. Now shows commit scope + file type as badges + improved message parsing
This commit is contained in:
@@ -158,26 +158,39 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
|
||||
# Check if this is a rename operation
|
||||
if 'rename' in data:
|
||||
new_name = data['rename']
|
||||
old_name = filename_to_display(os.path.basename(file_path)[:-4])
|
||||
|
||||
directory = os.path.dirname(file_path)
|
||||
new_file_path = os.path.join(directory,
|
||||
display_to_filename(new_name))
|
||||
|
||||
# Remove rename field before saving
|
||||
# Update references before performing the rename
|
||||
try:
|
||||
updated_files = update_references(category, old_name, new_name)
|
||||
logger.info(f"Updated references in: {updated_files}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update references: {e}")
|
||||
raise Exception(f"Failed to update references: {str(e)}")
|
||||
|
||||
# Remove rename field and update the name field in the data
|
||||
data_to_save = {k: v for k, v in data.items() if k != 'rename'}
|
||||
data_to_save['name'] = new_name
|
||||
|
||||
# First save the updated content to the CURRENT file location
|
||||
save_yaml_file(file_path,
|
||||
data_to_save,
|
||||
category,
|
||||
use_data_name=False)
|
||||
|
||||
# Check if file is being tracked by git
|
||||
repo = git.Repo(REPO_PATH)
|
||||
rel_old_path = os.path.relpath(file_path, REPO_PATH)
|
||||
rel_new_path = os.path.relpath(new_file_path, REPO_PATH)
|
||||
|
||||
try:
|
||||
# Check if file is tracked by git
|
||||
# First, save the content changes to the current file
|
||||
save_yaml_file(file_path,
|
||||
data_to_save,
|
||||
category,
|
||||
use_data_name=False)
|
||||
|
||||
# Stage the content changes first
|
||||
repo.index.add([rel_old_path])
|
||||
|
||||
# Then perform the rename
|
||||
tracked_files = repo.git.ls_files().splitlines()
|
||||
is_tracked = rel_old_path in tracked_files
|
||||
|
||||
@@ -187,6 +200,8 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
|
||||
else:
|
||||
# For untracked files, manually move
|
||||
os.rename(file_path, new_file_path)
|
||||
# Stage the new file
|
||||
repo.index.add([rel_new_path])
|
||||
|
||||
except git.GitCommandError as e:
|
||||
logger.error(f"Git operation failed: {e}")
|
||||
@@ -299,6 +314,95 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]:
|
||||
return False, f"Error checking references: {str(e)}"
|
||||
|
||||
|
||||
def update_references(category: str, old_name: str,
|
||||
new_name: str) -> List[str]:
|
||||
"""
|
||||
Update references to a renamed item across all relevant files.
|
||||
Returns a list of files that were updated.
|
||||
"""
|
||||
updated_files = []
|
||||
|
||||
try:
|
||||
# Convert names to use parentheses for comparison
|
||||
old_check_name = old_name.replace('[', '(').replace(']', ')')
|
||||
new_check_name = new_name.replace('[', '(').replace(']', ')')
|
||||
|
||||
if category == 'regex_pattern':
|
||||
# Update references in custom formats
|
||||
format_dir = get_category_directory('custom_format')
|
||||
for format_file in os.listdir(format_dir):
|
||||
if not format_file.endswith('.yml'):
|
||||
continue
|
||||
|
||||
format_path = os.path.join(format_dir, format_file)
|
||||
try:
|
||||
format_data = load_yaml_file(format_path)
|
||||
updated = False
|
||||
|
||||
# Check and update each condition in the format
|
||||
for condition in format_data.get('conditions', []):
|
||||
if (condition['type'] in [
|
||||
'release_title', 'release_group', 'edition'
|
||||
] and condition.get('pattern') == old_check_name):
|
||||
condition['pattern'] = new_check_name
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
save_yaml_file(format_path,
|
||||
format_data,
|
||||
'custom_format',
|
||||
use_data_name=False)
|
||||
updated_files.append(
|
||||
f"custom format: {format_data['name']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error updating format file {format_file}: {e}")
|
||||
continue
|
||||
|
||||
elif category == 'custom_format':
|
||||
# Update references in quality profiles
|
||||
profile_dir = get_category_directory('profile')
|
||||
for profile_file in os.listdir(profile_dir):
|
||||
if not profile_file.endswith('.yml'):
|
||||
continue
|
||||
|
||||
profile_path = os.path.join(profile_dir, profile_file)
|
||||
try:
|
||||
profile_data = load_yaml_file(profile_path)
|
||||
updated = False
|
||||
|
||||
# Update custom_formats array in profile
|
||||
for format_ref in profile_data.get('custom_formats', []):
|
||||
format_name = format_ref.get('name', '')
|
||||
# Convert format name to use parentheses for comparison
|
||||
format_name = format_name.replace('[', '(').replace(
|
||||
']', ')')
|
||||
|
||||
if format_name == old_check_name:
|
||||
format_ref['name'] = new_name
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
save_yaml_file(profile_path,
|
||||
profile_data,
|
||||
'profile',
|
||||
use_data_name=False)
|
||||
updated_files.append(
|
||||
f"quality profile: {profile_data['name']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error updating profile file {profile_file}: {e}")
|
||||
continue
|
||||
|
||||
return updated_files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating references: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def test_regex_pattern(
|
||||
pattern: str,
|
||||
tests: List[Dict[str, Any]]) -> Tuple[bool, str, List[Dict[str, Any]]]:
|
||||
|
||||
@@ -8,6 +8,16 @@ from .utils import determine_type, parse_commit_message
|
||||
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
|
||||
|
||||
|
||||
def check_merge_conflict(repo, branch, file_path):
|
||||
"""Check if pulling a file would cause merge conflicts"""
|
||||
try:
|
||||
@@ -15,7 +25,7 @@ def check_merge_conflict(repo, branch, file_path):
|
||||
status = repo.git.status('--porcelain', file_path).strip()
|
||||
if status:
|
||||
status_code = status[:2] if len(status) >= 2 else ''
|
||||
has_changes = 'M' in status_code or 'A' in status_code or 'D' in status_code
|
||||
has_changes = 'M' in status_code or 'A' in status_code or 'D' in status_code or 'R' in status_code
|
||||
else:
|
||||
# Check for unpushed commits
|
||||
merge_base = repo.git.merge_base('HEAD',
|
||||
@@ -49,32 +59,122 @@ def get_commit_message(repo, branch, file_path):
|
||||
raw_message = repo.git.show(f'HEAD...origin/{branch}', '--format=%B',
|
||||
'-s', '--', file_path).strip()
|
||||
return parse_commit_message(raw_message)
|
||||
except GitCommandError:
|
||||
except GitCommandError as e:
|
||||
logger.error(
|
||||
f"Git command error getting commit message for {file_path}: {str(e)}"
|
||||
)
|
||||
return {
|
||||
"body": "",
|
||||
"footer": "",
|
||||
"scope": "",
|
||||
"subject": "Unable to retrieve commit message",
|
||||
"subject": f"Error retrieving commit message: {str(e)}",
|
||||
"type": ""
|
||||
}
|
||||
|
||||
|
||||
def parse_commit_message(message):
|
||||
"""Parse a commit message into its components"""
|
||||
try:
|
||||
# Default structure
|
||||
parsed = {
|
||||
"type": "Unknown Type",
|
||||
"scope": "Unknown Scope",
|
||||
"subject": "",
|
||||
"body": "",
|
||||
"footer": ""
|
||||
}
|
||||
|
||||
if not message:
|
||||
return parsed
|
||||
|
||||
# Split message into lines
|
||||
lines = message.strip().split('\n')
|
||||
|
||||
# Parse first line (header)
|
||||
if lines:
|
||||
header = lines[0]
|
||||
|
||||
# Try to parse conventional commit format: type(scope): subject
|
||||
import re
|
||||
conventional_format = re.match(r'^(\w+)(?:\(([^)]+)\))?: (.+)$',
|
||||
header)
|
||||
|
||||
if conventional_format:
|
||||
groups = conventional_format.groups()
|
||||
parsed.update({
|
||||
"type": groups[0] or "Unknown Type",
|
||||
"scope": groups[1] or "Unknown Scope",
|
||||
"subject": groups[2]
|
||||
})
|
||||
else:
|
||||
parsed["subject"] = header
|
||||
|
||||
# Parse body and footer
|
||||
if len(lines) > 1:
|
||||
# Find the divider between body and footer (if any)
|
||||
footer_start = -1
|
||||
for i, line in enumerate(lines[1:], 1):
|
||||
if re.match(r'^[A-Z_-]+:', line):
|
||||
footer_start = i
|
||||
break
|
||||
|
||||
# Extract body and footer
|
||||
if footer_start != -1:
|
||||
parsed["body"] = '\n'.join(lines[1:footer_start]).strip()
|
||||
parsed["footer"] = '\n'.join(lines[footer_start:]).strip()
|
||||
else:
|
||||
parsed["body"] = '\n'.join(lines[1:]).strip()
|
||||
|
||||
return parsed
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing commit message: {str(e)}")
|
||||
return {
|
||||
"type": "Unknown Type",
|
||||
"scope": "Unknown Scope",
|
||||
"subject": "Error parsing commit message",
|
||||
"body": "",
|
||||
"footer": ""
|
||||
}
|
||||
|
||||
|
||||
def get_incoming_changes(repo, branch):
|
||||
"""Get list of changes that would come in from origin"""
|
||||
try:
|
||||
# Get changed files
|
||||
diff_index = repo.git.diff(f'HEAD...origin/{branch}',
|
||||
'--name-only').split('\n')
|
||||
changed_files = list(filter(None, set(diff_index)))
|
||||
# Get status including renames
|
||||
diff_output = repo.git.diff(f'HEAD...origin/{branch}', '--name-status',
|
||||
'-M').split('\n')
|
||||
changed_files = []
|
||||
rename_mapping = {}
|
||||
|
||||
# Process status to identify renames
|
||||
for line in diff_output:
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
status = parts[0]
|
||||
if status.startswith('R'):
|
||||
old_path, new_path = parts[1], parts[2]
|
||||
rename_mapping[new_path] = old_path
|
||||
changed_files.append(new_path)
|
||||
else:
|
||||
changed_files.append(parts[1])
|
||||
|
||||
logger.info(f"Processing {len(changed_files)} incoming changes")
|
||||
|
||||
incoming_changes = []
|
||||
for file_path in changed_files:
|
||||
try:
|
||||
# Handle renamed files
|
||||
old_path = rename_mapping.get(file_path, file_path)
|
||||
is_rename = file_path in rename_mapping
|
||||
|
||||
# Get local and remote versions
|
||||
try:
|
||||
local_content = repo.git.show(f'HEAD:{file_path}')
|
||||
local_content = repo.git.show(f'HEAD:{old_path}')
|
||||
local_data = yaml.safe_load(local_content)
|
||||
except (GitCommandError, yaml.YAMLError):
|
||||
local_data = None
|
||||
@@ -87,7 +187,7 @@ def get_incoming_changes(repo, branch):
|
||||
remote_data = None
|
||||
|
||||
# Skip if no actual changes
|
||||
if local_data == remote_data:
|
||||
if local_data == remote_data and not is_rename:
|
||||
continue
|
||||
|
||||
# Check for conflicts and get commit info
|
||||
@@ -109,13 +209,17 @@ def get_incoming_changes(repo, branch):
|
||||
'id':
|
||||
remote_data.get('id') if remote_data else None,
|
||||
'local_name':
|
||||
local_data.get('name') if local_data else None,
|
||||
extract_name(old_path)
|
||||
if is_rename else extract_name(file_path),
|
||||
'incoming_name':
|
||||
remote_data.get('name') if remote_data else None,
|
||||
extract_name(file_path),
|
||||
'staged':
|
||||
False
|
||||
})
|
||||
|
||||
if is_rename:
|
||||
change['status'] = 'Renamed'
|
||||
|
||||
incoming_changes.append(change)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -8,10 +8,19 @@ from .utils import determine_type
|
||||
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
|
||||
|
||||
|
||||
def get_outgoing_changes(repo):
|
||||
"""Get list of changes in working directory"""
|
||||
try:
|
||||
# Get status of working directory
|
||||
status = repo.git.status('--porcelain', '-z').split('\0')
|
||||
logger.info(f"Processing {len(status)} changes from git status")
|
||||
|
||||
@@ -37,50 +46,68 @@ def get_outgoing_changes(repo):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
is_staged = x != ' ' and x != '?'
|
||||
# Handle renamed files
|
||||
if x == 'R' or y == 'R':
|
||||
if i + 1 < len(status) and status[i + 1]:
|
||||
outgoing_name = extract_name(file_path)
|
||||
prior_name = extract_name(status[i + 1])
|
||||
original_path = status[i + 1] # Path for old content
|
||||
new_path = file_path # Path for new content
|
||||
is_staged = x == 'R'
|
||||
status_value = 'Renamed'
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
name = extract_name(file_path)
|
||||
prior_name = name
|
||||
outgoing_name = name
|
||||
original_path = file_path
|
||||
new_path = file_path
|
||||
is_staged = x != ' ' and x != '?'
|
||||
status_value = None
|
||||
i += 1
|
||||
|
||||
try:
|
||||
# Get old content (from HEAD)
|
||||
try:
|
||||
old_content = repo.git.show(f'HEAD:{file_path}')
|
||||
old_content = repo.git.show(f'HEAD:{original_path}')
|
||||
old_data = yaml.safe_load(old_content)
|
||||
except GitCommandError:
|
||||
old_data = None
|
||||
except yaml.YAMLError as e:
|
||||
logger.warning(
|
||||
f"Failed to parse old YAML for {file_path}: {str(e)}")
|
||||
f"Failed to parse old YAML for {original_path}: {str(e)}"
|
||||
)
|
||||
old_data = None
|
||||
|
||||
# Get new content (from working directory)
|
||||
try:
|
||||
full_path = os.path.join(repo.working_dir, file_path)
|
||||
full_path = os.path.join(repo.working_dir, new_path)
|
||||
with open(full_path, 'r') as f:
|
||||
new_data = yaml.safe_load(f.read())
|
||||
except (IOError, yaml.YAMLError) as e:
|
||||
logger.warning(
|
||||
f"Failed to read/parse current file {file_path}: {str(e)}"
|
||||
f"Failed to read/parse current file {new_path}: {str(e)}"
|
||||
)
|
||||
new_data = None
|
||||
|
||||
# Generate change summary
|
||||
change = create_change_summary(old_data, new_data, file_path)
|
||||
change['type'] = determine_type(file_path)
|
||||
change = create_change_summary(old_data, new_data, new_path)
|
||||
change['type'] = determine_type(new_path)
|
||||
change['staged'] = is_staged
|
||||
change['prior_name'] = prior_name
|
||||
change['outgoing_name'] = outgoing_name
|
||||
|
||||
if status_value:
|
||||
change['status'] = status_value
|
||||
|
||||
changes.append(change)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process {file_path}: {str(e)}",
|
||||
exc_info=True)
|
||||
|
||||
i += 1
|
||||
|
||||
# Handle renamed files
|
||||
if x == 'R' or y == 'R':
|
||||
if i + 1 < len(status) and status[i + 1]:
|
||||
i += 2 # Skip the old filename entry
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return changes
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import yaml
|
||||
import threading
|
||||
from datetime import datetime
|
||||
import json
|
||||
from ...db import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -217,37 +218,85 @@ class GitStatusManager:
|
||||
return self.status.copy()
|
||||
|
||||
|
||||
def format_git_status(status):
|
||||
"""Format git status for logging with truncation and pretty printing.
|
||||
|
||||
Args:
|
||||
status (dict): The git status dictionary to format
|
||||
|
||||
Returns:
|
||||
str: Formatted status string
|
||||
"""
|
||||
|
||||
def truncate_list(lst, max_items=3):
|
||||
"""Truncate a list and add count of remaining items."""
|
||||
if len(lst) <= max_items:
|
||||
return lst
|
||||
return lst[:max_items] + [f"... and {len(lst) - max_items} more items"]
|
||||
|
||||
def truncate_string(s, max_length=50):
|
||||
"""Truncate a string if it's too long."""
|
||||
if not s or len(s) <= max_length:
|
||||
return s
|
||||
return s[:max_length] + "..."
|
||||
|
||||
# Create a copy to modify
|
||||
formatted_status = status.copy()
|
||||
|
||||
# Truncate lists
|
||||
for key in [
|
||||
'outgoing_changes', 'merge_conflicts', 'incoming_changes',
|
||||
'unpushed_files'
|
||||
]:
|
||||
if key in formatted_status and isinstance(formatted_status[key], list):
|
||||
formatted_status[key] = truncate_list(formatted_status[key])
|
||||
|
||||
# Format any nested dictionaries in the lists
|
||||
for key in formatted_status:
|
||||
if isinstance(formatted_status[key], list):
|
||||
formatted_status[key] = [{
|
||||
k: truncate_string(str(v))
|
||||
for k, v in item.items()
|
||||
} if isinstance(item, dict) else item
|
||||
for item in formatted_status[key]]
|
||||
|
||||
# Convert to JSON with nice formatting
|
||||
formatted_json = json.dumps(formatted_status, indent=2, default=str)
|
||||
|
||||
# Add a timestamp header
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return f"=== Git Status at {timestamp} ===\n{formatted_json}"
|
||||
|
||||
|
||||
def get_git_status(repo_path):
|
||||
try:
|
||||
status_manager = GitStatusManager.get_instance(repo_path)
|
||||
status_manager.update_local_status()
|
||||
return True, status_manager.get_status()
|
||||
success, status = True, status_manager.get_status()
|
||||
|
||||
# Log the formatted status
|
||||
logger.info("\n" + format_git_status(status))
|
||||
|
||||
return success, status
|
||||
except git.exc.InvalidGitRepositoryError:
|
||||
logger.info(f"No git repository found at {repo_path}")
|
||||
# Return a valid status object indicating no repo
|
||||
return True, {
|
||||
# Local status - empty/false everything
|
||||
empty_status = {
|
||||
"branch": "",
|
||||
"outgoing_changes": [],
|
||||
"is_merging": False,
|
||||
"merge_conflicts": [],
|
||||
"has_conflicts": False,
|
||||
|
||||
# Remote status - explicitly show no repo/remote
|
||||
"remote_branch_exists": False,
|
||||
"commits_behind": 0,
|
||||
"commits_ahead": 0,
|
||||
"incoming_changes": [],
|
||||
"has_unpushed_commits": False,
|
||||
"unpushed_files": [],
|
||||
|
||||
# Metadata
|
||||
"last_local_update": None,
|
||||
"last_remote_update": None,
|
||||
|
||||
# New flag to explicitly indicate no repo
|
||||
"has_repo": False
|
||||
}
|
||||
return True, empty_status
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_git_status: {str(e)}", exc_info=True)
|
||||
return False, str(e)
|
||||
|
||||
@@ -176,11 +176,7 @@ const ProfileCard = ({
|
||||
{/* Description - Fixed Height with Scroll */}
|
||||
<div className='flex-1 overflow-hidden text-sm'>
|
||||
{content.description && (
|
||||
<div
|
||||
className='h-full overflow-y-auto prose prose-invert prose-gray max-w-none
|
||||
[&>ul]:list-disc [&>ul]:ml-4 [&>ul]:mt-2 [&>ul]:mb-4
|
||||
[&>ol]:list-decimal [&>ol]:ml-4 [&>ol]:mt-2 [&>ol]:mb-4
|
||||
[&>ul>li]:mt-0.5 [&>ol>li]:mt-0.5'>
|
||||
<div className='h-full overflow-y-auto prose prose-invert prose-gray max-w-none [&>ul]:list-disc [&>ul]:ml-4 [&>ul]:mt-2 [&>ul]:mb-4 [&>ol]:list-decimal [&>ol]:ml-4 [&>ol]:mt-2 [&>ol]:mb-4 [&>ul>li]:mt-0.5 [&>ol>li]:mt-0.5 scrollable'>
|
||||
<ReactMarkdown>
|
||||
{unsanitize(content.description)}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -7,10 +7,8 @@ const ProfileGeneralTab = ({
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
upgradesAllowed,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
onUpgradesAllowedChange,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
error
|
||||
@@ -42,37 +40,13 @@ const ProfileGeneralTab = ({
|
||||
)}
|
||||
<div className='space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Profile Name
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Name of this profile. Import will use the same
|
||||
name
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col items-end space-y-1'>
|
||||
<label className='flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={upgradesAllowed}
|
||||
onChange={e =>
|
||||
onUpgradesAllowedChange(
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
className='rounded border-gray-300 dark:border-gray-600
|
||||
text-blue-500 focus:ring-blue-500
|
||||
h-4 w-4 cursor-pointer
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
<span>Upgrades Allowed</span>
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Allow automatic upgrades for this profile
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Profile Name
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Name of this profile. Import will use the same name
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
@@ -141,20 +115,20 @@ const ProfileGeneralTab = ({
|
||||
<span
|
||||
key={tag}
|
||||
className='inline-flex items-center px-2.5 py-1 rounded-md
|
||||
text-xs font-semibold
|
||||
bg-blue-600/20 text-blue-400
|
||||
group'>
|
||||
text-xs font-semibold
|
||||
bg-blue-600/20 text-blue-400
|
||||
group'>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => onRemoveTag(tag)}
|
||||
className='ml-1.5 p-0.5 rounded-md
|
||||
hover:bg-blue-500/20
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-blue-500 focus:ring-offset-1
|
||||
transition-colors duration-200'>
|
||||
hover:bg-blue-500/20
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-blue-500 focus:ring-offset-1
|
||||
transition-colors duration-200'>
|
||||
<svg
|
||||
className='w-3.5 h-3.5 text-blue-400
|
||||
opacity-60 group-hover:opacity-100 transition-opacity'
|
||||
opacity-60 group-hover:opacity-100 transition-opacity'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'>
|
||||
@@ -172,10 +146,10 @@ const ProfileGeneralTab = ({
|
||||
) : (
|
||||
<div
|
||||
className='flex items-center justify-center h-12
|
||||
text-sm text-gray-500 dark:text-gray-400
|
||||
rounded-md border border-dashed
|
||||
border-gray-300 dark:border-gray-700
|
||||
bg-gray-50 dark:bg-gray-800/50'>
|
||||
text-sm text-gray-500 dark:text-gray-400
|
||||
rounded-md border border-dashed
|
||||
border-gray-300 dark:border-gray-700
|
||||
bg-gray-50 dark:bg-gray-800/50'>
|
||||
No tags added yet
|
||||
</div>
|
||||
)}
|
||||
@@ -189,10 +163,8 @@ ProfileGeneralTab.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
upgradesAllowed: PropTypes.bool.isRequired,
|
||||
onNameChange: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
onUpgradesAllowedChange: PropTypes.func.isRequired,
|
||||
onAddTag: PropTypes.func.isRequired,
|
||||
onRemoveTag: PropTypes.func.isRequired,
|
||||
error: PropTypes.string
|
||||
|
||||
@@ -5,7 +5,7 @@ import Modal from '../ui/Modal';
|
||||
import Alert from '@ui/Alert';
|
||||
import {Loader} from 'lucide-react';
|
||||
import ProfileGeneralTab from './ProfileGeneralTab';
|
||||
import ProfileScoringTab from './ProfileScoringTab';
|
||||
import ProfileScoringTab from './scoring/ProfileScoringTab';
|
||||
import ProfileQualitiesTab from './ProfileQualitiesTab';
|
||||
import ProfileLangaugesTab from './ProfileLangaugesTab';
|
||||
import ProfileTweaksTab from './ProfileTweaksTab';
|
||||
@@ -505,10 +505,8 @@ function ProfileModal({
|
||||
<ProfileGeneralTab
|
||||
name={name}
|
||||
description={description}
|
||||
upgradesAllowed={upgradesAllowed}
|
||||
onNameChange={setName}
|
||||
onDescriptionChange={setDescription}
|
||||
onUpgradesAllowedChange={setUpgradesAllowed}
|
||||
error={error}
|
||||
tags={tags}
|
||||
onAddTag={tag => setTags([...tags, tag])}
|
||||
@@ -541,7 +539,6 @@ function ProfileModal({
|
||||
...prev,
|
||||
[tag]: score
|
||||
}));
|
||||
|
||||
setCustomFormats(prev =>
|
||||
prev.map(format => {
|
||||
if (
|
||||
@@ -567,6 +564,8 @@ function ProfileModal({
|
||||
setUpgradeUntilScore
|
||||
}
|
||||
onMinIncrementChange={setMinScoreIncrement}
|
||||
upgradesAllowed={upgradesAllowed}
|
||||
onUpgradesAllowedChange={setUpgradesAllowed}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'qualities' && (
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {SortDropdown} from '@ui/DataBar/SortDropdown';
|
||||
import TabViewer from '@ui/TabViewer';
|
||||
import SearchBar from '@ui/DataBar/SearchBar';
|
||||
import useSearch from '@hooks/useSearch';
|
||||
|
||||
const ProfileScoringTab = ({
|
||||
formats,
|
||||
onScoreChange,
|
||||
formatSortKey,
|
||||
formatSortDirection,
|
||||
onFormatSort,
|
||||
tags,
|
||||
tagScores,
|
||||
onTagScoreChange,
|
||||
tagSortKey,
|
||||
tagSortDirection,
|
||||
onTagSort,
|
||||
minCustomFormatScore,
|
||||
upgradeUntilScore,
|
||||
minScoreIncrement,
|
||||
onMinScoreChange,
|
||||
onUpgradeUntilScoreChange,
|
||||
onMinIncrementChange
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('formats');
|
||||
const [localFormatScores, setLocalFormatScores] = useState({});
|
||||
const [localTagScores, setLocalTagScores] = useState({});
|
||||
|
||||
const {
|
||||
searchTerms: formatSearchTerms,
|
||||
currentInput: formatSearchInput,
|
||||
setCurrentInput: setFormatSearchInput,
|
||||
addSearchTerm: addFormatSearchTerm,
|
||||
removeSearchTerm: removeFormatSearchTerm,
|
||||
clearSearchTerms: clearFormatSearchTerms,
|
||||
items: filteredFormats
|
||||
} = useSearch(formats, {
|
||||
searchableFields: ['name', 'tags'],
|
||||
initialSortBy: formatSortKey
|
||||
});
|
||||
|
||||
const tagObjects = tags.map(tag => ({name: tag}));
|
||||
|
||||
const {
|
||||
searchTerms: tagSearchTerms,
|
||||
currentInput: tagSearchInput,
|
||||
setCurrentInput: setTagSearchInput,
|
||||
addSearchTerm: addTagSearchTerm,
|
||||
removeSearchTerm: removeTagSearchTerm,
|
||||
clearSearchTerms: clearTagSearchTerms,
|
||||
items: filteredTagObjects
|
||||
} = useSearch(tagObjects, {
|
||||
searchableFields: ['name']
|
||||
});
|
||||
|
||||
const filteredTags = filteredTagObjects.map(t => t.name);
|
||||
|
||||
const tabs = [
|
||||
{id: 'formats', label: 'Format Scoring'},
|
||||
{id: 'tags', label: 'Tag Scoring'},
|
||||
{id: 'upgrades', label: 'Upgrades'}
|
||||
];
|
||||
|
||||
// Handle local score changes
|
||||
const handleFormatScoreChange = (id, value) => {
|
||||
setLocalFormatScores(prev => ({...prev, [id]: value}));
|
||||
};
|
||||
|
||||
const handleTagScoreChange = (tag, value) => {
|
||||
setLocalTagScores(prev => ({...prev, [tag]: value}));
|
||||
};
|
||||
|
||||
// Handle blur events
|
||||
const handleFormatBlur = (id, currentScore) => {
|
||||
const localValue = localFormatScores[id];
|
||||
if (localValue === undefined) return;
|
||||
const numValue = localValue === '' ? 0 : parseInt(localValue);
|
||||
if (numValue !== currentScore) {
|
||||
onScoreChange(id, numValue);
|
||||
}
|
||||
setLocalFormatScores(prev => {
|
||||
const newState = {...prev};
|
||||
delete newState[id];
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTagBlur = tag => {
|
||||
const localValue = localTagScores[tag];
|
||||
if (localValue === undefined) return;
|
||||
const currentScore = tagScores[tag] ?? 0;
|
||||
const numValue = localValue === '' ? 0 : parseInt(localValue);
|
||||
if (numValue !== currentScore) {
|
||||
onTagScoreChange(tag, numValue);
|
||||
}
|
||||
setLocalTagScores(prev => {
|
||||
const newState = {...prev};
|
||||
delete newState[tag];
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
// Sort formats
|
||||
const sortedFormats = [...filteredFormats].sort((a, b) => {
|
||||
if (formatSortKey === 'name') {
|
||||
return formatSortDirection === 'asc'
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name);
|
||||
} else if (formatSortKey === 'score') {
|
||||
return formatSortDirection === 'asc'
|
||||
? a.score - b.score
|
||||
: b.score - a.score;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Sort tags
|
||||
const sortedTags = [...filteredTags].sort((a, b) => {
|
||||
if (tagSortKey === 'name') {
|
||||
return tagSortDirection === 'asc'
|
||||
? a.localeCompare(b)
|
||||
: b.localeCompare(a);
|
||||
} else if (tagSortKey === 'score') {
|
||||
return tagSortDirection === 'asc'
|
||||
? (tagScores[a] || 0) - (tagScores[b] || 0)
|
||||
: (tagScores[b] || 0) - (tagScores[a] || 0);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Handle keydown to submit on enter
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'formats':
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
<SearchBar
|
||||
placeholder='Search formats...'
|
||||
className='flex-1'
|
||||
searchTerms={formatSearchTerms}
|
||||
currentInput={formatSearchInput}
|
||||
onInputChange={setFormatSearchInput}
|
||||
onAddTerm={addFormatSearchTerm}
|
||||
onRemoveTerm={removeFormatSearchTerm}
|
||||
onClearTerms={clearFormatSearchTerms}
|
||||
/>
|
||||
<SortDropdown
|
||||
options={[
|
||||
{key: 'name', label: 'Name'},
|
||||
{key: 'score', label: 'Score'}
|
||||
]}
|
||||
currentKey={formatSortKey}
|
||||
currentDirection={formatSortDirection}
|
||||
onSort={onFormatSort}
|
||||
/>
|
||||
</div>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
{sortedFormats.length === 0 ? (
|
||||
<div className='p-3 text-center text-xs text-gray-500'>
|
||||
No formats found
|
||||
</div>
|
||||
) : (
|
||||
sortedFormats.map(format => (
|
||||
<div
|
||||
key={format.id}
|
||||
className='flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-800/50'>
|
||||
<div className='flex items-center gap-2 min-w-0'>
|
||||
<span className='text-xs font-medium truncate'>
|
||||
{format.name}
|
||||
</span>
|
||||
<div className='flex gap-1'>
|
||||
{format.tags?.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className='px-1.5 py-0.5 text-[10px] rounded bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
value={
|
||||
localFormatScores[
|
||||
format.id
|
||||
] !== undefined
|
||||
? localFormatScores[
|
||||
format.id
|
||||
]
|
||||
: format.score
|
||||
}
|
||||
onChange={e =>
|
||||
handleFormatScoreChange(
|
||||
format.id,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={() =>
|
||||
handleFormatBlur(
|
||||
format.id,
|
||||
format.score
|
||||
)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-20 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'tags':
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
<SearchBar
|
||||
placeholder='Search tags...'
|
||||
className='flex-1'
|
||||
searchTerms={tagSearchTerms}
|
||||
currentInput={tagSearchInput}
|
||||
onInputChange={setTagSearchInput}
|
||||
onAddTerm={addTagSearchTerm}
|
||||
onRemoveTerm={removeTagSearchTerm}
|
||||
onClearTerms={clearTagSearchTerms}
|
||||
/>
|
||||
<SortDropdown
|
||||
options={[
|
||||
{key: 'name', label: 'Name'},
|
||||
{key: 'score', label: 'Score'}
|
||||
]}
|
||||
currentKey={tagSortKey}
|
||||
currentDirection={tagSortDirection}
|
||||
onSort={onTagSort}
|
||||
/>
|
||||
</div>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
{sortedTags.length === 0 ? (
|
||||
<div className='p-3 text-center text-xs text-gray-500'>
|
||||
No tags found
|
||||
</div>
|
||||
) : (
|
||||
sortedTags.map(tag => (
|
||||
<div
|
||||
key={tag}
|
||||
className='flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-800/50'>
|
||||
<span className='text-xs'>
|
||||
{tag}
|
||||
</span>
|
||||
<input
|
||||
type='number'
|
||||
value={
|
||||
localTagScores[tag] !==
|
||||
undefined
|
||||
? localTagScores[tag]
|
||||
: tagScores[tag] || 0
|
||||
}
|
||||
onChange={e =>
|
||||
handleTagScoreChange(
|
||||
tag,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={() =>
|
||||
handleTagBlur(tag)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-20 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'upgrades':
|
||||
return (
|
||||
<div className='space-y-6 bg-white dark:bg-gray-800 rounded-lg p-3'>
|
||||
<div className='divide-y divide-gray-200 dark:divide-gray-700 space-y-4'>
|
||||
{/* Minimum Custom Format Score */}
|
||||
<div className='pt-4 first:pt-0'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Minimum Custom Format Score
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Minimum custom format score allowed
|
||||
to download
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
value={minCustomFormatScore}
|
||||
onChange={e =>
|
||||
onMinScoreChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='w-20 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Until Score */}
|
||||
<div className='pt-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Upgrade Until Custom Format Score
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Once the quality cutoff is met or
|
||||
exceeded and this custom format
|
||||
score is reached, no more upgrades
|
||||
will be grabbed
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
value={upgradeUntilScore}
|
||||
onChange={e =>
|
||||
onUpgradeUntilScoreChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='w-20 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Minimum Score Increment */}
|
||||
<div className='pt-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Minimum Custom Format Score
|
||||
Increment
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Minimum required improvement of the
|
||||
custom format score between existing
|
||||
and new releases before considering
|
||||
an upgrade
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
value={minScoreIncrement}
|
||||
onChange={e =>
|
||||
onMinIncrementChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='w-20 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full space-y-4'>
|
||||
<div className='flex items-center'>
|
||||
<TabViewer
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileScoringTab.propTypes = {
|
||||
formats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
score: PropTypes.number.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string)
|
||||
})
|
||||
).isRequired,
|
||||
formatFilter: PropTypes.string.isRequired,
|
||||
onFormatFilterChange: PropTypes.func.isRequired,
|
||||
onScoreChange: PropTypes.func.isRequired,
|
||||
formatSortKey: PropTypes.string.isRequired,
|
||||
formatSortDirection: PropTypes.string.isRequired,
|
||||
onFormatSort: PropTypes.func.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
tagFilter: PropTypes.string.isRequired,
|
||||
onTagFilterChange: PropTypes.func.isRequired,
|
||||
tagScores: PropTypes.object.isRequired,
|
||||
onTagScoreChange: PropTypes.func.isRequired,
|
||||
tagSortKey: PropTypes.string.isRequired,
|
||||
tagSortDirection: PropTypes.string.isRequired,
|
||||
onTagSort: PropTypes.func.isRequired,
|
||||
minCustomFormatScore: PropTypes.number.isRequired,
|
||||
upgradeUntilScore: PropTypes.number.isRequired,
|
||||
minScoreIncrement: PropTypes.number.isRequired,
|
||||
onMinScoreChange: PropTypes.func.isRequired,
|
||||
onUpgradeUntilScoreChange: PropTypes.func.isRequired,
|
||||
onMinIncrementChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ProfileScoringTab;
|
||||
200
frontend/src/components/profile/scoring/AdvancedView.jsx
Normal file
200
frontend/src/components/profile/scoring/AdvancedView.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import NumberInput from '@ui/NumberInput';
|
||||
import {useSorting} from '@hooks/useSorting';
|
||||
import SortDropdown from '@ui/SortDropdown';
|
||||
import {
|
||||
Music,
|
||||
Tv,
|
||||
Users,
|
||||
Cloud,
|
||||
Film,
|
||||
HardDrive,
|
||||
Maximize,
|
||||
Globe,
|
||||
Video,
|
||||
Flag,
|
||||
Zap,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdvancedView = ({formats, onScoreChange}) => {
|
||||
const sortOptions = [
|
||||
{label: 'Name', value: 'name'},
|
||||
{label: 'Score', value: 'score'}
|
||||
];
|
||||
|
||||
// Group formats by their tags
|
||||
const groupedFormats = formats.reduce((acc, format) => {
|
||||
// Check if format has any tags that match our known categories
|
||||
const hasKnownTag = format.tags?.some(
|
||||
tag =>
|
||||
tag.includes('Audio') ||
|
||||
tag.includes('Codec') ||
|
||||
tag.includes('Enhancement') ||
|
||||
tag.includes('HDR') ||
|
||||
tag.includes('Flag') ||
|
||||
tag.includes('Language') ||
|
||||
tag.includes('Release Group') ||
|
||||
tag.includes('Resolution') ||
|
||||
tag.includes('Source') ||
|
||||
tag.includes('Storage') ||
|
||||
tag.includes('Streaming Service')
|
||||
);
|
||||
|
||||
if (!hasKnownTag) {
|
||||
if (!acc['Uncategorized']) acc['Uncategorized'] = [];
|
||||
acc['Uncategorized'].push(format);
|
||||
return acc;
|
||||
}
|
||||
|
||||
format.tags.forEach(tag => {
|
||||
if (!acc[tag]) acc[tag] = [];
|
||||
acc[tag].push(format);
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const formatGroups = {
|
||||
Audio: Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Audio'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
Codecs: Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Codec'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
Enhancements: Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Enhancement'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
HDR: Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('HDR'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
'Indexer Flags': Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Flag'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
Language: Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Language'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
'Release Groups': Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Release Group'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
Resolution: Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Resolution'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
Source: Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Source'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
Storage: Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Storage'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
'Streaming Services': Object.entries(groupedFormats)
|
||||
.filter(([tag]) => tag.includes('Streaming Service'))
|
||||
.flatMap(([_, formats]) => formats),
|
||||
Uncategorized: groupedFormats['Uncategorized'] || []
|
||||
};
|
||||
|
||||
const getGroupIcon = groupName => {
|
||||
const icons = {
|
||||
Audio: <Music size={16} />,
|
||||
HDR: <Tv size={16} />,
|
||||
'Release Groups': <Users size={16} />,
|
||||
'Streaming Services': <Cloud size={16} />,
|
||||
Codecs: <Film size={16} />,
|
||||
Storage: <HardDrive size={16} />,
|
||||
Resolution: <Maximize size={16} />,
|
||||
Language: <Globe size={16} />,
|
||||
Source: <Video size={16} />,
|
||||
'Indexer Flags': <Flag size={16} />,
|
||||
Enhancements: <Zap size={16} />,
|
||||
Uncategorized: <Package size={16} />
|
||||
};
|
||||
return icons[groupName] || <Package size={16} />;
|
||||
};
|
||||
|
||||
// Create sort instances for each group
|
||||
const groupSorts = Object.entries(formatGroups).reduce(
|
||||
(acc, [groupName, formats]) => {
|
||||
const defaultSort = {field: 'name', direction: 'asc'};
|
||||
const {sortConfig, updateSort, sortData} = useSorting(defaultSort);
|
||||
|
||||
acc[groupName] = {
|
||||
sortedData: sortData(formats),
|
||||
sortConfig,
|
||||
updateSort
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-3'>
|
||||
{Object.entries(formatGroups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([groupName, formats]) => {
|
||||
const {sortedData, sortConfig, updateSort} =
|
||||
groupSorts[groupName];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupName}
|
||||
className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden'>
|
||||
<div className='px-4 py-3 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center'>
|
||||
<h3 className='text-sm font-bold text-gray-900 dark:text-gray-100 flex items-center'>
|
||||
{getGroupIcon(groupName)}
|
||||
<span className='ml-2'>{groupName}</span>
|
||||
</h3>
|
||||
<SortDropdown
|
||||
sortOptions={sortOptions}
|
||||
currentSort={sortConfig}
|
||||
onSortChange={updateSort}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
{sortedData.length > 0 ? (
|
||||
sortedData.map(format => (
|
||||
<div
|
||||
key={format.id}
|
||||
className='flex items-center justify-between px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 group'>
|
||||
<div className='flex-1 min-w-0 mr-4'>
|
||||
<p className='text-sm text-gray-900 dark:text-gray-100 truncate'>
|
||||
{format.name}
|
||||
</p>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={format.score}
|
||||
onChange={value =>
|
||||
onScoreChange(
|
||||
format.id,
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='px-4 py-3 text-sm text-gray-500 dark:text-gray-400'>
|
||||
No formats found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedView.propTypes = {
|
||||
formats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
score: PropTypes.number.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string)
|
||||
})
|
||||
).isRequired,
|
||||
onScoreChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AdvancedView;
|
||||
81
frontend/src/components/profile/scoring/BasicView.jsx
Normal file
81
frontend/src/components/profile/scoring/BasicView.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import NumberInput from '@ui/NumberInput';
|
||||
import {useSorting} from '@hooks/useSorting';
|
||||
import SortDropdown from '@ui/SortDropdown';
|
||||
|
||||
const BasicView = ({formats, onScoreChange}) => {
|
||||
const sortOptions = [
|
||||
{label: 'Score', value: 'score'},
|
||||
{label: 'Name', value: 'name'}
|
||||
];
|
||||
|
||||
const {sortConfig, updateSort, sortData} = useSorting({
|
||||
field: 'score',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
const sortedFormats = sortData(formats);
|
||||
|
||||
return (
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden'>
|
||||
<div className='px-4 py-3 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center'>
|
||||
<h3 className='text-sm font-bold text-gray-900 dark:text-gray-100'>
|
||||
Formats
|
||||
</h3>
|
||||
<SortDropdown
|
||||
sortOptions={sortOptions}
|
||||
currentSort={sortConfig}
|
||||
onSortChange={updateSort}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
{sortedFormats.length > 0 ? (
|
||||
sortedFormats.map(format => (
|
||||
<div
|
||||
key={format.id}
|
||||
className='flex items-center justify-between px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 group'>
|
||||
<div className='flex-1 min-w-0 mr-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='text-sm text-gray-900 dark:text-gray-100 truncate'>
|
||||
{format.name}
|
||||
</p>
|
||||
{format.tags && format.tags.length > 0 && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 truncate'>
|
||||
{format.tags.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={format.score}
|
||||
onChange={value =>
|
||||
onScoreChange(format.id, value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='px-4 py-3 text-sm text-gray-500 dark:text-gray-400'>
|
||||
No formats found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BasicView.propTypes = {
|
||||
formats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
score: PropTypes.number.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string)
|
||||
})
|
||||
).isRequired,
|
||||
onScoreChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default BasicView;
|
||||
147
frontend/src/components/profile/scoring/FormatSettings.jsx
Normal file
147
frontend/src/components/profile/scoring/FormatSettings.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import SearchBar from '@ui/DataBar/SearchBar';
|
||||
import useSearch from '@hooks/useSearch';
|
||||
import AdvancedView from './AdvancedView';
|
||||
import BasicView from './BasicView';
|
||||
import {ChevronDown, Settings, List} from 'lucide-react';
|
||||
|
||||
const FormatSettings = ({formats, onScoreChange}) => {
|
||||
const [isAdvancedView, setIsAdvancedView] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const {
|
||||
searchTerms,
|
||||
currentInput,
|
||||
setCurrentInput,
|
||||
addSearchTerm,
|
||||
removeSearchTerm,
|
||||
clearSearchTerms,
|
||||
items: filteredFormats
|
||||
} = useSearch(formats, {
|
||||
searchableFields: ['name']
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='flex gap-3'>
|
||||
<SearchBar
|
||||
className='flex-1'
|
||||
placeholder='Search formats...'
|
||||
searchTerms={searchTerms}
|
||||
currentInput={currentInput}
|
||||
onInputChange={setCurrentInput}
|
||||
onAddTerm={addSearchTerm}
|
||||
onRemoveTerm={removeSearchTerm}
|
||||
onClearTerms={clearSearchTerms}
|
||||
/>
|
||||
|
||||
<div className='relative flex'>
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(prev => !prev)}
|
||||
className='inline-flex items-center justify-between w-36 px-3 py-2 rounded-md border border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-haspopup='true'>
|
||||
<span className='flex items-center gap-2'>
|
||||
{isAdvancedView ? (
|
||||
<>
|
||||
<Settings
|
||||
size={16}
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span className='text-sm font-medium'>
|
||||
Advanced
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
size={16}
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span className='text-sm font-medium'>
|
||||
Basic
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`text-gray-500 dark:text-gray-400 transition-transform ${
|
||||
isDropdownOpen ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<>
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
/>
|
||||
<div className='absolute right-0 mt-12 w-36 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 z-10'>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdvancedView(false);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm ${
|
||||
!isAdvancedView
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<List size={16} />
|
||||
<span>Basic</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdvancedView(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm ${
|
||||
isAdvancedView
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Settings size={16} />
|
||||
<span>Advanced</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdvancedView ? (
|
||||
<AdvancedView
|
||||
formats={filteredFormats}
|
||||
onScoreChange={onScoreChange}
|
||||
/>
|
||||
) : (
|
||||
<BasicView
|
||||
formats={filteredFormats}
|
||||
onScoreChange={onScoreChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormatSettings.propTypes = {
|
||||
formats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
score: PropTypes.number.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string)
|
||||
})
|
||||
).isRequired,
|
||||
onScoreChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FormatSettings;
|
||||
107
frontend/src/components/profile/scoring/ProfileScoringTab.jsx
Normal file
107
frontend/src/components/profile/scoring/ProfileScoringTab.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormatSettings from './FormatSettings';
|
||||
import UpgradeSettings from './UpgradeSettings';
|
||||
|
||||
const ProfileScoringTab = ({
|
||||
formats,
|
||||
onScoreChange,
|
||||
minCustomFormatScore,
|
||||
upgradeUntilScore,
|
||||
minScoreIncrement,
|
||||
onMinScoreChange,
|
||||
onUpgradeUntilScoreChange,
|
||||
onMinIncrementChange,
|
||||
upgradesAllowed,
|
||||
onUpgradesAllowedChange
|
||||
}) => {
|
||||
return (
|
||||
<div className='w-full space-y-6'>
|
||||
{/* Upgrade Settings Section */}
|
||||
<div className='space-y-4'>
|
||||
<div className='flex justify-between items-start'>
|
||||
<div>
|
||||
<h2 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Upgrade Settings
|
||||
</h2>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Assign scores to different formats to control
|
||||
download preferences
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col items-end space-y-1'>
|
||||
<label className='flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={upgradesAllowed}
|
||||
onChange={e =>
|
||||
onUpgradesAllowedChange(e.target.checked)
|
||||
}
|
||||
className='rounded border-gray-300 dark:border-gray-600
|
||||
text-blue-500 focus:ring-blue-500
|
||||
h-4 w-4 cursor-pointer
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
<span>Upgrades Allowed</span>
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Allow automatic upgrades for this profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4'>
|
||||
<UpgradeSettings
|
||||
minCustomFormatScore={minCustomFormatScore}
|
||||
upgradeUntilScore={upgradeUntilScore}
|
||||
minScoreIncrement={minScoreIncrement}
|
||||
onMinScoreChange={onMinScoreChange}
|
||||
onUpgradeUntilScoreChange={onUpgradeUntilScoreChange}
|
||||
onMinIncrementChange={onMinIncrementChange}
|
||||
upgradesAllowed={upgradesAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Settings Section */}
|
||||
<div className='space-y-4 pb-6'>
|
||||
<div>
|
||||
<h2 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Format Settings
|
||||
</h2>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Configure when upgrades should be downloaded and what
|
||||
scores are required
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormatSettings
|
||||
formats={formats}
|
||||
onScoreChange={onScoreChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileScoringTab.propTypes = {
|
||||
formats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
score: PropTypes.number.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string)
|
||||
})
|
||||
).isRequired,
|
||||
onScoreChange: PropTypes.func.isRequired,
|
||||
minCustomFormatScore: PropTypes.number.isRequired,
|
||||
upgradeUntilScore: PropTypes.number.isRequired,
|
||||
minScoreIncrement: PropTypes.number.isRequired,
|
||||
onMinScoreChange: PropTypes.func.isRequired,
|
||||
onUpgradeUntilScoreChange: PropTypes.func.isRequired,
|
||||
onMinIncrementChange: PropTypes.func.isRequired,
|
||||
upgradesAllowed: PropTypes.bool.isRequired,
|
||||
onUpgradesAllowedChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ProfileScoringTab;
|
||||
90
frontend/src/components/profile/scoring/UpgradeSettings.jsx
Normal file
90
frontend/src/components/profile/scoring/UpgradeSettings.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import NumberInput from '@ui/NumberInput';
|
||||
|
||||
const UpgradeSettings = ({
|
||||
minCustomFormatScore,
|
||||
upgradeUntilScore,
|
||||
minScoreIncrement,
|
||||
onMinScoreChange,
|
||||
onUpgradeUntilScoreChange,
|
||||
onMinIncrementChange,
|
||||
upgradesAllowed
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className='space-y-6'>
|
||||
{/* Minimum Custom Format Score - Always visible */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Minimum Custom Format Score
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Minimum custom format score allowed to download
|
||||
</p>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={minCustomFormatScore}
|
||||
onChange={onMinScoreChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conditional settings that only show when upgrades are allowed */}
|
||||
{upgradesAllowed && (
|
||||
<>
|
||||
{/* Upgrade Until Score */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Upgrade Until Custom Format Score
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Once the quality cutoff is met or exceeded
|
||||
and this custom format score is reached, no
|
||||
more upgrades will be grabbed
|
||||
</p>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={upgradeUntilScore}
|
||||
onChange={onUpgradeUntilScoreChange}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Minimum Score Increment */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Minimum Custom Format Score Increment
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Minimum required improvement of the custom
|
||||
format score between existing and new
|
||||
releases before considering an upgrade
|
||||
</p>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={minScoreIncrement}
|
||||
onChange={onMinIncrementChange}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UpgradeSettings.propTypes = {
|
||||
minCustomFormatScore: PropTypes.number.isRequired,
|
||||
upgradeUntilScore: PropTypes.number.isRequired,
|
||||
minScoreIncrement: PropTypes.number.isRequired,
|
||||
onMinScoreChange: PropTypes.func.isRequired,
|
||||
onUpgradeUntilScoreChange: PropTypes.func.isRequired,
|
||||
onMinIncrementChange: PropTypes.func.isRequired,
|
||||
upgradesAllowed: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default UpgradeSettings;
|
||||
@@ -1,62 +1,62 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {GitCommit, Info} from 'lucide-react';
|
||||
import Tooltip from '@ui/Tooltip';
|
||||
|
||||
const DiffCommit = ({commitMessage}) => {
|
||||
if (!commitMessage) return null;
|
||||
|
||||
const {subject, body, footer} = commitMessage;
|
||||
const {subject, body} = commitMessage;
|
||||
|
||||
return (
|
||||
<div className='bg-gray-100 dark:bg-gray-800 p-4 rounded-lg'>
|
||||
<div className='mb-2'>
|
||||
<span className='text-xl font-semibold text-gray-700 dark:text-gray-300 mr-3'>
|
||||
Details
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start space-x-3'>
|
||||
<div className='flex-1'>
|
||||
<div className='mb-2'>
|
||||
<span className='text-sm font-semibold text-gray-700 dark:text-gray-300 mr-3'>
|
||||
{subject}
|
||||
</span>
|
||||
</div>
|
||||
<div className='overflow-hidden rounded-lg border border-gray-700'>
|
||||
<table className='w-full'>
|
||||
<tbody>
|
||||
{/* Subject row */}
|
||||
<tr className='bg-gray-800'>
|
||||
<td className='py-3 px-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<GitCommit className='w-5 h-5 text-blue-400' />
|
||||
<span className='text-gray-200 font-medium'>
|
||||
{subject}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip content='Additional information about the incoming change'>
|
||||
<Info className='w-4 h-4 text-gray-400' />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Render the body without double hyphens */}
|
||||
{/* Body row - only rendered if body exists */}
|
||||
{body && (
|
||||
<ul className='list-disc pl-5 space-y-1'>
|
||||
{body
|
||||
.split('\n')
|
||||
.filter(line => line.trim().startsWith('-')) // Ensure we only take lines starting with "-"
|
||||
.map((line, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
{line.trim().replace(/^-\s*/, '')}{' '}
|
||||
{/* Remove leading hyphen */}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<tr className='bg-gray-900'>
|
||||
<td className='py-3 px-4'>
|
||||
<div className='text-gray-300 text-sm whitespace-pre-wrap'>
|
||||
{body.split('\n').map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${
|
||||
line.startsWith('- ')
|
||||
? 'ml-4'
|
||||
: ''
|
||||
}`}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Render the footer if it exists */}
|
||||
{footer && (
|
||||
<div className='mt-2 text-sm text-gray-600 dark:text-gray-400'>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DiffCommit.propTypes = {
|
||||
commitMessage: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
scope: PropTypes.string,
|
||||
subject: PropTypes.string.isRequired,
|
||||
body: PropTypes.string,
|
||||
footer: PropTypes.string
|
||||
body: PropTypes.string
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
@@ -4,19 +4,55 @@ import Modal from '@ui/Modal';
|
||||
import DiffCommit from './DiffCommit';
|
||||
import {FileText} from 'lucide-react';
|
||||
import useChangeParser from '@hooks/useChangeParser';
|
||||
import {COMMIT_TYPES, FILE_TYPES, COMMIT_SCOPES} from '@constants/commits';
|
||||
import Tooltip from '@ui/Tooltip';
|
||||
|
||||
const Badge = ({icon: Icon, label, className, tooltipContent}) => (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<div
|
||||
className={`px-2.5 py-1 rounded-md flex items-center gap-2 ${className}`}>
|
||||
<Icon className='w-3.5 h-3.5' />
|
||||
<span className='text-xs font-medium'>{label}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const ViewChanges = ({isOpen, onClose, change, isIncoming}) => {
|
||||
// Parse the array of changes
|
||||
const parsedChanges = useChangeParser(change.changes || []);
|
||||
const typeInfo = FILE_TYPES[change.type] || {
|
||||
bg: 'bg-gray-500/10',
|
||||
text: 'text-gray-400',
|
||||
icon: FileText
|
||||
};
|
||||
|
||||
const commitType = COMMIT_TYPES.find(
|
||||
t => t.value === change.commit_message?.type
|
||||
);
|
||||
|
||||
const titleContent = (
|
||||
<div className='flex items-center space-x-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<FileText className='w-5 h-5 text-gray-400' />
|
||||
<span className='text-lg font-bold'>{change.name}</span>
|
||||
</div>
|
||||
<span className='px-2 py-0.5 bg-gray-700 rounded-full text-sm text-gray-300'>
|
||||
{change.type}
|
||||
<div className='flex items-center justify-between w-full mr-4'>
|
||||
<span className='text-lg font-bold text-gray-200'>
|
||||
{change.name}
|
||||
</span>
|
||||
<div className='flex items-center gap-3'>
|
||||
{commitType && (
|
||||
<Badge
|
||||
icon={commitType.icon}
|
||||
label={commitType.label}
|
||||
className={`${commitType.bg} ${commitType.text}`}
|
||||
tooltipContent={commitType.description}
|
||||
/>
|
||||
)}
|
||||
<Badge
|
||||
icon={typeInfo.icon}
|
||||
label={change.type}
|
||||
className={`${typeInfo.bg} ${typeInfo.text}`}
|
||||
tooltipContent={
|
||||
COMMIT_SCOPES.find(s => s.label === change.type)
|
||||
?.description
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,7 +63,6 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => {
|
||||
title={titleContent}
|
||||
width='10xl'>
|
||||
<div className='space-y-4'>
|
||||
{/* If there's a commit message, show it */}
|
||||
{change.commit_message && (
|
||||
<DiffCommit commitMessage={change.commit_message} />
|
||||
)}
|
||||
|
||||
151
frontend/src/components/ui/NumberInput.jsx
Normal file
151
frontend/src/components/ui/NumberInput.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {ChevronUp, ChevronDown} from 'lucide-react';
|
||||
|
||||
const NumberInput = ({
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
step = 1,
|
||||
disabled = false,
|
||||
min,
|
||||
max,
|
||||
...props
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const displayValue = isFocused ? localValue : value.toString();
|
||||
|
||||
const handleChange = e => {
|
||||
const input = e.target.value;
|
||||
if (input === '' || input === '-' || /^-?\d*$/.test(input)) {
|
||||
setLocalValue(input);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
const numValue =
|
||||
localValue === '' || localValue === '-' ? 0 : parseInt(localValue);
|
||||
|
||||
if (min !== undefined && numValue < min) {
|
||||
onChange(min);
|
||||
return;
|
||||
}
|
||||
if (max !== undefined && numValue > max) {
|
||||
onChange(max);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(numValue);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
setLocalValue(value.toString());
|
||||
};
|
||||
|
||||
const increment = () => {
|
||||
const newValue = value + step;
|
||||
if (max === undefined || newValue <= max) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const decrement = () => {
|
||||
const newValue = value - step;
|
||||
if (min === undefined || newValue >= min) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
increment();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
decrement();
|
||||
}
|
||||
};
|
||||
|
||||
const inputClasses = [
|
||||
'w-16 h-8 px-2 py-1 text-sm border border-gray-700',
|
||||
'rounded-l focus:outline-none',
|
||||
'bg-gray-800',
|
||||
isFocused ? 'text-blue-400' : 'text-gray-300',
|
||||
'[appearance:textfield]',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const buttonContainerClasses = [
|
||||
'inline-flex flex-col border border-l-0 border-gray-700 rounded-r overflow-hidden h-8',
|
||||
'bg-gray-800',
|
||||
disabled && 'opacity-50'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const buttonClasses = [
|
||||
'flex items-center justify-center h-1/2 px-1',
|
||||
'hover:bg-gray-700',
|
||||
isFocused ? 'text-blue-400' : 'text-gray-400',
|
||||
'hover:text-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex ${className}`}>
|
||||
<input
|
||||
type='text'
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
className={inputClasses}
|
||||
{...props}
|
||||
/>
|
||||
<div className={buttonContainerClasses}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={increment}
|
||||
disabled={disabled || (max !== undefined && value >= max)}
|
||||
className={`${buttonClasses} border-b border-gray-700`}>
|
||||
<ChevronUp className='h-3 w-3' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={decrement}
|
||||
disabled={disabled || (min !== undefined && value <= min)}
|
||||
className={buttonClasses}>
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NumberInput.propTypes = {
|
||||
/** Current number value */
|
||||
value: PropTypes.number.isRequired,
|
||||
/** Handler called when value changes */
|
||||
onChange: PropTypes.func.isRequired,
|
||||
/** Additional classes to apply to container */
|
||||
className: PropTypes.string,
|
||||
/** Amount to increment/decrement by */
|
||||
step: PropTypes.number,
|
||||
/** Whether the input is disabled */
|
||||
disabled: PropTypes.bool,
|
||||
/** Minimum allowed value */
|
||||
min: PropTypes.number,
|
||||
/** Maximum allowed value */
|
||||
max: PropTypes.number
|
||||
};
|
||||
|
||||
export default NumberInput;
|
||||
80
frontend/src/components/ui/Sort.jsx
Normal file
80
frontend/src/components/ui/Sort.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, {useState, useRef, useEffect} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {ChevronUp, ChevronDown} from 'lucide-react';
|
||||
|
||||
const Sort = ({options, value, onChange}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
const [field, direction] = value.split('-');
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleChange = newField => {
|
||||
if (newField === field) {
|
||||
onChange(`${field}-${direction === 'asc' ? 'desc' : 'asc'}`);
|
||||
} else {
|
||||
onChange(`${newField}-desc`);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative inline-block text-left' ref={dropdownRef}>
|
||||
<div
|
||||
className='inline-flex items-center gap-1.5 px-2.5 h-8 bg-white dark:bg-gray-800 border
|
||||
border-gray-200 dark:border-gray-700 rounded-md'>
|
||||
{direction === 'asc' ? (
|
||||
<ChevronUp size={14} className='text-gray-500' />
|
||||
) : (
|
||||
<ChevronDown size={14} className='text-gray-500' />
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className='bg-transparent border-none focus:ring-0 text-gray-600 dark:text-gray-300
|
||||
text-sm py-0 pl-0 pr-6'>
|
||||
{options.find(option => option.value === field)?.label}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className='absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50 border border-gray-200 dark:border-gray-700'>
|
||||
<div className='py-1'>
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleChange(option.value)}
|
||||
className='block w-full text-left px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Sort.propTypes = {
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})
|
||||
).isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Sort;
|
||||
75
frontend/src/components/ui/SortDropdown.jsx
Normal file
75
frontend/src/components/ui/SortDropdown.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, {useState} from 'react';
|
||||
import {ChevronDown, ChevronUp, ArrowDown, ArrowUp} from 'lucide-react';
|
||||
|
||||
const SortDropdown = ({
|
||||
sortOptions,
|
||||
currentSort,
|
||||
onSortChange,
|
||||
className = ''
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleDropdown = () => setIsOpen(prev => !prev);
|
||||
|
||||
const handleSortClick = field => {
|
||||
onSortChange(field);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const getCurrentSortLabel = () => {
|
||||
const option = sortOptions.find(opt => opt.value === currentSort.field);
|
||||
return option ? option.label : 'Sort by';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block text-left ${className}`}>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className='inline-flex items-center justify-between w-full px-4 py-2 text-xs
|
||||
bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
text-gray-900 dark:text-gray-100
|
||||
rounded-md
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500'>
|
||||
<span className='flex items-center gap-2'>
|
||||
{getCurrentSortLabel()}
|
||||
{currentSort.direction === 'asc' ? (
|
||||
<ArrowUp size={16} />
|
||||
) : (
|
||||
<ArrowDown size={16} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className='absolute right-0 z-10 w-56 mt-2 origin-top-right
|
||||
bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-md shadow-lg'>
|
||||
<div className='py-1'>
|
||||
{sortOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleSortClick(option.value)}
|
||||
className='flex items-center justify-between w-full px-4 py-2
|
||||
text-xs text-gray-700 dark:text-gray-200
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700'>
|
||||
<span>{option.label}</span>
|
||||
{currentSort.field === option.value &&
|
||||
(currentSort.direction === 'asc' ? (
|
||||
<ArrowUp size={16} />
|
||||
) : (
|
||||
<ArrowDown size={16} />
|
||||
))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortDropdown;
|
||||
@@ -1,16 +1,60 @@
|
||||
import React from 'react';
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const Tooltip = ({content, children}) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [position, setPosition] = useState({x: 0, y: 0});
|
||||
const triggerRef = React.useRef(null);
|
||||
|
||||
const updatePosition = () => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top - 10
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('scroll', updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='relative group'>
|
||||
{children}
|
||||
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-[9999]'>
|
||||
<div className='bg-gray-900 text-white text-xs rounded py-1 px-2 shadow-lg whitespace-nowrap'>
|
||||
{content}
|
||||
</div>
|
||||
<div className='absolute w-2.5 h-2.5 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2'></div>
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onMouseEnter={() => {
|
||||
updatePosition();
|
||||
setShowTooltip(true);
|
||||
}}
|
||||
onMouseLeave={() => setShowTooltip(false)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{showTooltip &&
|
||||
ReactDOM.createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
zIndex: 99999
|
||||
}}
|
||||
className='pointer-events-none'>
|
||||
<div className='bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap'>
|
||||
{content}
|
||||
<div className='absolute w-2.5 h-2.5 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2' />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,75 @@
|
||||
import {
|
||||
Plus, // for add
|
||||
Sparkles, // for create
|
||||
Wrench, // for tweak
|
||||
Trash, // for remove
|
||||
Bug, // for fix
|
||||
Code, // for regex
|
||||
FileJson, // for format
|
||||
Settings // for profile
|
||||
} from 'lucide-react';
|
||||
|
||||
export const COMMIT_TYPES = [
|
||||
{
|
||||
value: 'create',
|
||||
label: 'Create',
|
||||
description: 'Building entirely new components or systems'
|
||||
description: 'Building entirely new components or systems',
|
||||
icon: Sparkles,
|
||||
bg: 'bg-green-500/10',
|
||||
text: 'text-green-400'
|
||||
},
|
||||
{
|
||||
value: 'add',
|
||||
label: 'Add',
|
||||
description: 'Adding entries to existing systems'
|
||||
description: 'Adding entries to existing systems',
|
||||
icon: Plus,
|
||||
bg: 'bg-blue-500/10',
|
||||
text: 'text-blue-400'
|
||||
},
|
||||
{
|
||||
value: 'tweak',
|
||||
label: 'Tweak',
|
||||
description: 'Fine-tuning and adjustments to existing components'
|
||||
description: 'Fine-tuning and adjustments to existing components',
|
||||
icon: Wrench,
|
||||
bg: 'bg-amber-500/10',
|
||||
text: 'text-amber-400'
|
||||
},
|
||||
{
|
||||
value: 'remove',
|
||||
label: 'Remove',
|
||||
description: 'Removing components or features from the system'
|
||||
description: 'Removing components or features from the system',
|
||||
icon: Trash,
|
||||
bg: 'bg-red-500/10',
|
||||
text: 'text-red-400'
|
||||
},
|
||||
{
|
||||
value: 'fix',
|
||||
label: 'Fix',
|
||||
description: 'Corrections and bug fixes'
|
||||
description: 'Corrections and bug fixes',
|
||||
icon: Bug,
|
||||
bg: 'bg-purple-500/10',
|
||||
text: 'text-purple-400'
|
||||
}
|
||||
];
|
||||
|
||||
export const FILE_TYPES = {
|
||||
'Regex Pattern': {
|
||||
bg: 'bg-green-500/10',
|
||||
text: 'text-green-400',
|
||||
icon: Code
|
||||
},
|
||||
'Custom Format': {
|
||||
bg: 'bg-blue-500/10',
|
||||
text: 'text-blue-400',
|
||||
icon: FileJson
|
||||
},
|
||||
'Quality Profile': {
|
||||
bg: 'bg-amber-500/10',
|
||||
text: 'text-amber-400',
|
||||
icon: Settings
|
||||
}
|
||||
};
|
||||
|
||||
export const COMMIT_SCOPES = [
|
||||
{
|
||||
value: 'regex',
|
||||
|
||||
46
frontend/src/hooks/useSorting.js
Normal file
46
frontend/src/hooks/useSorting.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// hooks/useSorting.js
|
||||
import {useState, useCallback} from 'react';
|
||||
|
||||
export const useSorting = initialSortConfig => {
|
||||
const [sortConfig, setSortConfig] = useState(initialSortConfig);
|
||||
|
||||
const sortData = useCallback(
|
||||
data => {
|
||||
if (!sortConfig.field) return data;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aValue = a[sortConfig.field];
|
||||
const bValue = b[sortConfig.field];
|
||||
|
||||
if (aValue === null || aValue === undefined) return 1;
|
||||
if (bValue === null || bValue === undefined) return -1;
|
||||
|
||||
// If we're sorting numbers and they're equal, sort by name
|
||||
if (typeof aValue === 'number' && aValue === bValue) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
const comparison =
|
||||
aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
return sortConfig.direction === 'asc'
|
||||
? comparison
|
||||
: -comparison;
|
||||
});
|
||||
},
|
||||
[sortConfig]
|
||||
);
|
||||
|
||||
const updateSort = useCallback(field => {
|
||||
setSortConfig(prevConfig => ({
|
||||
field,
|
||||
direction:
|
||||
prevConfig.field === field
|
||||
? prevConfig.direction === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: prevConfig.direction
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {sortConfig, updateSort, sortData};
|
||||
};
|
||||
Reference in New Issue
Block a user