mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
@@ -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 }}
|
||||
15
.github/workflows/notify.yml
vendored
15
.github/workflows/notify.yml
vendored
@@ -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
53
.github/workflows/release-build.yml
vendored
Normal 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
25
CLAUDE.md
Normal 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
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -106,7 +106,7 @@ function FormatPage() {
|
||||
lastSelectedIndex
|
||||
} = useMassSelection();
|
||||
|
||||
useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
|
||||
useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
|
||||
|
||||
const {
|
||||
name,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
133
frontend/src/components/profile/language/AdvancedView.jsx
Normal file
133
frontend/src/components/profile/language/AdvancedView.jsx
Normal 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;
|
||||
447
frontend/src/components/profile/language/ProfileLangaugesTab.jsx
Normal file
447
frontend/src/components/profile/language/ProfileLangaugesTab.jsx
Normal 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;
|
||||
51
frontend/src/components/profile/language/SimpleView.jsx
Normal file
51
frontend/src/components/profile/language/SimpleView.jsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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={() => {
|
||||
32
frontend/src/components/profile/quality/QualityItem.jsx
Normal file
32
frontend/src/components/profile/quality/QualityItem.jsx
Normal 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;
|
||||
174
frontend/src/components/profile/quality/QualityItemGroup.jsx
Normal file
174
frontend/src/components/profile/quality/QualityItemGroup.jsx
Normal 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;
|
||||
147
frontend/src/components/profile/quality/QualityItemSingle.jsx
Normal file
147
frontend/src/components/profile/quality/QualityItemSingle.jsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
75
frontend/src/components/profile/scoring/FormatSelector.jsx
Normal file
75
frontend/src/components/profile/scoring/FormatSelector.jsx
Normal 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;
|
||||
235
frontend/src/components/profile/scoring/FormatSelectorModal.jsx
Normal file
235
frontend/src/components/profile/scoring/FormatSelectorModal.jsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
219
frontend/src/components/profile/scoring/SelectiveView.jsx
Normal file
219
frontend/src/components/profile/scoring/SelectiveView.jsx
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
250
frontend/src/components/settings/arrs/DataSelector.jsx
Normal file
250
frontend/src/components/settings/arrs/DataSelector.jsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]',
|
||||
|
||||
254
frontend/src/components/ui/SearchDropdown.jsx
Normal file
254
frontend/src/components/ui/SearchDropdown.jsx
Normal 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;
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
167
frontend/src/constants/formatGroups.js
Normal file
167
frontend/src/constants/formatGroups.js
Normal 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;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user