mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feature: save external app connections (#8)
- SQLite DB added - Store external app connections - Authentication, tags, type, name implemented
This commit is contained in:
@@ -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
114
backend/app/arr/__init__.py
Normal 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
107
backend/app/arr/manager.py
Normal 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)}
|
||||
44
backend/app/arr/status/ping.py
Normal file
44
backend/app/arr/status/ping.py
Normal 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
28
backend/app/db.py
Normal 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
79
frontend/src/api/arr.js
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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'>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
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;
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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'>
|
||||
×
|
||||
</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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user