diff --git a/.github/workflows/docker-build.yml b/.github/workflows/beta-build.yml
similarity index 91%
rename from .github/workflows/docker-build.yml
rename to .github/workflows/beta-build.yml
index c951dfb..71d2595 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/beta-build.yml
@@ -1,13 +1,12 @@
-# .github/workflows/docker-build.yml
-name: Build and Publish Docker Image
+name: Build Beta Docker Image
on:
push:
branches:
- - v2-beta
+ - dev
pull_request:
branches:
- - v2-beta
+ - dev
jobs:
build:
@@ -38,6 +37,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
+ if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
diff --git a/.github/workflows/notify.yml b/.github/workflows/notify.yml
index 038a396..df8e7a1 100644
--- a/.github/workflows/notify.yml
+++ b/.github/workflows/notify.yml
@@ -1,10 +1,11 @@
-name: Notify
+name: Release Notification
+
on:
- push:
- branches:
- - 'v2-beta'
+ release:
+ types: [published]
+
jobs:
- notify:
- uses: Dictionarry-Hub/parrot/.github/workflows/notify.yml@main
+ call-notify-release:
+ uses: Dictionarry-Hub/parrot/.github/workflows/notify-release.yml@v1
secrets:
- WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
+ PARROT_URL: ${{ secrets.PARROT_URL }}
diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml
new file mode 100644
index 0000000..b5ee300
--- /dev/null
+++ b/.github/workflows/release-build.yml
@@ -0,0 +1,53 @@
+name: Build Release Docker Image
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Get tag
+ id: tag
+ run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+
+ - name: Build frontend
+ working-directory: ./frontend
+ run: |
+ npm ci
+ npm run build
+
+ - name: Prepare dist directory
+ run: |
+ mkdir -p dist/backend dist/static
+ cp -r frontend/dist/* dist/static/
+ cp -r backend/* dist/backend/
+ cp backend/requirements.txt dist/
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: |
+ santiagosayshey/profilarr:latest
+ santiagosayshey/profilarr:${{ steps.tag.outputs.tag }}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..22d3bf1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,25 @@
+# Profilarr Development Guide
+
+## Commands
+- **Frontend**: `cd frontend && npm run dev` - Start React dev server
+- **Backend**: `cd backend && gunicorn -b 0.0.0.0:5000 app.main:app` - Run Flask server
+- **Docker**: `docker compose up` - Start both frontend/backend in dev mode
+- **Lint**: `cd frontend && npx eslint 'src/**/*.{js,jsx}'` - Check frontend code style
+- **Build**: `cd frontend && npm run build` - Build for production
+
+## Code Style
+### Frontend (React)
+- **Imports**: React first, third-party libs next, components, then utils
+- **Components**: Functional components with hooks, PascalCase naming
+- **Props**: PropTypes for validation, destructure props in component signature
+- **State**: Group related state, useCallback for memoized handlers
+- **JSX**: 4-space indentation, attributes on new lines for readability
+- **Error Handling**: try/catch for async operations, toast notifications
+
+### Backend (Python)
+- **Imports**: Standard lib first, third-party next, local modules last
+- **Naming**: snake_case for functions/vars/files, PascalCase for classes
+- **Functions**: Single responsibility, descriptive docstrings
+- **Error Handling**: Specific exception catches, return (success, message) tuples
+- **Indentation**: 4 spaces consistently
+- **Modularity**: Related functionality grouped in directories
\ No newline at end of file
diff --git a/backend/app/compile/profile_compiler.py b/backend/app/compile/profile_compiler.py
index a56a61c..dc285d0 100644
--- a/backend/app/compile/profile_compiler.py
+++ b/backend/app/compile/profile_compiler.py
@@ -172,22 +172,39 @@ class ProfileConverter:
return converted_group
def convert_profile(self, profile: Dict) -> ConvertedProfile:
- language = profile.get('language')
- if language != 'any':
+ language = profile.get('language', 'any')
+
+ # Handle language processing for advanced mode (with behavior_language format)
+ if language != 'any' and '_' in language:
language_parts = language.split('_', 1)
- behaviour, language = language_parts
+ behaviour, language_code = language_parts
try:
language_formats = self._process_language_formats(
- behaviour, language)
+ behaviour, language_code)
if 'custom_formats' not in profile:
profile['custom_formats'] = []
profile['custom_formats'].extend(language_formats)
except Exception as e:
logger.error(f"Failed to process language formats: {e}")
- selected_language = ValueResolver.get_language('any',
- self.target_app,
- for_profile=True)
+ # Simple mode: just use the language directly without custom formats
+ # This lets the Arr application's built-in language filter handle it
+
+ # Get the appropriate language data for the profile
+ if language != 'any' and '_' not in language:
+ # Simple mode - use the language directly
+ selected_language = ValueResolver.get_language(language,
+ self.target_app,
+ for_profile=True)
+ logger.info(f"Using simple language mode: {language}")
+ logger.info(f"Selected language data: {selected_language}")
+ else:
+ # Advanced mode or 'any' - set language to 'any' as filtering is done via formats
+ selected_language = ValueResolver.get_language('any',
+ self.target_app,
+ for_profile=True)
+ logger.info(
+ f"Using advanced mode or 'any', setting language to 'any'")
converted_profile = ConvertedProfile(
name=profile["name"],
@@ -201,15 +218,10 @@ class ProfileConverter:
language=selected_language)
used_qualities = set()
- tweaks = profile.get('tweaks', {})
- allow_prereleases = tweaks.get('allowPrereleases', False)
for quality_entry in profile.get("qualities", []):
if quality_entry.get("id", 0) < 0:
converted_group = self.convert_quality_group(quality_entry)
- if (quality_entry.get("name") == "Prereleases"
- and not allow_prereleases):
- converted_group["allowed"] = False
if converted_group["items"]:
converted_profile.items.append(converted_group)
for q in quality_entry.get("qualities", []):
@@ -246,7 +258,6 @@ class ProfileConverter:
if cutoff_id < 0:
converted_profile.cutoff = self._convert_group_id(cutoff_id)
else:
- # And use mapped_cutoff_name here instead of cutoff_name
converted_profile.cutoff = self.quality_mappings[
mapped_cutoff_name]["id"]
diff --git a/backend/app/data/utils.py b/backend/app/data/utils.py
index b468536..884a664 100644
--- a/backend/app/data/utils.py
+++ b/backend/app/data/utils.py
@@ -7,6 +7,7 @@ from typing import Dict, List, Any, Tuple, Union
import git
import regex
import logging
+from ..db.queries.arr import update_arr_config_on_rename, update_arr_config_on_delete
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@@ -33,8 +34,7 @@ PROFILE_FIELDS = [
"custom_formats", # Array of {name, score} objects
"qualities", # Array of strings
"upgrade_until",
- "language",
- "tweaks"
+ "language"
]
# Category mappings
@@ -166,8 +166,20 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
# Update references before performing the rename
try:
+ # Update regular references
updated_files = update_references(category, old_name, new_name)
logger.info(f"Updated references in: {updated_files}")
+
+ # Update arr configs if this is a format or profile
+ if category in ['custom_format', 'profile']:
+ arr_category = 'customFormats' if category == 'custom_format' else 'profiles'
+ updated_configs = update_arr_config_on_rename(
+ arr_category, old_name, new_name)
+ if updated_configs:
+ logger.info(
+ f"Updated arr configs for {category} rename: {updated_configs}"
+ )
+
except Exception as e:
logger.error(f"Failed to update references: {e}")
raise Exception(f"Failed to update references: {str(e)}")
@@ -263,9 +275,9 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]:
format_data = load_yaml_file(format_path)
# Check each condition in the format
for condition in format_data.get('conditions', []):
- if (condition['type'] in [
+ if condition['type'] in [
'release_title', 'release_group', 'edition'
- ] and condition.get('pattern') == check_name):
+ ] and condition.get('pattern') == check_name:
references.append(
f"custom format: {format_data['name']}")
except Exception as e:
@@ -300,6 +312,14 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]:
f"Error checking profile file {profile_file}: {e}")
continue
+ # Update arr configs for formats and profiles
+ if category in ['custom_format', 'profile']:
+ arr_category = 'customFormats' if category == 'custom_format' else 'profiles'
+ updated_configs = update_arr_config_on_delete(arr_category, name)
+ if updated_configs:
+ logger.info(
+ f"Removed {name} from arr configs: {updated_configs}")
+
if references:
error_msg = f"Cannot delete - item is referenced in:\n" + "\n".join(
f"- {ref}" for ref in references)
diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py
index a8b4f80..a67f20d 100644
--- a/backend/app/db/__init__.py
+++ b/backend/app/db/__init__.py
@@ -1,12 +1,15 @@
-# backend/app/db/__init__.py
from .connection import get_db
-from .queries.settings import get_settings, get_secret_key, save_settings
-from .queries.arr import get_unique_arrs
-from .queries.format_renames import add_format_to_renames, remove_format_from_renames, is_format_in_renames
+from .queries.settings import get_settings, get_secret_key, save_settings, update_pat_status
+from .queries.arr import (get_unique_arrs, update_arr_config_on_rename,
+ update_arr_config_on_delete)
+from .queries.format_renames import (add_format_to_renames,
+ remove_format_from_renames,
+ is_format_in_renames)
from .migrations.runner import run_migrations
__all__ = [
'get_db', 'get_settings', 'get_secret_key', 'save_settings',
- 'get_unique_arrs', 'run_migrations', 'add_format_to_renames',
- 'remove_format_from_renames', 'is_format_in_renames'
+ 'get_unique_arrs', 'update_arr_config_on_rename',
+ 'update_arr_config_on_delete', 'run_migrations', 'add_format_to_renames',
+ 'remove_format_from_renames', 'is_format_in_renames', 'update_pat_status'
]
diff --git a/backend/app/db/queries/arr.py b/backend/app/db/queries/arr.py
index 5b62c1e..592bc02 100644
--- a/backend/app/db/queries/arr.py
+++ b/backend/app/db/queries/arr.py
@@ -1,14 +1,15 @@
-# backend/app/db/queries/arr.py
from ..connection import get_db
+import json
+import logging
+
+logger = logging.getLogger(__name__)
def get_unique_arrs(arr_ids):
"""
Get import_as_unique settings for a list of arr IDs.
-
Args:
arr_ids (list): List of arr configuration IDs
-
Returns:
dict: Dictionary mapping arr IDs to their import_as_unique settings and names
"""
@@ -18,12 +19,12 @@ def get_unique_arrs(arr_ids):
with get_db() as conn:
placeholders = ','.join('?' * len(arr_ids))
query = f'''
- SELECT id, name, import_as_unique
- FROM arr_config
- WHERE id IN ({placeholders})
+ SELECT id, name, import_as_unique
+ FROM arr_config
+ WHERE id IN ({placeholders})
'''
-
results = conn.execute(query, arr_ids).fetchall()
+
return {
row['id']: {
'import_as_unique': bool(row['import_as_unique']),
@@ -31,3 +32,88 @@ def get_unique_arrs(arr_ids):
}
for row in results
}
+
+
+def update_arr_config_on_rename(category, old_name, new_name):
+ """
+ Update arr_config data_to_sync when a format or profile is renamed.
+ Args:
+ category (str): Either 'customFormats' or 'profiles'
+ old_name (str): Original name being changed
+ new_name (str): New name to change to
+ Returns:
+ list: IDs of arr_config rows that were updated
+ """
+ updated_ids = []
+
+ with get_db() as conn:
+ # Get all configs that might reference this name
+ rows = conn.execute(
+ 'SELECT id, data_to_sync FROM arr_config WHERE data_to_sync IS NOT NULL'
+ ).fetchall()
+
+ for row in rows:
+ try:
+ data = json.loads(row['data_to_sync'])
+ # Check if this config has the relevant category data
+ if category in data:
+ # Update any matching names
+ if old_name in data[category]:
+ # Replace old name with new name
+ data[category] = [
+ new_name if x == old_name else x
+ for x in data[category]
+ ]
+ # Save changes back to database
+ conn.execute(
+ 'UPDATE arr_config SET data_to_sync = ? WHERE id = ?',
+ (json.dumps(data), row['id']))
+ updated_ids.append(row['id'])
+ except json.JSONDecodeError:
+ logger.error(f"Invalid JSON in arr_config id={row['id']}")
+ continue
+
+ if updated_ids:
+ conn.commit()
+
+ return updated_ids
+
+
+def update_arr_config_on_delete(category, name):
+ """
+ Update arr_config data_to_sync when a format or profile is deleted.
+ Args:
+ category (str): Either 'customFormats' or 'profiles'
+ name (str): Name being deleted
+ Returns:
+ list: IDs of arr_config rows that were updated
+ """
+ updated_ids = []
+
+ with get_db() as conn:
+ # Get all configs that might reference this name
+ rows = conn.execute(
+ 'SELECT id, data_to_sync FROM arr_config WHERE data_to_sync IS NOT NULL'
+ ).fetchall()
+
+ for row in rows:
+ try:
+ data = json.loads(row['data_to_sync'])
+ # Check if this config has the relevant category data
+ if category in data:
+ # Remove any matching names
+ if name in data[category]:
+ data[category].remove(name)
+ # Save changes back to database
+ conn.execute(
+ 'UPDATE arr_config SET data_to_sync = ? WHERE id = ?',
+ (json.dumps(data), row['id']))
+ updated_ids.append(row['id'])
+ except json.JSONDecodeError:
+ logger.error(f"Invalid JSON in arr_config id={row['id']}")
+ continue
+
+ if updated_ids:
+ conn.commit()
+
+ return updated_ids
diff --git a/backend/app/db/queries/settings.py b/backend/app/db/queries/settings.py
index f2fcf1e..9d7377a 100644
--- a/backend/app/db/queries/settings.py
+++ b/backend/app/db/queries/settings.py
@@ -1,5 +1,9 @@
# backend/app/db/queries/settings.py
from ..connection import get_db
+import logging
+import os
+
+logger = logging.getLogger(__name__)
def get_settings():
@@ -30,3 +34,32 @@ def save_settings(settings_dict):
updated_at = CURRENT_TIMESTAMP
''', (key, value))
conn.commit()
+
+
+def update_pat_status():
+ """Update the has_profilarr_pat setting based on current environment."""
+ with get_db() as conn:
+ profilarr_pat = os.environ.get('PROFILARR_PAT')
+ pat_exists = str(bool(profilarr_pat)).lower()
+
+ # Get current value
+ current = conn.execute('SELECT value FROM settings WHERE key = ?',
+ ('has_profilarr_pat', )).fetchone()
+
+ conn.execute(
+ '''
+ INSERT INTO settings (key, value, updated_at)
+ VALUES ('has_profilarr_pat', ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(key) DO UPDATE SET
+ value = ?,
+ updated_at = CURRENT_TIMESTAMP
+ ''', (pat_exists, pat_exists))
+ conn.commit()
+
+ if current is None:
+ logger.info(f"PAT status created: {pat_exists}")
+ elif current[0] != pat_exists:
+ logger.info(
+ f"PAT status updated from {current[0]} to {pat_exists}")
+ else:
+ logger.debug("PAT status unchanged")
diff --git a/backend/app/git/operations/resolve.py b/backend/app/git/operations/resolve.py
index b993e34..363d718 100644
--- a/backend/app/git/operations/resolve.py
+++ b/backend/app/git/operations/resolve.py
@@ -30,10 +30,12 @@ def get_version_data(repo, ref, file_path):
def resolve_conflicts(
repo, resolutions: Dict[str, Dict[str, str]]) -> Dict[str, Any]:
- logger.debug(f"Received resolutions for files: {list(resolutions.keys())}")
"""
Resolve merge conflicts based on provided resolutions.
"""
+ logger.debug(f"Received resolutions for files: {list(resolutions.keys())}")
+ logger.debug(f"Full resolutions data: {resolutions}")
+
try:
status = repo.git.status('--porcelain', '-z').split('\0')
conflicts = []
@@ -82,6 +84,7 @@ def resolve_conflicts(
if modify_delete_conflicts[file_path]:
logger.debug(
f"Handling modify/delete conflict for {file_path}")
+ logger.debug(f"Field resolutions for modify/delete: {field_resolutions}")
# Get the existing version (either from HEAD or MERGE_HEAD)
head_data = get_version_data(repo, 'HEAD', file_path)
@@ -92,9 +95,17 @@ def resolve_conflicts(
is_deleted_in_head = head_data is None
existing_data = merge_head_data if is_deleted_in_head else head_data
logger.debug(f"Existing version data: {existing_data}")
+ logger.debug(f"is_deleted_in_head: {is_deleted_in_head}")
+ logger.debug(f"head_data: {head_data}")
+ logger.debug(f"merge_head_data: {merge_head_data}")
- choice = field_resolutions.get('file')
+ # Try both lowercase and capitalized versions of 'file'
+ choice = field_resolutions.get('file') or field_resolutions.get('File')
+ logger.debug(f"Resolution choice for file: {choice}")
+
if not choice:
+ logger.error("No 'file' or 'File' resolution found in field_resolutions!")
+ logger.error(f"Available keys: {list(field_resolutions.keys())}")
raise Exception(
"No resolution provided for modify/delete conflict")
@@ -153,9 +164,23 @@ def resolve_conflicts(
ours_data = get_version_data(repo, 'HEAD', file_path)
theirs_data = get_version_data(repo, 'MERGE_HEAD', file_path)
+ # For files that were previously involved in modify/delete conflicts
+ # we may not be able to get all versions
if not base_data or not ours_data or not theirs_data:
- raise Exception(
- f"Couldn't get all versions of {file_path}")
+ logger.warning(f"Couldn't get all versions of {file_path} - may have been previously resolved as a modify/delete conflict")
+ logger.warning(f"base_data: {base_data}, ours_data: {ours_data}, theirs_data: {theirs_data}")
+
+ # If it was previously resolved as "incoming" but ours_data is missing, use theirs_data
+ if not ours_data and theirs_data:
+ logger.info(f"Using incoming version for {file_path} as base for resolution")
+ ours_data = theirs_data
+ # If it was previously resolved as "local" but theirs_data is missing, use ours_data
+ elif ours_data and not theirs_data:
+ logger.info(f"Using local version for {file_path} as base for resolution")
+ theirs_data = ours_data
+ # If we can't recover either version, we can't proceed
+ else:
+ raise Exception(f"Couldn't get required versions of {file_path}")
# Start with a deep copy of ours_data to preserve all fields
resolved_data = deepcopy(ours_data)
diff --git a/backend/app/git/status/conflict_comparison.py b/backend/app/git/status/conflict_comparison.py
index 6aece54..fc51de3 100644
--- a/backend/app/git/status/conflict_comparison.py
+++ b/backend/app/git/status/conflict_comparison.py
@@ -29,16 +29,20 @@ def compare_conflict_yaml(ours_data: Any,
if ours_data is None and theirs_data is None:
return conflicts
if ours_data is None:
+ # Local version deleted
+ param_name = path or 'File'
return [{
- 'parameter': path or 'File',
- 'local_value': 'deleted',
- 'incoming_value': theirs_data
+ 'parameter': param_name,
+ 'local_value': '🗑️ File deleted in local version',
+ 'incoming_value': '📄 File exists in incoming version'
}]
if theirs_data is None:
+ # Incoming version deleted
+ param_name = path or 'File'
return [{
- 'parameter': path or 'File',
- 'local_value': ours_data,
- 'incoming_value': 'deleted'
+ 'parameter': param_name,
+ 'local_value': '📄 File exists in local version',
+ 'incoming_value': '🗑️ File deleted in incoming version'
}]
# Handle different types as conflicts
@@ -197,6 +201,7 @@ def create_conflict_summary(file_path: str,
- file_path: Path to the conflicted file
- type: Type of item
- name: Name from our version or filename
+ - incoming_name: Name from their version (if available)
- status: Current conflict status
- conflict_details: List of specific conflicts
"""
@@ -209,17 +214,36 @@ def create_conflict_summary(file_path: str,
compare_conflict_yaml(ours_data, theirs_data)
}
- # Get name from our version or fallback to filename
- name = ours_data.get('name') if ours_data else os.path.basename(
- file_path)
+ # Get local name
+ local_name = None
+ if ours_data and isinstance(ours_data, dict) and 'name' in ours_data:
+ local_name = ours_data.get('name')
+
+ if not local_name:
+ # Strip the extension to get a cleaner name
+ basename = os.path.basename(file_path)
+ local_name = os.path.splitext(basename)[0]
+
+ # Get incoming name
+ incoming_name = None
+ if theirs_data and isinstance(theirs_data, dict) and 'name' in theirs_data:
+ incoming_name = theirs_data.get('name')
+
+ if not incoming_name:
+ # Strip the extension to get a cleaner name
+ basename = os.path.basename(file_path)
+ incoming_name = os.path.splitext(basename)[0]
- return {
+ result = {
'file_path': file_path,
'type': determine_type(file_path),
- 'name': name,
+ 'name': local_name,
+ 'incoming_name': incoming_name,
'status': status,
'conflict_details': conflict_details
}
+
+ return result
except Exception as e:
logger.error(
diff --git a/backend/app/git/status/merge_conflicts.py b/backend/app/git/status/merge_conflicts.py
index 950fe83..31825b9 100644
--- a/backend/app/git/status/merge_conflicts.py
+++ b/backend/app/git/status/merge_conflicts.py
@@ -31,20 +31,46 @@ def process_modify_delete_conflict(repo, file_path, deleted_in_head):
else:
status = MODIFY_DELETE
- # Get the surviving version
+ # For delete conflicts, we need to extract the name for display purposes
+ # This will be the name of the actual file before it was deleted
+ basename = os.path.basename(file_path)
+ filename = os.path.splitext(basename)[0] # Strip extension
+
+ # Get metadata from existing version to extract name if possible
if file_exists:
- with open(os.path.join(repo.working_dir, file_path), 'r') as f:
- existing_data = yaml.safe_load(f.read())
+ # File exists locally, read it
+ try:
+ with open(os.path.join(repo.working_dir, file_path), 'r') as f:
+ existing_data = yaml.safe_load(f.read())
+ except Exception as read_error:
+ logger.warning(f"Could not read existing file {file_path}: {str(read_error)}")
+ existing_data = {'name': filename}
else:
- existing_data = get_version_data(repo, 'MERGE_HEAD', file_path)
+ # File was deleted locally, try to get from merge head
+ try:
+ existing_data = get_version_data(repo, 'MERGE_HEAD', file_path)
+ except Exception as merge_error:
+ logger.warning(f"Could not get merge head for {file_path}: {str(merge_error)}")
+ existing_data = {'name': filename}
- if not existing_data:
- return None
+ # Simplified placeholder data for deleted version
+ if deleted_in_head:
+ # File was deleted in HEAD (local) but exists in MERGE_HEAD (incoming)
+ local_data = None # This indicates deleted
+ try:
+ # Try to get name from incoming
+ incoming_data = existing_data if existing_data else {'name': filename}
+ except Exception:
+ incoming_data = {'name': filename}
+ else:
+ # File exists in HEAD (local) but deleted in MERGE_HEAD (incoming)
+ try:
+ local_data = existing_data if existing_data else {'name': filename}
+ except Exception:
+ local_data = {'name': filename}
+ incoming_data = None # This indicates deleted
- # Create summary with the appropriate sides marked as deleted
- return create_conflict_summary(
- file_path, None if deleted_in_head else existing_data,
- existing_data if deleted_in_head else None, status)
+ return create_conflict_summary(file_path, local_data, incoming_data, status)
except Exception as e:
logger.error(
diff --git a/backend/app/importarr/profile.py b/backend/app/importarr/profile.py
index 1360fba..cedc1e8 100644
--- a/backend/app/importarr/profile.py
+++ b/backend/app/importarr/profile.py
@@ -73,15 +73,19 @@ def import_profiles_to_arr(profile_names: List[str], original_names: List[str],
profile_language = profile_data.get('language', 'any')
if profile_language != 'any':
- logger.info(
- f"Profile '{profile_name}' has language override: {profile_language}"
- )
-
- logger.info(
- f"Processing tweaks and importing formats for profile '{profile_name}'"
- )
- profile_data = process_tweaks(profile_data, base_url, api_key,
- arr_type, import_as_unique)
+ # Detect if we're using simple or advanced mode
+ is_simple_mode = '_' not in profile_language
+ if is_simple_mode:
+ logger.info(
+ f"Profile '{profile_name}' has simple mode language: {profile_language}"
+ )
+ logger.info(
+ f"Simple mode will set language filter to: {profile_language}"
+ )
+ else:
+ logger.info(
+ f"Profile '{profile_name}' has advanced mode language: {profile_language}"
+ )
logger.info("Compiling quality profile...")
compiled_profiles = compile_quality_profile(
@@ -221,123 +225,6 @@ def sync_format_ids(profile_data: Dict, format_id_map: Dict[str, int]) -> Dict:
return profile_data
-def process_tweaks(profile_data: Dict,
- base_url: str,
- api_key: str,
- arr_type: str,
- import_as_unique: bool = False) -> Dict:
- logger.debug(f"Processing tweaks for profile: {profile_data.get('name')}")
- tweaks = profile_data.get('tweaks', {})
-
- if tweaks.get('preferFreeleech', False):
- freeleech_formats = ["Free25", "Free50", "Free75", "Free100"]
- freeleech_scores = [{
- "name": n,
- "score": s
- } for n, s in zip(freeleech_formats, range(1, 5))]
- _import_and_score_formats(formats=freeleech_formats,
- scores=freeleech_scores,
- profile_data=profile_data,
- base_url=base_url,
- api_key=api_key,
- arr_type=arr_type,
- feature_name="freeleech",
- import_as_unique=import_as_unique)
-
- lossless_formats = [
- "FLAC", "DTS-X", "DTS-HD MA", "TrueHD", "TrueHD (Missing)"
- ]
- default_score = 0 if tweaks.get('allowLosslessAudio', False) else -9999
- lossless_scores = [{
- "name": f,
- "score": default_score
- } for f in lossless_formats]
- _import_and_score_formats(formats=lossless_formats,
- scores=lossless_scores,
- profile_data=profile_data,
- base_url=base_url,
- api_key=api_key,
- arr_type=arr_type,
- feature_name="lossless audio",
- import_as_unique=import_as_unique)
-
- dv_formats = ["Dolby Vision (Without Fallback)"]
- dv_score = 0 if tweaks.get('allowDVNoFallback', False) else -9999
- dv_scores = [{"name": n, "score": dv_score} for n in dv_formats]
- _import_and_score_formats(formats=dv_formats,
- scores=dv_scores,
- profile_data=profile_data,
- base_url=base_url,
- api_key=api_key,
- arr_type=arr_type,
- feature_name="Dolby Vision no fallback",
- import_as_unique=import_as_unique)
-
- codec_formats = ["AV1", "VVC"]
- codec_score = 0 if tweaks.get('allowBleedingEdgeCodecs', False) else -9999
- codec_scores = [{"name": f, "score": codec_score} for f in codec_formats]
- _import_and_score_formats(formats=codec_formats,
- scores=codec_scores,
- profile_data=profile_data,
- base_url=base_url,
- api_key=api_key,
- arr_type=arr_type,
- feature_name="bleeding edge codecs",
- import_as_unique=import_as_unique)
-
- return profile_data
-
-
-def _import_and_score_formats(formats: List[str],
- scores: List[Dict[str, Any]],
- profile_data: Dict,
- base_url: str,
- api_key: str,
- arr_type: str,
- feature_name: str,
- import_as_unique: bool = False) -> None:
- logger.info(
- f"Processing {feature_name} formats for profile '{profile_data.get('name')}'"
- )
- try:
- # Create modified format names if import_as_unique is true
- format_names = [
- f"{name} [Dictionarry]" if import_as_unique else name
- for name in formats
- ]
-
- result = import_formats_to_arr(
- format_names=format_names, # Use modified names for import
- original_names=formats, # Original names for file lookup
- base_url=base_url,
- api_key=api_key,
- arr_type=arr_type)
-
- if not result.get('success', False):
- logger.warning(
- f"Failed to import {feature_name} formats for '{profile_data.get('name')}'"
- )
- return
-
- if 'custom_formats' not in profile_data:
- profile_data['custom_formats'] = []
-
- # Use the modified format names in the profile's format list
- modified_scores = []
- for i, score in enumerate(scores):
- score_copy = score.copy()
- # Use the same modified name that was used for import
- score_copy['name'] = format_names[i]
- modified_scores.append(score_copy)
-
- # Only append once with the modified scores
- profile_data['custom_formats'].extend(modified_scores)
-
- except Exception as e:
- logger.error(f"Error importing {feature_name} formats: {str(e)}")
- return
-
-
def process_profile(profile_data: Dict, existing_names: Dict[str, int],
base_url: str, api_key: str) -> Dict:
profile_name = profile_data['name']
diff --git a/backend/app/init.py b/backend/app/init.py
index 62422b8..bbdec4a 100644
--- a/backend/app/init.py
+++ b/backend/app/init.py
@@ -4,7 +4,7 @@ import subprocess
import logging
import logging.config
from .config import config
-from .db import get_secret_key
+from .db import get_secret_key, update_pat_status
def setup_logging():
@@ -103,7 +103,7 @@ def setup_logging():
def init_git_user():
- """Initialize Git user configuration globally."""
+ """Initialize Git user configuration globally and update PAT status."""
logger = logging.getLogger(__name__)
logger.info("Starting Git user configuration")
@@ -124,6 +124,9 @@ def init_git_user():
subprocess.run(['git', 'config', '--global', 'user.email', git_email],
check=True)
+ # Update PAT status in database
+ update_pat_status()
+
# Verify configuration
configured_name = subprocess.run(
['git', 'config', '--global', 'user.name'],
diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js
index 9234beb..b8d1830 100644
--- a/frontend/src/api/api.js
+++ b/frontend/src/api/api.js
@@ -173,11 +173,6 @@ export const pushFiles = async () => {
const response = await axios.post(`/api/git/push`);
return response.data;
} catch (error) {
- console.log(
- 'Push error full structure:',
- JSON.stringify(error.response?.data, null, 2)
- );
-
if (error.response?.data?.error) {
return error.response.data;
}
diff --git a/frontend/src/api/arr.js b/frontend/src/api/arr.js
index 796a4f4..f6fe5fe 100644
--- a/frontend/src/api/arr.js
+++ b/frontend/src/api/arr.js
@@ -76,8 +76,6 @@ export const updateArrConfig = async (id, config) => {
export const getArrConfigs = async () => {
try {
const response = await axios.get(`/api/arr/config`);
- console.log('Raw axios response:', response);
- console.log('Response data:', response.data);
return response.data;
} catch (error) {
console.error('Error fetching arr configs:', error);
diff --git a/frontend/src/components/format/FormatCard.jsx b/frontend/src/components/format/FormatCard.jsx
index 0f1b9ba..506cb43 100644
--- a/frontend/src/components/format/FormatCard.jsx
+++ b/frontend/src/components/format/FormatCard.jsx
@@ -168,7 +168,7 @@ function FormatCard({
: 'translate-x-0'
}`}>
{/* Conditions */}
-
+
{content.conditions?.map((condition, index) => (
{/* Description */}
-
+
{content.description ? (
diff --git a/frontend/src/components/format/FormatPage.jsx b/frontend/src/components/format/FormatPage.jsx
index 55966ec..1c8d2b7 100644
--- a/frontend/src/components/format/FormatPage.jsx
+++ b/frontend/src/components/format/FormatPage.jsx
@@ -106,7 +106,7 @@ function FormatPage() {
lastSelectedIndex
} = useMassSelection();
- useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
+ useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
const {
name,
diff --git a/frontend/src/components/format/FormatTestingTab.jsx b/frontend/src/components/format/FormatTestingTab.jsx
index 7cf2f3f..3a5a346 100644
--- a/frontend/src/components/format/FormatTestingTab.jsx
+++ b/frontend/src/components/format/FormatTestingTab.jsx
@@ -120,7 +120,7 @@ const FormatTestingTab = ({
))}
) : (
-
+
No tests added yet
diff --git a/frontend/src/components/format/conditions/ConditionCard.jsx b/frontend/src/components/format/conditions/ConditionCard.jsx
index f720f47..78ef1c3 100644
--- a/frontend/src/components/format/conditions/ConditionCard.jsx
+++ b/frontend/src/components/format/conditions/ConditionCard.jsx
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import {CONDITION_TYPES, createCondition} from './conditionTypes';
import {ArrowUp, ArrowDown, X, ChevronsUp, ChevronsDown} from 'lucide-react';
-import BrowserSelect from '@ui/BrowserSelect';
+import SearchDropdown from '@ui/SearchDropdown';
const ConditionCard = ({
condition,
@@ -21,7 +21,8 @@ const ConditionCard = ({
const typeOptions = Object.values(CONDITION_TYPES).map(type => ({
value: type.id,
- label: type.name
+ label: type.name,
+ description: type.description || ''
}));
const handleTypeChange = e => {
@@ -57,14 +58,13 @@ const ConditionCard = ({
{/* Type Selection */}
-
{/* Render the specific condition component */}
diff --git a/frontend/src/components/format/conditions/EditionCondition.jsx b/frontend/src/components/format/conditions/EditionCondition.jsx
index 6cc2448..8bc0243 100644
--- a/frontend/src/components/format/conditions/EditionCondition.jsx
+++ b/frontend/src/components/format/conditions/EditionCondition.jsx
@@ -1,25 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
-import BrowserSelect from '@ui/BrowserSelect';
+import SearchDropdown from '@ui/SearchDropdown';
const EditionCondition = ({condition, onChange, patterns}) => {
- // Convert patterns to options format
+ // Format patterns for the dropdown with descriptions if available
const patternOptions = patterns.map(pattern => ({
value: pattern.name,
- label: pattern.name
+ label: pattern.name,
+ description: pattern.description || 'No description available',
+ priority: pattern.priority
}));
+ const handlePatternChange = e => {
+ onChange({...condition, pattern: e.target.value});
+ };
+
return (
-
-
+
- onChange({...condition, pattern: e.target.value})
- }
+ onChange={handlePatternChange}
options={patternOptions}
placeholder='Select edition pattern...'
- className='w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600
- rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
+ searchableFields={['label', 'description']}
+ className='min-w-[200px]'
+ width='w-auto'
+ dropdownWidth='100%'
/>
);
diff --git a/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx b/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx
index aefccec..7a60953 100644
--- a/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx
+++ b/frontend/src/components/format/conditions/ReleaseGroupCondition.jsx
@@ -1,23 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
+import SearchDropdown from '@ui/SearchDropdown';
const ReleaseGroupCondition = ({condition, onChange, patterns}) => {
- const sortedPatterns = [...patterns].sort((a, b) =>
- a.name.localeCompare(b.name)
- );
+ // Format patterns for the dropdown with descriptions if available
+ const patternOptions = patterns.map(pattern => ({
+ value: pattern.name,
+ label: pattern.name,
+ description: pattern.description || 'No description available',
+ priority: pattern.priority
+ }));
+
+ const handlePatternChange = e => {
+ onChange({...condition, pattern: e.target.value});
+ };
return (
-
onChange({...condition, pattern: e.target.value})}>
- Select release group pattern...
- {sortedPatterns.map(pattern => (
-
- {pattern.name}
-
- ))}
-
+
+
+
);
};
diff --git a/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx b/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx
index dad2ae7..7231791 100644
--- a/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx
+++ b/frontend/src/components/format/conditions/ReleaseTitleCondition.jsx
@@ -1,23 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
+import SearchDropdown from '@ui/SearchDropdown';
const ReleaseTitleCondition = ({condition, onChange, patterns}) => {
- const sortedPatterns = [...patterns].sort((a, b) =>
- a.name.localeCompare(b.name)
- );
+ // Format patterns for the dropdown with enhanced descriptions
+ const patternOptions = patterns.map(pattern => ({
+ value: pattern.name,
+ label: pattern.name,
+ description: pattern.description || 'No description available',
+ priority: pattern.priority
+ }));
+
+ const handlePatternChange = e => {
+ onChange({...condition, pattern: e.target.value});
+ };
return (
-
onChange({...condition, pattern: e.target.value})}>
- Select release title pattern...
- {sortedPatterns.map(pattern => (
-
- {pattern.name}
-
- ))}
-
+
+
+
);
};
diff --git a/frontend/src/components/profile/ProfileCard.jsx b/frontend/src/components/profile/ProfileCard.jsx
index 56c59e4..bb23d9b 100644
--- a/frontend/src/components/profile/ProfileCard.jsx
+++ b/frontend/src/components/profile/ProfileCard.jsx
@@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import Tooltip from '@ui/Tooltip';
import ReactMarkdown from 'react-markdown';
+import { LANGUAGES } from '@constants/languages';
function unsanitize(text) {
if (!text) return '';
@@ -17,19 +18,38 @@ function unsanitize(text) {
}
function parseLanguage(languageStr) {
- if (!languageStr || languageStr === 'any') return 'Any';
-
+ // Handle empty or "any" case
+ if (!languageStr || languageStr === 'any') return 'Any Language';
+
+ // Handle "original" language case
+ if (languageStr === 'original') return 'Original';
+
+ // Check if this is a simple language choice (not in format of type_language)
+ const matchedLanguage = LANGUAGES.find(lang => lang.id === languageStr);
+ if (matchedLanguage) {
+ return matchedLanguage.name;
+ }
+
+ // If we get here, it's an advanced language setting with type_language format
const [type, language] = languageStr.split('_');
- const capitalizedLanguage =
- language.charAt(0).toUpperCase() + language.slice(1);
+
+ // If language part is missing, just return the type
+ if (!language) return type;
+
+ // Find language name from constants
+ const langObj = LANGUAGES.find(lang => lang.id === language);
+ const languageName = langObj ? langObj.name : language.charAt(0).toUpperCase() + language.slice(1);
+ // Format based on type
switch (type) {
case 'only':
- return `Must Only Be: ${capitalizedLanguage}`;
+ return `Must Only Be: ${languageName}`;
case 'must':
- return `Must Include: ${capitalizedLanguage}`;
+ return `Must Include: ${languageName}`;
+ case 'mustnot':
+ return `Must Not Include: ${languageName}`;
default:
- return capitalizedLanguage;
+ return languageName;
}
}
diff --git a/frontend/src/components/profile/ProfileLangaugesTab.jsx b/frontend/src/components/profile/ProfileLangaugesTab.jsx
deleted file mode 100644
index a1ef56b..0000000
--- a/frontend/src/components/profile/ProfileLangaugesTab.jsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {InfoIcon, AlertTriangle} from 'lucide-react';
-import {LANGUAGES} from '@constants/languages';
-
-const ProfileLanguagesTab = ({language, onLanguageChange}) => {
- const handleLanguageChange = (type, value) => {
- // If selecting 'any' behavior, just return 'any'
- if (type === 'behavior' && value === 'any') {
- onLanguageChange('any');
- return;
- }
-
- // For other cases, split current language setting
- const [behavior, lang] = (language || 'must_english').split('_');
-
- const newValue =
- type === 'behavior'
- ? `${value}_${lang || 'english'}`
- : `${behavior || 'must'}_${value}`;
-
- onLanguageChange(newValue);
- };
-
- // Split current language setting only if it's not 'any'
- const [currentBehavior, currentLanguage] =
- language === 'any'
- ? ['any', '']
- : (language || 'must_english').split('_');
-
- return (
-
-
-
-
- Language Requirements
-
-
- Configure language requirements for media content.
-
-
-
-
-
-
-
-
- Configure how languages should be handled for your media
- content. Select "Any" to accept all languages, or
- configure specific language requirements.
-
-
-
-
-
-
-
- Language Settings
-
-
- Configure language requirements for releases
-
-
-
-
-
- handleLanguageChange(
- 'behavior',
- e.target.value
- )
- }
- className='block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'>
- Any
- Must Include
- Must Only Be
-
- Must Not Include
-
-
-
- {currentBehavior !== 'any' && (
-
- handleLanguageChange(
- 'language',
- e.target.value
- )
- }
- className='block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'>
- {LANGUAGES.map(language => (
-
- {language.name}
-
- ))}
-
- )}
-
-
- {currentBehavior === 'only' && (
-
-
-
- "Must Only Be" will reject releases with
- multiple languages
-
-
- )}
-
-
-
-
- );
-};
-
-ProfileLanguagesTab.propTypes = {
- language: PropTypes.string.isRequired,
- onLanguageChange: PropTypes.func.isRequired
-};
-
-export default ProfileLanguagesTab;
diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx
index 1fb620d..0d6c680 100644
--- a/frontend/src/components/profile/ProfileModal.jsx
+++ b/frontend/src/components/profile/ProfileModal.jsx
@@ -3,22 +3,13 @@ import PropTypes from 'prop-types';
import {Profiles} from '@api/data';
import Modal from '../ui/Modal';
import Alert from '@ui/Alert';
-import {Loader} from 'lucide-react';
+import {Loader, Save, Trash2, Check} from 'lucide-react';
import ProfileGeneralTab from './ProfileGeneralTab';
import ProfileScoringTab from './scoring/ProfileScoringTab';
-import ProfileQualitiesTab from './ProfileQualitiesTab';
-import ProfileLangaugesTab from './ProfileLangaugesTab';
-import ProfileTweaksTab from './ProfileTweaksTab';
+import ProfileQualitiesTab from './quality/ProfileQualitiesTab';
+import ProfileLangaugesTab from './language/ProfileLangaugesTab';
import QUALITIES from '../../constants/qualities';
-const DEFAULT_TWEAKS = {
- preferFreeleech: true,
- allowLosslessAudio: true,
- allowDVNoFallback: false,
- allowBleedingEdgeCodecs: false,
- allowPrereleases: false
-};
-
function unsanitize(text) {
if (!text) return '';
return text.replace(/\\:/g, ':').replace(/\\n/g, '\n');
@@ -37,6 +28,7 @@ function ProfileModal({
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [modalTitle, setModalTitle] = useState('');
@@ -70,15 +62,11 @@ function ProfileModal({
// Language state
const [language, setLanguage] = useState('must_english');
- // Tweaks state
- const [tweaks, setTweaks] = useState(DEFAULT_TWEAKS);
-
const tabs = [
{id: 'general', label: 'General'},
{id: 'scoring', label: 'Scoring'},
{id: 'qualities', label: 'Qualities'},
- {id: 'languages', label: 'Languages'},
- {id: 'tweaks', label: 'Tweaks'}
+ {id: 'languages', label: 'Languages'}
];
const resetState = () => {
@@ -124,12 +112,13 @@ function ProfileModal({
// Reset other states
setLanguage('must_english');
- setTweaks(DEFAULT_TWEAKS);
};
useEffect(() => {
if (isOpen) {
setLoading(true);
+ setIsDeleting(false);
+ setIsSaving(false);
setModalTitle(
isCloning
@@ -185,12 +174,6 @@ function ProfileModal({
});
setTagScores(initialTagScores);
- // Tweaks
- setTweaks({
- ...DEFAULT_TWEAKS,
- ...(content.tweaks || {})
- });
-
// Qualities setup - include all qualities, set enabled status
const allQualitiesMap = {}; // Map of all qualities by id
QUALITIES.forEach(quality => {
@@ -335,7 +318,6 @@ function ProfileModal({
// Initialize with defaults
setLanguage('must_english');
- setTweaks(DEFAULT_TWEAKS);
}
setLoading(false);
@@ -343,92 +325,168 @@ function ProfileModal({
}, [initialProfile, isOpen, formats, isCloning]);
const handleSave = async () => {
- if (!name.trim()) {
- setError('Name is required.');
- Alert.error('Please enter a profile name');
- return;
- }
-
- try {
- const profileData = {
- name,
- description,
- tags,
- upgradesAllowed,
- minCustomFormatScore,
- upgradeUntilScore,
- minScoreIncrement,
- custom_formats: customFormats
- .filter(format => format.score !== 0)
- .sort((a, b) => {
- // First sort by score (descending)
- if (b.score !== a.score) {
- return b.score - a.score;
- }
- // Then alphabetically for equal scores
- return a.name.localeCompare(b.name);
- })
- .map(format => ({
- name: format.name,
- score: format.score
- })),
- qualities: sortedQualities
- .filter(q => q.enabled)
- .map(q => {
- if ('qualities' in q) {
- return {
- id: q.id,
- name: q.name,
- description: q.description || '',
- qualities: q.qualities.map(subQ => ({
- id: subQ.id,
- name: subQ.name
- }))
- };
- } else {
- return {
- id: q.id,
- name: q.name
- };
- }
- }),
- upgrade_until: selectedUpgradeQuality
- ? {
- id: selectedUpgradeQuality.id,
- name: selectedUpgradeQuality.name,
- ...(selectedUpgradeQuality.description && {
- description: selectedUpgradeQuality.description
- })
- }
- : null,
- language,
- tweaks
- };
-
- if (isCloning || !initialProfile) {
- // Creating new profile
- await Profiles.create(profileData);
- Alert.success('Profile created successfully');
- } else {
- // Updating existing profile
- const originalName = initialProfile.content.name;
- const isNameChanged = originalName !== name;
- await Profiles.update(
- initialProfile.file_name.replace('.yml', ''),
- profileData,
- isNameChanged ? name : undefined
- );
- Alert.success('Profile updated successfully');
+ if (isSaving) {
+ // This is the confirmation click
+ if (!name.trim()) {
+ setError('Name is required.');
+ Alert.error('Please enter a profile name');
+ setIsSaving(false);
+ return;
}
- onSave();
- onClose();
- } catch (error) {
- console.error('Error saving profile:', error);
- const errorMessage =
- error.message || 'An unexpected error occurred';
- Alert.error(errorMessage);
- setError(errorMessage);
+ try {
+ const profileData = {
+ name,
+ description,
+ tags,
+ upgradesAllowed,
+ minCustomFormatScore,
+ upgradeUntilScore,
+ minScoreIncrement,
+ custom_formats: (() => {
+ // Check if selective mode is enabled
+ const selectiveMode = localStorage.getItem(
+ 'formatSettingsSelectiveMode'
+ );
+ const useSelectiveMode =
+ selectiveMode !== null && JSON.parse(selectiveMode);
+
+ if (useSelectiveMode) {
+ // In selective mode, save both:
+ // 1. Formats with non-zero scores as usual
+ // 2. Formats with zero score that have been explicitly selected in selectedFormatIds
+
+ try {
+ // Get the list of explicitly selected format IDs
+ const selectedFormatIdsStr =
+ localStorage.getItem('selectedFormatIds');
+ const selectedFormatIds = selectedFormatIdsStr
+ ? JSON.parse(selectedFormatIdsStr)
+ : [];
+
+ // Get formats with non-zero scores
+ const nonZeroFormats = customFormats.filter(
+ format => format.score !== 0
+ );
+
+ // Get formats with zero scores that are explicitly selected
+ const explicitlySelectedZeroFormats =
+ customFormats.filter(
+ format =>
+ format.score === 0 &&
+ selectedFormatIds.includes(format.id)
+ );
+
+ // Combine both lists
+ return [
+ ...nonZeroFormats,
+ ...explicitlySelectedZeroFormats
+ ]
+ .sort((a, b) => {
+ // First sort by score (descending)
+ if (b.score !== a.score) {
+ return b.score - a.score;
+ }
+ // Then alphabetically for equal scores
+ return a.name.localeCompare(b.name);
+ })
+ .map(format => ({
+ name: format.name,
+ score: format.score
+ }));
+ } catch (e) {
+ // If there's any error parsing the selectedFormatIds, fall back to just non-zero scores
+ return customFormats
+ .filter(format => format.score !== 0)
+ .sort((a, b) => {
+ if (b.score !== a.score)
+ return b.score - a.score;
+ return a.name.localeCompare(b.name);
+ })
+ .map(format => ({
+ name: format.name,
+ score: format.score
+ }));
+ }
+ } else {
+ // Standard behavior - only include formats with non-zero scores
+ return customFormats
+ .filter(format => format.score !== 0)
+ .sort((a, b) => {
+ // First sort by score (descending)
+ if (b.score !== a.score) {
+ return b.score - a.score;
+ }
+ // Then alphabetically for equal scores
+ return a.name.localeCompare(b.name);
+ })
+ .map(format => ({
+ name: format.name,
+ score: format.score
+ }));
+ }
+ })(),
+ qualities: sortedQualities
+ .filter(q => q.enabled)
+ .map(q => {
+ if ('qualities' in q) {
+ return {
+ id: q.id,
+ name: q.name,
+ description: q.description || '',
+ qualities: q.qualities.map(subQ => ({
+ id: subQ.id,
+ name: subQ.name
+ }))
+ };
+ } else {
+ return {
+ id: q.id,
+ name: q.name
+ };
+ }
+ }),
+ upgrade_until: selectedUpgradeQuality
+ ? {
+ id: selectedUpgradeQuality.id,
+ name: selectedUpgradeQuality.name,
+ ...(selectedUpgradeQuality.description && {
+ description: selectedUpgradeQuality.description
+ })
+ }
+ : null,
+ language
+ };
+
+ if (isCloning || !initialProfile) {
+ // Creating new profile
+ await Profiles.create(profileData);
+ Alert.success('Profile created successfully');
+ } else {
+ // Updating existing profile
+ const originalName = initialProfile.content.name;
+ const isNameChanged = originalName !== name;
+ await Profiles.update(
+ initialProfile.file_name.replace('.yml', ''),
+ profileData,
+ isNameChanged ? name : undefined
+ );
+ Alert.success('Profile updated successfully');
+ }
+
+ onSave();
+ onClose();
+ } catch (error) {
+ console.error('Error saving profile:', error);
+ const errorMessage =
+ error.message || 'An unexpected error occurred';
+ Alert.error(errorMessage);
+ setError(errorMessage);
+ setIsSaving(false);
+ }
+ } else {
+ // First click - show confirmation
+ setIsSaving(true);
}
};
@@ -477,16 +535,24 @@ function ProfileModal({
{initialProfile && (
- {isDeleting ? 'Confirm Delete' : 'Delete'}
+ className='inline-flex items-center gap-2 px-4 py-2 rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors'>
+ {isDeleting ? (
+
+ ) : (
+
+ )}
+ Delete
)}
- Save
+ className='inline-flex items-center gap-2 px-4 py-2 rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors'>
+ {isSaving ? (
+
+ ) : (
+
+ )}
+ Save
}>
@@ -589,12 +655,6 @@ function ProfileModal({
onLanguageChange={setLanguage}
/>
)}
- {activeTab === 'tweaks' && (
-
- )}
)}
@@ -638,8 +698,7 @@ ProfileModal.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired
}),
- language: PropTypes.string,
- tweaks: PropTypes.object
+ language: PropTypes.string
})
}),
isOpen: PropTypes.bool.isRequired,
diff --git a/frontend/src/components/profile/ProfilePage.jsx b/frontend/src/components/profile/ProfilePage.jsx
index 1376ef0..8ff6b29 100644
--- a/frontend/src/components/profile/ProfilePage.jsx
+++ b/frontend/src/components/profile/ProfilePage.jsx
@@ -111,7 +111,7 @@ function ProfilePage() {
lastSelectedIndex
} = useMassSelection();
- useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
+ useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
useEffect(() => {
fetchGitStatus();
@@ -242,9 +242,10 @@ function ProfilePage() {
if (isSelectionMode) return;
const clonedProfile = {
...profile,
- id: 0,
- name: `${profile.name} [COPY]`,
- custom_formats: profile.custom_formats || []
+ content: {
+ ...profile.content,
+ name: `${profile.content.name} [COPY]`
+ }
};
setSelectedProfile(clonedProfile);
setIsModalOpen(true);
diff --git a/frontend/src/components/profile/ProfileTweaksTab.jsx b/frontend/src/components/profile/ProfileTweaksTab.jsx
deleted file mode 100644
index 71af22e..0000000
--- a/frontend/src/components/profile/ProfileTweaksTab.jsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {InfoIcon, AlertTriangle} from 'lucide-react';
-
-const ProfileTweaksTab = ({tweaks, onTweaksChange}) => {
- const handleTweakChange = key => {
- onTweaksChange({
- ...tweaks,
- [key]: !tweaks[key]
- });
- };
-
- return (
-
-
-
-
-
- Tweaks are custom changes that can be toggled according
- to your preference. These settings are profile-specific
- and won't create merge conflicts when synchronizing with
- remote repositories. Use tweaks to fine-tune your
- profile's behavior without affecting the core
- configuration.
-
-
-
-
- {/* Allow Dolby Vision without Fallback */}
-
handleTweakChange('allowDVNoFallback')}
- className={`
- p-4 rounded-lg cursor-pointer select-none
- border transition-colors duration-200
- ${
- tweaks.allowDVNoFallback
- ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20'
- : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
- }
- hover:border-blue-500 dark:hover:border-blue-400
- `}>
-
-
- Allow Dolby Vision without Fallback
-
-
- Allow Dolby Vision releases that don't include
- HDR10 fallback. These may display incorrectly on
- non-Dolby Vision displays.
-
-
-
-
- Only enable if your display supports Dolby
- Vision
-
-
-
-
-
- {/* Allow Bleeding Edge Codecs */}
-
- handleTweakChange('allowBleedingEdgeCodecs')
- }
- className={`
- p-4 rounded-lg cursor-pointer select-none
- border transition-colors duration-200
- ${
- tweaks.allowBleedingEdgeCodecs
- ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20'
- : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
- }
- hover:border-blue-500 dark:hover:border-blue-400
- `}>
-
-
- Allow Bleeding Edge Codecs
-
-
- Allow releases using newer codecs like AV1 and
- H.266/VVC. These may offer better compression
- but have limited hardware support.
-
-
-
-
- {/* Allow Lossless Audio */}
-
handleTweakChange('allowLosslessAudio')}
- className={`
- p-4 rounded-lg cursor-pointer select-none
- border transition-colors duration-200
- ${
- tweaks.allowLosslessAudio ?? true
- ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20'
- : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
- }
- hover:border-blue-500 dark:hover:border-blue-400
- `}>
-
-
- Allow Lossless Audio
-
-
- Allow high-quality lossless audio formats
- including TrueHD + Atmos, DTS-HD MA, DTS-X,
- FLAC, and PCM.
-
-
-
-
- May skip better quality releases if disabled
-
-
-
-
-
- {/* Allow Prereleases */}
-
handleTweakChange('allowPrereleases')}
- className={`
- p-4 rounded-lg cursor-pointer select-none
- border transition-colors duration-200
- ${
- tweaks.allowPrereleases
- ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20'
- : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
- }
- hover:border-blue-500 dark:hover:border-blue-400
- `}>
-
-
- Allow Prereleases
-
-
- Allow early releases like CAMs, Telecines,
- Telesyncs, and Screeners. These are typically
- available before official releases but at lower
- quality.
-
-
-
-
- {/* Prefer Freeleech */}
-
handleTweakChange('preferFreeleech')}
- className={`
- p-4 rounded-lg cursor-pointer select-none
- border transition-colors duration-200
- ${
- tweaks.preferFreeleech
- ? 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20'
- : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
- }
- hover:border-blue-500 dark:hover:border-blue-400
- `}>
-
-
- Prefer Freeleech
-
-
- Prioritize releases tagged as freeleech when
- choosing between different indexers' releases.
-
-
-
-
-
-
- );
-};
-
-ProfileTweaksTab.propTypes = {
- tweaks: PropTypes.object.isRequired,
- onTweaksChange: PropTypes.func.isRequired
-};
-
-export default ProfileTweaksTab;
diff --git a/frontend/src/components/profile/QualityItem.jsx b/frontend/src/components/profile/QualityItem.jsx
deleted file mode 100644
index 078099f..0000000
--- a/frontend/src/components/profile/QualityItem.jsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import React from 'react';
-import RadarrLogo from '@logo/Radarr.svg';
-import SonarrLogo from '@logo/Sonarr.svg';
-import {Pencil, Trash2} from 'lucide-react';
-
-const QualityItem = ({
- quality,
- isDragging,
- listeners,
- attributes,
- style,
- onDelete,
- onEdit
-}) => {
- const isGroup = 'qualities' in quality;
-
- return (
-
- {/* Header Section */}
-
- {/* Title and Description */}
-
-
- {quality.name}
-
- {isGroup && quality.description && (
-
- {quality.description}
-
- )}
-
-
- {/* Actions and Icons */}
-
- {/* App Icons */}
-
- {quality.radarr && (
-
- )}
- {quality.sonarr && (
-
- )}
-
-
- {/* Edit/Delete Actions */}
- {isGroup && (
-
- {onEdit && (
-
{
- e.stopPropagation();
- onEdit(quality);
- }}
- className='hidden group-hover:flex items-center justify-center h-6 w-6 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 transition-all'>
-
-
- )}
- {onDelete && (
-
{
- e.stopPropagation();
- onDelete(quality);
- }}
- className='hidden group-hover:flex items-center justify-center h-6 w-6 rounded-md text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 transition-all'>
-
-
- )}
-
- )}
-
-
-
- {/* Quality Tags Section */}
- {isGroup && (
-
- {quality.qualities.map(q => (
-
- {q.name}
-
- ))}
-
- )}
-
- {/* Non-group Description */}
- {!isGroup && quality.description && (
-
- {quality.description}
-
- )}
-
- );
-};
-
-export default QualityItem;
diff --git a/frontend/src/components/profile/language/AdvancedView.jsx b/frontend/src/components/profile/language/AdvancedView.jsx
new file mode 100644
index 0000000..89835fa
--- /dev/null
+++ b/frontend/src/components/profile/language/AdvancedView.jsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {InfoIcon, AlertTriangle} from 'lucide-react';
+import {LANGUAGES} from '@constants/languages';
+
+const AdvancedView = ({language, onLanguageChange}) => {
+ const handleLanguageChange = (type, value) => {
+ // If selecting 'any' behavior, just return 'any'
+ if (type === 'behavior' && value === 'any') {
+ onLanguageChange('any');
+ return;
+ }
+
+ // For other cases, split current language setting
+ const [behavior, lang] = (language || 'must_english').split('_');
+
+ const newValue =
+ type === 'behavior'
+ ? `${value}_${lang || 'english'}`
+ : `${behavior || 'must'}_${value}`;
+
+ onLanguageChange(newValue);
+ };
+
+ // Split current language setting only if it's not 'any'
+ const [currentBehavior, currentLanguage] =
+ language === 'any'
+ ? ['any', '']
+ : (language || 'must_english').split('_');
+
+ return (
+ <>
+ {/* Type Dropdown */}
+
+ handleLanguageChange('behavior', e.target.value)
+ }
+ className='flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'>
+ Any
+ Must Include
+ Must Only Be
+ Must Not Include
+
+
+ {/* Language Dropdown */}
+ {currentBehavior !== 'any' && (
+
+ handleLanguageChange(
+ 'language',
+ e.target.value
+ )
+ }
+ className='flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'>
+ {LANGUAGES.map(language => (
+
+ {language.name}
+
+ ))}
+
+ )}
+
+ {/* Help text below the controls is in the parent component */}
+
+
+ {currentBehavior === 'any' && (
+
+
+
Accept content in any language.
+
+ )}
+
+ {currentBehavior === 'must' && (
+
+
+
+ Content must include{' '}
+ {currentLanguage
+ ? LANGUAGES.find(
+ l => l.id === currentLanguage
+ )?.name || currentLanguage
+ : 'English'}
+ , but can include other languages as well.
+
+
+ )}
+
+ {currentBehavior === 'only' && (
+
+
+
+ Content must ONLY be in{' '}
+ {currentLanguage
+ ? LANGUAGES.find(
+ l => l.id === currentLanguage
+ )?.name || currentLanguage
+ : 'English'}
+ . This will reject releases containing
+ multiple languages.
+
+
+ )}
+
+ {currentBehavior === 'mustnot' && (
+
+
+
+ Content must NOT include{' '}
+ {currentLanguage
+ ? LANGUAGES.find(
+ l => l.id === currentLanguage
+ )?.name || currentLanguage
+ : 'English'}
+ . Any other language is acceptable.
+
+
+ )}
+
+
+
+ );
+};
+
+AdvancedView.propTypes = {
+ language: PropTypes.string.isRequired,
+ onLanguageChange: PropTypes.func.isRequired
+};
+
+export default AdvancedView;
diff --git a/frontend/src/components/profile/language/ProfileLangaugesTab.jsx b/frontend/src/components/profile/language/ProfileLangaugesTab.jsx
new file mode 100644
index 0000000..f0008b3
--- /dev/null
+++ b/frontend/src/components/profile/language/ProfileLangaugesTab.jsx
@@ -0,0 +1,447 @@
+import React, {useState, useEffect, useMemo} from 'react';
+import PropTypes from 'prop-types';
+import {
+ Settings,
+ List,
+ ChevronDown,
+ InfoIcon,
+ AlertTriangle
+} from 'lucide-react';
+import {LANGUAGES} from '@constants/languages';
+import SearchDropdown from '@ui/SearchDropdown';
+
+const ProfileLanguagesTab = ({language, onLanguageChange}) => {
+ // Determine advanced view based on language format
+ const [isAdvancedView, setIsAdvancedView] = useState(() => {
+ // If language includes an underscore (e.g., must_english) or doesn't exist, it's advanced mode
+ // If it's a simple language ID without underscore (e.g., english, original, any), it's simple mode
+ return !language || language.includes('_');
+ });
+
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+ // Update mode whenever language changes externally
+ useEffect(() => {
+ // If no language provided, set a default value for new profiles
+ if (!language) {
+ // Default for new profiles: Advanced mode with "must include original"
+ onLanguageChange('must_original');
+ } else {
+ // Otherwise determine mode from language format
+ setIsAdvancedView(!language || language.includes('_'));
+ }
+ }, [language, onLanguageChange]);
+
+ // For simple view - language options
+ const languageOptions = useMemo(() => {
+ return [
+ // Any language at the very top - special item
+ {
+ value: 'any',
+ label: 'Any Language',
+ description: 'Accepting content in any language',
+ isSpecial: true
+ },
+ // Original language next - special item
+ {
+ value: 'original',
+ label: 'Original',
+ description:
+ 'Content must include Original, but can include other languages as well',
+ isSpecial: true
+ },
+ // All other languages - sorted alphabetically
+ ...LANGUAGES.filter(lang => lang.id !== 'original') // Skip original since we added it manually above
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map(lang => ({
+ value: lang.id,
+ label: lang.name,
+ description: `Content must include ${lang.name}, but can include other languages as well`
+ }))
+ ];
+ }, []);
+
+ // For advanced view - handle language changes
+ const handleAdvancedLanguageChange = (type, value) => {
+ // If selecting 'any' behavior, just return 'any'
+ if (type === 'behavior' && value === 'any') {
+ onLanguageChange('any');
+ return;
+ }
+
+ // For other cases, split current language setting
+ const [behavior, lang] = (language || 'must_english').split('_');
+
+ const newValue =
+ type === 'behavior'
+ ? `${value}_${lang || 'english'}`
+ : `${behavior || 'must'}_${value}`;
+
+ onLanguageChange(newValue);
+ };
+
+ // Split current language setting only if it's not 'any'
+ const [currentBehavior, currentLanguage] =
+ language === 'any'
+ ? ['any', '']
+ : (language || 'must_english').split('_');
+
+ return (
+
+
+ {/* Simple header with title and description */}
+
+
+ Language Settings
+
+
+ Configure how language preferences are applied to your
+ profiles.
+ {isAdvancedView
+ ? ' Advanced mode creates custom formats for precise language control in both Radarr and Sonarr. This is required for Sonarr as it lacks built-in language settings.'
+ : ' Simple mode sets language preferences directly in Radarr without custom formats. For Sonarr, consider using Advanced mode since it has no built-in language filtering.'}
+
+
+
+ {/* Controls row - display mode dropdown with other controls */}
+
+ {/* Mode Selector (always visible) */}
+
+
setIsDropdownOpen(prev => !prev)}
+ className='inline-flex items-center justify-between w-full px-3 py-2 rounded-md border border-gray-600 bg-gray-800 hover:border-gray-500 transition-colors text-gray-100'
+ aria-expanded={isDropdownOpen}
+ aria-haspopup='true'>
+
+ {isAdvancedView ? (
+ <>
+
+
+ Advanced
+
+ >
+ ) : (
+ <>
+
+
+ Simple
+
+ >
+ )}
+
+
+
+ {isDropdownOpen && (
+ <>
+
setIsDropdownOpen(false)}
+ />
+
+
+
{
+ setIsDropdownOpen(false);
+ // When switching from advanced to simple mode, convert to simple format
+ if (
+ isAdvancedView &&
+ language
+ ) {
+ if (language === 'any') {
+ // Keep 'any' as is
+ } else if (
+ language.includes('_')
+ ) {
+ // Extract the language part from format like "must_english"
+ const langPart =
+ language.split(
+ '_'
+ )[1];
+ // If no language part or if it's not a valid simple language, use 'any'
+ if (!langPart) {
+ onLanguageChange(
+ 'any'
+ );
+ } else {
+ onLanguageChange(
+ langPart
+ );
+ }
+ }
+ }
+ }}
+ 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'
+ }`}>
+
+
+ Simple
+
+
+
{
+ // When switching from simple to advanced mode, convert basic language
+ // to proper advanced format if necessary
+ if (
+ !isAdvancedView &&
+ language &&
+ !language.includes('_')
+ ) {
+ // Default to "must include original" if language is "any"
+ if (language === 'any') {
+ onLanguageChange(
+ 'must_original'
+ );
+ } else {
+ // For other languages, use must_[language]
+ onLanguageChange(
+ `must_${language}`
+ );
+ }
+ }
+ 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'
+ }`}>
+
+
+ Advanced
+
+
+
+
+ >
+ )}
+
+
+ {/* SIMPLE MODE: just one language dropdown */}
+ {!isAdvancedView && (
+
+ onLanguageChange(e.target.value)}
+ options={languageOptions}
+ placeholder='Select language...'
+ dropdownWidth='100%'
+ className='bg-gray-800 dark:border-gray-600 text-gray-100'
+ />
+
+ )}
+
+ {/* ADVANCED MODE: two dropdowns (type and language) */}
+ {isAdvancedView && (
+ <>
+ {/* Type Dropdown - Custom styled */}
+
+
+ handleAdvancedLanguageChange(
+ 'behavior',
+ e.target.value
+ )
+ }
+ className='w-full appearance-none rounded-md border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 pr-8'>
+ Any
+ Must Include
+ Must Only Be
+
+ Must Not Include
+
+
+
+
+
+
+
+ {/* Language Dropdown */}
+ {currentBehavior !== 'any' && (
+
+
+ handleAdvancedLanguageChange(
+ 'language',
+ e.target.value
+ )
+ }
+ options={[
+ // Special items at the top
+ {
+ value: 'original',
+ label: 'Original',
+ description:
+ 'Content must include Original, but can include other languages as well',
+ isSpecial: true
+ },
+ // All other languages - sorted alphabetically
+ ...LANGUAGES.filter(
+ lang => lang.id !== 'original'
+ ) // Skip original since we added it manually above
+ .sort((a, b) =>
+ a.name.localeCompare(b.name)
+ )
+ .map(lang => ({
+ value: lang.id,
+ label: lang.name,
+ description: `Content must include ${lang.name}, but can include other languages as well`
+ }))
+ ]}
+ placeholder='Select language...'
+ dropdownWidth='100%'
+ className='bg-gray-800 dark:border-gray-600 text-gray-100'
+ />
+
+ )}
+ >
+ )}
+
+
+ {/* Help text section - display the appropriate help text based on view mode and selection */}
+
+ {/* Simple mode help */}
+ {!isAdvancedView && (
+
+
+
+ {language === 'any' ? (
+ <>
+ Attempts to set{' '}
+
+ Any Language
+ {' '}
+ in Radarr profiles. For Sonarr, language
+ will default to "Original" since it
+ lacks native language settings.
+ >
+ ) : language === 'original' ? (
+ <>
+ Attempts to set{' '}
+
+ Original
+ {' '}
+ language in Radarr profiles. For Sonarr,
+ language will default to "Original"
+ since it lacks native language settings.
+ >
+ ) : (
+ <>
+ Attempts to set{' '}
+
+ {LANGUAGES.find(
+ l => l.id === language
+ )?.name || language}
+ {' '}
+ language in Radarr profiles. For Sonarr,
+ language will default to "Original"
+ since it lacks native language settings.
+ >
+ )}
+
+
+ )}
+
+ {/* Advanced mode help based on selections */}
+ {isAdvancedView && (
+ <>
+ {language === 'any' && (
+
+
+
Accept content in any language.
+
+ )}
+
+ {language && language.startsWith('must_') && (
+
+
+
+ Content must include{' '}
+
+ {language.split('_')[1]
+ ? LANGUAGES.find(
+ l =>
+ l.id ===
+ language.split('_')[1]
+ )?.name ||
+ language.split('_')[1]
+ : 'English'}
+
+ , but can include other languages as
+ well.
+
+
+ )}
+
+ {language && language.startsWith('only_') && (
+
+
+
+ Content must ONLY be in{' '}
+
+ {language.split('_')[1]
+ ? LANGUAGES.find(
+ l =>
+ l.id ===
+ language.split('_')[1]
+ )?.name ||
+ language.split('_')[1]
+ : 'English'}
+
+ . This will reject releases containing
+ multiple languages.
+
+
+ )}
+
+ {language && language.startsWith('mustnot_') && (
+
+
+
+ Content must NOT include{' '}
+
+ {language.split('_')[1]
+ ? LANGUAGES.find(
+ l =>
+ l.id ===
+ language.split('_')[1]
+ )?.name ||
+ language.split('_')[1]
+ : 'English'}
+
+ . Any other language is acceptable.
+
+
+ )}
+ >
+ )}
+
+
+
+ );
+};
+
+ProfileLanguagesTab.propTypes = {
+ language: PropTypes.string.isRequired,
+ onLanguageChange: PropTypes.func.isRequired
+};
+
+export default ProfileLanguagesTab;
diff --git a/frontend/src/components/profile/language/SimpleView.jsx b/frontend/src/components/profile/language/SimpleView.jsx
new file mode 100644
index 0000000..bd222fb
--- /dev/null
+++ b/frontend/src/components/profile/language/SimpleView.jsx
@@ -0,0 +1,51 @@
+import React, {useMemo} from 'react';
+import PropTypes from 'prop-types';
+import {LANGUAGES} from '@constants/languages';
+import SearchDropdown from '@ui/SearchDropdown';
+
+const SimpleView = ({language, onLanguageChange}) => {
+ const languageOptions = useMemo(() => {
+ return [
+ // Any language at the very top - special item
+ {
+ value: 'any',
+ label: 'Any Language',
+ description: 'Accepting content in any language',
+ isSpecial: true
+ },
+ // Original language next - special item
+ {
+ value: 'original',
+ label: 'Original',
+ description: 'Content must include Original, but can include other languages as well',
+ isSpecial: true
+ },
+ // All other languages - sorted alphabetically
+ ...LANGUAGES
+ .filter(lang => lang.id !== 'original') // Skip original since we added it manually above
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map(lang => ({
+ value: lang.id,
+ label: lang.name,
+ description: `Content must include ${lang.name}, but can include other languages as well`
+ }))
+ ];
+ }, []);
+
+ return (
+
onLanguageChange(e.target.value)}
+ options={languageOptions}
+ placeholder="Select language..."
+ dropdownWidth="100%"
+ />
+ );
+};
+
+SimpleView.propTypes = {
+ language: PropTypes.string.isRequired,
+ onLanguageChange: PropTypes.func.isRequired
+};
+
+export default SimpleView;
diff --git a/frontend/src/components/profile/CreateGroupModal.jsx b/frontend/src/components/profile/quality/CreateGroupModal.jsx
similarity index 99%
rename from frontend/src/components/profile/CreateGroupModal.jsx
rename to frontend/src/components/profile/quality/CreateGroupModal.jsx
index b5c5de5..af3862c 100644
--- a/frontend/src/components/profile/CreateGroupModal.jsx
+++ b/frontend/src/components/profile/quality/CreateGroupModal.jsx
@@ -1,5 +1,5 @@
import React, {useState, useEffect} from 'react';
-import Modal from '../ui/Modal';
+import Modal from '@ui/Modal';
import Tooltip from '@ui/Tooltip';
import {InfoIcon} from 'lucide-react';
diff --git a/frontend/src/components/profile/ProfileQualitiesTab.jsx b/frontend/src/components/profile/quality/ProfileQualitiesTab.jsx
similarity index 57%
rename from frontend/src/components/profile/ProfileQualitiesTab.jsx
rename to frontend/src/components/profile/quality/ProfileQualitiesTab.jsx
index bb99876..3acf7d7 100644
--- a/frontend/src/components/profile/ProfileQualitiesTab.jsx
+++ b/frontend/src/components/profile/quality/ProfileQualitiesTab.jsx
@@ -6,8 +6,7 @@ import {
KeyboardSensor,
PointerSensor,
useSensor,
- useSensors,
- DragOverlay
+ useSensors
} from '@dnd-kit/core';
import {
arrayMove,
@@ -20,54 +19,20 @@ import {
restrictToVerticalAxis,
restrictToParentElement
} from '@dnd-kit/modifiers';
-import {InfoIcon} from 'lucide-react';
-import Modal from '../ui/Modal';
+import Modal from '@ui/Modal';
import CreateGroupModal from './CreateGroupModal';
import QualityItem from './QualityItem';
-import QUALITIES from '../../constants/qualities';
+import QUALITIES from '@constants/qualities';
import Alert from '@ui/Alert';
-const UpgradeSection = ({
- enabledQualities,
- selectedUpgradeQuality,
- onUpgradeQualityChange
+const SortableItem = ({
+ quality,
+ onToggle,
+ onDelete,
+ onEdit,
+ isUpgradeUntil,
+ onUpgradeUntilClick
}) => {
- if (enabledQualities.length === 0) {
- return null;
- }
-
- return (
-
-
-
- Upgrade Until
-
-
- Downloads will be upgraded until this quality is reached.
- Lower qualities will be upgraded, while higher qualities
- will be left unchanged.
-
-
{
- const quality = enabledQualities.find(
- q => q.id === parseInt(e.target.value)
- );
- onUpgradeQualityChange(quality);
- }}>
- {enabledQualities.map(quality => (
-
- {quality.name}
-
- ))}
-
-
-
- );
-};
-
-const SortableItem = ({quality, onToggle, onDelete, onEdit}) => {
const {
attributes,
listeners,
@@ -99,6 +64,10 @@ const SortableItem = ({quality, onToggle, onDelete, onEdit}) => {
style={style}
onEdit={'qualities' in quality ? onEdit : undefined}
onDelete={'qualities' in quality ? onDelete : undefined}
+ isUpgradeUntil={isUpgradeUntil}
+ onUpgradeUntilClick={
+ quality.enabled ? onUpgradeUntilClick : undefined
+ }
/>
);
@@ -217,6 +186,19 @@ const ProfileQualitiesTab = ({
const handleQualityToggle = quality => {
if (!activeId) {
+ // Prevent disabling a quality that's set as the upgrade until quality
+ if (
+ quality.enabled &&
+ upgradesAllowed &&
+ selectedUpgradeQuality &&
+ isUpgradeUntilQuality(quality)
+ ) {
+ Alert.error(
+ "You can't disable a quality that's set as 'upgrade until'. Please set another quality as 'upgrade until' first."
+ );
+ return;
+ }
+
const currentEnabledCount = sortedQualities.filter(
q => q.enabled
).length;
@@ -252,34 +234,69 @@ const ProfileQualitiesTab = ({
onQualitiesChange(allEnabledQualities);
- // Only update the upgrade quality if we're disabling the current upgrade quality
+ // We shouldn't reach this point for the upgrade until quality,
+ // but keeping as a safety measure
if (
+ upgradesAllowed &&
selectedUpgradeQuality &&
quality.enabled === false && // We're disabling a quality
- (quality.id === selectedUpgradeQuality.id || // Direct match
- ('qualities' in quality && // Group match
+ (quality.id === selectedUpgradeQuality.id || // Direct match (group or quality)
+ ('qualities' in quality && // Quality is in a group that's being disabled
+ !('qualities' in selectedUpgradeQuality) &&
quality.qualities.some(
q => q.id === selectedUpgradeQuality.id
)))
) {
+ // Find another enabled quality to set as upgrade until
const nearestQuality = findNearestEnabledQuality(
newQualities,
quality.id
);
- onSelectedUpgradeQualityChange?.(nearestQuality);
+
+ if (nearestQuality) {
+ onSelectedUpgradeQualityChange?.(nearestQuality);
+ Alert.info(
+ `Upgrade until quality changed to ${nearestQuality.name}`
+ );
+ }
}
}
};
+ const handleUpgradeUntilClick = quality => {
+ // Make sure we're setting the quality object properly
+ if (quality) {
+ // For single qualities, pass as is
+ // For groups, we pass the group itself to maintain the group ID in the selection
+ onSelectedUpgradeQualityChange?.(quality);
+
+ // Provide user feedback
+ Alert.success(`${quality.name} set as upgrade until quality`);
+ }
+ };
+
const handleCreateOrUpdateGroup = groupData => {
- if (
- selectedUpgradeQuality &&
- !('qualities' in selectedUpgradeQuality)
- ) {
- const qualityMovingToGroup = groupData.qualities.some(
- q => q.id === selectedUpgradeQuality.id
- );
- if (qualityMovingToGroup) {
+ // Check if the currently selected upgrade quality is being moved into this group
+ if (selectedUpgradeQuality) {
+ // If the selected upgrade quality is a single quality (not a group itself)
+ if (!('qualities' in selectedUpgradeQuality)) {
+ const qualityMovingToGroup = groupData.qualities.some(
+ q => q.id === selectedUpgradeQuality.id
+ );
+
+ // If the current upgrade quality is being moved into this group
+ // Update the upgrade quality to be the group instead
+ if (qualityMovingToGroup) {
+ onSelectedUpgradeQualityChange({
+ id: groupData.id,
+ name: groupData.name,
+ description: groupData.description
+ });
+ }
+ }
+ // If the selected upgrade quality is the group we're editing
+ else if (selectedUpgradeQuality.id === editingGroup?.id) {
+ // Update the upgrade quality to reflect the new group data
onSelectedUpgradeQualityChange({
id: groupData.id,
name: groupData.name,
@@ -371,15 +388,39 @@ const ProfileQualitiesTab = ({
const handleDeleteGroup = group => {
// Check if we're deleting the currently selected upgrade group
if (selectedUpgradeQuality && selectedUpgradeQuality.id === group.id) {
- const firstQualityFromGroup = group.qualities[0];
- onSelectedUpgradeQualityChange(firstQualityFromGroup);
+ // Find the first quality from the group and set it as the upgrade until quality
+ if (group.qualities && group.qualities.length > 0) {
+ const firstQualityFromGroup = group.qualities[0];
+ onSelectedUpgradeQualityChange(firstQualityFromGroup);
+ Alert.info(
+ `Upgrade until quality changed to ${firstQualityFromGroup.name}`
+ );
+ } else {
+ // If somehow the group has no qualities, find the first enabled quality
+ const firstEnabledQuality = sortedQualities.find(
+ q => q.enabled && q.id !== group.id
+ );
+ if (firstEnabledQuality) {
+ onSelectedUpgradeQualityChange(firstEnabledQuality);
+ Alert.info(
+ `Upgrade until quality changed to ${firstEnabledQuality.name}`
+ );
+ }
+ }
}
onSortedQualitiesChange(prev => {
const index = prev.findIndex(q => q.id === group.id);
if (index === -1) return prev;
const newQualities = [...prev];
- newQualities.splice(index, 1, ...group.qualities);
+
+ // Make sure all qualities from the group are set as enabled
+ const enabledGroupQualities = group.qualities.map(q => ({
+ ...q,
+ enabled: true
+ }));
+
+ newQualities.splice(index, 1, ...enabledGroupQualities);
return newQualities;
});
};
@@ -410,75 +451,146 @@ const ProfileQualitiesTab = ({
setActiveId(null);
};
+ const isUpgradeUntilQuality = quality => {
+ if (!selectedUpgradeQuality) return false;
+
+ // Direct ID match (works for both individual qualities and groups)
+ if (quality.id === selectedUpgradeQuality.id) {
+ return true;
+ }
+
+ // Check if the selected upgrade quality is a member of this group
+ if (
+ 'qualities' in quality &&
+ !('qualities' in selectedUpgradeQuality) &&
+ quality.qualities.some(q => q.id === selectedUpgradeQuality.id)
+ ) {
+ return true;
+ }
+
+ return false;
+ };
+
return (
-
-
-
+
+
+
Quality Rankings
-
-
- Qualities higher in the list are more preferred even if
- not checked. Qualities within the same group are equal.
- Only checked qualities are wanted.
-
-
-
setIsCreateGroupModalOpen(true)}
- className='h-10 px-6 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 flex items-center gap-2'>
-
- Create Group
-
-
-
-
- {upgradesAllowed && (
- q.enabled)}
- selectedUpgradeQuality={selectedUpgradeQuality}
- onUpgradeQualityChange={onSelectedUpgradeQualityChange}
- />
- )}
-
-
-
-
-
- q.id)}
- strategy={verticalListSortingStrategy}>
- {sortedQualities.map(quality => (
-
- ))}
-
-
+
+
+
+
+
+ Drag to reorder
+
+
•
+
+
+
+
+ Click to toggle
+
+ {upgradesAllowed && (
+ <>
+
•
+
+
+
+
+ Set upgrade target
+
+ >
+ )}
-
+
+ setIsCreateGroupModalOpen(true)}
+ className='h-8 flex items-center space-x-1 text-sm font-medium bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-md'>
+
+
+
+ Create Group
+
+
+
+
+ q.id)}
+ strategy={verticalListSortingStrategy}>
+ {sortedQualities.map(quality => (
+
+ ))}
+
+
+
+
+
{
diff --git a/frontend/src/components/profile/quality/QualityItem.jsx b/frontend/src/components/profile/quality/QualityItem.jsx
new file mode 100644
index 0000000..3697bd3
--- /dev/null
+++ b/frontend/src/components/profile/quality/QualityItem.jsx
@@ -0,0 +1,32 @@
+import React, { useState } from 'react';
+import QualityItemSingle from './QualityItemSingle';
+import QualityItemGroup from './QualityItemGroup';
+
+const QualityItem = (props) => {
+ const [hoveredItem, setHoveredItem] = useState(null);
+ const isGroup = 'qualities' in props.quality;
+
+ const handleMouseEnter = (id) => {
+ setHoveredItem(id);
+ };
+
+ const handleMouseLeave = () => {
+ setHoveredItem(null);
+ };
+
+ // Add mouseEnter/Leave handlers and hoveredState to props
+ const enhancedProps = {
+ ...props,
+ onMouseEnter: handleMouseEnter,
+ onMouseLeave: handleMouseLeave,
+ willBeSelected: hoveredItem === props.quality.id
+ };
+
+ if (isGroup) {
+ return ;
+ } else {
+ return ;
+ }
+};
+
+export default QualityItem;
\ No newline at end of file
diff --git a/frontend/src/components/profile/quality/QualityItemGroup.jsx b/frontend/src/components/profile/quality/QualityItemGroup.jsx
new file mode 100644
index 0000000..bb9119b
--- /dev/null
+++ b/frontend/src/components/profile/quality/QualityItemGroup.jsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import RadarrLogo from '@logo/Radarr.svg';
+import SonarrLogo from '@logo/Sonarr.svg';
+import {Pencil, Trash2, Check, ArrowUp} from 'lucide-react';
+import Tooltip from '@ui/Tooltip';
+
+const QualityItemGroup = ({
+ quality,
+ isDragging,
+ listeners,
+ attributes,
+ style,
+ onDelete,
+ onEdit,
+ onMouseEnter,
+ onMouseLeave,
+ willBeSelected,
+ isUpgradeUntil,
+ onUpgradeUntilClick
+}) => {
+ const handleUpgradeClick = e => {
+ e.stopPropagation();
+ onUpgradeUntilClick?.(quality);
+ };
+
+ return (
+ onMouseEnter?.(quality.id)}
+ onMouseLeave={onMouseLeave}>
+ {/* Header Row */}
+
+ {/* Title and Description */}
+
+
+
+ {quality.name}
+
+
+ {/* Quality tags inline with name */}
+
+ {quality.qualities.map(q => (
+
+ {q.name}
+
+ ))}
+
+
+ {quality.description && (
+
+ {quality.description}
+
+ )}
+
+
+ {/* Right Section */}
+
+ {/* App Icons */}
+
+ {quality.radarr && (
+
+
+
+ Radarr
+
+
+ )}
+ {quality.sonarr && (
+
+
+
+ Sonarr
+
+
+ )}
+
+
+ {/* Edit/Delete Actions */}
+
+ {onEdit && (
+
{
+ e.stopPropagation();
+ onEdit(quality);
+ }}
+ className='flex items-center justify-center h-6 w-6 rounded text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600 transition-colors'>
+
+
+ )}
+ {onDelete && (
+
{
+ e.stopPropagation();
+ onDelete(quality);
+ }}
+ className='flex items-center justify-center h-6 w-6 rounded text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 border border-red-200 hover:border-red-300 dark:border-red-800/40 dark:hover:border-red-700/40 transition-colors'>
+
+
+ )}
+
+
+ {/* Upgrade Until button - only shown when enabled and upgrade is allowed */}
+ {quality.enabled && onUpgradeUntilClick && (
+
+
+
+
+
+ )}
+
+ {/* Selected indicator - shows all three states */}
+
+ {quality.enabled && (
+
+ )}
+ {willBeSelected && !quality.enabled && (
+
+ )}
+
+
+
+
+ );
+};
+
+export default QualityItemGroup;
diff --git a/frontend/src/components/profile/quality/QualityItemSingle.jsx b/frontend/src/components/profile/quality/QualityItemSingle.jsx
new file mode 100644
index 0000000..34d2b2f
--- /dev/null
+++ b/frontend/src/components/profile/quality/QualityItemSingle.jsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import {Check, Info, ArrowUp} from 'lucide-react';
+import Tooltip from '@ui/Tooltip';
+import RadarrLogo from '@logo/Radarr.svg';
+import SonarrLogo from '@logo/Sonarr.svg';
+
+const QualityItemSingle = ({
+ quality,
+ isDragging,
+ listeners,
+ attributes,
+ style,
+ onMouseEnter,
+ onMouseLeave,
+ willBeSelected,
+ isUpgradeUntil,
+ onUpgradeUntilClick
+}) => {
+ // Create tooltip content with just icons and text
+ const AppTooltipContent = () => (
+
+ {quality.radarr && (
+
+
+
Radarr
+
+ )}
+ {quality.sonarr && (
+
+
+
Sonarr
+
+ )}
+
+ );
+
+ const handleUpgradeClick = e => {
+ e.stopPropagation();
+ onUpgradeUntilClick?.(quality);
+ };
+
+ return (
+ onMouseEnter?.(quality.id)}
+ onMouseLeave={onMouseLeave}>
+ {/* Content Row */}
+
+ {/* Left Section with Title and Info */}
+
+ {/* Title Row */}
+
+
+ {quality.name}
+
+
+
+ {/* Description Row */}
+ {quality.description && (
+
+ {quality.description}
+
+ )}
+
+
+ {/* Right Section - Info Icon and Selection indicators */}
+
+ {/* Info Badge with Tooltip */}
+ {(quality.radarr || quality.sonarr) && (
+
}>
+
+
+
+
+ )}
+
+ {/* Upgrade Until button - only shown when enabled and upgrade is allowed */}
+ {quality.enabled && onUpgradeUntilClick && (
+
+
+
+
+
+ )}
+
+ {/* Selection indicator */}
+
+ {quality.enabled && (
+
+ )}
+ {willBeSelected && !quality.enabled && (
+
+ )}
+
+
+
+
+ );
+};
+
+export default QualityItemSingle;
diff --git a/frontend/src/components/profile/scoring/AdvancedView.jsx b/frontend/src/components/profile/scoring/AdvancedView.jsx
index 00c76fb..2cfd114 100644
--- a/frontend/src/components/profile/scoring/AdvancedView.jsx
+++ b/frontend/src/components/profile/scoring/AdvancedView.jsx
@@ -1,138 +1,43 @@
-import React from 'react';
+import React, { useMemo } 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';
+import { X } from 'lucide-react';
+import { groupFormatsByTags, getGroupIcon } from '@constants/formatGroups';
-const AdvancedView = ({formats, onScoreChange}) => {
+const AdvancedView = ({formats, onScoreChange, onFormatRemove, showRemoveButton}) => {
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')
- );
+ // Use the shared helper function to group formats
+ const formatGroups = groupFormatsByTags(formats);
- 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: ,
- HDR: ,
- 'Release Groups': ,
- 'Streaming Services': ,
- Codecs: ,
- Storage: ,
- Resolution: ,
- Language: ,
- Source: ,
- 'Indexer Flags': ,
- Enhancements: ,
- Uncategorized:
- };
- return icons[groupName] || ;
- };
-
- // 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;
- },
- {}
- );
+ // Create a single sort instance for all formats
+ const defaultSort = {field: 'name', direction: 'asc'};
+ const {sortConfig: globalSortConfig, updateSort: globalUpdateSort, sortData: globalSortData} = useSorting(defaultSort);
+
+ // Pre-sort all groups using the global sort function
+ const sortedGroups = useMemo(() => {
+ const result = {};
+ Object.entries(formatGroups)
+ .filter(([_, formats]) => formats.length > 0)
+ .forEach(([groupName, formats]) => {
+ result[groupName] = globalSortData(formats);
+ });
+ return result;
+ }, [formatGroups, globalSortData]);
return (
{Object.entries(formatGroups)
+ .filter(([_, formats]) => formats.length > 0) // Only render non-empty groups
.sort(([a], [b]) => a.localeCompare(b))
.map(([groupName, formats]) => {
- const {sortedData, sortConfig, updateSort} =
- groupSorts[groupName];
+ // Use pre-sorted data from our useMemo
+ const sortedData = sortedGroups[groupName] || [];
return (
{
- {sortedData.length > 0 ? (
- sortedData.map(format => (
-
-
+ {sortedData.map(format => (
+
+
+
- onScoreChange(
- format.id,
- value
- )
+ onScoreChange(format.id, value)
}
/>
+ {showRemoveButton && (
+ onFormatRemove(format.id)}
+ className="text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 p-1"
+ title="Remove format"
+ >
+
+
+ )}
- ))
- ) : (
-
- No formats found
- )}
+ ))}
);
@@ -194,7 +101,14 @@ AdvancedView.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
- onScoreChange: PropTypes.func.isRequired
+ onScoreChange: PropTypes.func.isRequired,
+ onFormatRemove: PropTypes.func,
+ showRemoveButton: PropTypes.bool
+};
+
+AdvancedView.defaultProps = {
+ onFormatRemove: () => {},
+ showRemoveButton: false
};
export default AdvancedView;
diff --git a/frontend/src/components/profile/scoring/BasicView.jsx b/frontend/src/components/profile/scoring/BasicView.jsx
index 7bbce91..a72d305 100644
--- a/frontend/src/components/profile/scoring/BasicView.jsx
+++ b/frontend/src/components/profile/scoring/BasicView.jsx
@@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
import NumberInput from '@ui/NumberInput';
import {useSorting} from '@hooks/useSorting';
import SortDropdown from '@ui/SortDropdown';
+import { X } from 'lucide-react';
-const BasicView = ({formats, onScoreChange}) => {
+const BasicView = ({formats, onScoreChange, onFormatRemove, showRemoveButton}) => {
const sortOptions = [
{label: 'Score', value: 'score'},
{label: 'Name', value: 'name'}
@@ -48,12 +49,23 @@ const BasicView = ({formats, onScoreChange}) => {
)}
-
- onScoreChange(format.id, value)
- }
- />
+
+
+ onScoreChange(format.id, value)
+ }
+ />
+ {showRemoveButton && (
+ onFormatRemove(format.id)}
+ className="text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 p-1"
+ title="Remove format"
+ >
+
+
+ )}
+
))
) : (
@@ -75,7 +87,14 @@ BasicView.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
- onScoreChange: PropTypes.func.isRequired
+ onScoreChange: PropTypes.func.isRequired,
+ onFormatRemove: PropTypes.func,
+ showRemoveButton: PropTypes.bool
+};
+
+BasicView.defaultProps = {
+ onFormatRemove: () => {},
+ showRemoveButton: false
};
export default BasicView;
diff --git a/frontend/src/components/profile/scoring/FormatSelector.jsx b/frontend/src/components/profile/scoring/FormatSelector.jsx
new file mode 100644
index 0000000..cf29019
--- /dev/null
+++ b/frontend/src/components/profile/scoring/FormatSelector.jsx
@@ -0,0 +1,75 @@
+import React, {useState, useEffect} from 'react';
+import PropTypes from 'prop-types';
+import SearchDropdown from '@ui/SearchDropdown';
+
+const FormatSelector = ({availableFormats, onFormatAdd}) => {
+ const [selectedFormats, setSelectedFormats] = useState([]);
+ const [dropdownOptions, setDropdownOptions] = useState([]);
+
+ // Transform availableFormats into the format expected by SearchDropdown
+ useEffect(() => {
+ if (availableFormats && availableFormats.length > 0) {
+ const options = availableFormats.map(format => ({
+ value: format.id,
+ label: format.name,
+ description: format.tags ? format.tags.join(', ') : '',
+ tags: format.tags
+ }));
+ setDropdownOptions(options);
+ } else {
+ setDropdownOptions([]);
+ }
+ }, [availableFormats]);
+
+ const handleSelectFormat = e => {
+ const formatId = e.target.value;
+ if (formatId && !selectedFormats.includes(formatId)) {
+ setSelectedFormats(prev => [...prev, formatId]);
+ onFormatAdd(formatId);
+ }
+ };
+
+ return (
+
+
+
+ Available Formats
+
+
+ Select formats to include in your profile. Zero-scored
+ formats are still saved when selected.
+
+
+
+
+
+ {dropdownOptions.length === 0 && (
+
+ No available formats to add
+
+ )}
+
+ );
+};
+
+FormatSelector.propTypes = {
+ availableFormats: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ score: PropTypes.number.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.string)
+ })
+ ).isRequired,
+ onFormatAdd: PropTypes.func.isRequired
+};
+
+export default FormatSelector;
diff --git a/frontend/src/components/profile/scoring/FormatSelectorModal.jsx b/frontend/src/components/profile/scoring/FormatSelectorModal.jsx
new file mode 100644
index 0000000..2111e40
--- /dev/null
+++ b/frontend/src/components/profile/scoring/FormatSelectorModal.jsx
@@ -0,0 +1,235 @@
+import React, { useState, useMemo } from 'react';
+import PropTypes from 'prop-types';
+import Modal from '@ui/Modal';
+import SearchBar from '@ui/DataBar/SearchBar';
+import useSearch from '@hooks/useSearch';
+import { Plus, Check, Settings, Grid3X3 } from 'lucide-react';
+import { groupFormatsByTags, getGroupIcon, FORMAT_GROUP_NAMES } from '@constants/formatGroups';
+
+const FormatSelectorModal = ({
+ isOpen,
+ onClose,
+ availableFormats,
+ selectedFormatIds,
+ allFormats,
+ onFormatToggle
+}) => {
+ // State to track view mode (basic/advanced)
+ const [viewMode, setViewMode] = useState(() => {
+ const stored = localStorage.getItem('formatSelectorViewMode');
+ return stored === null ? 'basic' : JSON.parse(stored);
+ });
+
+ // Save view mode preference
+ const toggleViewMode = () => {
+ const newMode = viewMode === 'basic' ? 'advanced' : 'basic';
+ setViewMode(newMode);
+ localStorage.setItem('formatSelectorViewMode', JSON.stringify(newMode));
+ };
+
+ // Group formats for advanced view
+ const groupedFormats = useMemo(() => {
+ return groupFormatsByTags(allFormats);
+ }, [allFormats]);
+
+ // Search functionality
+ const {
+ searchTerms,
+ currentInput,
+ setCurrentInput,
+ addSearchTerm,
+ removeSearchTerm,
+ clearSearchTerms,
+ items: filteredFormats
+ } = useSearch(allFormats, {
+ searchableFields: ['name', 'tags']
+ });
+
+ // Handle format selection/deselection
+ const handleFormatClick = (formatId) => {
+ onFormatToggle(formatId);
+ };
+
+ // Handle format card rendering for basic view
+ const renderFormatCard = (format) => {
+ const isSelected = selectedFormatIds.includes(format.id) || format.score !== 0;
+
+ return (
+
handleFormatClick(format.id)}
+ >
+
+
+
{format.name}
+ {format.tags && format.tags.length > 0 && (
+
+ {format.tags.slice(0, 2).map(tag => (
+
+ {tag}
+
+ ))}
+ {format.tags.length > 2 && (
+
+ +{format.tags.length - 2}
+
+ )}
+
+ )}
+
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ };
+
+ // Render advanced (grouped) view
+ const renderAdvancedView = () => {
+ return (
+
+ {Object.entries(groupedFormats)
+ .filter(([_, formats]) => formats.length > 0) // Only render non-empty groups
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([groupName, formats]) => {
+ // Filter formats to match search
+ const filteredGroupFormats = formats.filter(format =>
+ filteredFormats.some(f => f.id === format.id)
+ );
+
+ // Skip empty groups after filtering
+ if (filteredGroupFormats.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {getGroupIcon(groupName)}
+ {groupName}
+ ({filteredGroupFormats.length})
+
+
+ {filteredGroupFormats.map(renderFormatCard)}
+
+
+ );
+ })
+ }
+
+ {filteredFormats.length === 0 && (
+
+ No formats found matching your search
+
+ )}
+
+ );
+ };
+
+ // Render basic view (simple grid)
+ const renderBasicView = () => {
+ return (
+ <>
+ {filteredFormats.length > 0 ? (
+
+ {filteredFormats.map(renderFormatCard)}
+
+ ) : (
+
+ No formats found matching your search
+
+ )}
+ >
+ );
+ };
+
+ return (
+
+
+
+
+ Select formats to include in your profile. Click a format to toggle its selection.
+
+
+
+
+
+
+ {viewMode === 'basic' ? (
+ <>
+
+ Advanced
+ >
+ ) : (
+ <>
+
+ Basic
+ >
+ )}
+
+
+
+
+
+ {selectedFormatIds.length + allFormats.filter(f => f.score !== 0).length} of {allFormats.length} formats selected
+
+
+
+ {viewMode === 'basic' ? renderBasicView() : renderAdvancedView()}
+
+
+
+ );
+};
+
+FormatSelectorModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ availableFormats: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ score: PropTypes.number,
+ tags: PropTypes.arrayOf(PropTypes.string)
+ })
+ ),
+ selectedFormatIds: PropTypes.arrayOf(PropTypes.string).isRequired,
+ allFormats: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ score: PropTypes.number,
+ tags: PropTypes.arrayOf(PropTypes.string)
+ })
+ ).isRequired,
+ onFormatToggle: PropTypes.func.isRequired
+};
+
+export default FormatSelectorModal;
\ No newline at end of file
diff --git a/frontend/src/components/profile/scoring/FormatSettings.jsx b/frontend/src/components/profile/scoring/FormatSettings.jsx
index 1739715..93ca15f 100644
--- a/frontend/src/components/profile/scoring/FormatSettings.jsx
+++ b/frontend/src/components/profile/scoring/FormatSettings.jsx
@@ -1,15 +1,87 @@
-import React, {useState} from 'react';
+import React, {useState, useEffect, useMemo} 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';
+import FormatSelectorModal from './FormatSelectorModal';
+import {ChevronDown, Settings, List, CheckSquare, Plus} from 'lucide-react';
+import Tooltip from '@ui/Tooltip';
const FormatSettings = ({formats, onScoreChange}) => {
- const [isAdvancedView, setIsAdvancedView] = useState(false);
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ // Initialize state from localStorage, falling back to true if no value is stored
+ const [isAdvancedView, setIsAdvancedView] = useState(() => {
+ const stored = localStorage.getItem('formatSettingsView');
+ return stored === null ? true : JSON.parse(stored);
+ });
+ // Initialize selectiveMode from localStorage
+ const [showSelectiveMode, setShowSelectiveMode] = useState(() => {
+ const stored = localStorage.getItem('formatSettingsSelectiveMode');
+ return stored === null ? false : JSON.parse(stored);
+ });
+
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [availableFormats, setAvailableFormats] = useState([]);
+ const [selectedFormatIds, setSelectedFormatIds] = useState(() => {
+ try {
+ const stored = localStorage.getItem('selectedFormatIds');
+ return stored ? JSON.parse(stored) : [];
+ } catch {
+ return [];
+ }
+ });
+
+ // Format selector modal state
+ const [isSelectorModalOpen, setIsSelectorModalOpen] = useState(false);
+
+ // Calculate which formats to display
+ const displayFormats = useMemo(() => {
+ if (showSelectiveMode) {
+ // In selective mode:
+ // 1. Display all formats with non-zero scores
+ // 2. Also display formats with zero scores that are explicitly selected
+ const nonZeroFormats = formats.filter(f => f.score !== 0);
+ const selectedZeroFormats = formats.filter(f =>
+ f.score === 0 && selectedFormatIds.includes(f.id)
+ );
+
+ return [...nonZeroFormats, ...selectedZeroFormats];
+ } else {
+ // In regular mode, display all formats as usual
+ return formats;
+ }
+ }, [formats, showSelectiveMode, selectedFormatIds]);
+
+ // Save to localStorage whenever view preferences change
+ useEffect(() => {
+ localStorage.setItem('formatSettingsView', JSON.stringify(isAdvancedView));
+ }, [isAdvancedView]);
+
+ useEffect(() => {
+ localStorage.setItem('formatSettingsSelectiveMode', JSON.stringify(showSelectiveMode));
+ }, [showSelectiveMode]);
+
+ // Save selected format IDs to localStorage
+ useEffect(() => {
+ localStorage.setItem('selectedFormatIds', JSON.stringify(selectedFormatIds));
+ }, [selectedFormatIds]);
+
+ // Calculate available formats for selection (not already in use)
+ useEffect(() => {
+ // To be "available", a format must have zero score and not be in selectedFormatIds
+ const usedFormatIds = formats.filter(f => f.score !== 0).map(f => f.id);
+ const allUnavailableIds = [...usedFormatIds, ...selectedFormatIds];
+
+ // Available formats are those not already used or selected
+ const available = formats.filter(format =>
+ !allUnavailableIds.includes(format.id)
+ );
+
+ setAvailableFormats(available);
+ }, [formats, selectedFormatIds]);
+
+ // Search hook for filtering formats
const {
searchTerms,
currentInput,
@@ -18,12 +90,66 @@ const FormatSettings = ({formats, onScoreChange}) => {
removeSearchTerm,
clearSearchTerms,
items: filteredFormats
- } = useSearch(formats, {
+ } = useSearch(displayFormats, {
searchableFields: ['name']
});
+ // Handle format toggle (add/remove)
+ const handleFormatToggle = (formatId) => {
+ const format = formats.find(f => f.id === formatId);
+
+ if (!format) return;
+
+ // Check if this format is already selected (either has a non-zero score or is in selectedFormatIds)
+ const isSelected = format.score !== 0 || selectedFormatIds.includes(formatId);
+
+ if (isSelected) {
+ // Remove format
+ if (format.score !== 0) {
+ // If format has a non-zero score, set it to 0 (don't remove it completely)
+ onScoreChange(formatId, 0);
+ }
+ // If format was explicitly selected, remove from the selection list
+ setSelectedFormatIds(prev => prev.filter(id => id !== formatId));
+ } else {
+ // Add format
+ // Set the format score to 0 initially, just to mark it as "selected"
+ onScoreChange(formatId, 0);
+
+ // Add to our list of explicitly selected format IDs
+ setSelectedFormatIds(prev => [...prev, formatId]);
+ }
+ };
+
+ // When a format score changes, we need to update our tracking
+ const handleScoreChange = (formatId, score) => {
+ // Pass the score change to parent
+ onScoreChange(formatId, score);
+
+ // If the score is changing from 0 to non-zero, we no longer need to track it
+ // as an explicitly selected format (it's tracked by virtue of its non-zero score)
+ if (score !== 0) {
+ const format = formats.find(f => f.id === formatId);
+ if (format && format.score === 0 && selectedFormatIds.includes(formatId)) {
+ // Format was previously explicitly selected with zero score, but now has a non-zero score
+ // We can remove it from our explicit selection tracking
+ setSelectedFormatIds(prev => prev.filter(id => id !== formatId));
+ }
+ }
+ };
+
+ // Toggle selective mode on/off
+ const toggleSelectiveMode = () => {
+ setShowSelectiveMode(prev => !prev);
+ };
+
+ // Open the format selector modal
+ const openFormatSelector = () => {
+ setIsSelectorModalOpen(true);
+ };
+
return (
-
+
{
onClearTerms={clearSearchTerms}
/>
-
-
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'>
-
- {isAdvancedView ? (
- <>
-
-
- Advanced
-
- >
- ) : (
- <>
-
-
- Basic
-
- >
- )}
-
-
-
- {isDropdownOpen && (
- <>
-
setIsDropdownOpen(false)}
+
+ {/* View Mode Dropdown */}
+
+
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'
+ >
+
+ {isAdvancedView ? (
+ <>
+
+
+ Advanced
+
+ >
+ ) : (
+ <>
+
+
+ Basic
+
+ >
+ )}
+
+
-
-
-
{
- 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'
- }`}>
-
-
- Basic
-
-
-
{
- 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'
- }`}>
-
-
- Advanced
-
-
+
+ {isDropdownOpen && (
+ <>
+
setIsDropdownOpen(false)}
+ />
+
+
+
{
+ 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'
+ }`}>
+
+
+ Basic
+
+
+
{
+ 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'
+ }`}>
+
+
+ Advanced
+
+
+
-
- >
- )}
+ >
+ )}
+
+
+ {/* Selective Mode with Format Selector */}
+
+
+
+ Selective
+
+
+ {showSelectiveMode && (
+
+
+
+ Add
+
+
+ )}
+
+ {!showSelectiveMode && (
+
+
+
+ )}
+
+ {/* Format Selector Modal */}
+
setIsSelectorModalOpen(false)}
+ availableFormats={availableFormats}
+ selectedFormatIds={selectedFormatIds}
+ allFormats={formats}
+ onFormatToggle={handleFormatToggle}
+ />
+
+ {/* Format Display */}
{isAdvancedView ? (
handleFormatToggle(formatId)}
+ showRemoveButton={showSelectiveMode}
/>
) : (
handleFormatToggle(formatId)}
+ showRemoveButton={showSelectiveMode}
/>
)}
@@ -144,4 +332,4 @@ FormatSettings.propTypes = {
onScoreChange: PropTypes.func.isRequired
};
-export default FormatSettings;
+export default FormatSettings;
\ No newline at end of file
diff --git a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx
index e32d462..0f73429 100644
--- a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx
+++ b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx
@@ -25,8 +25,8 @@ const ProfileScoringTab = ({
Upgrade Settings
- Assign scores to different formats to control
- download preferences
+ Configure when upgrades should be downloaded and
+ what scores are required
@@ -70,8 +70,10 @@ const ProfileScoringTab = ({
Format Settings
- Configure when upgrades should be downloaded and what
- scores are required
+ Customize format scoring to prioritize your preferred downloads.
+ Use Basic mode for a simple list view with sliders, Advanced mode for
+ detailed A/V category grids, and Selective mode to display and manage
+ only formats you care about instead of all available formats.
diff --git a/frontend/src/components/profile/scoring/SelectiveView.jsx b/frontend/src/components/profile/scoring/SelectiveView.jsx
new file mode 100644
index 0000000..d19ef89
--- /dev/null
+++ b/frontend/src/components/profile/scoring/SelectiveView.jsx
@@ -0,0 +1,219 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import NumberInput from '@ui/NumberInput';
+import { useSorting } from '@hooks/useSorting';
+import SortDropdown from '@ui/SortDropdown';
+import { Plus, X } from 'lucide-react';
+
+const SelectiveView = ({ formats, onScoreChange, allFormats }) => {
+ const [selectedFormats, setSelectedFormats] = useState([]);
+ const [availableFormats, setAvailableFormats] = useState([]);
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [searchInput, setSearchInput] = useState('');
+
+ const sortOptions = [
+ { label: 'Score', value: 'score' },
+ { label: 'Name', value: 'name' }
+ ];
+
+ const { sortConfig, updateSort, sortData } = useSorting({
+ field: 'score',
+ direction: 'desc'
+ });
+
+ // Initialize selected formats from the formats prop
+ useEffect(() => {
+ setSelectedFormats(formats);
+
+ // Set available formats (those not already selected)
+ updateAvailableFormats(formats);
+
+ // Save selected format IDs to localStorage
+ const selectedIds = formats.map(f => f.id);
+ localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds));
+ }, [formats, allFormats]);
+
+ // Update available formats list (excluding already selected ones)
+ const updateAvailableFormats = (selectedFormats) => {
+ const selectedIds = selectedFormats.map(f => f.id);
+ setAvailableFormats(allFormats.filter(f => !selectedIds.includes(f.id)));
+ };
+
+ // Add a format to the selected list
+ const addFormat = (format) => {
+ // Always start with score 0 for newly added formats
+ const formatWithScore = {...format, score: 0};
+ const newSelectedFormats = [...selectedFormats, formatWithScore];
+ setSelectedFormats(newSelectedFormats);
+ updateAvailableFormats(newSelectedFormats);
+ setDropdownOpen(false);
+ setSearchInput('');
+
+ // Update the localStorage list of selected formats
+ const selectedIds = newSelectedFormats.map(f => f.id);
+ localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds));
+
+ // Notify parent component about the new format
+ onScoreChange(format.id, 0);
+ };
+
+ // Remove a format from the selected list
+ const removeFormat = (formatId) => {
+ const newSelectedFormats = selectedFormats.filter(f => f.id !== formatId);
+ setSelectedFormats(newSelectedFormats);
+ updateAvailableFormats(newSelectedFormats);
+
+ // Update the localStorage list of selected formats
+ const selectedIds = newSelectedFormats.map(f => f.id);
+ localStorage.setItem('selectedFormatsList', JSON.stringify(selectedIds));
+
+ // Also notify parent component that this format is no longer used
+ onScoreChange(formatId, 0);
+ };
+
+ // Filter available formats based on search input
+ const filteredAvailableFormats = availableFormats.filter(format =>
+ format.name.toLowerCase().includes(searchInput.toLowerCase())
+ );
+
+ const sortedFormats = sortData(selectedFormats);
+
+ return (
+
+
+
+ Selected Formats
+
+
+
+
+
+
+
+ {/* Add new format button */}
+
+
setDropdownOpen(!dropdownOpen)}
+ className='w-full flex items-center justify-center px-4 py-2 text-blue-500 hover:bg-gray-50 dark:hover:bg-gray-800/50'
+ >
+
+ Add Format
+
+
+ {/* Dropdown for selecting a format to add */}
+ {dropdownOpen && (
+ <>
+
setDropdownOpen(false)}
+ />
+
+
+ setSearchInput(e.target.value)}
+ className='w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-700 dark:text-gray-300'
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+ {filteredAvailableFormats.length > 0 ? (
+ filteredAvailableFormats.map(format => (
+
addFormat(format)}
+ >
+
+
+ {format.name}
+
+ {format.tags && format.tags.length > 0 && (
+
+ {format.tags.join(', ')}
+
+ )}
+
+
+ ))
+ ) : (
+
+ No formats available
+
+ )}
+
+
+ >
+ )}
+
+
+ {/* List of selected formats */}
+ {sortedFormats.length > 0 ? (
+ sortedFormats.map(format => (
+
+
+
+
+ {format.name}
+
+ {format.tags && format.tags.length > 0 && (
+
+ {format.tags.join(', ')}
+
+ )}
+
+
+
+ onScoreChange(format.id, value)}
+ />
+ removeFormat(format.id)}
+ className='text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400'
+ >
+
+
+
+
+ ))
+ ) : (
+
+ No formats selected
+
+ )}
+
+
+ );
+};
+
+SelectiveView.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,
+ allFormats: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ score: PropTypes.number.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.string)
+ })
+ ).isRequired
+};
+
+export default SelectiveView;
\ No newline at end of file
diff --git a/frontend/src/components/regex/RegexCard.jsx b/frontend/src/components/regex/RegexCard.jsx
index b36efeb..1fde0ac 100644
--- a/frontend/src/components/regex/RegexCard.jsx
+++ b/frontend/src/components/regex/RegexCard.jsx
@@ -142,7 +142,7 @@ const RegexCard = ({
[&>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
- [&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50'>
+ [&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50 scrollable'>
{pattern.description}
)}
diff --git a/frontend/src/components/regex/RegexPage.jsx b/frontend/src/components/regex/RegexPage.jsx
index 080cf52..436431d 100644
--- a/frontend/src/components/regex/RegexPage.jsx
+++ b/frontend/src/components/regex/RegexPage.jsx
@@ -78,7 +78,7 @@ function RegexPage() {
} = useMassSelection();
// Keyboard shortcuts
- useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
+ useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
// Mouse position tracking for shift-select
useEffect(() => {
diff --git a/frontend/src/components/regex/RegexTestingTab.jsx b/frontend/src/components/regex/RegexTestingTab.jsx
index cad5c62..8b64dc4 100644
--- a/frontend/src/components/regex/RegexTestingTab.jsx
+++ b/frontend/src/components/regex/RegexTestingTab.jsx
@@ -148,7 +148,7 @@ const RegexTestingTab = ({
))}
) : (
-
+
No tests added yet
diff --git a/frontend/src/components/settings/arrs/ArrModal.jsx b/frontend/src/components/settings/arrs/ArrModal.jsx
index 7c79ed2..88b558c 100644
--- a/frontend/src/components/settings/arrs/ArrModal.jsx
+++ b/frontend/src/components/settings/arrs/ArrModal.jsx
@@ -1,8 +1,10 @@
+// ArrModal.jsx
+
import React from 'react';
import {Plus, TestTube, Loader, Save, X, Trash, Check} from 'lucide-react';
import Modal from '@ui/Modal';
import {useArrModal} from '@hooks/useArrModal';
-import DataSelectorModal from './DataSelectorModal';
+import DataSelector from './DataSelector';
import SyncModal from './SyncModal';
const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
@@ -44,43 +46,34 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
{value: 'schedule', label: 'Scheduled'}
];
- // Ensure data_to_sync always has the required structure
const safeSelectedData = {
profiles: formData.data_to_sync?.profiles || [],
customFormats: formData.data_to_sync?.customFormats || []
};
- // Handle sync method change
- const handleSyncMethodChange = e => {
- const newMethod = e.target.value;
- handleInputChange({
- target: {
- id: 'sync_method',
- value: newMethod
- }
- });
-
- // Reset data_to_sync when switching to manual
- if (newMethod === 'manual') {
- handleInputChange({
- target: {
- id: 'data_to_sync',
- value: {profiles: [], customFormats: []}
- }
- });
- }
+ const handleFormSubmit = e => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleSubmit(e);
};
- const inputClasses = errorKey =>
- `w-full px-3 py-2 text-sm rounded-lg border ${
- errors[errorKey]
- ? 'border-red-500'
- : 'border-gray-300 dark:border-gray-600'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 ${
- errors[errorKey]
- ? 'focus:ring-red-500 focus:border-red-500'
- : 'focus:ring-blue-500 focus:border-blue-500'
- } placeholder-gray-400 dark:placeholder-gray-500 transition-all`;
+ const inputClasses = errorKey => `
+ w-full px-3 py-2 text-sm rounded-lg border ${
+ errors[errorKey]
+ ? 'border-red-500'
+ : 'border-gray-300 dark:border-gray-600'
+ } bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 ${
+ errors[errorKey]
+ ? 'focus:ring-red-500 focus:border-red-500'
+ : 'focus:ring-blue-500 focus:border-blue-500'
+ } placeholder-gray-400 dark:placeholder-gray-500 transition-all
+ `;
+
+ const handleSyncMethodChange = e => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleInputChange(e);
+ };
return (
{
!formData.apiKey
}
className='flex items-center px-3 py-2 text-sm rounded-lg bg-emerald-600 hover:bg-emerald-700
- disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'>
+ disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'>
{isTestingConnection ? (
<>
@@ -142,7 +135,7 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
type='submit'
form='arrForm'
className='flex items-center px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700
- text-white font-medium transition-colors'>
+ text-white font-medium transition-colors'>
{saveConfirm ? (
<>
@@ -162,8 +155,16 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
}>
-
- {/* Server URL Field */}
{
)}
- {/* API Key Field */}
{
/>
- {/* Sync Method Field */}
{
{formData.sync_method === 'manual' && (
Manual sync allows you to selectively import data
- when changes occur in the source instance. You'll
- need to manually select and import the data you want
- to sync.
+ when changes occur in the source instance.
)}
{formData.sync_method === 'pull' && (
On Pull automatically syncs data whenever the
- database pulls in new changes. This is a "set and
- forget" option - perfect for maintaining consistency
- across instances without manual intervention.
+ database pulls in new changes.
)}
{formData.sync_method === 'schedule' && (
Scheduled sync runs at fixed intervals, ensuring
- your instances stay in sync at regular times.
+ your instances stay in sync.
)}
- {/* Import as Unique - Now always visible */}
@@ -352,13 +346,11 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
Creates a unique hash from the data and target
- instance name, allowing the same profile/format to
- be imported multiple times
+ instance name
- {/* Conditional Fields for Sync Method */}
{formData.sync_method === 'schedule' && (
{
)}
- {/* Sync Options */}
{formData.sync_method !== 'manual' && (
<>
- setIsDataDrawerOpen(true)}
- className='w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200
- bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700
- rounded-lg transition-colors
- border border-gray-200 dark:border-gray-700'>
-
-
- Select Data to Sync
-
- {(safeSelectedData.profiles.length > 0 ||
- safeSelectedData.customFormats.length >
- 0) && (
-
- {safeSelectedData.profiles.map(
- profile => (
-
- {profile}
-
- )
- )}
- {safeSelectedData.customFormats.map(
- format => (
-
- {format}
-
- )
- )}
-
- )}
-
-
-
+
+
+ Select Data to Sync
+
+
+
{errors.data_to_sync && (
{errors.data_to_sync}
@@ -436,34 +397,23 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
)}
>
)}
-
- setIsDataDrawerOpen(false)}
- isLoading={isLoading}
- availableData={availableData}
- selectedData={safeSelectedData}
- onDataToggle={handleDataToggle}
- error={errors.data_to_sync}
- />
- {showSyncConfirm && (
- {
- setShowSyncConfirm(false);
- onSubmit();
- }}
- onSkip={() => {
- setShowSyncConfirm(false);
- onSubmit();
- }}
- onSync={handleManualSync}
- isSyncing={isInitialSyncing}
- />
- )}
+
+ {showSyncConfirm && (
+ {
+ setShowSyncConfirm(false);
+ onSubmit();
+ }}
+ onSkip={() => {
+ setShowSyncConfirm(false);
+ onSubmit();
+ }}
+ onSync={handleManualSync}
+ isSyncing={isInitialSyncing}
+ />
+ )}
);
};
diff --git a/frontend/src/components/settings/arrs/DataSelector.jsx b/frontend/src/components/settings/arrs/DataSelector.jsx
new file mode 100644
index 0000000..1c970ad
--- /dev/null
+++ b/frontend/src/components/settings/arrs/DataSelector.jsx
@@ -0,0 +1,250 @@
+import React from 'react';
+import {Loader, AlertTriangle} from 'lucide-react';
+import useSearch from '@hooks/useSearch';
+import {useSorting} from '@hooks/useSorting';
+import SearchBar from '@ui/DataBar/SearchBar';
+import SortDropdown from '@ui/SortDropdown';
+
+const DataSelector = ({
+ isLoading,
+ availableData = {profiles: [], customFormats: []},
+ selectedData = {profiles: [], customFormats: []},
+ onDataToggle,
+ error
+}) => {
+ const profiles = selectedData?.profiles || [];
+ const customFormats = selectedData?.customFormats || [];
+
+ const availableProfileNames = new Set(
+ availableData.profiles.map(p => p.content.name)
+ );
+ const availableFormatNames = new Set(
+ availableData.customFormats.map(f => f.content.name)
+ );
+
+ const missingProfiles = profiles.filter(
+ name => !availableProfileNames.has(name)
+ );
+ const missingFormats = customFormats.filter(
+ name => !availableFormatNames.has(name)
+ );
+
+ const combinedProfiles = [
+ ...missingProfiles.map(name => ({name, isMissing: true})),
+ ...availableData.profiles.map(profile => ({
+ name: profile.content.name,
+ isMissing: false
+ }))
+ ];
+
+ const combinedFormats = [
+ ...missingFormats.map(name => ({name, isMissing: true})),
+ ...availableData.customFormats.map(format => ({
+ name: format.content.name,
+ isMissing: false
+ }))
+ ];
+
+ const {
+ items: filteredProfiles,
+ searchTerms: searchTermsProfiles,
+ currentInput: currentInputProfiles,
+ setCurrentInput: setCurrentInputProfiles,
+ addSearchTerm: addSearchTermProfiles,
+ removeSearchTerm: removeSearchTermProfiles,
+ clearSearchTerms: clearSearchTermsProfiles
+ } = useSearch(combinedProfiles, {
+ searchableFields: ['name']
+ });
+
+ const {
+ items: filteredFormats,
+ searchTerms: searchTermsFormats,
+ currentInput: currentInputFormats,
+ setCurrentInput: setCurrentInputFormats,
+ addSearchTerm: addSearchTermFormats,
+ removeSearchTerm: removeSearchTermFormats,
+ clearSearchTerms: clearSearchTermsFormats
+ } = useSearch(combinedFormats, {
+ searchableFields: ['name']
+ });
+
+ const {
+ sortConfig: profilesSortConfig,
+ updateSort: updateProfilesSort,
+ sortData: sortProfiles
+ } = useSorting({field: 'name', direction: 'asc'});
+
+ const {
+ sortConfig: formatsSortConfig,
+ updateSort: updateFormatsSort,
+ sortData: sortFormats
+ } = useSorting({field: 'name', direction: 'asc'});
+
+ const sortedProfiles = sortProfiles(filteredProfiles);
+ const sortedFormats = sortFormats(filteredFormats);
+
+ const sortOptions = [{label: 'Name', value: 'name'}];
+
+ const renderItem = (item, type) => (
+
+
+
onDataToggle(type, item.name)}
+ className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-offset-0'
+ />
+
+ {item.name}
+
+ {item.isMissing && (
+
+ )}
+
+
+ );
+
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+
+ Quality Profiles
+
+
+ {missingProfiles.length > 0 && (
+
+ {missingProfiles.length} missing
+
+ )}
+
+ {profiles.length} selected
+
+
+
+
+
{
+ clearSearchTermsProfiles();
+ setCurrentInputProfiles('');
+ }}
+ className='flex-1'
+ />
+ {
+ e.stopPropagation();
+ }}>
+
+
+
+
+ {sortedProfiles.map(item =>
+ renderItem(item, 'profiles')
+ )}
+
+
+
+
+
+
+
+ Custom Formats
+
+
+ {missingFormats.length > 0 && (
+
+ {missingFormats.length} missing
+
+ )}
+
+ {customFormats.length} selected
+
+
+
+
+ Note: Custom formats used in selected quality
+ profiles are automatically imported and don't
+ need to be selected here.
+
+
+
+
{
+ clearSearchTermsFormats();
+ setCurrentInputFormats('');
+ }}
+ className='flex-1'
+ />
+ {
+ e.stopPropagation();
+ }}>
+
+
+
+
+ {sortedFormats.map(item =>
+ renderItem(item, 'customFormats')
+ )}
+
+
+
+ {error && (
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default DataSelector;
diff --git a/frontend/src/components/settings/arrs/DataSelectorModal.jsx b/frontend/src/components/settings/arrs/DataSelectorModal.jsx
deleted file mode 100644
index 6b3cb8e..0000000
--- a/frontend/src/components/settings/arrs/DataSelectorModal.jsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React from 'react';
-import {Loader} from 'lucide-react';
-import Modal from '@ui/Modal';
-
-const DataSelectorModal = ({
- isOpen,
- onClose,
- isLoading,
- availableData = {profiles: [], customFormats: []},
- selectedData = {profiles: [], customFormats: []},
- onDataToggle,
- error
-}) => {
- // Ensure we have safe defaults for selectedData
- const profiles = selectedData?.profiles || [];
- const customFormats = selectedData?.customFormats || [];
-
- return (
-
-
- {isLoading ? (
-
-
-
- ) : (
- <>
- {/* Quality Profiles Section */}
-
-
-
- Quality Profiles
-
-
- {profiles.length} selected
-
-
-
- {(availableData?.profiles || []).map(
- profile => (
-
-
- onDataToggle(
- 'profiles',
- profile.content.name
- )
- }
- className='rounded border-gray-300 text-blue-600
- focus:ring-blue-500 focus:ring-offset-0'
- />
-
- {profile.content.name}
-
-
- )
- )}
-
-
-
- {/* Custom Formats Section */}
-
-
-
-
- Custom Formats
-
-
- {customFormats.length} selected
-
-
-
- Note: Custom formats used in selected
- quality profiles are automatically imported
- and don't need to be selected here.
-
-
-
- {(availableData?.customFormats || []).map(
- format => (
-
-
- onDataToggle(
- 'customFormats',
- format.content.name
- )
- }
- className='rounded border-gray-300 text-blue-600
- focus:ring-blue-500 focus:ring-offset-0'
- />
-
- {format.content.name}
-
-
- )
- )}
-
-
-
- {error && (
-
- )}
- >
- )}
-
-
- );
-};
-
-export default DataSelectorModal;
diff --git a/frontend/src/components/settings/git/status/ConflictRow.jsx b/frontend/src/components/settings/git/status/ConflictRow.jsx
index 675e27c..c6d36ba 100644
--- a/frontend/src/components/settings/git/status/ConflictRow.jsx
+++ b/frontend/src/components/settings/git/status/ConflictRow.jsx
@@ -15,9 +15,10 @@ const ConflictRow = ({change, fetchGitStatus}) => {
param => param.parameter === 'name'
);
+ // Check for name in conflicting parameters or directly in change object
const displayLocalName =
nameConflict?.local_value || change.name || 'Unnamed';
- const displayIncomingName = nameConflict?.incoming_value || 'Unnamed';
+ const displayIncomingName = nameConflict?.incoming_value || change?.incoming_name || 'Unnamed';
const isResolved = change.status === 'RESOLVED';
diff --git a/frontend/src/components/settings/git/status/MergeConflicts.jsx b/frontend/src/components/settings/git/status/MergeConflicts.jsx
index 85f4bd4..15643ef 100644
--- a/frontend/src/components/settings/git/status/MergeConflicts.jsx
+++ b/frontend/src/components/settings/git/status/MergeConflicts.jsx
@@ -10,14 +10,22 @@ const MergeConflicts = ({
areAllConflictsResolved,
fetchGitStatus
}) => {
- if (!conflicts || conflicts.length === 0) return null;
-
+ const hasConflicts = conflicts && conflicts.length > 0;
+
return (
-
- Merge Conflicts
+ {areAllConflictsResolved() ? (
+
+ ) : (
+
+ )}
+
+ {areAllConflictsResolved()
+ ? 'All Conflicts Resolved'
+ : 'Merge Conflicts'}
+
-
+
+ {/* Only show the conflict table if there are conflicts */}
+ {hasConflicts && (
+
+ )}
+
+ {/* Show a success message when all conflicts are resolved */}
+ {!hasConflicts && areAllConflictsResolved() && (
+
+
+ All conflicts have been successfully resolved. You can now commit the merge.
+
+
+ )}
);
};
diff --git a/frontend/src/components/settings/git/status/PushRow.jsx b/frontend/src/components/settings/git/status/PushRow.jsx
index d342dde..3f3365e 100644
--- a/frontend/src/components/settings/git/status/PushRow.jsx
+++ b/frontend/src/components/settings/git/status/PushRow.jsx
@@ -2,8 +2,6 @@ import React from 'react';
import {GitCommit, Code, FileText, Settings, File} from 'lucide-react';
const PushRow = ({change}) => {
- console.log('Push Row Change:', change);
-
const getTypeIcon = type => {
switch (type) {
case 'Regex Pattern':
diff --git a/frontend/src/components/ui/DataBar/SearchBar.jsx b/frontend/src/components/ui/DataBar/SearchBar.jsx
index cad58ba..78abd30 100644
--- a/frontend/src/components/ui/DataBar/SearchBar.jsx
+++ b/frontend/src/components/ui/DataBar/SearchBar.jsx
@@ -1,3 +1,5 @@
+// SearchBar.jsx
+
import React, {useState, useEffect} from 'react';
import {Search, X} from 'lucide-react';
@@ -10,12 +12,16 @@ const SearchBar = ({
onInputChange,
onAddTerm,
onRemoveTerm,
- onClearTerms
+ onClearTerms,
+ textSize = 'text-sm', // Default text size
+ badgeTextSize = 'text-sm', // Default badge text size
+ iconSize = 'h-4 w-4', // Default icon size
+ minHeight = 'min-h-10' // Default min height
}) => {
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
- const handleKeyDown = e => {
+ const handleKeyDownGlobal = e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
document.querySelector('input[type="text"]')?.focus();
@@ -24,93 +30,102 @@ const SearchBar = ({
onClearTerms();
}
};
-
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
+ document.addEventListener('keydown', handleKeyDownGlobal);
+ return () =>
+ document.removeEventListener('keydown', handleKeyDownGlobal);
}, [onClearTerms]);
const handleKeyDown = e => {
- // Handle backspace when input is empty and there are search terms
+ if (requireEnter && e.key === 'Enter' && currentInput.trim()) {
+ e.preventDefault();
+ onAddTerm(currentInput);
+ return;
+ }
if (e.key === 'Backspace' && !currentInput && searchTerms.length > 0) {
e.preventDefault();
onRemoveTerm(searchTerms[searchTerms.length - 1]);
}
};
- const handleKeyPress = e => {
- if (requireEnter && e.key === 'Enter' && currentInput.trim()) {
- onAddTerm(currentInput);
- }
- };
-
return (
-
+ w-full ${minHeight} pl-9 pr-8 rounded-md
+ transition-all duration-200 ease-in-out
+ border shadow-sm flex items-center gap-2 p-2
+ ${
+ minHeight && minHeight.startsWith('h-')
+ ? 'overflow-x-auto overflow-y-hidden whitespace-nowrap'
+ : ''
+ }
+ ${
+ isFocused
+ ? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5'
+ : 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600'
+ }
+ bg-white dark:bg-gray-800
+ `}>
{searchTerms.map((term, index) => (
-
+ group/badge flex-shrink-0
+ transition-all duration-200
+ `}>
+
{term}
onRemoveTerm(term)}
className='p-0.5 hover:bg-blue-500/20
- rounded-sm transition-colors
- opacity-70 group-hover/badge:opacity-100'
- aria-label={`Remove ${term} filter`}>
+ rounded-sm transition-colors
+ opacity-70 group-hover/badge:opacity-100'>
))}
+
onInputChange(e.target.value)}
- onKeyPress={handleKeyPress}
- onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
+ onChange={e => onInputChange(e.target.value)}
+ onKeyDown={handleKeyDown}
placeholder={
searchTerms.length
? 'Add another filter...'
: placeholder
}
- className='flex-1 min-w-[200px] bg-transparent
- text-gray-900 dark:text-gray-100
- placeholder:text-gray-500 dark:placeholder:text-gray-400
- focus:outline-none'
+ className={`flex-1 min-w-[200px] bg-transparent
+ ${textSize} text-gray-900 dark:text-gray-100
+ placeholder:text-gray-500 dark:placeholder:text-gray-400
+ focus:outline-none`}
/>
@@ -118,13 +133,12 @@ const SearchBar = ({
-
+ p-1.5 rounded-full
+ text-gray-400 hover:text-gray-600
+ hover:bg-gray-100 dark:hover:bg-gray-700
+ transition-all duration-200
+ group/clear'>
+
)}
diff --git a/frontend/src/components/ui/Modal.jsx b/frontend/src/components/ui/Modal.jsx
index c8885e4..f541275 100644
--- a/frontend/src/components/ui/Modal.jsx
+++ b/frontend/src/components/ui/Modal.jsx
@@ -18,12 +18,27 @@ const Modal = ({
}) => {
const modalRef = useRef();
const [activeTab, setActiveTab] = useState(tabs?.[0]?.id);
+ const [isClosing, setIsClosing] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ setIsClosing(false);
+ }
+ }, [isOpen]);
+
+ const handleClose = () => {
+ setIsClosing(true);
+ setTimeout(() => {
+ onClose();
+ setIsClosing(false);
+ }, 200); // Match animation duration
+ };
useEffect(() => {
if (isOpen && !disableCloseOnEscape) {
const handleEscape = event => {
if (event.key === 'Escape') {
- onClose();
+ handleClose();
}
};
document.addEventListener('keydown', handleEscape);
@@ -31,7 +46,7 @@ const Modal = ({
document.removeEventListener('keydown', handleEscape);
};
}
- }, [isOpen, onClose, disableCloseOnEscape]);
+ }, [isOpen, disableCloseOnEscape]);
const handleClickOutside = e => {
// Get the current selection
@@ -42,9 +57,10 @@ const Modal = ({
modalRef.current &&
!modalRef.current.contains(e.target) &&
!disableCloseOnOutsideClick &&
- !hasSelection // Don't close if there's text selected
+ !hasSelection && // Don't close if there's text selected
+ !isClosing
) {
- onClose();
+ handleClose();
}
};
@@ -87,13 +103,13 @@ const Modal = ({
return (
e.stopPropagation()}>
+
{/* Header */}
@@ -128,7 +149,7 @@ const Modal = ({
)}
{
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedOption, setSelectedOption] = useState(
+ options.find(opt => opt[valueKey] === value) || null
+ );
+
+ const dropdownRef = useRef(null);
+ const menuRef = useRef(null);
+
+ const {
+ searchTerms,
+ currentInput,
+ setCurrentInput,
+ addSearchTerm,
+ removeSearchTerm,
+ clearSearchTerms,
+ items: filteredOptions
+ } = useSearch(options, {
+ searchableFields,
+ initialSortBy: 'label'
+ });
+
+ const {sortConfig, updateSort, sortData} = useSorting({
+ field: 'label',
+ direction: 'asc'
+ });
+
+ // Sort options configuration for the dropdown (name only)
+ const sortOptions = [{value: 'label', label: 'Name (A-Z)'}];
+
+ // Update selected option when value changes externally
+ useEffect(() => {
+ setSelectedOption(options.find(opt => opt[valueKey] === value) || null);
+ }, [value, options, valueKey]);
+
+ // Handle dropdown visibility
+ useEffect(() => {
+ // Handle clicks outside dropdown (close the dropdown)
+ const handleClickOutside = event => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isOpen]);
+
+ // Apply final sorting to the filtered results
+ const sortedOptions = useCallback(() => {
+ // Separate special and regular items
+ const specialItems = filteredOptions.filter(item => item.isSpecial);
+ const regularItems = filteredOptions.filter(item => !item.isSpecial);
+
+ // Sort each group separately
+ const sortedSpecialItems = [...specialItems].sort((a, b) =>
+ sortConfig.direction === 'asc'
+ ? a[sortConfig.field].localeCompare(b[sortConfig.field])
+ : b[sortConfig.field].localeCompare(a[sortConfig.field])
+ );
+
+ const sortedRegularItems = [...regularItems].sort((a, b) =>
+ sortConfig.direction === 'asc'
+ ? a[sortConfig.field].localeCompare(b[sortConfig.field])
+ : b[sortConfig.field].localeCompare(a[sortConfig.field])
+ );
+
+ // We're adding a divider dynamically in the render based on the transition from special to regular items
+
+ // Combine the two sorted arrays
+ return [...sortedSpecialItems, ...sortedRegularItems];
+ }, [filteredOptions, sortConfig]);
+
+ // Handle selection
+ const handleSelect = useCallback(
+ option => {
+ setSelectedOption(option);
+ onChange({target: {value: option[valueKey]}});
+ setIsOpen(false);
+ },
+ [onChange, valueKey]
+ );
+
+ return (
+
+ {/* Selected Value Button */}
+
setIsOpen(!isOpen)}
+ className={`w-full flex items-center justify-between px-3 py-2 text-sm
+ border rounded-md
+ bg-gray-700 border-gray-600 text-gray-100 hover:border-gray-500
+ focus:outline-none focus:ring-2 focus:ring-blue-500
+ transition-colors ${className}`}>
+
+ {selectedOption
+ ? selectedOption[labelKey]
+ : placeholder || 'Select option...'}
+
+
+
+
+ {/* Dropdown Menu */}
+ {isOpen && (
+
+
+
+ {/* Options List */}
+
+ {sortedOptions().length > 0 ? (
+
+ {sortedOptions().map((option, index, array) => (
+
+ {/* Add a divider after the last special item */}
+ {index > 0 &&
+ !option.isSpecial &&
+ array[index-1].isSpecial && (
+
+ )}
+
+ handleSelect(option)}
+ className={`px-2.5 py-1.5 text-xs cursor-pointer rounded
+ ${
+ selectedOption?.[valueKey] ===
+ option[valueKey]
+ ? 'bg-blue-600 text-white'
+ : option.isSpecial
+ ? 'text-blue-300 hover:bg-gray-700/70 font-medium'
+ : 'text-gray-100 hover:bg-gray-700'
+ }
+ `}>
+
+
+ {option[labelKey]}
+
+ {selectedOption?.[valueKey] ===
+ option[valueKey] && (
+
+ )}
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+ No options match your search
+
+
+ Clear search
+
+
+
+ )}
+
+
+ )}
+
+ );
+};
+
+SearchDropdown.propTypes = {
+ value: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ options: PropTypes.arrayOf(
+ PropTypes.shape({
+ value: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ description: PropTypes.string
+ })
+ ).isRequired,
+ placeholder: PropTypes.string,
+ className: PropTypes.string,
+ width: PropTypes.string,
+ dropdownWidth: PropTypes.string,
+ labelKey: PropTypes.string,
+ valueKey: PropTypes.string,
+ searchableFields: PropTypes.arrayOf(PropTypes.string)
+};
+
+export default SearchDropdown;
diff --git a/frontend/src/components/ui/SortDropdown.jsx b/frontend/src/components/ui/SortDropdown.jsx
index 8e52fb1..c10eac8 100644
--- a/frontend/src/components/ui/SortDropdown.jsx
+++ b/frontend/src/components/ui/SortDropdown.jsx
@@ -1,11 +1,16 @@
+// SortDropdown.jsx
+
import React, {useState} from 'react';
-import {ChevronDown, ChevronUp, ArrowDown, ArrowUp} from 'lucide-react';
+import {ArrowDown, ArrowUp} from 'lucide-react';
const SortDropdown = ({
sortOptions,
currentSort,
onSortChange,
- className = ''
+ className = '',
+ textSize = 'text-sm', // Default text size
+ menuTextSize = 'text-xs', // Default menu text size
+ iconSize = 16 // Default icon size
}) => {
const [isOpen, setIsOpen] = useState(false);
@@ -24,44 +29,54 @@ const SortDropdown = ({
return (
+ className={`
+ inline-flex items-center justify-between
+ px-4 py-2 ${textSize}
+ bg-white dark:bg-gray-800
+ border border-gray-300 dark:border-gray-700
+ text-gray-900 dark:text-gray-100
+ rounded-md
+ hover:bg-gray-50 dark:hover:bg-gray-700
+ focus:outline-none focus:ring-2 focus:ring-blue-500
+ transition-all
+ `}>
{getCurrentSortLabel()}
{currentSort.direction === 'asc' ? (
-
+
) : (
-
+
)}
{isOpen && (
+ 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
+ '>
{sortOptions.map(option => (
handleSortClick(option.value)}
- className='flex items-center justify-between w-full px-4 py-2
- text-xs text-gray-700 dark:text-gray-200
- hover:bg-gray-50 dark:hover:bg-gray-700'>
+ className={`
+ flex items-center justify-between w-full px-4 py-2
+ ${menuTextSize} text-gray-700 dark:text-gray-200
+ hover:bg-gray-50 dark:hover:bg-gray-700
+ `}>
{option.label}
{currentSort.field === option.value &&
(currentSort.direction === 'asc' ? (
-
+
) : (
-
+
))}
))}
diff --git a/frontend/src/constants/formatGroups.js b/frontend/src/constants/formatGroups.js
new file mode 100644
index 0000000..89679f7
--- /dev/null
+++ b/frontend/src/constants/formatGroups.js
@@ -0,0 +1,167 @@
+import React from 'react';
+import {
+ Music,
+ Tv,
+ Users,
+ Cloud,
+ Film,
+ HardDrive,
+ Maximize,
+ Globe,
+ Video,
+ Flag,
+ Zap,
+ Package,
+ List,
+ BookOpen,
+ X
+} from 'lucide-react';
+
+// Format tag categories for grouping
+export const FORMAT_TAG_CATEGORIES = {
+ AUDIO: 'Audio',
+ CODEC: 'Codec',
+ EDITION: 'Edition',
+ ENHANCEMENT: 'Enhancement',
+ HDR: 'HDR',
+ FLAG: 'Flag',
+ LANGUAGE: 'Language',
+ RELEASE_GROUP: 'Release Group',
+ RELEASE_GROUP_TIER: 'Release Group Tier',
+ RESOLUTION: 'Resolution',
+ SOURCE: 'Source',
+ STORAGE: 'Storage',
+ STREAMING_SERVICE: 'Streaming Service'
+};
+
+// Format grouping mappings (tag to display group)
+export const FORMAT_GROUP_NAMES = {
+ Audio: 'Audio',
+ Codecs: 'Codecs',
+ Edition: 'Edition',
+ Enhancements: 'Enhancements',
+ HDR: 'HDR',
+ 'Indexer Flags': 'Indexer Flags',
+ Language: 'Language',
+ 'Release Groups': 'Release Groups',
+ 'Group Tier Lists': 'Group Tier Lists',
+ Resolution: 'Resolution',
+ Source: 'Source',
+ Storage: 'Storage',
+ 'Streaming Services': 'Streaming Services',
+ Uncategorized: 'Uncategorized'
+};
+
+// Icon components creation function
+const createIcon = (IconComponent, size = 16) => {
+ return React.createElement(IconComponent, { size });
+};
+
+// Icons for each format group
+export const FORMAT_GROUP_ICONS = {
+ Audio: createIcon(Music),
+ HDR: createIcon(Tv),
+ 'Release Groups': createIcon(Users),
+ 'Group Tier Lists': createIcon(List),
+ 'Streaming Services': createIcon(Cloud),
+ Codecs: createIcon(Film),
+ Edition: createIcon(BookOpen),
+ Storage: createIcon(HardDrive),
+ Resolution: createIcon(Maximize),
+ Language: createIcon(Globe),
+ Source: createIcon(Video),
+ 'Indexer Flags': createIcon(Flag),
+ Enhancements: createIcon(Zap),
+ Uncategorized: createIcon(Package),
+ Remove: createIcon(X)
+};
+
+// Helper function to group formats by their tags
+export const groupFormatsByTags = (formats) => {
+ // First group by tags
+ const groupedByTags = formats.reduce((acc, format) => {
+ // Check if format has any tags that match known categories
+ const hasKnownTag = format.tags?.some(
+ tag =>
+ tag.includes(FORMAT_TAG_CATEGORIES.AUDIO) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.CODEC) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.EDITION) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.ENHANCEMENT) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.HDR) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.FLAG) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.LANGUAGE) ||
+ (tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP) && !tag.includes('Tier')) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP_TIER) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.RESOLUTION) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.SOURCE) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.STORAGE) ||
+ tag.includes(FORMAT_TAG_CATEGORIES.STREAMING_SERVICE)
+ );
+
+ // Place in uncategorized if no known tags
+ if (!hasKnownTag) {
+ if (!acc['Uncategorized']) acc['Uncategorized'] = [];
+ acc['Uncategorized'].push(format);
+ return acc;
+ }
+
+ // Otherwise, place in each relevant tag category
+ format.tags.forEach(tag => {
+ if (!acc[tag]) acc[tag] = [];
+ acc[tag].push(format);
+ });
+ return acc;
+ }, {});
+
+ // Then map to proper format groups
+ return {
+ Audio: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.AUDIO))
+ .flatMap(([_, formats]) => formats),
+ Codecs: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.CODEC))
+ .flatMap(([_, formats]) => formats),
+ Edition: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.EDITION))
+ .flatMap(([_, formats]) => formats),
+ Enhancements: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.ENHANCEMENT))
+ .flatMap(([_, formats]) => formats),
+ HDR: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.HDR))
+ .flatMap(([_, formats]) => formats),
+ 'Indexer Flags': Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.FLAG))
+ .flatMap(([_, formats]) => formats),
+ Language: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.LANGUAGE))
+ .flatMap(([_, formats]) => formats),
+ 'Release Groups': Object.entries(groupedByTags)
+ .filter(
+ ([tag]) =>
+ tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP) && !tag.includes('Tier')
+ )
+ .flatMap(([_, formats]) => formats),
+ 'Group Tier Lists': Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.RELEASE_GROUP_TIER))
+ .flatMap(([_, formats]) => formats),
+ Resolution: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.RESOLUTION))
+ .flatMap(([_, formats]) => formats),
+ Source: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.SOURCE))
+ .flatMap(([_, formats]) => formats),
+ Storage: Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.STORAGE))
+ .flatMap(([_, formats]) => formats),
+ 'Streaming Services': Object.entries(groupedByTags)
+ .filter(([tag]) => tag.includes(FORMAT_TAG_CATEGORIES.STREAMING_SERVICE))
+ .flatMap(([_, formats]) => formats),
+ Uncategorized: groupedByTags['Uncategorized'] || []
+ };
+};
+
+// Get the appropriate icon for a group name
+export const getGroupIcon = (groupName) => {
+ return FORMAT_GROUP_ICONS[groupName] || FORMAT_GROUP_ICONS.Uncategorized;
+};
\ No newline at end of file
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 4219739..3a7ea50 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -20,11 +20,21 @@ module.exports = {
'slide-down': {
'0%': {
opacity: '0',
- transform: 'translate3d(0, -100%, 0)'
+ transform: 'translateY(-80px)'
},
'100%': {
opacity: '1',
- transform: 'translate3d(0, 0, 0)'
+ transform: 'translateY(0)'
+ }
+ },
+ 'slide-up': {
+ '0%': {
+ opacity: '1',
+ transform: 'translateY(0)'
+ },
+ '100%': {
+ opacity: '0',
+ transform: 'translateY(80px)'
}
},
wiggle: {
@@ -40,15 +50,42 @@ module.exports = {
'eye-blink': {
'0%, 100%': {transform: 'scale(1)', opacity: 1},
'50%': {transform: 'scale(1.2)', opacity: 0.8}
+ },
+ 'modal-in': {
+ '0%': {
+ opacity: '0',
+ transform: 'translateY(20px) scale(0.97)'
+ },
+ '60%': {
+ opacity: '1',
+ transform: 'translateY(-3px) scale(1.01)'
+ },
+ '100%': {
+ opacity: '1',
+ transform: 'translateY(0) scale(1)'
+ }
+ },
+ 'modal-out': {
+ '0%': {
+ opacity: '1',
+ transform: 'translateY(0) scale(1)'
+ },
+ '100%': {
+ opacity: '0',
+ transform: 'translateY(20px) scale(0.97)'
+ }
}
},
animation: {
'modal-open': 'modal-open 0.3s ease-out forwards',
'fade-in': 'fade-in 0.5s ease-in-out forwards',
- 'slide-down': 'slide-down 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
+ 'slide-down': 'slide-down 0.2s ease-out',
+ 'slide-up': 'slide-up 0.2s ease-in forwards',
wiggle: 'wiggle 0.3s ease-in-out',
'check-bounce': 'check-bounce 0.3s ease-in-out',
- 'eye-blink': 'eye-blink 0.5s ease-in-out'
+ 'eye-blink': 'eye-blink 0.5s ease-in-out',
+ 'modal-in': 'modal-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards',
+ 'modal-out': 'modal-out 0.15s ease-in-out forwards'
},
colors: {
'dark-bg': '#1a1c23',