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:
santiagosayshey
2025-02-17 07:06:11 +10:30
committed by GitHub
parent 80d388fdc6
commit f6ad7485b1
21 changed files with 1520 additions and 594 deletions

View File

@@ -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]]]:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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)

View File

@@ -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>

View File

@@ -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

View File

@@ -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' && (

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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
})
};

View File

@@ -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} />
)}

View 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;

View 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;

View 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;

View File

@@ -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
)}
</>
);
};

View File

@@ -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',

View 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};
};