diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3376edf..2fc5418 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -5,7 +5,9 @@ from .regex import bp as regex_bp from .format import bp as format_bp from .profile import bp as profile_bp from .git import bp as git_bp +from .arr import bp as arr_bp from .settings_utils import create_empty_settings_if_not_exists, load_settings +from .db import init_db REGEX_DIR = os.path.join('data', 'db', 'regex_patterns') FORMAT_DIR = os.path.join('data', 'db', 'custom_formats') @@ -21,11 +23,14 @@ def create_app(): initialize_directories() create_empty_settings_if_not_exists() + init_db() + # Register Blueprints app.register_blueprint(regex_bp) app.register_blueprint(format_bp) app.register_blueprint(profile_bp) app.register_blueprint(git_bp) + app.register_blueprint(arr_bp) # Add settings route @app.route('/settings', methods=['GET']) diff --git a/backend/app/arr/__init__.py b/backend/app/arr/__init__.py new file mode 100644 index 0000000..fdbea2a --- /dev/null +++ b/backend/app/arr/__init__.py @@ -0,0 +1,114 @@ +from flask import Blueprint, request, jsonify +from flask_cors import cross_origin +import logging +from .status.ping import ping_service +from .manager import (save_arr_config, get_all_arr_configs, get_arr_config, + update_arr_config, delete_arr_config) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +bp = Blueprint('arr', __name__, url_prefix='/arr') + + +@bp.route('/ping', methods=['POST', 'OPTIONS']) +@cross_origin() +def ping(): + if request.method == 'OPTIONS': + return jsonify({}), 200 + + data = request.get_json() + url = data.get('url') + api_key = data.get('apiKey') + arr_type = data.get('type') + + if not url or not api_key or not arr_type: + return jsonify({ + 'success': False, + 'error': 'URL, API key, and type are required' + }), 400 + + logger.debug(f"Attempting to ping URL: {url} of type: {arr_type}") + success, message = ping_service(url, api_key, arr_type) + logger.debug(f"Ping result - Success: {success}, Message: {message}") + + return jsonify({ + 'success': success, + 'message': message + }), 200 if success else 400 + + +@bp.route('/config', methods=['POST', 'OPTIONS']) +@cross_origin() +def add_config(): + if request.method == 'OPTIONS': + return jsonify({}), 200 + + try: + config = request.json + id = save_arr_config(config) + logger.debug(f"Saved new arr config with ID: {id}") + return jsonify({'success': True, 'id': id}), 200 + except Exception as e: + logger.error(f"Error saving arr config: {str(e)}") + return jsonify({'success': False, 'error': str(e)}), 400 + + +@bp.route('/config', methods=['GET', 'OPTIONS']) +@cross_origin() +def get_configs(): + if request.method == 'OPTIONS': + return jsonify({}), 200 + + try: + configs = get_all_arr_configs() + logger.debug(f"Retrieved {len(configs)} arr configs") + return jsonify(configs), 200 + except Exception as e: + logger.error(f"Error getting arr configs: {str(e)}") + return jsonify({'success': False, 'error': str(e)}), 400 + + +@bp.route('/config/', methods=['GET', 'PUT', 'DELETE', 'OPTIONS']) +@cross_origin() +def handle_config(id): + if request.method == 'OPTIONS': + return jsonify({}), 200 + + try: + if request.method == 'GET': + config = get_arr_config(id) + if config: + logger.debug(f"Retrieved arr config: {id}") + return jsonify({'success': True, 'data': config}), 200 + logger.debug(f"Arr config not found: {id}") + return jsonify({ + 'success': False, + 'error': 'Config not found' + }), 404 + + elif request.method == 'PUT': + success = update_arr_config(id, request.json) + if success: + logger.debug(f"Updated arr config: {id}") + return jsonify({'success': True}), 200 + logger.debug(f"Arr config not found for update: {id}") + return jsonify({ + 'success': False, + 'error': 'Config not found' + }), 404 + + elif request.method == 'DELETE': + success = delete_arr_config(id) + if success: + logger.debug(f"Deleted arr config: {id}") + return jsonify({'success': True}), 200 + logger.debug(f"Arr config not found for deletion: {id}") + return jsonify({ + 'success': False, + 'error': 'Config not found' + }), 404 + + except Exception as e: + logger.error(f"Error handling arr config {id}: {str(e)}") + return jsonify({'success': False, 'error': str(e)}), 400 diff --git a/backend/app/arr/manager.py b/backend/app/arr/manager.py new file mode 100644 index 0000000..40f3123 --- /dev/null +++ b/backend/app/arr/manager.py @@ -0,0 +1,107 @@ +from ..db import get_db +import json +import logging + +logger = logging.getLogger(__name__) + + +def save_arr_config(config): + """Save a new arr configuration""" + with get_db() as conn: + cursor = conn.cursor() + try: + cursor.execute( + ''' + INSERT INTO arr_config (name, type, tags, profilarr_server, arr_server, api_key) + VALUES (?, ?, ?, ?, ?, ?) + ''', (config['name'], config['type'], json.dumps( + config['tags']), config['profilarrServer'], + config['arrServer'], config['apiKey'])) + conn.commit() + return {'success': True, 'id': cursor.lastrowid} + except Exception as e: + logger.error(f"Error saving arr config: {str(e)}") + return {'success': False, 'error': str(e)} + + +def get_all_arr_configs(): + """Get all arr configurations""" + with get_db() as conn: + cursor = conn.execute('SELECT * FROM arr_config') + rows = cursor.fetchall() + try: + configs = [{ + 'id': row['id'], + 'name': row['name'], + 'type': row['type'], + 'tags': json.loads(row['tags']), + 'profilarrServer': row['profilarr_server'], + 'arrServer': row['arr_server'], + 'apiKey': row['api_key'] + } for row in rows] + response = {'success': True, 'data': configs} + logger.debug(f"Sending response: {response}") + return response + except Exception as e: + logger.error(f"Error getting arr configs: {str(e)}") + return {'success': False, 'error': str(e)} + + +def get_arr_config(id): + """Get a specific arr configuration""" + with get_db() as conn: + cursor = conn.execute('SELECT * FROM arr_config WHERE id = ?', (id, )) + row = cursor.fetchone() + try: + if row: + config = { + 'id': row['id'], + 'name': row['name'], + 'type': row['type'], + 'tags': json.loads(row['tags']), + 'profilarrServer': row['profilarr_server'], + 'arrServer': row['arr_server'], + 'apiKey': row['api_key'] + } + return {'success': True, 'data': config} + return {'success': False, 'error': 'Configuration not found'} + except Exception as e: + logger.error(f"Error getting arr config: {str(e)}") + return {'success': False, 'error': str(e)} + + +def update_arr_config(id, config): + """Update an existing arr configuration""" + with get_db() as conn: + cursor = conn.cursor() + try: + cursor.execute( + ''' + UPDATE arr_config + SET name = ?, type = ?, tags = ?, profilarr_server = ?, arr_server = ?, api_key = ? + WHERE id = ? + ''', (config['name'], config['type'], json.dumps( + config['tags']), config['profilarrServer'], + config['arrServer'], config['apiKey'], id)) + conn.commit() + if cursor.rowcount > 0: + return {'success': True} + return {'success': False, 'error': 'Configuration not found'} + except Exception as e: + logger.error(f"Error updating arr config: {str(e)}") + return {'success': False, 'error': str(e)} + + +def delete_arr_config(id): + """Delete an arr configuration""" + with get_db() as conn: + cursor = conn.cursor() + try: + cursor.execute('DELETE FROM arr_config WHERE id = ?', (id, )) + conn.commit() + if cursor.rowcount > 0: + return {'success': True} + return {'success': False, 'error': 'Configuration not found'} + except Exception as e: + logger.error(f"Error deleting arr config: {str(e)}") + return {'success': False, 'error': str(e)} diff --git a/backend/app/arr/status/ping.py b/backend/app/arr/status/ping.py new file mode 100644 index 0000000..bda0496 --- /dev/null +++ b/backend/app/arr/status/ping.py @@ -0,0 +1,44 @@ +# app/arr/status/ping.py +import socket +import requests +import logging + +logger = logging.getLogger(__name__) + + +def ping_service(url, api_key, arr_type): + """ + Ping an Arr service and verify its type + """ + try: + # Clean up URL + base_url = url.rstrip('/') + headers = {'X-Api-Key': api_key} + + # First check if service is responsive + response = requests.get(f"{base_url}/api/v3/system/status", + headers=headers, + timeout=10) + + if response.status_code != 200: + return False, f"Service returned status code: {response.status_code}" + + data = response.json() + + # Verify the application type + app_name = data.get('appName', '').lower() + + if arr_type == 'radarr' and app_name != 'radarr': + return False, f"Expected Radarr but found {app_name}" + elif arr_type == 'sonarr' and app_name != 'sonarr': + return False, f"Expected Sonarr but found {app_name}" + + return True, "Connection successful and application type verified" + + except requests.exceptions.Timeout: + return False, "Connection timed out" + except requests.exceptions.ConnectionError: + return False, "Failed to connect to service" + except Exception as e: + logger.error(f"Error pinging service: {str(e)}") + return False, f"Error: {str(e)}" diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..d777633 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,28 @@ +import sqlite3 +import os + +DB_PATH = '/app/data/profilarr.db' + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + with get_db() as conn: + conn.execute(''' + CREATE TABLE IF NOT EXISTS arr_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + tags TEXT, + profilarr_server TEXT NOT NULL, + arr_server TEXT NOT NULL, + api_key TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() diff --git a/frontend/src/api/arr.js b/frontend/src/api/arr.js new file mode 100644 index 0000000..61c303b --- /dev/null +++ b/frontend/src/api/arr.js @@ -0,0 +1,79 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + +export const pingService = async (url, apiKey, type) => { + try { + const response = await axios.post( + `${API_BASE_URL}/arr/ping`, + { + url, + apiKey, + type + }, + { + validateStatus: status => { + return (status >= 200 && status < 300) || status === 400; + } + } + ); + return response.data; + } catch (error) { + console.error('Error pinging service:', error); + if (error.response?.data) { + return { + success: false, + message: error.response.data.error + }; + } + return { + success: false, + message: 'Failed to ping service' + }; + } +}; + +export const saveArrConfig = async config => { + try { + const response = await axios.post(`${API_BASE_URL}/arr/config`, config); + return response.data; + } catch (error) { + console.error('Error saving arr config:', error); + throw error; + } +}; + +export const getArrConfigs = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/arr/config`); + console.log('Raw axios response:', response); + console.log('Response data:', response.data); + return response.data; // This is correct - don't change this + } catch (error) { + console.error('Error fetching arr configs:', error); + throw error; + } +}; + +export const updateArrConfig = async (id, config) => { + try { + const response = await axios.put( + `${API_BASE_URL}/arr/config/${id}`, + config + ); + return response.data; + } catch (error) { + console.error('Error updating arr config:', error); + throw error; + } +}; + +export const deleteArrConfig = async id => { + try { + const response = await axios.delete(`${API_BASE_URL}/arr/config/${id}`); + return response.data; + } catch (error) { + console.error('Error deleting arr config:', error); + throw error; + } +}; diff --git a/frontend/src/components/format/FormatModal.jsx b/frontend/src/components/format/FormatModal.jsx index 1e8186e..593b0c9 100644 --- a/frontend/src/components/format/FormatModal.jsx +++ b/frontend/src/components/format/FormatModal.jsx @@ -1,284 +1,287 @@ -import { useState, useEffect } from "react"; -import PropTypes from "prop-types"; -import { saveFormat, deleteFormat, getRegexes } from "../../api/api"; -import ConditionModal from "../condition/ConditionModal"; -import ConditionCard from "../condition/ConditionCard"; -import Modal from "../ui/Modal"; -import Alert from "../ui/Alert"; +import {useState, useEffect} from 'react'; +import PropTypes from 'prop-types'; +import {saveFormat, deleteFormat, getRegexes} from '../../api/api'; +import ConditionModal from '../condition/ConditionModal'; +import ConditionCard from '../condition/ConditionCard'; +import Modal from '../ui/Modal'; +import Alert from '../ui/Alert'; function FormatModal({ - format: initialFormat, - isOpen, - onClose, - onSave, - isCloning = false, + format: initialFormat, + isOpen, + onClose, + onSave, + isCloning = false }) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [conditions, setConditions] = useState([]); - const [isConditionModalOpen, setIsConditionModalOpen] = useState(false); - const [selectedCondition, setSelectedCondition] = useState(null); - const [regexes, setRegexes] = useState([]); - const [error, setError] = useState(""); - const [tags, setTags] = useState([]); - const [newTag, setNewTag] = useState(""); - const [isDeleting, setIsDeleting] = useState(false); - const [modalTitle, setModalTitle] = useState(""); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [conditions, setConditions] = useState([]); + const [isConditionModalOpen, setIsConditionModalOpen] = useState(false); + const [selectedCondition, setSelectedCondition] = useState(null); + const [regexes, setRegexes] = useState([]); + const [error, setError] = useState(''); + const [tags, setTags] = useState([]); + const [newTag, setNewTag] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [modalTitle, setModalTitle] = useState(''); - useEffect(() => { - if (isOpen) { - if (isCloning) { - setModalTitle("Clone Custom Format"); - } else if (initialFormat && initialFormat.id !== 0) { - setModalTitle("Edit Custom Format"); - } else { - setModalTitle("Add Custom Format"); - } + useEffect(() => { + if (isOpen) { + if (isCloning) { + setModalTitle('Clone Custom Format'); + } else if (initialFormat && initialFormat.id !== 0) { + setModalTitle('Edit Custom Format'); + } else { + setModalTitle('Add Custom Format'); + } - if (initialFormat && (initialFormat.id !== 0 || isCloning)) { - setName(initialFormat.name); - setDescription(initialFormat.description); - setConditions(initialFormat.conditions || []); - setTags(initialFormat.tags || []); - } else { - setName(""); - setDescription(""); - setConditions([]); - setTags([]); - } + if (initialFormat && (initialFormat.id !== 0 || isCloning)) { + setName(initialFormat.name); + setDescription(initialFormat.description); + setConditions(initialFormat.conditions || []); + setTags(initialFormat.tags || []); + } else { + setName(''); + setDescription(''); + setConditions([]); + setTags([]); + } - setError(""); - setNewTag(""); - setIsDeleting(false); - fetchRegexes(); - } - }, [isOpen, initialFormat, isCloning]); - - const fetchRegexes = async () => { - try { - const fetchedRegexes = await getRegexes(); - setRegexes(fetchedRegexes); - } catch (error) { - console.error("Error fetching regexes:", error); - setError("Failed to fetch regexes. Please try again."); - } - }; - - const handleSave = async () => { - if (!name.trim() || !description.trim() || conditions.length === 0) { - setError("Name, description, and at least one condition are required."); - return; - } - try { - await saveFormat({ - id: initialFormat ? initialFormat.id : 0, - name, - description, - conditions, - tags, - }); - onSave(); - onClose(); - } catch (error) { - console.error("Error saving format:", error); - setError("Failed to save format. Please try again."); - } - }; - - const handleDelete = async () => { - if (isDeleting) { - try { - console.log("Attempting to delete format with ID:", initialFormat.id); - const response = await deleteFormat(initialFormat.id); - console.log("Delete response:", response); - if (response.error) { - Alert.error(`Cannot delete: ${response.message}`); - } else { - Alert.success("Format deleted successfully"); - onSave(); - onClose(); + setError(''); + setNewTag(''); + setIsDeleting(false); + fetchRegexes(); } - } catch (error) { - console.error("Error deleting format:", error); - Alert.error("Failed to delete format. Please try again."); - } finally { - setIsDeleting(false); - } - } else { - setIsDeleting(true); - } - }; + }, [isOpen, initialFormat, isCloning]); - const handleOpenConditionModal = (condition = null) => { - setSelectedCondition(condition); - setIsConditionModalOpen(true); - }; + const fetchRegexes = async () => { + try { + const fetchedRegexes = await getRegexes(); + setRegexes(fetchedRegexes); + } catch (error) { + console.error('Error fetching regexes:', error); + setError('Failed to fetch regexes. Please try again.'); + } + }; - const handleCloseConditionModal = () => { - setIsConditionModalOpen(false); - }; + const handleSave = async () => { + if (!name.trim() || !description.trim() || conditions.length === 0) { + setError( + 'Name, description, and at least one condition are required.' + ); + return; + } + try { + await saveFormat({ + id: initialFormat ? initialFormat.id : 0, + name, + description, + conditions, + tags + }); + onSave(); + onClose(); + } catch (error) { + console.error('Error saving format:', error); + setError('Failed to save format. Please try again.'); + } + }; - const handleSaveCondition = (newCondition) => { - if (selectedCondition) { - setConditions( - conditions.map((c) => (c === selectedCondition ? newCondition : c)) - ); - } else { - setConditions([...conditions, newCondition]); - } - setIsConditionModalOpen(false); - }; + const handleDelete = async () => { + if (isDeleting) { + try { + console.log( + 'Attempting to delete format with ID:', + initialFormat.id + ); + const response = await deleteFormat(initialFormat.id); + console.log('Delete response:', response); + if (response.error) { + Alert.error(`Cannot delete: ${response.message}`); + } else { + Alert.success('Format deleted successfully'); + onSave(); + onClose(); + } + } catch (error) { + console.error('Error deleting format:', error); + Alert.error('Failed to delete format. Please try again.'); + } finally { + setIsDeleting(false); + } + } else { + setIsDeleting(true); + } + }; - const handleDeleteCondition = (conditionToDelete) => { - setConditions(conditions.filter((c) => c !== conditionToDelete)); - }; + const handleOpenConditionModal = (condition = null) => { + setSelectedCondition(condition); + setIsConditionModalOpen(true); + }; - const handleAddTag = () => { - if (newTag.trim() && !tags.includes(newTag.trim())) { - setTags([...tags, newTag.trim()]); - setNewTag(""); - } - }; + const handleCloseConditionModal = () => { + setIsConditionModalOpen(false); + }; - const handleRemoveTag = (tagToRemove) => { - setTags(tags.filter((tag) => tag !== tagToRemove)); - }; + const handleSaveCondition = newCondition => { + if (selectedCondition) { + setConditions( + conditions.map(c => + c === selectedCondition ? newCondition : c + ) + ); + } else { + setConditions([...conditions, newCondition]); + } + setIsConditionModalOpen(false); + }; - return ( - <> - - {error &&
{error}
} -
- - setName(e.target.value)} - placeholder="Enter format name" - className="w-full p-3 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> -
-
- - setDescription(e.target.value)} - placeholder="Enter description" - className="w-full p-3 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> -
-
- -
- {tags.map((tag) => ( - - {tag} + const handleDeleteCondition = conditionToDelete => { + setConditions(conditions.filter(c => c !== conditionToDelete)); + }; + + const handleAddTag = () => { + if (newTag.trim() && !tags.includes(newTag.trim())) { + setTags([...tags, newTag.trim()]); + setNewTag(''); + } + }; + + const handleRemoveTag = tagToRemove => { + setTags(tags.filter(tag => tag !== tagToRemove)); + }; + + return ( + <> + + {error &&
{error}
} +
+ + setName(e.target.value)} + placeholder='Enter format name' + className='w-full p-3 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600' + /> +
+
+ + setDescription(e.target.value)} + placeholder='Enter description' + className='w-full p-3 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600' + /> +
+
+ +
+ {tags.map(tag => ( + + {tag} + + + ))} +
+
+ setNewTag(e.target.value)} + placeholder='Add a tag' + className='flex-grow p-2 border rounded-l dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600' + /> + +
+
+

+ Conditions: +

+
+ {conditions.map((condition, index) => ( + handleOpenConditionModal(condition)} + /> + ))} +
-
- ))} -
-
- setNewTag(e.target.value)} - placeholder="Add a tag" - className="flex-grow p-2 border rounded-l dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> - -
-
-

Conditions:

-
- {conditions.map((condition, index) => ( - handleOpenConditionModal(condition)} - /> - ))} -
- -
- - {initialFormat && initialFormat.id !== 0 && ( - - )} -
- -
- - ); +
+ + {initialFormat && initialFormat.id !== 0 && ( + + )} +
+ + + + ); } FormatModal.propTypes = { - format: PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - conditions: PropTypes.arrayOf( - PropTypes.shape({ + format: PropTypes.shape({ + id: PropTypes.number, name: PropTypes.string.isRequired, - negate: PropTypes.bool, - required: PropTypes.bool, - }) - ).isRequired, - tags: PropTypes.arrayOf(PropTypes.string), - }), - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onSave: PropTypes.func.isRequired, - isCloning: PropTypes.bool, + description: PropTypes.string.isRequired, + conditions: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + negate: PropTypes.bool, + required: PropTypes.bool + }) + ).isRequired, + tags: PropTypes.arrayOf(PropTypes.string) + }), + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + isCloning: PropTypes.bool }; export default FormatModal; diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx index 623afac..614ef9f 100644 --- a/frontend/src/components/profile/ProfileModal.jsx +++ b/frontend/src/components/profile/ProfileModal.jsx @@ -265,7 +265,12 @@ function ProfileModal({ }; return ( - + {loading ? (
diff --git a/frontend/src/components/regex/RegexModal.jsx b/frontend/src/components/regex/RegexModal.jsx index 35a0504..9995949 100644 --- a/frontend/src/components/regex/RegexModal.jsx +++ b/frontend/src/components/regex/RegexModal.jsx @@ -1,344 +1,348 @@ -import { useState, useEffect, useRef } from "react"; -import PropTypes from "prop-types"; -import { saveRegex, deleteRegex, createRegex101Link } from "../../api/api"; -import Modal from "../ui/Modal"; -import Alert from "../ui/Alert"; +import {useState, useEffect, useRef} from 'react'; +import PropTypes from 'prop-types'; +import {saveRegex, deleteRegex, createRegex101Link} from '../../api/api'; +import Modal from '../ui/Modal'; +import Alert from '../ui/Alert'; function unsanitize(text) { - return text.replace(/\\:/g, ":").replace(/\\n/g, "\n"); + return text.replace(/\\:/g, ':').replace(/\\n/g, '\n'); } function RegexModal({ - regex: initialRegex, - isOpen, - onClose, - onSave, - isCloning = false, + regex: initialRegex, + isOpen, + onClose, + onSave, + isCloning = false }) { - const [name, setName] = useState(""); - const [pattern, setPattern] = useState(""); - const [description, setDescription] = useState(""); - const [tags, setTags] = useState([]); - const [newTag, setNewTag] = useState(""); - const [regex101Link, setRegex101Link] = useState(""); - const [error, setError] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [modalTitle, setModalTitle] = useState(""); + const [name, setName] = useState(''); + const [pattern, setPattern] = useState(''); + const [description, setDescription] = useState(''); + const [tags, setTags] = useState([]); + const [newTag, setNewTag] = useState(''); + const [regex101Link, setRegex101Link] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [modalTitle, setModalTitle] = useState(''); - useEffect(() => { - if (isOpen) { - // Set the modal title - if (isCloning) { - setModalTitle("Clone Regex Pattern"); - } else if (initialRegex && initialRegex.id !== 0) { - setModalTitle("Edit Regex Pattern"); - } else { - setModalTitle("Add Regex Pattern"); - } + useEffect(() => { + if (isOpen) { + // Set the modal title + if (isCloning) { + setModalTitle('Clone Regex Pattern'); + } else if (initialRegex && initialRegex.id !== 0) { + setModalTitle('Edit Regex Pattern'); + } else { + setModalTitle('Add Regex Pattern'); + } - if (initialRegex && (initialRegex.id !== 0 || isCloning)) { - setName(unsanitize(initialRegex.name)); - setPattern(initialRegex.pattern); - setDescription(unsanitize(initialRegex.description)); - setTags(initialRegex.tags ? initialRegex.tags.map(unsanitize) : []); - setRegex101Link(initialRegex.regex101Link || ""); - } else { - setName(""); - setPattern(""); - setDescription(""); - setTags([]); - setRegex101Link(""); - } - setError(""); - setNewTag(""); - setIsLoading(false); - setIsDeleting(false); - } - }, [initialRegex, isOpen, isCloning]); - - const handleCreateRegex101Link = async () => { - if (!pattern.trim()) { - setError("Please provide a regex pattern before creating tests."); - return; - } - - const unitTests = [ - { - description: "Test if 'D-Z0N3' is detected correctly", - testString: "Test D-Z0N3 pattern", - criteria: "DOES_MATCH", - target: "REGEX", - }, - { - description: "Test if 'random text' does not match", - testString: "random text", - criteria: "DOES_NOT_MATCH", - target: "REGEX", - }, - ]; - - setIsLoading(true); - try { - const response = await createRegex101Link({ - regex: pattern, - flavor: "pcre", - flags: "gmi", - delimiter: "/", - unitTests: unitTests, - }); - const permalinkFragment = response.permalinkFragment; - - const regex101Link = `https://regex101.com/r/${permalinkFragment}`; - setRegex101Link(regex101Link); - - await saveRegex({ - id: regex ? regex.id : 0, - name, - pattern, - description, - tags, - regex101Link, - }); - - window.open(regex101Link, "_blank"); - onSave(); - setError(""); - } catch (error) { - console.error("Error creating regex101 link:", error); - setError("Failed to create regex101 link. Please try again."); - } finally { - setIsLoading(false); - } - }; - - const handleRemoveRegex101Link = async () => { - const confirmRemoval = window.confirm( - "Are you sure you want to remove this Regex101 link?" - ); - if (!confirmRemoval) return; - - setIsLoading(true); - setRegex101Link(""); - - try { - await saveRegex({ - id: regex ? regex.id : 0, - name, - pattern, - description, - tags, - regex101Link: "", - }); - - onSave(); - setError(""); - } catch (error) { - console.error("Error removing regex101 link:", error); - setError("Failed to remove regex101 link. Please try again."); - } finally { - setIsLoading(false); - } - }; - - const handleSave = async () => { - if (!name.trim() || !pattern.trim()) { - setError("Name and pattern are required."); - return; - } - try { - await saveRegex({ - id: initialRegex ? initialRegex.id : 0, - name, - pattern, - description, - tags, - regex101Link, - }); - onSave(); - onClose(); - } catch (error) { - console.error("Error saving regex:", error); - setError("Failed to save regex. Please try again."); - } - }; - - const handleDelete = async () => { - if (isDeleting) { - try { - console.log("Attempting to delete regex with ID:", initialRegex.id); - const response = await deleteRegex(initialRegex.id); - console.log("Delete response:", response); - if (response.error) { - Alert.error(`Cannot delete: ${response.message}`); - } else { - Alert.success("Regex deleted successfully"); - onSave(); - onClose(); + if (initialRegex && (initialRegex.id !== 0 || isCloning)) { + setName(unsanitize(initialRegex.name)); + setPattern(initialRegex.pattern); + setDescription(unsanitize(initialRegex.description)); + setTags( + initialRegex.tags ? initialRegex.tags.map(unsanitize) : [] + ); + setRegex101Link(initialRegex.regex101Link || ''); + } else { + setName(''); + setPattern(''); + setDescription(''); + setTags([]); + setRegex101Link(''); + } + setError(''); + setNewTag(''); + setIsLoading(false); + setIsDeleting(false); } - } catch (error) { - console.error("Error deleting regex:", error); - Alert.error("Failed to delete regex. Please try again."); - } finally { - setIsDeleting(false); - } - } else { - setIsDeleting(true); - } - }; + }, [initialRegex, isOpen, isCloning]); - const handleAddTag = () => { - if (newTag.trim() && !tags.includes(newTag.trim())) { - setTags([...tags, newTag.trim()]); - setNewTag(""); - } - }; + const handleCreateRegex101Link = async () => { + if (!pattern.trim()) { + setError('Please provide a regex pattern before creating tests.'); + return; + } - const handleRemoveTag = (tagToRemove) => { - setTags(tags.filter((tag) => tag !== tagToRemove)); - }; + const unitTests = [ + { + description: "Test if 'D-Z0N3' is detected correctly", + testString: 'Test D-Z0N3 pattern', + criteria: 'DOES_MATCH', + target: 'REGEX' + }, + { + description: "Test if 'random text' does not match", + testString: 'random text', + criteria: 'DOES_NOT_MATCH', + target: 'REGEX' + } + ]; - return ( - <> - - {error &&
{error}
} -
- - setName(e.target.value)} - placeholder="Enter regex name" - className="w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> -
-
- - setPattern(e.target.value)} - placeholder="Enter regex pattern" - className="w-full p-2 border rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> -
-
- {regex101Link ? ( -
- - View in Regex101 - - -
- ) : ( - - )} -
-
- - setDescription(e.target.value)} - placeholder="Enter description" - className="w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> -
-
- -
- {tags.map((tag) => ( - - {tag} - - - ))} -
-
- setNewTag(e.target.value)} - placeholder="Add a tag" - className="flex-grow p-2 border rounded-l dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600" - /> - -
-
-
- {initialRegex && ( - - )} - -
-
- - ); + setIsLoading(true); + try { + const response = await createRegex101Link({ + regex: pattern, + flavor: 'pcre', + flags: 'gmi', + delimiter: '/', + unitTests: unitTests + }); + const permalinkFragment = response.permalinkFragment; + + const regex101Link = `https://regex101.com/r/${permalinkFragment}`; + setRegex101Link(regex101Link); + + await saveRegex({ + id: regex ? regex.id : 0, + name, + pattern, + description, + tags, + regex101Link + }); + + window.open(regex101Link, '_blank'); + onSave(); + setError(''); + } catch (error) { + console.error('Error creating regex101 link:', error); + setError('Failed to create regex101 link. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleRemoveRegex101Link = async () => { + const confirmRemoval = window.confirm( + 'Are you sure you want to remove this Regex101 link?' + ); + if (!confirmRemoval) return; + + setIsLoading(true); + setRegex101Link(''); + + try { + await saveRegex({ + id: regex ? regex.id : 0, + name, + pattern, + description, + tags, + regex101Link: '' + }); + + onSave(); + setError(''); + } catch (error) { + console.error('Error removing regex101 link:', error); + setError('Failed to remove regex101 link. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleSave = async () => { + if (!name.trim() || !pattern.trim()) { + setError('Name and pattern are required.'); + return; + } + try { + await saveRegex({ + id: initialRegex ? initialRegex.id : 0, + name, + pattern, + description, + tags, + regex101Link + }); + onSave(); + onClose(); + } catch (error) { + console.error('Error saving regex:', error); + setError('Failed to save regex. Please try again.'); + } + }; + + const handleDelete = async () => { + if (isDeleting) { + try { + console.log( + 'Attempting to delete regex with ID:', + initialRegex.id + ); + const response = await deleteRegex(initialRegex.id); + console.log('Delete response:', response); + if (response.error) { + Alert.error(`Cannot delete: ${response.message}`); + } else { + Alert.success('Regex deleted successfully'); + onSave(); + onClose(); + } + } catch (error) { + console.error('Error deleting regex:', error); + Alert.error('Failed to delete regex. Please try again.'); + } finally { + setIsDeleting(false); + } + } else { + setIsDeleting(true); + } + }; + + const handleAddTag = () => { + if (newTag.trim() && !tags.includes(newTag.trim())) { + setTags([...tags, newTag.trim()]); + setNewTag(''); + } + }; + + const handleRemoveTag = tagToRemove => { + setTags(tags.filter(tag => tag !== tagToRemove)); + }; + + return ( + <> + + {error &&
{error}
} +
+ + setName(e.target.value)} + placeholder='Enter regex name' + className='w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600' + /> +
+
+ + setPattern(e.target.value)} + placeholder='Enter regex pattern' + className='w-full p-2 border rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600' + /> +
+
+ {regex101Link ? ( +
+ + View in Regex101 + + +
+ ) : ( + + )} +
+
+ + setDescription(e.target.value)} + placeholder='Enter description' + className='w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600' + /> +
+
+ +
+ {tags.map(tag => ( + + {tag} + + + ))} +
+
+ setNewTag(e.target.value)} + placeholder='Add a tag' + className='flex-grow p-2 border rounded-l dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600' + /> + +
+
+
+ {initialRegex && ( + + )} + +
+
+ + ); } RegexModal.propTypes = { - regex: PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string.isRequired, - pattern: PropTypes.string.isRequired, - description: PropTypes.string, - tags: PropTypes.arrayOf(PropTypes.string), - regex101Link: PropTypes.string, - }), - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onSave: PropTypes.func.isRequired, - isCloning: PropTypes.bool, + regex: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string.isRequired, + pattern: PropTypes.string.isRequired, + description: PropTypes.string, + tags: PropTypes.arrayOf(PropTypes.string), + regex101Link: PropTypes.string + }), + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + isCloning: PropTypes.bool }; export default RegexModal; diff --git a/frontend/src/components/settings/arrs/ArrCard.jsx b/frontend/src/components/settings/arrs/ArrCard.jsx index 35e8d5d..31f4829 100644 --- a/frontend/src/components/settings/arrs/ArrCard.jsx +++ b/frontend/src/components/settings/arrs/ArrCard.jsx @@ -1,24 +1,117 @@ -// ArrCard.jsx -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {useState, useEffect} from 'react'; +import {Film, Tv, Headphones, Clock, Database, CheckCircle} from 'lucide-react'; +import {pingService} from '../../../api/arr'; -const ArrCard = ({title, icon: Icon, onClick}) => ( -
-
- -

- {title} -

+const ArrCard = ({title, type, serverUrl, apiKey, tags = [], onClick}) => { + const [status, setStatus] = useState('unknown'); + const [isChecking, setIsChecking] = useState(false); + + const sampleData = { + lastSync: new Date(Date.now() - 1000 * 60 * 30), + syncStatus: { + available: 1250, + imported: 1250, + percentage: 12 + } + }; + + const checkStatus = async () => { + setIsChecking(true); + try { + const result = await pingService(serverUrl, apiKey, type); + setStatus(result.success ? 'online' : 'offline'); + } catch { + setStatus('offline'); + } finally { + setIsChecking(false); + } + }; + + useEffect(() => { + checkStatus(); + const interval = setInterval(checkStatus, 60000); + return () => clearInterval(interval); + }, [serverUrl, apiKey]); + + const getIcon = () => { + const lowerName = title.toLowerCase(); + if (lowerName.includes('radarr')) return Film; + if (lowerName.includes('sonarr')) return Tv; + if (lowerName.includes('lidarr')) return Headphones; + return Film; + }; + + const Icon = getIcon(); + + const getStatusColor = () => { + if (isChecking) return 'bg-yellow-400'; + switch (status) { + case 'online': + return 'bg-emerald-400'; + case 'offline': + return 'bg-red-400'; + default: + return 'bg-gray-400'; + } + }; + + const formatTimeAgo = date => { + const minutes = Math.floor((Date.now() - date.getTime()) / 60000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; + }; + + return ( +
+ {/* Main Content */} +
+ {/* Header */} +
+
+ + + {title} + +
+
+
+ {tags.map(tag => ( + + {tag} + + ))} +
+
+ + {/* Footer Stats */} +
+
+ + {sampleData.syncStatus.percentage}% + {sampleData.syncStatus.percentage === 100 && ( + + )} +
+
+ + + Last Synced: {formatTimeAgo(sampleData.lastSync)} + +
+
+
-
-); - -ArrCard.propTypes = { - title: PropTypes.string.isRequired, - icon: PropTypes.elementType.isRequired, - onClick: PropTypes.func.isRequired + ); }; export default ArrCard; diff --git a/frontend/src/components/settings/arrs/ArrContainer.jsx b/frontend/src/components/settings/arrs/ArrContainer.jsx index 6a7e33f..6aa264b 100644 --- a/frontend/src/components/settings/arrs/ArrContainer.jsx +++ b/frontend/src/components/settings/arrs/ArrContainer.jsx @@ -1,11 +1,41 @@ -// ArrContainer.jsx -import React, {useState} from 'react'; -import AddNewCard from '../../ui/AddNewCard'; +import React, {useState, useEffect} from 'react'; +import {Loader, Plus} from 'lucide-react'; import ArrModal from './ArrModal'; +import ArrCard from './ArrCard'; +import {getArrConfigs} from '../../../api/arr'; const ArrContainer = () => { const [showModal, setShowModal] = useState(false); const [editingArr, setEditingArr] = useState(null); + const [arrs, setArrs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchArrs = async () => { + try { + setLoading(true); + setError(null); + const response = await getArrConfigs(); + if (response.success && Array.isArray(response.data)) { + setArrs(response.data); + } else { + setArrs([]); + setError( + response.error || 'Failed to fetch Arr configurations' + ); + } + } catch (error) { + console.error('Error fetching Arr configs:', error); + setArrs([]); + setError('An error occurred while fetching Arr configurations'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchArrs(); + }, []); const handleAddArr = () => { setEditingArr(null); @@ -22,35 +52,56 @@ const ArrContainer = () => { setEditingArr(null); }; - const handleSubmit = arrData => { - if (editingArr) { - console.log('Updating arr:', arrData); - // Implement your update logic here - } else { - console.log('Adding new arr:', arrData); - // Implement your add logic here - } - setShowModal(false); - setEditingArr(null); + const handleModalSubmit = () => { + fetchArrs(); + handleCloseModal(); }; - return ( - <> -
- + if (loading) { + return ( +
+
+ ); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ {arrs.map(arrConfig => ( + handleEditArr(arrConfig)} + /> + ))} + {/* Add New Card */} +
+
+ + Add New App +
+
+
+ - +
); }; diff --git a/frontend/src/components/settings/arrs/ArrModal.jsx b/frontend/src/components/settings/arrs/ArrModal.jsx index 2b13587..38952dc 100644 --- a/frontend/src/components/settings/arrs/ArrModal.jsx +++ b/frontend/src/components/settings/arrs/ArrModal.jsx @@ -1,128 +1,489 @@ -// ArrModal.js import React, {useState, useEffect} from 'react'; -import {Plus, TestTube, Loader, Save} from 'lucide-react'; +import {Plus, TestTube, Loader, Save, Tag, X, Trash, Check} from 'lucide-react'; import Modal from '../../ui/Modal'; +import Tooltip from '../../ui/Tooltip'; +import Alert from '../../ui/Alert'; +import { + pingService, + saveArrConfig, + updateArrConfig, + deleteArrConfig +} from '../../../api/arr'; const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { - const [arrType, setArrType] = useState(''); - const [arrName, setArrName] = useState(''); - const [apiKey, setApiKey] = useState(''); + const [formData, setFormData] = useState({ + name: '', + type: 'radarr', + tags: [], + profilarrServer: '', + arrServer: '', + apiKey: '' + }); + const [tagInput, setTagInput] = useState(''); + const [errors, setErrors] = useState({}); const [isTestingConnection, setIsTestingConnection] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [saveConfirm, setSaveConfirm] = useState(false); + const [testConfirm, setTestConfirm] = useState(false); + + const arrTypes = [ + {value: 'radarr', label: 'Radarr'}, + {value: 'sonarr', label: 'Sonarr'} + ]; useEffect(() => { if (editingArr) { - setArrType(editingArr.type); - setArrName(editingArr.name); - setApiKey(editingArr.apiKey); + setFormData(editingArr); } else { - setArrType(''); - setArrName(''); - setApiKey(''); + setFormData({ + name: '', + type: '', + tags: [], + profilarrServer: '', + arrServer: '', + apiKey: '' + }); } + setTagInput(''); + setErrors({}); + setDeleteConfirm(false); + setSaveConfirm(false); + setTestConfirm(false); }, [editingArr]); - const handleTestConnection = async () => { - setIsTestingConnection(true); + const validateUrl = url => { try { - const result = await testConnection(arrType, apiKey); - if (result.success) { - alert('Connection successful!'); - } else { - alert('Connection failed: ' + result.error); - } - } catch (error) { - alert('An error occurred while testing the connection.'); - console.error('Error testing connection:', error); - } finally { - setIsTestingConnection(false); + new URL(url); + return true; + } catch { + return false; } }; - const handleSubmit = e => { - e.preventDefault(); - onSubmit({type: arrType, name: arrName, apiKey}); + const validateForm = () => { + const newErrors = {}; + + if ( + formData.profilarrServer && + !validateUrl(formData.profilarrServer) + ) { + newErrors.profilarrServer = + 'Please enter a valid URL (e.g., http://localhost:7441)'; + } + + if (formData.arrServer && !validateUrl(formData.arrServer)) { + newErrors.arrServer = + 'Please enter a valid URL (e.g., http://localhost:7878)'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; }; + const handleTestConnection = async () => { + if (!validateForm()) return; + + if (testConfirm) { + setIsTestingConnection(true); + try { + const result = await pingService( + formData.arrServer, + formData.apiKey, + formData.type // Add type + ); + if (result.success) { + Alert.success('Connection successful!'); + } else { + Alert.error(`Connection failed: ${result.message}`); + } + } catch (error) { + Alert.error('An error occurred while testing the connection.'); + console.error('Error testing connection:', error); + } finally { + setIsTestingConnection(false); + setTestConfirm(false); + } + } else { + setTestConfirm(true); + setTimeout(() => setTestConfirm(false), 3000); + } + }; + + const handleSubmit = async e => { + e.preventDefault(); + if (!validateForm()) return; + + if (saveConfirm) { + try { + // Test connection with type verification + const testResult = await pingService( + formData.arrServer, + formData.apiKey, + formData.type // Add type + ); + + if (!testResult.success) { + Alert.error( + `Connection test failed: ${testResult.message}` + ); + setSaveConfirm(false); + return; + } + + // If connection test passed, proceed with save/update + if (editingArr) { + const result = await updateArrConfig( + editingArr.id, + formData + ); + if (result.success) { + Alert.success('Configuration updated successfully'); + onSubmit(); + } + } else { + const result = await saveArrConfig(formData); + if (result.success) { + Alert.success('Configuration saved successfully'); + onSubmit(); + } + } + } catch (error) { + Alert.error('Failed to save configuration'); + console.error('Error saving configuration:', error); + } + setSaveConfirm(false); + } else { + setSaveConfirm(true); + setTimeout(() => setSaveConfirm(false), 3000); + } + }; + + const handleDelete = async () => { + if (deleteConfirm) { + try { + const result = await deleteArrConfig(editingArr.id); + if (result.success) { + Alert.success('Configuration deleted successfully'); + onSubmit(); + } + } catch (error) { + Alert.error('Failed to delete configuration'); + console.error('Error deleting configuration:', error); + } + setDeleteConfirm(false); + } else { + setDeleteConfirm(true); + setTimeout(() => setDeleteConfirm(false), 3000); + } + }; + + const handleInputChange = e => { + const {id, value} = e.target; + setFormData(prev => ({ + ...prev, + [id]: value + })); + + if (errors[id]) { + setErrors(prev => ({ + ...prev, + [id]: '' + })); + } + }; + + const handleAddTag = e => { + e.preventDefault(); + if (tagInput.trim()) { + if (!formData.tags.includes(tagInput.trim())) { + setFormData(prev => ({ + ...prev, + tags: [...prev.tags, tagInput.trim()] + })); + } + setTagInput(''); + } + }; + + const handleRemoveTag = tagToRemove => { + setFormData(prev => ({ + ...prev, + tags: prev.tags.filter(tag => tag !== tagToRemove) + })); + }; + + const handleTagInputKeyDown = e => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + handleAddTag(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 + `; + return ( -
-
- + width='2xl'> + +
+
+ + + +
+ +
+ + {/* Add Type Selection */} +
+
+ + + +
-
- + + {/* Keep the rest of your existing form fields */} +
+ {/* Tags section */} +
+ + + +
+ +
+ {formData.tags.map((tag, index) => ( + + {tag} + + + ))} +
+ +
+ setTagInput(e.target.value)} + onKeyDown={handleTagInputKeyDown} + className={inputClasses('tagInput')} + placeholder='Enter tags...' + /> + +
+
+ + {/* Rest of the fields */} +
+
+ + + +
setArrName(e.target.value)} - className='mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white' + id='profilarrServer' + value={formData.profilarrServer} + onChange={handleInputChange} + className={inputClasses('profilarrServer')} + placeholder='http://localhost:7441' required /> + {errors.profilarrServer && ( +

+ {errors.profilarrServer} +

+ )}
-
- + +
+
+ + + +
+ {errors.arrServer && ( +

+ {errors.arrServer} +

+ )} +
+ +
+
+ + + +
+ setApiKey(e.target.value)} - className='mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white' + value={formData.apiKey} + onChange={handleInputChange} + className={inputClasses('apiKey')} + placeholder='Enter your API key' required />
-
+ +
+ {editingArr && ( + + )} + - + + diff --git a/frontend/src/components/settings/git/modal/ViewBranches.jsx b/frontend/src/components/settings/git/modal/ViewBranches.jsx index 3132255..9bc17a1 100644 --- a/frontend/src/components/settings/git/modal/ViewBranches.jsx +++ b/frontend/src/components/settings/git/modal/ViewBranches.jsx @@ -246,7 +246,8 @@ const SettingsBranchModal = ({ isOpen={isOpen} onClose={onClose} title='Manage Git Branches' - size='4xl'> + width='xl' + height='auto'>
e.stopPropagation()}>