feature: save external app connections (#8)

- SQLite DB added
- Store external app connections
- Authentication, tags, type, name implemented
This commit is contained in:
Sam Chau
2024-11-20 02:33:16 +10:30
committed by Sam Chau
parent 674bd2c313
commit 19c6be2f21
14 changed files with 1642 additions and 749 deletions

View File

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

114
backend/app/arr/__init__.py Normal file
View File

@@ -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/<int:id>', 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

107
backend/app/arr/manager.py Normal file
View File

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

View File

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

28
backend/app/db.py Normal file
View File

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

79
frontend/src/api/arr.js Normal file
View File

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

View File

@@ -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 (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={modalTitle}
className="max-w-3xl min-h-96"
>
{error && <div className="text-red-500 mb-4">{error}</div>}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Format Name
</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<input
type="text"
value={description}
onChange={(e) => 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"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tags
</label>
<div className="flex flex-wrap mb-2">
{tags.map((tag) => (
<span
key={tag}
className="bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300"
>
{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 (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={modalTitle}
height='auto'
width='xl'>
{error && <div className='text-red-500 mb-4'>{error}</div>}
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Format Name
</label>
<input
type='text'
value={name}
onChange={e => 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'
/>
</div>
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Description
</label>
<input
type='text'
value={description}
onChange={e => 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'
/>
</div>
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Tags
</label>
<div className='flex flex-wrap mb-2'>
{tags.map(tag => (
<span
key={tag}
className='bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300'>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className='ml-1 text-xs'>
&times;
</button>
</span>
))}
</div>
<div className='flex'>
<input
type='text'
value={newTag}
onChange={e => 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'
/>
<button
onClick={handleAddTag}
className='bg-blue-500 text-white px-4 py-2 rounded-r hover:bg-blue-600 transition-colors'>
Add
</button>
</div>
</div>
<h3 className='font-bold mb-4 dark:text-gray-300'>
Conditions:
</h3>
<div className='mb-6 max-h-96 overflow-y-auto grid grid-cols-1 gap-4'>
{conditions.map((condition, index) => (
<ConditionCard
key={index}
condition={condition}
onEdit={() => handleOpenConditionModal(condition)}
/>
))}
</div>
<button
onClick={() => handleRemoveTag(tag)}
className="ml-1 text-xs"
>
&times;
onClick={() => handleOpenConditionModal()}
className='bg-green-500 text-white px-4 py-3 rounded hover:bg-green-600 mb-6 transition-colors'>
Add Condition
</button>
</span>
))}
</div>
<div className="flex">
<input
type="text"
value={newTag}
onChange={(e) => 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"
/>
<button
onClick={handleAddTag}
className="bg-blue-500 text-white px-4 py-2 rounded-r hover:bg-blue-600 transition-colors"
>
Add
</button>
</div>
</div>
<h3 className="font-bold mb-4 dark:text-gray-300">Conditions:</h3>
<div className="mb-6 max-h-96 overflow-y-auto grid grid-cols-1 gap-4">
{conditions.map((condition, index) => (
<ConditionCard
key={index}
condition={condition}
onEdit={() => handleOpenConditionModal(condition)}
/>
))}
</div>
<button
onClick={() => handleOpenConditionModal()}
className="bg-green-500 text-white px-4 py-3 rounded hover:bg-green-600 mb-6 transition-colors"
>
Add Condition
</button>
<div className="flex justify-between mt-6">
<button
onClick={handleSave}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
>
Save
</button>
{initialFormat && initialFormat.id !== 0 && (
<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"}
</button>
)}
</div>
<ConditionModal
condition={selectedCondition}
isOpen={isConditionModalOpen}
onClose={handleCloseConditionModal}
onSave={handleSaveCondition}
onDelete={handleDeleteCondition}
regexes={regexes}
level={1}
/>
</Modal>
</>
);
<div className='flex justify-between mt-6'>
<button
onClick={handleSave}
className='bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors'>
Save
</button>
{initialFormat && initialFormat.id !== 0 && (
<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'}
</button>
)}
</div>
<ConditionModal
condition={selectedCondition}
isOpen={isConditionModalOpen}
onClose={handleCloseConditionModal}
onSave={handleSaveCondition}
onDelete={handleDeleteCondition}
regexes={regexes}
level={1}
/>
</Modal>
</>
);
}
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;

View File

@@ -265,7 +265,12 @@ function ProfileModal({
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle}>
<Modal
isOpen={isOpen}
onClose={onClose}
title={modalTitle}
height='4xl'
width='6xl'>
{loading ? (
<div className='flex justify-center items-center'>
<Loader size={24} className='animate-spin text-gray-300' />

View File

@@ -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 (
<>
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle}>
{error && <div className="text-red-500 mb-4">{error}</div>}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Regex Name
</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Regex Pattern
</label>
<input
type="text"
value={pattern}
onChange={(e) => 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"
/>
</div>
<div className="mb-4">
{regex101Link ? (
<div className="flex items-center space-x-2">
<a
href={regex101Link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 transition-colors"
>
View in Regex101
</a>
<button
onClick={handleRemoveRegex101Link}
className="text-red-500 hover:text-red-600 transition-colors"
disabled={isLoading}
>
{isLoading ? "Removing..." : "Remove Link"}
</button>
</div>
) : (
<button
onClick={handleCreateRegex101Link}
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
disabled={isLoading}
>
{isLoading ? "Creating Tests..." : "Create Tests in Regex101"}
</button>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description (Optional)
</label>
<input
type="text"
value={description}
onChange={(e) => 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"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tags
</label>
<div className="flex flex-wrap mb-2">
{tags.map((tag) => (
<span
key={tag}
className="bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300"
>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="ml-1 text-xs"
>
&times;
</button>
</span>
))}
</div>
<div className="flex">
<input
type="text"
value={newTag}
onChange={(e) => 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"
/>
<button
onClick={handleAddTag}
className="bg-blue-500 text-white px-4 py-2 rounded-r hover:bg-blue-600 transition-colors"
>
Add
</button>
</div>
</div>
<div className="flex justify-between">
{initialRegex && (
<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"}
</button>
)}
<button
onClick={handleSave}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
>
Save
</button>
</div>
</Modal>
</>
);
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 (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={modalTitle}
height='auto'
width='xl'>
{error && <div className='text-red-500 mb-4'>{error}</div>}
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Regex Name
</label>
<input
type='text'
value={name}
onChange={e => 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'
/>
</div>
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Regex Pattern
</label>
<input
type='text'
value={pattern}
onChange={e => 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'
/>
</div>
<div className='mb-4'>
{regex101Link ? (
<div className='flex items-center space-x-2'>
<a
href={regex101Link}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 transition-colors'>
View in Regex101
</a>
<button
onClick={handleRemoveRegex101Link}
className='text-red-500 hover:text-red-600 transition-colors'
disabled={isLoading}>
{isLoading ? 'Removing...' : 'Remove Link'}
</button>
</div>
) : (
<button
onClick={handleCreateRegex101Link}
className='bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors'
disabled={isLoading}>
{isLoading
? 'Creating Tests...'
: 'Create Tests in Regex101'}
</button>
)}
</div>
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Description (Optional)
</label>
<input
type='text'
value={description}
onChange={e => 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'
/>
</div>
<div className='mb-4'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
Tags
</label>
<div className='flex flex-wrap mb-2'>
{tags.map(tag => (
<span
key={tag}
className='bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300'>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className='ml-1 text-xs'>
&times;
</button>
</span>
))}
</div>
<div className='flex'>
<input
type='text'
value={newTag}
onChange={e => 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'
/>
<button
onClick={handleAddTag}
className='bg-blue-500 text-white px-4 py-2 rounded-r hover:bg-blue-600 transition-colors'>
Add
</button>
</div>
</div>
<div className='flex justify-between'>
{initialRegex && (
<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'}
</button>
)}
<button
onClick={handleSave}
className='bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors'>
Save
</button>
</div>
</Modal>
</>
);
}
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;

View File

@@ -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}) => (
<div
className='bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-sm cursor-pointer hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-all'
onClick={onClick}>
<div className='flex flex-col items-center justify-center h-32'>
<Icon size={48} className='text-blue-500 mb-2' />
<h3 className='font-bold text-lg text-gray-800 dark:text-gray-200'>
{title}
</h3>
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 (
<div
onClick={onClick}
className='group bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-200 cursor-pointer p-3 h-24'>
{/* Main Content */}
<div className='flex flex-col h-full justify-between'>
{/* Header */}
<div className='flex items-center justify-between'>
<div className='flex items-center space-x-2'>
<Icon size={16} className='text-blue-500' />
<span className='font-medium text-sm text-gray-900 dark:text-gray-100'>
{title}
</span>
<div
className={`w-1.5 h-1.5 rounded-full ${getStatusColor()} ${
isChecking ? 'animate-pulse' : ''
}`}
/>
</div>
<div className='flex gap-1'>
{tags.map(tag => (
<span
key={tag}
className='bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300 text-[8px] px-1.5 rounded-full'>
{tag}
</span>
))}
</div>
</div>
{/* Footer Stats */}
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<div className='flex items-center space-x-1'>
<Database size={12} />
<span>{sampleData.syncStatus.percentage}%</span>
{sampleData.syncStatus.percentage === 100 && (
<CheckCircle size={12} className='text-green-500' />
)}
</div>
<div className='flex items-center space-x-1'>
<Clock size={12} />
<span>
Last Synced: {formatTimeAgo(sampleData.lastSync)}
</span>
</div>
</div>
</div>
</div>
</div>
);
ArrCard.propTypes = {
title: PropTypes.string.isRequired,
icon: PropTypes.elementType.isRequired,
onClick: PropTypes.func.isRequired
);
};
export default ArrCard;

View File

@@ -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 (
<>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4'>
<AddNewCard
onAdd={handleAddArr}
width='200px'
height='200px'
minHeight='200px'
/>
if (loading) {
return (
<div className='flex items-center justify-center h-32'>
<Loader className='w-6 h-6 animate-spin text-blue-500' />
</div>
);
}
return (
<div className='space-y-4'>
{error && (
<div className='text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg text-sm'>
{error}
</div>
)}
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3'>
{arrs.map(arrConfig => (
<ArrCard
key={arrConfig.id}
title={arrConfig.name}
type={arrConfig.type}
serverUrl={arrConfig.arrServer}
apiKey={arrConfig.apiKey}
tags={arrConfig.tags}
onClick={() => handleEditArr(arrConfig)}
/>
))}
{/* Add New Card */}
<div
onClick={handleAddArr}
className='bg-white dark:bg-gray-800 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-200 cursor-pointer h-24 flex items-center justify-center'>
<div className='flex items-center space-x-2 text-gray-500 dark:text-gray-400'>
<Plus size={16} />
<span className='text-sm font-medium'>Add New App</span>
</div>
</div>
</div>
<ArrModal
isOpen={showModal}
onClose={handleCloseModal}
onSubmit={handleSubmit}
onSubmit={handleModalSubmit}
editingArr={editingArr}
/>
</>
</div>
);
};

View File

@@ -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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={editingArr ? 'Edit Arr' : 'Add New Arr'}
size='md'>
<form onSubmit={handleSubmit} className='space-y-4'>
<div>
<label
htmlFor='arrType'
className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Arr Type
</label>
width='2xl'>
<form
onSubmit={handleSubmit}
className='space-y-4 overflow-visible'>
<div className='space-y-1.5'>
<div className='flex items-center space-x-2'>
<Tooltip content='A unique name to identify this Arr instance'>
<label
htmlFor='name'
className='text-xs font-medium text-gray-700 dark:text-gray-300 cursor-help'>
Name
</label>
</Tooltip>
</div>
<input
id='name'
value={formData.name}
onChange={handleInputChange}
className={inputClasses('name')}
placeholder='My App Instance'
required
/>
</div>
{/* Add Type Selection */}
<div className='space-y-1.5'>
<div className='flex items-center space-x-2'>
<Tooltip content='Select the type of Arr instance'>
<label
htmlFor='type'
className='text-xs font-medium text-gray-700 dark:text-gray-300 cursor-help'>
Type
</label>
</Tooltip>
</div>
<select
id='arrType'
value={arrType}
onChange={e => setArrType(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='type'
value={formData.type}
onChange={handleInputChange}
className={inputClasses('type')}
required>
<option value=''>Select an arr type</option>
<option value='radarr'>Radarr</option>
<option value='sonarr'>Sonarr</option>
<option value='custom'>Custom</option>
{arrTypes.map(type => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label
htmlFor='arrName'
className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Arr Name
</label>
{/* Keep the rest of your existing form fields */}
<div className='space-y-1.5'>
{/* Tags section */}
<div className='flex items-center space-x-2'>
<Tooltip content='Optional tags to categorize and filter your Arr instances'>
<label
htmlFor='tags'
className='text-xs font-medium text-gray-700 dark:text-gray-300 cursor-help flex items-center'>
Tags
<Tag size={12} className='ml-1 text-gray-400' />
</label>
</Tooltip>
</div>
<div className='flex flex-wrap gap-2 mb-2'>
{formData.tags.map((tag, index) => (
<span
key={index}
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'>
{tag}
<button
type='button'
onClick={() => handleRemoveTag(tag)}
className='ml-1 hover:text-blue-900 dark:hover:text-blue-200'>
<X size={12} />
</button>
</span>
))}
</div>
<div className='flex gap-2'>
<input
id='tagInput'
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
className={inputClasses('tagInput')}
placeholder='Enter tags...'
/>
<button
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'>
Add
</button>
</div>
</div>
{/* Rest of the fields */}
<div className='space-y-1.5'>
<div className='flex items-center space-x-2'>
<Tooltip content='The URL of your Profilarr server as this app would see it.(e.g., http://localhost:7441)'>
<label
htmlFor='profilarrServer'
className='text-xs font-medium text-gray-700 dark:text-gray-300 cursor-help'>
Profilarr Server
</label>
</Tooltip>
</div>
<input
type='text'
id='arrName'
value={arrName}
onChange={e => 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 && (
<p className='text-xs text-red-500 mt-1'>
{errors.profilarrServer}
</p>
)}
</div>
<div>
<label
htmlFor='apiKey'
className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
API Key
</label>
<div className='space-y-1.5'>
<div className='flex items-center space-x-2'>
<Tooltip content='The URL of this app as Profilarr would see it (e.g., http://localhost:7878 for Radarr)'>
<label
htmlFor='arrServer'
className='text-xs font-medium text-gray-700 dark:text-gray-300 cursor-help'>
Arr Server
</label>
</Tooltip>
</div>
<input
type='text'
id='arrServer'
value={formData.arrServer}
onChange={handleInputChange}
className={inputClasses('arrServer')}
placeholder='http://localhost:7878'
required
/>
{errors.arrServer && (
<p className='text-xs text-red-500 mt-1'>
{errors.arrServer}
</p>
)}
</div>
<div className='space-y-1.5'>
<div className='flex items-center space-x-2'>
<Tooltip content='Your Arr instance API key. Find this in Settings > General'>
<label
htmlFor='apiKey'
className='text-xs font-medium text-gray-700 dark:text-gray-300 cursor-help'>
API Key
</label>
</Tooltip>
</div>
<input
type='password'
id='apiKey'
value={apiKey}
onChange={e => 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
/>
</div>
<div className='flex justify-end space-x-2'>
<div className='flex justify-end space-x-3 pt-4'>
{editingArr && (
<button
type='button'
onClick={handleDelete}
className='flex items-center px-3 py-2 text-sm rounded-lg
bg-red-600 hover:bg-red-700
text-white font-medium transition-colors'>
{deleteConfirm ? (
<>
<Check className='w-3.5 h-3.5 mr-2' />
Confirm Delete
</>
) : (
<>
<Trash className='w-3.5 h-3.5 mr-2' />
Delete
</>
)}
</button>
)}
<button
type='button'
onClick={handleTestConnection}
className='px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors duration-200 ease-in-out flex items-center'
disabled={isTestingConnection || !arrType || !apiKey}>
disabled={
isTestingConnection ||
!formData.arrServer ||
!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'>
{isTestingConnection ? (
<Loader size={16} className='animate-spin mr-2' />
) : (
<TestTube size={16} className='mr-2' />
)}
Test Connection
</button>
<button
type='submit'
className='px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out flex items-center'>
{editingArr ? (
<>
<Save size={16} className='mr-2' />
Update Arr
<Loader className='w-3.5 h-3.5 mr-2 animate-spin' />
Testing...
</>
) : testConfirm ? (
<>
<Check className='w-3.5 h-3.5 mr-2' />
Confirm Test
</>
) : (
<>
<Plus size={16} className='mr-2' />
Add Arr
<TestTube className='w-3.5 h-3.5 mr-2' />
Test Connection
</>
)}
</button>
<button
type='submit'
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'>
{saveConfirm ? (
<>
<Check className='w-3.5 h-3.5 mr-2' />
Confirm {editingArr ? 'Update' : 'Add'}
</>
) : editingArr ? (
<>
<Save className='w-3.5 h-3.5 mr-2' />
Update
</>
) : (
<>
<Plus className='w-3.5 h-3.5 mr-2' />
Add
</>
)}
</button>

View File

@@ -246,7 +246,8 @@ const SettingsBranchModal = ({
isOpen={isOpen}
onClose={onClose}
title='Manage Git Branches'
size='4xl'>
width='xl'
height='auto'>
<div className='space-y-6'>
<div className='relative mb-4'>
<input

View File

@@ -41,37 +41,36 @@ function Modal({
const widthClasses = {
auto: 'w-auto max-w-[60%]',
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl',
full: 'max-w-full',
screen: 'max-w-screen',
'screen-sm': 'max-w-screen-sm',
'screen-md': 'max-w-screen-md',
'screen-lg': 'max-w-screen-lg',
'screen-xl': 'max-w-screen-xl',
'screen-2xl': 'max-w-screen-2xl'
sm: 'w-[384px]', // 24rem
md: 'w-[448px]', // 28rem
lg: 'w-[512px]', // 32rem
xl: 'w-[576px]', // 36rem
'2xl': 'w-[672px]', // 42rem
'3xl': 'w-[768px]', // 48rem
'4xl': 'w-[896px]', // 56rem
'5xl': 'w-[1024px]', // 64rem
'6xl': 'w-[1152px]', // 72rem
'7xl': 'w-[1280px]', // 80rem
full: 'w-full',
'screen-sm': 'w-screen-sm',
'screen-md': 'w-screen-md',
'screen-lg': 'w-screen-lg',
'screen-xl': 'w-screen-xl',
'screen-2xl': 'w-screen-2xl'
};
const heightClasses = {
auto: 'max-h-screen',
sm: 'max-h-64',
md: 'max-h-96',
lg: 'max-h-128',
xl: 'max-h-160',
'2xl': 'max-h-192',
'3xl': 'max-h-224',
'4xl': 'max-h-256',
'5xl': 'max-h-288',
'6xl': 'max-h-320',
full: 'max-h-full'
auto: 'h-auto',
sm: 'h-[384px]', // 24rem
md: 'h-[448px]', // 28rem
lg: 'h-[512px]', // 32rem
xl: 'h-[576px]', // 36rem
'2xl': 'h-[672px]', // 42rem
'3xl': 'h-[768px]', // 48rem
'4xl': 'h-[896px]', // 56rem
'5xl': 'h-[1024px]', // 64rem
'6xl': 'h-[1152px]', // 72rem
full: 'h-full'
};
return (
@@ -88,17 +87,16 @@ function Modal({
style={{zIndex: 1000 + level * 10}}></div>
<div
ref={modalRef}
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl ${
widthClasses[width]
} ${
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl
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'
}`}
}
transition-all duration-300 ease-out transform
${isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
overflow-visible`}
style={{
zIndex: 1001 + level * 10,
overflowY: 'auto',
maxHeight: maxHeight
maxHeight: maxHeight || '80vh'
}}
onClick={e => e.stopPropagation()}>
<div className='flex justify-between items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'>