Merge pull request #156 from Dictionarry-Hub/dev

Stable v0.5.0
This commit is contained in:
Samuel Chau
2025-03-09 00:58:16 +10:30
committed by GitHub
60 changed files with 3741 additions and 1536 deletions

View File

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

View File

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

53
.github/workflows/release-build.yml vendored Normal file
View File

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

25
CLAUDE.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -168,7 +168,7 @@ function FormatCard({
: 'translate-x-0'
}`}>
{/* Conditions */}
<div className='w-full flex-shrink-0 overflow-y-auto'>
<div className='w-full flex-shrink-0 overflow-y-auto scrollable'>
<div className='flex flex-wrap gap-1.5 content-start'>
{content.conditions?.map((condition, index) => (
<span
@@ -189,7 +189,7 @@ function FormatCard({
: 'translate-x-full'
}`}>
{/* Description */}
<div className='w-full h-full overflow-y-auto'>
<div className='w-full h-full overflow-y-auto scrollable'>
{content.description ? (
<div className='text-gray-300 text-xs prose prose-invert prose-gray max-w-none'>
<ReactMarkdown>

View File

@@ -106,7 +106,7 @@ function FormatPage() {
lastSelectedIndex
} = useMassSelection();
useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
const {
name,

View File

@@ -120,7 +120,7 @@ const FormatTestingTab = ({
))}
</div>
) : (
<div className='text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg'>
<div className='text-center py-12 rounded-lg'>
<p className='text-gray-500 dark:text-gray-400'>
No tests added yet
</p>

View File

@@ -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 = ({
<div className='flex items-center gap-4'>
{/* Type Selection */}
<BrowserSelect
<SearchDropdown
value={condition.type || ''}
onChange={handleTypeChange}
options={typeOptions}
placeholder='Select type...'
className='min-w-[140px] px-3 py-2 text-sm rounded-md
bg-gray-700 border border-gray-700
text-gray-200'
className='min-w-[200px] condition-type-dropdown'
width='w-auto'
/>
{/* Render the specific condition component */}

View File

@@ -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 (
<div className='flex-1'>
<BrowserSelect
<div className="flex-1">
<SearchDropdown
value={condition.pattern || ''}
onChange={e =>
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%'
/>
</div>
);

View File

@@ -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 (
<select
className='flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700'
value={condition.pattern || ''}
onChange={e => onChange({...condition, pattern: e.target.value})}>
<option value=''>Select release group pattern...</option>
{sortedPatterns.map(pattern => (
<option key={pattern.name} value={pattern.name}>
{pattern.name}
</option>
))}
</select>
<div className="flex-1">
<SearchDropdown
value={condition.pattern || ''}
onChange={handlePatternChange}
options={patternOptions}
placeholder='Select release group pattern...'
searchableFields={['label', 'description']}
className='min-w-[200px]'
width='w-auto'
dropdownWidth='100%'
/>
</div>
);
};

View File

@@ -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 (
<select
className='flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700'
value={condition.pattern || ''}
onChange={e => onChange({...condition, pattern: e.target.value})}>
<option value=''>Select release title pattern...</option>
{sortedPatterns.map(pattern => (
<option key={pattern.name} value={pattern.name}>
{pattern.name}
</option>
))}
</select>
<div className="flex-1">
<SearchDropdown
value={condition.pattern || ''}
onChange={handlePatternChange}
options={patternOptions}
placeholder='Select release title pattern...'
searchableFields={['label', 'description']}
className='min-w-[200px]'
width='w-auto'
dropdownWidth='100%'
/>
</div>
);
};

View File

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

View File

@@ -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 (
<div className='h-full flex flex-col'>
<div className='bg-white dark:bg-gray-800 pb-4'>
<div className='grid grid-cols-[auto_1fr] gap-4 items-center'>
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100 leading-tight'>
Language Requirements
</h2>
<p className='text-xs text-gray-600 dark:text-gray-400 leading-relaxed'>
Configure language requirements for media content.
</p>
</div>
</div>
<div className='mt-4 space-y-4'>
<div className='flex gap-2 p-3 text-xs bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
<InfoIcon className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
<p className='text-blue-700 dark:text-blue-300'>
Configure how languages should be handled for your media
content. Select "Any" to accept all languages, or
configure specific language requirements.
</p>
</div>
<div className='p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'>
<div className='space-y-3'>
<div className='space-y-1'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
Language Settings
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Configure language requirements for releases
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
<select
value={currentBehavior}
onChange={e =>
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'>
<option value='any'>Any</option>
<option value='must'>Must Include</option>
<option value='only'>Must Only Be</option>
<option value='mustnot'>
Must Not Include
</option>
</select>
{currentBehavior !== 'any' && (
<select
value={currentLanguage || 'english'}
onChange={e =>
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 => (
<option
key={language.id}
value={language.id}>
{language.name}
</option>
))}
</select>
)}
</div>
{currentBehavior === 'only' && (
<div className='flex items-center gap-1.5 mt-2'>
<AlertTriangle className='h-3 w-3 text-amber-500' />
<p className='text-[10px] text-amber-600 dark:text-amber-400'>
"Must Only Be" will reject releases with
multiple languages
</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
ProfileLanguagesTab.propTypes = {
language: PropTypes.string.isRequired,
onLanguageChange: PropTypes.func.isRequired
};
export default ProfileLanguagesTab;

View File

@@ -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 && (
<button
onClick={handleDelete}
className={`bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors ${
isDeleting ? 'bg-red-600' : ''
}`}>
{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 ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Trash2 className="w-4 h-4 text-red-500" />
)}
<span>Delete</span>
</button>
)}
<button
onClick={handleSave}
className='bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors'>
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 ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Save className="w-4 h-4 text-blue-500" />
)}
<span>Save</span>
</button>
</div>
}>
@@ -589,12 +655,6 @@ function ProfileModal({
onLanguageChange={setLanguage}
/>
)}
{activeTab === 'tweaks' && (
<ProfileTweaksTab
tweaks={tweaks}
onTweaksChange={setTweaks}
/>
)}
</div>
)}
</div>
@@ -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,

View File

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

View File

@@ -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 (
<div className='h-full flex flex-col'>
<div className='mt-4 space-y-4'>
<div className='flex gap-2 p-3 text-xs bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
<InfoIcon className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
<p className='text-blue-700 dark:text-blue-300'>
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.
</p>
</div>
<div className='space-y-2'>
{/* Allow Dolby Vision without Fallback */}
<div
onClick={() => 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
`}>
<div className='space-y-1'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
Allow Dolby Vision without Fallback
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Allow Dolby Vision releases that don't include
HDR10 fallback. These may display incorrectly on
non-Dolby Vision displays.
</p>
<div className='flex items-center gap-1.5 mt-2'>
<AlertTriangle className='h-3 w-3 text-amber-500' />
<p className='text-[10px] text-amber-600 dark:text-amber-400'>
Only enable if your display supports Dolby
Vision
</p>
</div>
</div>
</div>
{/* Allow Bleeding Edge Codecs */}
<div
onClick={() =>
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
`}>
<div className='space-y-1'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
Allow Bleeding Edge Codecs
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Allow releases using newer codecs like AV1 and
H.266/VVC. These may offer better compression
but have limited hardware support.
</p>
</div>
</div>
{/* Allow Lossless Audio */}
<div
onClick={() => 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
`}>
<div className='space-y-1'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
Allow Lossless Audio
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Allow high-quality lossless audio formats
including TrueHD + Atmos, DTS-HD MA, DTS-X,
FLAC, and PCM.
</p>
<div className='flex items-center gap-1.5 mt-2'>
<AlertTriangle className='h-3 w-3 text-amber-500' />
<p className='text-[10px] text-amber-600 dark:text-amber-400'>
May skip better quality releases if disabled
</p>
</div>
</div>
</div>
{/* Allow Prereleases */}
<div
onClick={() => 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
`}>
<div className='space-y-1'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
Allow Prereleases
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Allow early releases like CAMs, Telecines,
Telesyncs, and Screeners. These are typically
available before official releases but at lower
quality.
</p>
</div>
</div>
{/* Prefer Freeleech */}
<div
onClick={() => 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
`}>
<div className='space-y-1'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
Prefer Freeleech
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Prioritize releases tagged as freeleech when
choosing between different indexers' releases.
</p>
</div>
</div>
</div>
</div>
</div>
);
};
ProfileTweaksTab.propTypes = {
tweaks: PropTypes.object.isRequired,
onTweaksChange: PropTypes.func.isRequired
};
export default ProfileTweaksTab;

View File

@@ -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 (
<div
className={`
relative p-2.5 rounded-lg select-none cursor-grab active:cursor-grabbing
border ${
quality.enabled
? 'border-blue-200 dark:border-blue-800'
: 'border-gray-200 dark:border-gray-700'
}
transition-colors duration-200
${
quality.enabled
? 'bg-blue-50 dark:bg-blue-900/20'
: 'bg-white dark:bg-gray-800'
}
hover:border-blue-500 dark:hover:border-blue-400
${isDragging ? 'opacity-50' : ''}
group
`}
style={style}
{...attributes}
{...listeners}>
{/* Header Section */}
<div className='flex items-start justify-between gap-3'>
{/* Title and Description */}
<div className='flex-1 min-w-0'>
<h3 className='text-xs font-medium text-gray-900 dark:text-gray-100'>
{quality.name}
</h3>
{isGroup && quality.description && (
<p className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
{quality.description}
</p>
)}
</div>
{/* Actions and Icons */}
<div className='flex items-center gap-2'>
{/* App Icons */}
<div className='flex items-center gap-1.5'>
{quality.radarr && (
<img
src={RadarrLogo}
className='w-3.5 h-3.5'
alt='Radarr'
/>
)}
{quality.sonarr && (
<img
src={SonarrLogo}
className='w-3.5 h-3.5'
alt='Sonarr'
/>
)}
</div>
{/* Edit/Delete Actions */}
{isGroup && (
<div className='flex items-center gap-1 ml-1'>
{onEdit && (
<button
onClick={e => {
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'>
<Pencil className='w-3 h-3' />
</button>
)}
{onDelete && (
<button
onClick={e => {
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'>
<Trash2 className='w-3 h-3' />
</button>
)}
</div>
)}
</div>
</div>
{/* Quality Tags Section */}
{isGroup && (
<div className='mt-2 flex flex-wrap items-center gap-1'>
{quality.qualities.map(q => (
<span
key={q.id}
className='inline-flex px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'>
{q.name}
</span>
))}
</div>
)}
{/* Non-group Description */}
{!isGroup && quality.description && (
<p className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
{quality.description}
</p>
)}
</div>
);
};
export default QualityItem;

View File

@@ -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 */}
<select
value={currentBehavior}
onChange={e =>
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'>
<option value='any'>Any</option>
<option value='must'>Must Include</option>
<option value='only'>Must Only Be</option>
<option value='mustnot'>Must Not Include</option>
</select>
{/* Language Dropdown */}
{currentBehavior !== 'any' && (
<select
value={currentLanguage || 'english'}
onChange={e =>
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 => (
<option
key={language.id}
value={language.id}>
{language.name}
</option>
))}
</select>
)}
{/* Help text below the controls is in the parent component */}
<div>
{currentBehavior === 'any' && (
<div className='flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>Accept content in any language.</p>
</div>
)}
{currentBehavior === 'must' && (
<div className='flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
Content must include{' '}
{currentLanguage
? LANGUAGES.find(
l => l.id === currentLanguage
)?.name || currentLanguage
: 'English'}
, but can include other languages as well.
</p>
</div>
)}
{currentBehavior === 'only' && (
<div className='flex items-center gap-1.5 text-xs'>
<AlertTriangle className='h-3.5 w-3.5 text-amber-500 flex-shrink-0' />
<p className='text-amber-600 dark:text-amber-400'>
Content must ONLY be in{' '}
{currentLanguage
? LANGUAGES.find(
l => l.id === currentLanguage
)?.name || currentLanguage
: 'English'}
. This will reject releases containing
multiple languages.
</p>
</div>
)}
{currentBehavior === 'mustnot' && (
<div className='flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
Content must NOT include{' '}
{currentLanguage
? LANGUAGES.find(
l => l.id === currentLanguage
)?.name || currentLanguage
: 'English'}
. Any other language is acceptable.
</p>
</div>
)}
</div>
</div>
</div>
);
};
AdvancedView.propTypes = {
language: PropTypes.string.isRequired,
onLanguageChange: PropTypes.func.isRequired
};
export default AdvancedView;

View File

@@ -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 (
<div className='w-full space-y-6'>
<div className='space-y-4'>
{/* Simple header with title and description */}
<div className='mb-4'>
<h2 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Language Settings
</h2>
<p className='text-xs text-gray-500 dark:text-gray-400'>
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.'}
</p>
</div>
{/* Controls row - display mode dropdown with other controls */}
<div className='flex gap-3'>
{/* Mode Selector (always visible) */}
<div className='w-[144px] relative'>
<button
onClick={() => 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'>
<span className='flex items-center gap-2'>
{isAdvancedView ? (
<>
<Settings
size={16}
className='text-gray-400'
/>
<span className='text-sm font-medium'>
Advanced
</span>
</>
) : (
<>
<List
size={16}
className='text-gray-400'
/>
<span className='text-sm font-medium'>
Simple
</span>
</>
)}
</span>
<ChevronDown
size={16}
className={`text-gray-400 transition-transform ${
isDropdownOpen ? 'transform rotate-180' : ''
}`}
/>
</button>
{isDropdownOpen && (
<>
<div
className='fixed inset-0'
onClick={() => setIsDropdownOpen(false)}
/>
<div className='absolute left-0 mt-1 w-full rounded-md shadow-lg bg-gray-800 border border-gray-600 z-10'>
<div>
<button
onClick={() => {
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'
}`}>
<div className='flex items-center gap-2'>
<List size={16} />
<span>Simple</span>
</div>
</button>
<button
onClick={() => {
// 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'
}`}>
<div className='flex items-center gap-2'>
<Settings size={16} />
<span>Advanced</span>
</div>
</button>
</div>
</div>
</>
)}
</div>
{/* SIMPLE MODE: just one language dropdown */}
{!isAdvancedView && (
<div className='flex-1'>
<SearchDropdown
value={language}
onChange={e => onLanguageChange(e.target.value)}
options={languageOptions}
placeholder='Select language...'
dropdownWidth='100%'
className='bg-gray-800 dark:border-gray-600 text-gray-100'
/>
</div>
)}
{/* ADVANCED MODE: two dropdowns (type and language) */}
{isAdvancedView && (
<>
{/* Type Dropdown - Custom styled */}
<div className='w-[144px] relative'>
<select
value={currentBehavior}
onChange={e =>
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'>
<option value='any'>Any</option>
<option value='must'>Must Include</option>
<option value='only'>Must Only Be</option>
<option value='mustnot'>
Must Not Include
</option>
</select>
<div className='pointer-events-none absolute inset-y-0 right-0 flex items-center px-2'>
<ChevronDown
size={16}
className='text-gray-400'
/>
</div>
</div>
{/* Language Dropdown */}
{currentBehavior !== 'any' && (
<div className='flex-1'>
<SearchDropdown
value={currentLanguage || 'english'}
onChange={e =>
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'
/>
</div>
)}
</>
)}
</div>
{/* Help text section - display the appropriate help text based on view mode and selection */}
<div className='border border-gray-600 rounded-md p-4 bg-gray-800'>
{/* Simple mode help */}
{!isAdvancedView && (
<div className='flex items-center gap-1.5 text-xs text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
{language === 'any' ? (
<>
Attempts to set{' '}
<span className='font-medium text-gray-200'>
Any Language
</span>{' '}
in Radarr profiles. For Sonarr, language
will default to "Original" since it
lacks native language settings.
</>
) : language === 'original' ? (
<>
Attempts to set{' '}
<span className='font-medium text-gray-200'>
Original
</span>{' '}
language in Radarr profiles. For Sonarr,
language will default to "Original"
since it lacks native language settings.
</>
) : (
<>
Attempts to set{' '}
<span className='font-medium text-gray-200'>
{LANGUAGES.find(
l => l.id === language
)?.name || language}
</span>{' '}
language in Radarr profiles. For Sonarr,
language will default to "Original"
since it lacks native language settings.
</>
)}
</p>
</div>
)}
{/* Advanced mode help based on selections */}
{isAdvancedView && (
<>
{language === 'any' && (
<div className='flex items-center gap-1.5 text-xs text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>Accept content in any language.</p>
</div>
)}
{language && language.startsWith('must_') && (
<div className='flex items-center gap-1.5 text-xs text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
Content must include{' '}
<span className='font-medium text-gray-200'>
{language.split('_')[1]
? LANGUAGES.find(
l =>
l.id ===
language.split('_')[1]
)?.name ||
language.split('_')[1]
: 'English'}
</span>
, but can include other languages as
well.
</p>
</div>
)}
{language && language.startsWith('only_') && (
<div className='flex items-center gap-1.5 text-xs'>
<AlertTriangle className='h-3.5 w-3.5 text-amber-500 flex-shrink-0' />
<p className='text-amber-400'>
Content must ONLY be in{' '}
<span className='font-medium text-amber-300'>
{language.split('_')[1]
? LANGUAGES.find(
l =>
l.id ===
language.split('_')[1]
)?.name ||
language.split('_')[1]
: 'English'}
</span>
. This will reject releases containing
multiple languages.
</p>
</div>
)}
{language && language.startsWith('mustnot_') && (
<div className='flex items-center gap-1.5 text-xs text-gray-400'>
<InfoIcon className='h-3.5 w-3.5 text-blue-500 flex-shrink-0' />
<p>
Content must NOT include{' '}
<span className='font-medium text-gray-200'>
{language.split('_')[1]
? LANGUAGES.find(
l =>
l.id ===
language.split('_')[1]
)?.name ||
language.split('_')[1]
: 'English'}
</span>
. Any other language is acceptable.
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};
ProfileLanguagesTab.propTypes = {
language: PropTypes.string.isRequired,
onLanguageChange: PropTypes.func.isRequired
};
export default ProfileLanguagesTab;

View File

@@ -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 (
<SearchDropdown
value={language}
onChange={e => onLanguageChange(e.target.value)}
options={languageOptions}
placeholder="Select language..."
dropdownWidth="100%"
/>
);
};
SimpleView.propTypes = {
language: PropTypes.string.isRequired,
onLanguageChange: PropTypes.func.isRequired
};
export default SimpleView;

View File

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

View File

@@ -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 (
<div className='bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 mb-4'>
<div className='grid grid-cols-[auto_1fr_auto] gap-4 items-center'>
<h3 className='text-base font-semibold text-gray-900 dark:text-gray-100'>
Upgrade Until
</h3>
<p className='text-xs text-gray-600 dark:text-gray-400'>
Downloads will be upgraded until this quality is reached.
Lower qualities will be upgraded, while higher qualities
will be left unchanged.
</p>
<select
className='w-48 p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm'
value={selectedUpgradeQuality?.id || ''}
onChange={e => {
const quality = enabledQualities.find(
q => q.id === parseInt(e.target.value)
);
onUpgradeQualityChange(quality);
}}>
{enabledQualities.map(quality => (
<option key={quality.id} value={quality.id}>
{quality.name}
</option>
))}
</select>
</div>
</div>
);
};
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
}
/>
</div>
);
@@ -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 (
<div className='h-full flex flex-col'>
<div className='bg-white dark:bg-gray-800 pb-4'>
<div className='grid grid-cols-[auto_1fr_auto] gap-4 items-center'>
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100 leading-tight'>
<div className='mb-4 flex justify-between items-center'>
<div className='flex items-center'>
<h2 className='text-sm font-medium text-gray-700 dark:text-gray-300 mr-4'>
Quality Rankings
</h2>
<p className='text-xs text-gray-600 dark:text-gray-400 leading-relaxed'>
Qualities higher in the list are more preferred even if
not checked. Qualities within the same group are equal.
Only checked qualities are wanted.
</p>
<button
onClick={() => 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'>
<InfoIcon className='w-4 h-4' />
Create Group
</button>
</div>
</div>
{upgradesAllowed && (
<UpgradeSection
enabledQualities={sortedQualities.filter(q => q.enabled)}
selectedUpgradeQuality={selectedUpgradeQuality}
onUpgradeQualityChange={onSelectedUpgradeQualityChange}
/>
)}
<div className='flex-1 overflow-auto'>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
modifiers={[
restrictToVerticalAxis,
restrictToParentElement
]}>
<div className=''>
<div className='space-y-2'>
<SortableContext
items={sortedQualities.map(q => q.id)}
strategy={verticalListSortingStrategy}>
{sortedQualities.map(quality => (
<SortableItem
key={quality.id}
quality={quality}
onToggle={handleQualityToggle}
onDelete={
'qualities' in quality
? handleDeleteClick
: undefined
}
onEdit={
'qualities' in quality
? handleEditClick
: undefined
}
/>
))}
</SortableContext>
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 flex items-center space-x-2'>
<span className='inline-flex items-center'>
<svg
className='h-3 w-3 mr-1'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M7 16V4m0 0L3 8m4-4l4 4'
/>
</svg>
Drag to reorder
</span>
<span></span>
<span className='inline-flex items-center'>
<svg
className='h-3 w-3 mr-1'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M5 13l4 4L19 7'
/>
</svg>
Click to toggle
</span>
{upgradesAllowed && (
<>
<span></span>
<span className='inline-flex items-center'>
<svg
className='h-3 w-3 mr-1'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth='2'>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M5 10l7-7m0 0l7 7m-7-7v18'
/>
</svg>
Set upgrade target
</span>
</>
)}
</div>
</DndContext>
</div>
<button
onClick={() => 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'>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-4 w-4'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'>
<path d='M12 5v14M5 12h14' />
</svg>
<span>Create Group</span>
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}>
<div>
<div className='space-y-2 mb-4'>
<SortableContext
items={sortedQualities.map(q => q.id)}
strategy={verticalListSortingStrategy}>
{sortedQualities.map(quality => (
<SortableItem
key={quality.id}
quality={quality}
onToggle={handleQualityToggle}
onDelete={
'qualities' in quality
? handleDeleteClick
: undefined
}
onEdit={
'qualities' in quality
? handleEditClick
: undefined
}
isUpgradeUntil={isUpgradeUntilQuality(
quality
)}
onUpgradeUntilClick={
upgradesAllowed
? handleUpgradeUntilClick
: undefined
}
/>
))}
</SortableContext>
</div>
</div>
</DndContext>
<CreateGroupModal
isOpen={isCreateGroupModalOpen}
onClose={() => {

View File

@@ -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 <QualityItemGroup {...enhancedProps} />;
} else {
return <QualityItemSingle {...enhancedProps} />;
}
};
export default QualityItem;

View File

@@ -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 (
<div
className={`
relative p-2.5 rounded-lg select-none cursor-grab active:cursor-grabbing
border border-gray-200 dark:border-gray-700
transition-colors duration-200
bg-white dark:bg-gray-800
hover:border-gray-300 dark:hover:border-gray-600
${isDragging ? 'opacity-50' : ''}
group
`}
style={style}
{...attributes}
{...listeners}
onMouseEnter={() => onMouseEnter?.(quality.id)}
onMouseLeave={onMouseLeave}>
{/* Header Row */}
<div className='flex items-center justify-between'>
{/* Title and Description */}
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3 flex-wrap'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
{quality.name}
</h3>
{/* Quality tags inline with name */}
<div className='flex flex-wrap items-center gap-1.5'>
{quality.qualities.map(q => (
<span
key={q.id}
className='inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'>
{q.name}
</span>
))}
</div>
</div>
{quality.description && (
<p className='mt-1.5 text-xs text-gray-600 dark:text-gray-400'>
{quality.description}
</p>
)}
</div>
{/* Right Section */}
<div className='flex items-center gap-2'>
{/* App Icons */}
<div className='flex items-center gap-1.5'>
{quality.radarr && (
<div className='flex items-center bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-200 rounded px-1.5 py-0.5'>
<img
src={RadarrLogo}
className='w-3 h-3 mr-1'
alt='Radarr'
/>
<span className='text-[10px] font-medium'>
Radarr
</span>
</div>
)}
{quality.sonarr && (
<div className='flex items-center bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200 rounded px-1.5 py-0.5'>
<img
src={SonarrLogo}
className='w-3 h-3 mr-1'
alt='Sonarr'
/>
<span className='text-[10px] font-medium'>
Sonarr
</span>
</div>
)}
</div>
{/* Edit/Delete Actions */}
<div className='flex items-center gap-2'>
{onEdit && (
<button
onClick={e => {
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'>
<Pencil className='w-3 h-3' />
</button>
)}
{onDelete && (
<button
onClick={e => {
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'>
<Trash2 className='w-3 h-3' />
</button>
)}
</div>
{/* Upgrade Until button - only shown when enabled and upgrade is allowed */}
{quality.enabled && onUpgradeUntilClick && (
<Tooltip
content={
isUpgradeUntil
? 'This quality is set as upgrade until'
: 'Set as upgrade until quality'
}>
<button
onClick={handleUpgradeClick}
className={`
w-5 h-5 rounded-full flex items-center justify-center
${
isUpgradeUntil
? 'bg-green-500 dark:bg-green-600 text-white'
: 'border border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500 hover:border-green-400 dark:hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/10'
}
`}>
<ArrowUp size={12} />
</button>
</Tooltip>
)}
{/* Selected indicator - shows all three states */}
<div
className={`
w-5 h-5 rounded-full flex items-center justify-center
${
quality.enabled
? 'bg-blue-500 dark:bg-blue-600'
: 'border border-gray-300 dark:border-gray-600'
}
${
!quality.enabled && willBeSelected
? 'bg-blue-100 dark:bg-blue-900/30'
: ''
}
`}>
{quality.enabled && (
<Check size={14} className='text-white' />
)}
{willBeSelected && !quality.enabled && (
<div className='w-2 h-2 rounded-full bg-blue-400' />
)}
</div>
</div>
</div>
</div>
);
};
export default QualityItemGroup;

View File

@@ -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 = () => (
<div className='flex items-center gap-3'>
{quality.radarr && (
<div className='flex items-center text-white'>
<img
src={RadarrLogo}
className='w-3.5 h-3.5 mr-1.5'
alt='Radarr'
/>
<span className='text-xs'>Radarr</span>
</div>
)}
{quality.sonarr && (
<div className='flex items-center text-white'>
<img
src={SonarrLogo}
className='w-3.5 h-3.5 mr-1.5'
alt='Sonarr'
/>
<span className='text-xs'>Sonarr</span>
</div>
)}
</div>
);
const handleUpgradeClick = e => {
e.stopPropagation();
onUpgradeUntilClick?.(quality);
};
return (
<div
className={`
relative p-2.5 rounded-lg select-none cursor-grab active:cursor-grabbing
border border-gray-200 dark:border-gray-700
transition-colors duration-200
bg-white dark:bg-gray-800
hover:border-gray-300 dark:hover:border-gray-600
${isDragging ? 'opacity-50' : ''}
group
`}
style={style}
{...attributes}
{...listeners}
onMouseEnter={() => onMouseEnter?.(quality.id)}
onMouseLeave={onMouseLeave}>
{/* Content Row */}
<div className='flex items-center justify-between'>
{/* Left Section with Title and Info */}
<div className='flex-1 min-w-0'>
{/* Title Row */}
<div className='flex items-center flex-wrap'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
{quality.name}
</h3>
</div>
{/* Description Row */}
{quality.description && (
<p className='mt-1.5 text-xs text-gray-600 dark:text-gray-400'>
{quality.description}
</p>
)}
</div>
{/* Right Section - Info Icon and Selection indicators */}
<div className='flex items-center gap-2'>
{/* Info Badge with Tooltip */}
{(quality.radarr || quality.sonarr) && (
<Tooltip content={<AppTooltipContent />}>
<div className='flex items-center text-blue-500 dark:text-blue-400 cursor-help'>
<Info className='w-3.5 h-3.5' />
</div>
</Tooltip>
)}
{/* Upgrade Until button - only shown when enabled and upgrade is allowed */}
{quality.enabled && onUpgradeUntilClick && (
<Tooltip
content={
isUpgradeUntil
? 'This quality is set as upgrade until'
: 'Set as upgrade until quality'
}>
<button
onClick={handleUpgradeClick}
className={`
w-5 h-5 rounded-full flex items-center justify-center
${
isUpgradeUntil
? 'bg-green-500 dark:bg-green-600 text-white'
: 'border border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500 hover:border-green-400 dark:hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/10'
}
`}>
<ArrowUp size={12} />
</button>
</Tooltip>
)}
{/* Selection indicator */}
<div
className={`
w-5 h-5 rounded-full flex items-center justify-center
${
quality.enabled
? 'bg-blue-500 dark:bg-blue-600'
: 'border border-gray-300 dark:border-gray-600'
}
${
!quality.enabled && willBeSelected
? 'bg-blue-100 dark:bg-blue-900/30'
: ''
}
`}>
{quality.enabled && (
<Check size={14} className='text-white' />
)}
{willBeSelected && !quality.enabled && (
<div className='w-2 h-2 rounded-full bg-blue-400' />
)}
</div>
</div>
</div>
</div>
);
};
export default QualityItemSingle;

View File

@@ -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: <Music size={16} />,
HDR: <Tv size={16} />,
'Release Groups': <Users size={16} />,
'Streaming Services': <Cloud size={16} />,
Codecs: <Film size={16} />,
Storage: <HardDrive size={16} />,
Resolution: <Maximize size={16} />,
Language: <Globe size={16} />,
Source: <Video size={16} />,
'Indexer Flags': <Flag size={16} />,
Enhancements: <Zap size={16} />,
Uncategorized: <Package size={16} />
};
return icons[groupName] || <Package size={16} />;
};
// Create sort instances for each group
const groupSorts = Object.entries(formatGroups).reduce(
(acc, [groupName, formats]) => {
const defaultSort = {field: 'name', direction: 'asc'};
const {sortConfig, updateSort, sortData} = useSorting(defaultSort);
acc[groupName] = {
sortedData: sortData(formats),
sortConfig,
updateSort
};
return acc;
},
{}
);
// 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 (
<div className='grid grid-cols-1 md:grid-cols-2 gap-3'>
{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 (
<div
@@ -145,38 +50,40 @@ const AdvancedView = ({formats, onScoreChange}) => {
</h3>
<SortDropdown
sortOptions={sortOptions}
currentSort={sortConfig}
onSortChange={updateSort}
currentSort={globalSortConfig}
onSortChange={globalUpdateSort}
/>
</div>
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
{sortedData.length > 0 ? (
sortedData.map(format => (
<div
key={format.id}
className='flex items-center justify-between px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 group'>
<div className='flex-1 min-w-0 mr-4'>
<p className='text-sm text-gray-900 dark:text-gray-100 truncate'>
{format.name}
</p>
</div>
{sortedData.map(format => (
<div
key={format.id}
className='flex items-center justify-between px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 group'>
<div className='flex-1 min-w-0 mr-4'>
<p className='text-sm text-gray-900 dark:text-gray-100 truncate'>
{format.name}
</p>
</div>
<div className="flex items-center gap-2">
<NumberInput
value={format.score}
onChange={value =>
onScoreChange(
format.id,
value
)
onScoreChange(format.id, value)
}
/>
{showRemoveButton && (
<button
onClick={() => 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"
>
<X size={16} />
</button>
)}
</div>
))
) : (
<div className='px-4 py-3 text-sm text-gray-500 dark:text-gray-400'>
No formats found
</div>
)}
))}
</div>
</div>
);
@@ -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;

View File

@@ -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}) => {
)}
</div>
</div>
<NumberInput
value={format.score}
onChange={value =>
onScoreChange(format.id, value)
}
/>
<div className='flex items-center gap-2'>
<NumberInput
value={format.score}
onChange={value =>
onScoreChange(format.id, value)
}
/>
{showRemoveButton && (
<button
onClick={() => 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"
>
<X size={16} />
</button>
)}
</div>
</div>
))
) : (
@@ -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;

View File

@@ -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 (
<div className='bg-gray-800 rounded-lg border border-gray-700 overflow-visible mb-4'>
<div className='px-4 py-3 border-b border-gray-700'>
<h3 className='text-sm font-bold text-gray-100 mb-2'>
Available Formats
</h3>
<p className='text-xs text-gray-400 mb-3'>
Select formats to include in your profile. Zero-scored
formats are still saved when selected.
</p>
<SearchDropdown
options={dropdownOptions}
value=''
onChange={handleSelectFormat}
placeholder='Select formats to add...'
searchableFields={['label', 'description']}
dropdownWidth='100%'
width='100%'
/>
</div>
{dropdownOptions.length === 0 && (
<div className='py-4 text-sm text-gray-400 text-center italic'>
No available formats to add
</div>
)}
</div>
);
};
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;

View File

@@ -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 (
<div
key={format.id}
className={`p-2 rounded border transition-colors mb-1.5 cursor-pointer
${isSelected
? 'border-green-500 bg-green-50 dark:bg-green-900/30 dark:border-green-700'
: 'border-gray-300 bg-white hover:border-blue-400 dark:bg-gray-800 dark:border-gray-700 dark:hover:border-blue-600'
}`}
onClick={() => handleFormatClick(format.id)}
>
<div className="flex justify-between items-center">
<div className="flex-1 truncate mr-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{format.name}</h3>
{format.tags && format.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{format.tags.slice(0, 2).map(tag => (
<span key={tag} className="px-1.5 py-0.5 text-xs rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{tag}
</span>
))}
{format.tags.length > 2 && (
<span className="px-1.5 py-0.5 text-xs rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
+{format.tags.length - 2}
</span>
)}
</div>
)}
</div>
{isSelected ? (
<Check className="text-green-500 dark:text-green-400 flex-shrink-0" size={16} />
) : (
<Plus className="text-gray-400 dark:text-gray-500 flex-shrink-0" size={16} />
)}
</div>
</div>
);
};
// Render advanced (grouped) view
const renderAdvancedView = () => {
return (
<div className="space-y-4">
{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 (
<div key={groupName} className="mb-4">
<h3 className="text-xs font-bold text-gray-900 dark:text-gray-100 flex items-center mb-2">
{getGroupIcon(groupName)}
<span className="ml-1">{groupName}</span>
<span className="ml-1 text-gray-500 dark:text-gray-400">({filteredGroupFormats.length})</span>
</h3>
<div className="grid grid-cols-2 gap-x-3 gap-y-0">
{filteredGroupFormats.map(renderFormatCard)}
</div>
</div>
);
})
}
{filteredFormats.length === 0 && (
<div className="py-8 text-center text-gray-500 dark:text-gray-400 italic">
No formats found matching your search
</div>
)}
</div>
);
};
// Render basic view (simple grid)
const renderBasicView = () => {
return (
<>
{filteredFormats.length > 0 ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-0">
{filteredFormats.map(renderFormatCard)}
</div>
) : (
<div className="py-8 text-center text-gray-500 dark:text-gray-400 italic">
No formats found matching your search
</div>
)}
</>
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Select Formats"
width="2xl"
height="4xl"
>
<div className="h-full flex flex-col">
<div className="mb-2">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Select formats to include in your profile. Click a format to toggle its selection.
</div>
<div className="flex items-center gap-3">
<SearchBar
className='flex-1'
placeholder='Search formats...'
searchTerms={searchTerms}
currentInput={currentInput}
onInputChange={setCurrentInput}
onAddTerm={addSearchTerm}
onRemoveTerm={removeSearchTerm}
onClearTerms={clearSearchTerms}
/>
<button
onClick={toggleViewMode}
className="flex items-center gap-1 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"
title={viewMode === 'basic' ? 'Switch to Advanced View' : 'Switch to Basic View'}
>
{viewMode === 'basic' ? (
<>
<Settings size={16} className="text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium">Advanced</span>
</>
) : (
<>
<Grid3X3 size={16} className="text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium">Basic</span>
</>
)}
</button>
</div>
</div>
<div className="format-count text-xs mb-2">
<span className="text-green-600 dark:text-green-400 font-medium">{selectedFormatIds.length + allFormats.filter(f => f.score !== 0).length}</span> of {allFormats.length} formats selected
</div>
<div className="flex-1 overflow-y-auto pr-1">
{viewMode === 'basic' ? renderBasicView() : renderAdvancedView()}
</div>
</div>
</Modal>
);
};
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;

View File

@@ -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 (
<div className='space-y-3'>
<div className='space-y-4'>
<div className='flex gap-3'>
<SearchBar
className='flex-1'
@@ -36,96 +162,158 @@ const FormatSettings = ({formats, onScoreChange}) => {
onClearTerms={clearSearchTerms}
/>
<div className='relative flex'>
<button
onClick={() => setIsDropdownOpen(prev => !prev)}
className='inline-flex items-center justify-between w-36 px-3 py-2 rounded-md border border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600'
aria-expanded={isDropdownOpen}
aria-haspopup='true'>
<span className='flex items-center gap-2'>
{isAdvancedView ? (
<>
<Settings
size={16}
className='text-gray-500 dark:text-gray-400'
/>
<span className='text-sm font-medium'>
Advanced
</span>
</>
) : (
<>
<List
size={16}
className='text-gray-500 dark:text-gray-400'
/>
<span className='text-sm font-medium'>
Basic
</span>
</>
)}
</span>
<ChevronDown
size={16}
className={`text-gray-500 dark:text-gray-400 transition-transform ${
isDropdownOpen ? 'transform rotate-180' : ''
}`}
/>
</button>
{isDropdownOpen && (
<>
<div
className='fixed inset-0'
onClick={() => setIsDropdownOpen(false)}
<div className='flex gap-2'>
{/* View Mode Dropdown */}
<div className='relative flex'>
<button
onClick={() => setIsDropdownOpen(prev => !prev)}
className='inline-flex items-center justify-between w-36 px-3 py-2 rounded-md border border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600'
aria-expanded={isDropdownOpen}
aria-haspopup='true'
>
<span className='flex items-center gap-2'>
{isAdvancedView ? (
<>
<Settings
size={16}
className='text-gray-500 dark:text-gray-400'
/>
<span className='text-sm font-medium'>
Advanced
</span>
</>
) : (
<>
<List
size={16}
className='text-gray-500 dark:text-gray-400'
/>
<span className='text-sm font-medium'>
Basic
</span>
</>
)}
</span>
<ChevronDown
size={16}
className={`text-gray-500 dark:text-gray-400 transition-transform ${
isDropdownOpen ? 'transform rotate-180' : ''
}`}
/>
<div className='absolute right-0 mt-12 w-36 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 z-10'>
<div>
<button
onClick={() => {
setIsAdvancedView(false);
setIsDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm ${
!isAdvancedView
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}>
<div className='flex items-center gap-2'>
<List size={16} />
<span>Basic</span>
</div>
</button>
<button
onClick={() => {
setIsAdvancedView(true);
setIsDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm ${
isAdvancedView
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}>
<div className='flex items-center gap-2'>
<Settings size={16} />
<span>Advanced</span>
</div>
</button>
</button>
{isDropdownOpen && (
<>
<div
className='fixed inset-0'
onClick={() => setIsDropdownOpen(false)}
/>
<div className='absolute right-0 mt-12 w-36 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 z-10'>
<div>
<button
onClick={() => {
setIsAdvancedView(false);
setIsDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm ${
!isAdvancedView
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}>
<div className='flex items-center gap-2'>
<List size={16} />
<span>Basic</span>
</div>
</button>
<button
onClick={() => {
setIsAdvancedView(true);
setIsDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm ${
isAdvancedView
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}>
<div className='flex items-center gap-2'>
<Settings size={16} />
<span>Advanced</span>
</div>
</button>
</div>
</div>
</div>
</>
)}
</>
)}
</div>
{/* Selective Mode with Format Selector */}
<div className="flex">
<button
onClick={toggleSelectiveMode}
className={`px-3 py-2 rounded-l-md border transition-colors flex items-center gap-1 ${
showSelectiveMode
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
: 'border-gray-300 bg-white hover:border-gray-400 dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600'
}`}
title={showSelectiveMode ? 'Hide unused formats' : 'Show all formats'}
>
<CheckSquare size={16} />
<span className='text-sm font-medium'>Selective</span>
</button>
{showSelectiveMode && (
<Tooltip
content="Select formats to include in your profile"
position="bottom"
>
<button
onClick={openFormatSelector}
className="px-3 py-2 border rounded-r-md border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600 flex items-center gap-1 h-full -ml-[1px]"
>
<Plus size={16} />
<span className="text-sm font-medium">Add</span>
</button>
</Tooltip>
)}
{!showSelectiveMode && (
<Tooltip
content="Enable selective mode to add formats"
position="bottom"
>
<div className="px-3 py-2 border rounded-r-md bg-gray-100 border-gray-300 text-gray-400 dark:bg-gray-700 dark:border-gray-700 dark:text-gray-500 flex items-center gap-1 cursor-not-allowed h-full -ml-[1px]">
<Plus size={16} />
<span className="text-sm font-medium">Add</span>
</div>
</Tooltip>
)}
</div>
</div>
</div>
{/* Format Selector Modal */}
<FormatSelectorModal
isOpen={isSelectorModalOpen}
onClose={() => setIsSelectorModalOpen(false)}
availableFormats={availableFormats}
selectedFormatIds={selectedFormatIds}
allFormats={formats}
onFormatToggle={handleFormatToggle}
/>
{/* Format Display */}
{isAdvancedView ? (
<AdvancedView
formats={filteredFormats}
onScoreChange={onScoreChange}
onScoreChange={handleScoreChange}
onFormatRemove={formatId => handleFormatToggle(formatId)}
showRemoveButton={showSelectiveMode}
/>
) : (
<BasicView
formats={filteredFormats}
onScoreChange={onScoreChange}
onScoreChange={handleScoreChange}
onFormatRemove={formatId => handleFormatToggle(formatId)}
showRemoveButton={showSelectiveMode}
/>
)}
</div>
@@ -144,4 +332,4 @@ FormatSettings.propTypes = {
onScoreChange: PropTypes.func.isRequired
};
export default FormatSettings;
export default FormatSettings;

View File

@@ -25,8 +25,8 @@ const ProfileScoringTab = ({
Upgrade Settings
</h2>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Assign scores to different formats to control
download preferences
Configure when upgrades should be downloaded and
what scores are required
</p>
</div>
<div className='flex flex-col items-end space-y-1'>
@@ -70,8 +70,10 @@ const ProfileScoringTab = ({
Format Settings
</h2>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Configure when upgrades should be downloaded and what
scores are required
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.
</p>
</div>

View File

@@ -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 (
<div className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden'>
<div className='px-4 py-3 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center'>
<h3 className='text-sm font-bold text-gray-900 dark:text-gray-100'>
Selected Formats
</h3>
<div className='flex gap-2'>
<SortDropdown
sortOptions={sortOptions}
currentSort={sortConfig}
onSortChange={updateSort}
/>
</div>
</div>
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
{/* Add new format button */}
<div className='relative'>
<button
onClick={() => 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'
>
<Plus size={16} className='mr-2' />
<span>Add Format</span>
</button>
{/* Dropdown for selecting a format to add */}
{dropdownOpen && (
<>
<div
className='fixed inset-0 z-10'
onClick={() => setDropdownOpen(false)}
/>
<div className='absolute left-0 right-0 mt-1 max-h-60 overflow-y-auto z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg'>
<div className='p-2'>
<input
type='text'
placeholder='Search formats...'
value={searchInput}
onChange={(e) => 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()}
/>
</div>
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
{filteredAvailableFormats.length > 0 ? (
filteredAvailableFormats.map(format => (
<button
key={format.id}
className='w-full text-left px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm'
onClick={() => addFormat(format)}
>
<div className='flex items-center gap-2'>
<p className='text-gray-900 dark:text-gray-100 truncate'>
{format.name}
</p>
{format.tags && format.tags.length > 0 && (
<span className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{format.tags.join(', ')}
</span>
)}
</div>
</button>
))
) : (
<div className='px-4 py-3 text-sm text-gray-500 dark:text-gray-400'>
No formats available
</div>
)}
</div>
</div>
</>
)}
</div>
{/* List of selected formats */}
{sortedFormats.length > 0 ? (
sortedFormats.map(format => (
<div
key={format.id}
className='flex items-center justify-between px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 group'
>
<div className='flex-1 min-w-0 mr-4'>
<div className='flex items-center gap-2'>
<p className='text-sm text-gray-900 dark:text-gray-100 truncate'>
{format.name}
</p>
{format.tags && format.tags.length > 0 && (
<span className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{format.tags.join(', ')}
</span>
)}
</div>
</div>
<div className='flex items-center gap-2'>
<NumberInput
value={format.score}
onChange={value => onScoreChange(format.id, value)}
/>
<button
onClick={() => removeFormat(format.id)}
className='text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400'
>
<X size={16} />
</button>
</div>
</div>
))
) : (
<div className='px-4 py-3 text-sm text-gray-500 dark:text-gray-400'>
No formats selected
</div>
)}
</div>
</div>
);
};
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;

View File

@@ -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'>
<ReactMarkdown>{pattern.description}</ReactMarkdown>
</div>
)}

View File

@@ -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(() => {

View File

@@ -148,7 +148,7 @@ const RegexTestingTab = ({
))}
</div>
) : (
<div className='text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg'>
<div className='text-center py-12 rounded-lg'>
<p className='text-gray-500 dark:text-gray-400'>
No tests added yet
</p>

View File

@@ -1,8 +1,10 @@
// ArrModal.jsx
import React from 'react';
import {Plus, TestTube, Loader, Save, X, Trash, Check} from 'lucide-react';
import Modal from '@ui/Modal';
import {useArrModal} from '@hooks/useArrModal';
import DataSelectorModal from './DataSelectorModal';
import DataSelector from './DataSelector';
import SyncModal from './SyncModal';
const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
@@ -44,43 +46,34 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
{value: 'schedule', label: 'Scheduled'}
];
// Ensure data_to_sync always has the required structure
const safeSelectedData = {
profiles: formData.data_to_sync?.profiles || [],
customFormats: formData.data_to_sync?.customFormats || []
};
// Handle sync method change
const handleSyncMethodChange = e => {
const newMethod = e.target.value;
handleInputChange({
target: {
id: 'sync_method',
value: newMethod
}
});
// Reset data_to_sync when switching to manual
if (newMethod === 'manual') {
handleInputChange({
target: {
id: 'data_to_sync',
value: {profiles: [], customFormats: []}
}
});
}
const handleFormSubmit = e => {
e.preventDefault();
e.stopPropagation();
handleSubmit(e);
};
const inputClasses = errorKey =>
`w-full px-3 py-2 text-sm rounded-lg border ${
errors[errorKey]
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 ${
errors[errorKey]
? 'focus:ring-red-500 focus:border-red-500'
: 'focus:ring-blue-500 focus:border-blue-500'
} placeholder-gray-400 dark:placeholder-gray-500 transition-all`;
const inputClasses = errorKey => `
w-full px-3 py-2 text-sm rounded-lg border ${
errors[errorKey]
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'
} bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 ${
errors[errorKey]
? 'focus:ring-red-500 focus:border-red-500'
: 'focus:ring-blue-500 focus:border-blue-500'
} placeholder-gray-400 dark:placeholder-gray-500 transition-all
`;
const handleSyncMethodChange = e => {
e.preventDefault();
e.stopPropagation();
handleInputChange(e);
};
return (
<Modal
@@ -119,7 +112,7 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
!formData.apiKey
}
className='flex items-center px-3 py-2 text-sm rounded-lg bg-emerald-600 hover:bg-emerald-700
disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'>
disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'>
{isTestingConnection ? (
<>
<Loader className='w-3.5 h-3.5 mr-2 animate-spin' />
@@ -142,7 +135,7 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
type='submit'
form='arrForm'
className='flex items-center px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700
text-white font-medium transition-colors'>
text-white font-medium transition-colors'>
{saveConfirm ? (
<>
<Check className='w-3.5 h-3.5 mr-2' />
@@ -162,8 +155,16 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
</button>
</div>
}>
<form id='arrForm' onSubmit={handleSubmit} className='space-y-4'>
{/* Name Field */}
<form
id='arrForm'
onSubmit={handleFormSubmit}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
}
}}
className='space-y-4'>
<div className='space-y-1.5'>
<label
htmlFor='name'
@@ -180,7 +181,6 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
/>
</div>
{/* Type Field */}
<div className='space-y-1.5'>
<label
htmlFor='type'
@@ -201,7 +201,6 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
</select>
</div>
{/* Tags Field */}
<div className='space-y-1.5'>
<label
htmlFor='tags'
@@ -216,7 +215,10 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
{tag}
<button
type='button'
onClick={() => handleRemoveTag(tag)}
onClick={e => {
e.preventDefault();
handleRemoveTag(tag);
}}
className='ml-1 hover:text-blue-900 dark:hover:text-blue-200'>
<X size={12} />
</button>
@@ -236,14 +238,13 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
type='button'
onClick={handleAddTag}
className='px-3 py-2 text-sm rounded-lg bg-blue-100 text-blue-600 hover:bg-blue-200
dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800
font-medium transition-colors'>
dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800
font-medium transition-colors'>
Add
</button>
</div>
</div>
{/* Server URL Field */}
<div className='space-y-1.5'>
<label
htmlFor='arrServer'
@@ -265,7 +266,6 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
)}
</div>
{/* API Key Field */}
<div className='space-y-1.5'>
<label
htmlFor='apiKey'
@@ -283,7 +283,6 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
/>
</div>
{/* Sync Method Field */}
<div className='space-y-1.5'>
<label
htmlFor='sync_method'
@@ -308,28 +307,23 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
{formData.sync_method === 'manual' && (
<p>
Manual sync allows you to selectively import data
when changes occur in the source instance. You'll
need to manually select and import the data you want
to sync.
when changes occur in the source instance.
</p>
)}
{formData.sync_method === 'pull' && (
<p>
On Pull automatically syncs data whenever the
database pulls in new changes. This is a "set and
forget" option - perfect for maintaining consistency
across instances without manual intervention.
database pulls in new changes.
</p>
)}
{formData.sync_method === 'schedule' && (
<p>
Scheduled sync runs at fixed intervals, ensuring
your instances stay in sync at regular times.
your instances stay in sync.
</p>
)}
</div>
{/* Import as Unique - Now always visible */}
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
<label className='flex items-center space-x-2'>
@@ -352,13 +346,11 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
</label>
<span className='text-xs text-gray-500 dark:text-gray-400 max-w-sm text-right'>
Creates a unique hash from the data and target
instance name, allowing the same profile/format to
be imported multiple times
instance name
</span>
</div>
</div>
{/* Conditional Fields for Sync Method */}
{formData.sync_method === 'schedule' && (
<div className='space-y-1.5'>
<label
@@ -384,51 +376,20 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
</div>
)}
{/* Sync Options */}
{formData.sync_method !== 'manual' && (
<>
<button
type='button'
onClick={() => setIsDataDrawerOpen(true)}
className='w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200
bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700
rounded-lg transition-colors
border border-gray-200 dark:border-gray-700'>
<div className='flex flex-col space-y-2'>
<div className='flex items-center justify-between'>
<span>Select Data to Sync</span>
</div>
{(safeSelectedData.profiles.length > 0 ||
safeSelectedData.customFormats.length >
0) && (
<div className='flex flex-wrap gap-2'>
{safeSelectedData.profiles.map(
profile => (
<span
key={profile}
className='inline-flex items-center bg-blue-100 text-blue-800
dark:bg-blue-900 dark:text-blue-300
text-xs rounded px-2 py-1'>
{profile}
</span>
)
)}
{safeSelectedData.customFormats.map(
format => (
<span
key={format}
className='inline-flex items-center bg-green-100 text-green-800
dark:bg-green-900 dark:text-green-300
text-xs rounded px-2 py-1'>
{format}
</span>
)
)}
</div>
)}
</div>
</button>
<div className='border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800'>
<h3 className='text-sm font-medium mb-4'>
Select Data to Sync
</h3>
<DataSelector
isLoading={isLoading}
availableData={availableData}
selectedData={safeSelectedData}
onDataToggle={handleDataToggle}
error={errors.data_to_sync}
/>
</div>
{errors.data_to_sync && (
<p className='text-xs text-red-500 mt-1'>
{errors.data_to_sync}
@@ -436,34 +397,23 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
)}
</>
)}
<DataSelectorModal
isOpen={
formData.sync_method !== 'manual' && isDataDrawerOpen
}
onClose={() => setIsDataDrawerOpen(false)}
isLoading={isLoading}
availableData={availableData}
selectedData={safeSelectedData}
onDataToggle={handleDataToggle}
error={errors.data_to_sync}
/>
{showSyncConfirm && (
<SyncModal
isOpen={showSyncConfirm}
onClose={() => {
setShowSyncConfirm(false);
onSubmit();
}}
onSkip={() => {
setShowSyncConfirm(false);
onSubmit();
}}
onSync={handleManualSync}
isSyncing={isInitialSyncing}
/>
)}
</form>
{showSyncConfirm && (
<SyncModal
isOpen={showSyncConfirm}
onClose={() => {
setShowSyncConfirm(false);
onSubmit();
}}
onSkip={() => {
setShowSyncConfirm(false);
onSubmit();
}}
onSync={handleManualSync}
isSyncing={isInitialSyncing}
/>
)}
</Modal>
);
};

View File

@@ -0,0 +1,250 @@
import React from 'react';
import {Loader, AlertTriangle} from 'lucide-react';
import useSearch from '@hooks/useSearch';
import {useSorting} from '@hooks/useSorting';
import SearchBar from '@ui/DataBar/SearchBar';
import SortDropdown from '@ui/SortDropdown';
const DataSelector = ({
isLoading,
availableData = {profiles: [], customFormats: []},
selectedData = {profiles: [], customFormats: []},
onDataToggle,
error
}) => {
const profiles = selectedData?.profiles || [];
const customFormats = selectedData?.customFormats || [];
const availableProfileNames = new Set(
availableData.profiles.map(p => p.content.name)
);
const availableFormatNames = new Set(
availableData.customFormats.map(f => f.content.name)
);
const missingProfiles = profiles.filter(
name => !availableProfileNames.has(name)
);
const missingFormats = customFormats.filter(
name => !availableFormatNames.has(name)
);
const combinedProfiles = [
...missingProfiles.map(name => ({name, isMissing: true})),
...availableData.profiles.map(profile => ({
name: profile.content.name,
isMissing: false
}))
];
const combinedFormats = [
...missingFormats.map(name => ({name, isMissing: true})),
...availableData.customFormats.map(format => ({
name: format.content.name,
isMissing: false
}))
];
const {
items: filteredProfiles,
searchTerms: searchTermsProfiles,
currentInput: currentInputProfiles,
setCurrentInput: setCurrentInputProfiles,
addSearchTerm: addSearchTermProfiles,
removeSearchTerm: removeSearchTermProfiles,
clearSearchTerms: clearSearchTermsProfiles
} = useSearch(combinedProfiles, {
searchableFields: ['name']
});
const {
items: filteredFormats,
searchTerms: searchTermsFormats,
currentInput: currentInputFormats,
setCurrentInput: setCurrentInputFormats,
addSearchTerm: addSearchTermFormats,
removeSearchTerm: removeSearchTermFormats,
clearSearchTerms: clearSearchTermsFormats
} = useSearch(combinedFormats, {
searchableFields: ['name']
});
const {
sortConfig: profilesSortConfig,
updateSort: updateProfilesSort,
sortData: sortProfiles
} = useSorting({field: 'name', direction: 'asc'});
const {
sortConfig: formatsSortConfig,
updateSort: updateFormatsSort,
sortData: sortFormats
} = useSorting({field: 'name', direction: 'asc'});
const sortedProfiles = sortProfiles(filteredProfiles);
const sortedFormats = sortFormats(filteredFormats);
const sortOptions = [{label: 'Name', value: 'name'}];
const renderItem = (item, type) => (
<label
key={item.name}
className={`flex items-center p-2 bg-white dark:bg-gray-800
hover:bg-gray-50 dark:hover:bg-gray-700
rounded-lg cursor-pointer group transition-colors
border ${
item.isMissing
? 'border-amber-500/50 dark:border-amber-500/30'
: 'border-gray-200 dark:border-gray-700'
}`}>
<div className='flex-1 flex items-center'>
<input
type='checkbox'
checked={
type === 'profiles'
? profiles.includes(item.name)
: customFormats.includes(item.name)
}
onChange={() => onDataToggle(type, item.name)}
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-offset-0'
/>
<span
className='ml-3 text-sm text-gray-700 dark:text-gray-300
group-hover:text-gray-900 dark:group-hover:text-gray-100 flex-1'>
{item.name}
</span>
{item.isMissing && (
<div className='flex items-center text-amber-500 dark:text-amber-400'>
<AlertTriangle className='w-4 h-4 mr-1' />
<span className='text-xs'>File not found</span>
</div>
)}
</div>
</label>
);
return (
<div className='space-y-6'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader className='w-6 h-6 animate-spin text-blue-500' />
</div>
) : (
<>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>
Quality Profiles
</h4>
<div className='flex items-center space-x-2'>
{missingProfiles.length > 0 && (
<span className='text-xs text-amber-500 dark:text-amber-400'>
{missingProfiles.length} missing
</span>
)}
<span className='text-xs text-gray-500 dark:text-gray-400'>
{profiles.length} selected
</span>
</div>
</div>
<div className='flex items-center gap-2 mb-2'>
<SearchBar
placeholder='Search profiles...'
requireEnter={true}
searchTerms={searchTermsProfiles}
currentInput={currentInputProfiles}
onInputChange={setCurrentInputProfiles}
onAddTerm={addSearchTermProfiles}
onRemoveTerm={removeSearchTermProfiles}
onClearTerms={() => {
clearSearchTermsProfiles();
setCurrentInputProfiles('');
}}
className='flex-1'
/>
<div
onClick={e => {
e.stopPropagation();
}}>
<SortDropdown
sortOptions={sortOptions}
currentSort={profilesSortConfig}
onSortChange={updateProfilesSort}
/>
</div>
</div>
<div className='grid grid-cols-1 gap-2'>
{sortedProfiles.map(item =>
renderItem(item, 'profiles')
)}
</div>
</div>
<div className='space-y-3'>
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>
Custom Formats
</h4>
<div className='flex items-center space-x-2'>
{missingFormats.length > 0 && (
<span className='text-xs text-amber-500 dark:text-amber-400'>
{missingFormats.length} missing
</span>
)}
<span className='text-xs text-gray-500 dark:text-gray-400'>
{customFormats.length} selected
</span>
</div>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Note: Custom formats used in selected quality
profiles are automatically imported and don't
need to be selected here.
</p>
</div>
<div className='flex items-center gap-2 mb-2'>
<SearchBar
placeholder='Search custom formats...'
requireEnter={true}
searchTerms={searchTermsFormats}
currentInput={currentInputFormats}
onInputChange={setCurrentInputFormats}
onAddTerm={addSearchTermFormats}
onRemoveTerm={removeSearchTermFormats}
onClearTerms={() => {
clearSearchTermsFormats();
setCurrentInputFormats('');
}}
className='flex-1'
/>
<div
onClick={e => {
e.stopPropagation();
}}>
<SortDropdown
sortOptions={sortOptions}
currentSort={formatsSortConfig}
onSortChange={updateFormatsSort}
/>
</div>
</div>
<div className='grid grid-cols-1 gap-2'>
{sortedFormats.map(item =>
renderItem(item, 'customFormats')
)}
</div>
</div>
{error && (
<div className='pt-2'>
<p className='text-xs text-red-500'>{error}</p>
</div>
)}
</>
)}
</div>
);
};
export default DataSelector;

View File

@@ -1,139 +0,0 @@
import React from 'react';
import {Loader} from 'lucide-react';
import Modal from '@ui/Modal';
const DataSelectorModal = ({
isOpen,
onClose,
isLoading,
availableData = {profiles: [], customFormats: []},
selectedData = {profiles: [], customFormats: []},
onDataToggle,
error
}) => {
// Ensure we have safe defaults for selectedData
const profiles = selectedData?.profiles || [];
const customFormats = selectedData?.customFormats || [];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title='Select Data to Sync'
height='2xl'
width='2xl'>
<div className='space-y-6'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader className='w-6 h-6 animate-spin text-blue-500' />
</div>
) : (
<>
{/* Quality Profiles Section */}
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>
Quality Profiles
</h4>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{profiles.length} selected
</span>
</div>
<div className='grid grid-cols-2 gap-2'>
{(availableData?.profiles || []).map(
profile => (
<label
key={profile.file_name}
className='flex items-center p-2 bg-white dark:bg-gray-800
hover:bg-gray-50 dark:hover:bg-gray-700
rounded-lg cursor-pointer group transition-colors
border border-gray-200 dark:border-gray-700'>
<input
type='checkbox'
checked={profiles.includes(
profile.content.name
)}
onChange={() =>
onDataToggle(
'profiles',
profile.content.name
)
}
className='rounded border-gray-300 text-blue-600
focus:ring-blue-500 focus:ring-offset-0'
/>
<span
className='ml-3 text-sm text-gray-700 dark:text-gray-300
group-hover:text-gray-900 dark:group-hover:text-gray-100'>
{profile.content.name}
</span>
</label>
)
)}
</div>
</div>
{/* Custom Formats Section */}
<div className='space-y-3'>
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>
Custom Formats
</h4>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{customFormats.length} selected
</span>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Note: Custom formats used in selected
quality profiles are automatically imported
and don't need to be selected here.
</p>
</div>
<div className='grid grid-cols-2 gap-2'>
{(availableData?.customFormats || []).map(
format => (
<label
key={format.file_name}
className='flex items-center p-2 bg-white dark:bg-gray-800
hover:bg-gray-50 dark:hover:bg-gray-700
rounded-lg cursor-pointer group transition-colors
border border-gray-200 dark:border-gray-700'>
<input
type='checkbox'
checked={customFormats.includes(
format.content.name
)}
onChange={() =>
onDataToggle(
'customFormats',
format.content.name
)
}
className='rounded border-gray-300 text-blue-600
focus:ring-blue-500 focus:ring-offset-0'
/>
<span
className='ml-3 text-sm text-gray-700 dark:text-gray-300
group-hover:text-gray-900 dark:group-hover:text-gray-100'>
{format.content.name}
</span>
</label>
)
)}
</div>
</div>
{error && (
<div className='pt-2'>
<p className='text-xs text-red-500'>{error}</p>
</div>
)}
</>
)}
</div>
</Modal>
);
};
export default DataSelectorModal;

View File

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

View File

@@ -10,14 +10,22 @@ const MergeConflicts = ({
areAllConflictsResolved,
fetchGitStatus
}) => {
if (!conflicts || conflicts.length === 0) return null;
const hasConflicts = conflicts && conflicts.length > 0;
return (
<div className='mb-4'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-200 flex items-center'>
<AlertTriangle className='text-yellow-400 mr-2' size={16} />
<span>Merge Conflicts</span>
{areAllConflictsResolved() ? (
<CheckCircle className='text-green-400 mr-2' size={16} />
) : (
<AlertTriangle className='text-yellow-400 mr-2' size={16} />
)}
<span>
{areAllConflictsResolved()
? 'All Conflicts Resolved'
: 'Merge Conflicts'}
</span>
</h4>
<div className='flex space-x-2'>
<Tooltip
@@ -47,10 +55,23 @@ const MergeConflicts = ({
</Tooltip>
</div>
</div>
<ConflictTable
conflicts={conflicts}
fetchGitStatus={fetchGitStatus}
/>
{/* Only show the conflict table if there are conflicts */}
{hasConflicts && (
<ConflictTable
conflicts={conflicts}
fetchGitStatus={fetchGitStatus}
/>
)}
{/* Show a success message when all conflicts are resolved */}
{!hasConflicts && areAllConflictsResolved() && (
<div className='mt-3 p-4 bg-gray-800 border border-gray-700 rounded-lg text-center'>
<p className='text-gray-300'>
All conflicts have been successfully resolved. You can now commit the merge.
</p>
</div>
)}
</div>
);
};

View File

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

View File

@@ -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 (
<div className={`relative flex-1 min-w-0 group ${className}`}>
<Search
className={`
absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4
transition-colors duration-200
${
isFocused
? 'text-blue-500'
: 'text-gray-400 group-hover:text-gray-500 dark:group-hover:text-gray-300'
}
`}
absolute left-3 top-1/2 -translate-y-1/2 ${iconSize}
transition-colors duration-200
${
isFocused
? 'text-blue-500'
: 'text-gray-400 group-hover:text-gray-500 dark:group-hover:text-gray-300'
}
`}
/>
<div
className={`
w-full min-h-10 pl-9 pr-8 rounded-md
transition-all duration-200 ease-in-out
border shadow-sm flex items-center flex-wrap gap-2 p-2
${
isFocused
? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5'
: 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600'
}
bg-white dark:bg-gray-800
`}>
w-full ${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) => (
<div
key={index}
className='flex items-center gap-1.5 px-2 py-1
className={`
flex items-center gap-1.5 px-2
${
minHeight && minHeight.startsWith('h-')
? 'py-0.5'
: 'py-1'
}
bg-blue-500/10 dark:bg-blue-500/20
border border-blue-500/20 dark:border-blue-400/20
text-blue-600 dark:text-blue-400
rounded-md shadow-sm
hover:bg-blue-500/15 dark:hover:bg-blue-500/25
hover:border-blue-500/30 dark:hover:border-blue-400/30
group/badge
transition-all duration-200'>
<span className='text-sm font-medium leading-none'>
group/badge flex-shrink-0
transition-all duration-200
`}>
<span
className={`${badgeTextSize} font-medium leading-none`}>
{term}
</span>
<button
onClick={() => onRemoveTerm(term)}
className='p-0.5 hover:bg-blue-500/20
rounded-sm transition-colors
opacity-70 group-hover/badge:opacity-100'
aria-label={`Remove ${term} filter`}>
rounded-sm transition-colors
opacity-70 group-hover/badge:opacity-100'>
<X className='h-3 w-3' />
</button>
</div>
))}
<input
type='text'
value={currentInput}
onChange={e => onInputChange(e.target.value)}
onKeyPress={handleKeyPress}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={e => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
searchTerms.length
? 'Add another filter...'
: placeholder
}
className='flex-1 min-w-[200px] bg-transparent
text-gray-900 dark:text-gray-100
placeholder:text-gray-500 dark:placeholder:text-gray-400
focus:outline-none'
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`}
/>
</div>
@@ -118,13 +133,12 @@ const SearchBar = ({
<button
onClick={onClearTerms}
className='absolute right-3 top-1/2 -translate-y-1/2
p-1.5 rounded-full
text-gray-400 hover:text-gray-600
hover:bg-gray-100 dark:hover:bg-gray-700
transition-all duration-200
group/clear'
aria-label='Clear all searches'>
<X className='h-4 w-4' />
p-1.5 rounded-full
text-gray-400 hover:text-gray-600
hover:bg-gray-100 dark:hover:bg-gray-700
transition-all duration-200
group/clear'>
<X className={iconSize} />
</button>
)}
</div>

View File

@@ -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 (
<div
className={`fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center transition-opacity duration-300 ease-out scrollable ${
className={`fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center transition-opacity duration-200 scrollable ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
style={{zIndex: 1000 + level * 10}}
onClick={handleClickOutside}>
<div
className={`fixed inset-0 bg-black transition-opacity duration-300 ease-out ${
className={`fixed inset-0 bg-black transition-opacity duration-200 ${
isOpen ? 'bg-opacity-50' : 'bg-opacity-0'
}`}
style={{zIndex: 1000 + level * 10}}
@@ -105,14 +121,19 @@ const Modal = ({
min-w-[320px] min-h-[200px] ${widthClasses[width]} ${
heightClasses[height]
}
transition-all duration-300 ease-out transform
${isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
${isClosing
? 'animate-slide-up'
: isOpen
? 'animate-slide-down'
: 'opacity-0'
}
flex flex-col overflow-hidden`}
style={{
zIndex: 1001 + level * 10,
maxHeight: maxHeight || '80vh'
maxHeight: maxHeight || '80vh',
}}
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className='flex items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-200'>
@@ -128,7 +149,7 @@ const Modal = ({
</div>
)}
<button
onClick={onClose}
onClick={handleClose}
className='ml-auto text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors'>
<svg
className='w-6 h-6'

View File

@@ -71,8 +71,8 @@ const NumberInput = ({
};
const inputClasses = [
'w-16 h-8 px-2 py-1 text-sm border border-gray-700',
'rounded-l focus:outline-none',
'w-20 h-8 px-2 py-1 text-sm border border-gray-700',
'rounded-l focus:outline-none text-left',
'bg-gray-800',
isFocused ? 'text-blue-400' : 'text-gray-300',
'[appearance:textfield]',

View File

@@ -0,0 +1,254 @@
import React, {useEffect, useRef, useState, useCallback} from 'react';
import PropTypes from 'prop-types';
import {ChevronDown, Check, Search} from 'lucide-react';
import SearchBar from './DataBar/SearchBar';
import SortDropdown from './SortDropdown';
import useSearch from '@hooks/useSearch';
import {useSorting} from '@hooks/useSorting';
const SearchDropdown = ({
value,
onChange,
options,
placeholder,
className,
width = 'w-full',
dropdownWidth,
labelKey = 'label',
valueKey = 'value',
searchableFields = ['label', 'description']
}) => {
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 (
<div className={`relative ${width}`} ref={dropdownRef}>
{/* Selected Value Button */}
<button
type='button'
onClick={() => 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}`}>
<span className='truncate'>
{selectedOption
? selectedOption[labelKey]
: placeholder || 'Select option...'}
</span>
<ChevronDown
className={`w-4 h-4 ml-2 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div
ref={menuRef}
className='absolute z-50 mt-1
bg-gray-800 border border-gray-700 rounded-md shadow-lg
flex flex-col overflow-hidden'
style={{
width: dropdownWidth || '650px',
maxHeight: '700px',
left: '0'
}}>
<div className='p-3 bg-gray-800 shadow-sm relative'>
<div className='absolute left-0 right-0 bottom-0 h-px bg-gray-700/50'></div>
<div className='flex items-center gap-2'>
<div className='flex-grow'>
<SearchBar
placeholder='Search options...'
searchTerms={searchTerms}
currentInput={currentInput}
onInputChange={setCurrentInput}
onAddTerm={addSearchTerm}
onRemoveTerm={removeSearchTerm}
onClearTerms={clearSearchTerms}
requireEnter={true}
textSize='text-xs'
badgeTextSize='text-xs'
iconSize='h-3.5 w-3.5'
minHeight='h-8'
/>
</div>
<SortDropdown
sortOptions={sortOptions}
currentSort={sortConfig}
onSortChange={updateSort}
className='flex-shrink-0'
textSize='text-xs'
menuTextSize='text-xs'
iconSize={14}
/>
</div>
</div>
{/* Options List */}
<div className='flex-1 p-2 pt-3 overflow-auto'>
{sortedOptions().length > 0 ? (
<div className='flex flex-col'>
{sortedOptions().map((option, index, array) => (
<React.Fragment key={option[valueKey]}>
{/* Add a divider after the last special item */}
{index > 0 &&
!option.isSpecial &&
array[index-1].isSpecial && (
<div className="h-px bg-gray-600/80 mx-2 my-2"></div>
)}
<div
onClick={() => 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'
}
`}>
<div className='flex items-center'>
<div className='flex-grow truncate'>
{option[labelKey]}
</div>
{selectedOption?.[valueKey] ===
option[valueKey] && (
<Check className='w-3.5 h-3.5 ml-1.5 flex-shrink-0' />
)}
</div>
</div>
</React.Fragment>
))}
</div>
) : (
<div className='px-3 py-12 text-center'>
<div className='bg-gray-700/30 rounded-lg p-4 max-w-xs mx-auto'>
<Search className='w-6 h-6 mb-2 mx-auto text-gray-500 opacity-40' />
<p className='text-gray-300 text-xs'>
No options match your search
</p>
<button
onClick={clearSearchTerms}
className='mt-2 text-xs text-blue-400 hover:underline'>
Clear search
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
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;

View File

@@ -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 (
<div className={`relative inline-block text-left ${className}`}>
<button
type='button'
onClick={toggleDropdown}
className='inline-flex items-center justify-between w-full px-4 py-2 text-xs
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
text-gray-900 dark:text-gray-100
rounded-md
hover:bg-gray-50 dark:hover:bg-gray-700
focus:outline-none focus:ring-2 focus:ring-blue-500'>
className={`
inline-flex items-center justify-between
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
`}>
<span className='flex items-center gap-2'>
{getCurrentSortLabel()}
{currentSort.direction === 'asc' ? (
<ArrowUp size={16} />
<ArrowUp size={iconSize} />
) : (
<ArrowDown size={16} />
<ArrowDown size={iconSize} />
)}
</span>
</button>
{isOpen && (
<div
className='absolute right-0 z-10 w-56 mt-2 origin-top-right
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-md shadow-lg'>
className='
absolute right-0 z-10 w-56 mt-2 origin-top-right
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-md shadow-lg
'>
<div className='py-1'>
{sortOptions.map(option => (
<button
key={option.value}
type='button'
onClick={() => handleSortClick(option.value)}
className='flex items-center justify-between w-full px-4 py-2
text-xs text-gray-700 dark:text-gray-200
hover:bg-gray-50 dark:hover:bg-gray-700'>
className={`
flex items-center justify-between w-full px-4 py-2
${menuTextSize} text-gray-700 dark:text-gray-200
hover:bg-gray-50 dark:hover:bg-gray-700
`}>
<span>{option.label}</span>
{currentSort.field === option.value &&
(currentSort.direction === 'asc' ? (
<ArrowUp size={16} />
<ArrowUp size={iconSize} />
) : (
<ArrowDown size={16} />
<ArrowDown size={iconSize} />
))}
</button>
))}

View File

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

View File

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