mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-28 21:40:58 +01:00
feat: dynamic language score (#207)
- add database setting for language score on imports - add settings container on general tab to let user update the value - improved styling of general settings page to be more like media management page
This commit is contained in:
@@ -11,6 +11,7 @@ import aiohttp
|
||||
from .mappings import TargetApp, ValueResolver
|
||||
from ..data.utils import load_yaml_file, get_category_directory
|
||||
from ..importarr.format_memory import import_format_from_memory, async_import_format_from_memory
|
||||
from ..db.queries.settings import get_language_import_score
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -162,7 +163,7 @@ class ProfileConverter:
|
||||
|
||||
format_configs.append({
|
||||
'name': format_name,
|
||||
'score': -9999
|
||||
'score': get_language_import_score()
|
||||
})
|
||||
|
||||
return format_configs
|
||||
@@ -182,7 +183,7 @@ class ProfileConverter:
|
||||
|
||||
format_configs.append({
|
||||
'name': format_name,
|
||||
'score': -9999
|
||||
'score': get_language_import_score()
|
||||
})
|
||||
|
||||
return format_configs
|
||||
@@ -226,7 +227,7 @@ class ProfileConverter:
|
||||
|
||||
format_configs.append({
|
||||
'name': format_name,
|
||||
'score': -9999
|
||||
'score': get_language_import_score()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -280,7 +281,7 @@ class ProfileConverter:
|
||||
|
||||
format_configs.append({
|
||||
'name': display_name,
|
||||
'score': -9999
|
||||
'score': get_language_import_score()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing format {format_name}: {str(e)} (async)")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# backend/app/db/migrations/versions/003_language_import_score.py
|
||||
from ...connection import get_db
|
||||
|
||||
version = 3
|
||||
name = "language_import_score"
|
||||
|
||||
|
||||
def up():
|
||||
"""Add language_import_config table."""
|
||||
with get_db() as conn:
|
||||
# Create language_import_config table
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS language_import_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
score INTEGER NOT NULL DEFAULT -99999,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Insert default record
|
||||
conn.execute('''
|
||||
INSERT INTO language_import_config (score, updated_at)
|
||||
VALUES (-99999, CURRENT_TIMESTAMP)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def down():
|
||||
"""Remove language_import_config table."""
|
||||
with get_db() as conn:
|
||||
conn.execute('DROP TABLE IF EXISTS language_import_config')
|
||||
conn.commit()
|
||||
@@ -63,3 +63,49 @@ def update_pat_status():
|
||||
f"PAT status updated from {current[0]} to {pat_exists}")
|
||||
else:
|
||||
logger.debug("PAT status unchanged")
|
||||
|
||||
|
||||
def get_language_import_score():
|
||||
"""Get the current language import score."""
|
||||
with get_db() as conn:
|
||||
result = conn.execute(
|
||||
'SELECT score FROM language_import_config ORDER BY id DESC LIMIT 1'
|
||||
).fetchone()
|
||||
return result['score'] if result else -99999
|
||||
|
||||
|
||||
def update_language_import_score(score):
|
||||
"""Update the language import score."""
|
||||
with get_db() as conn:
|
||||
# Get current score first
|
||||
current = conn.execute(
|
||||
'SELECT score FROM language_import_config ORDER BY id DESC LIMIT 1'
|
||||
).fetchone()
|
||||
current_score = current['score'] if current else None
|
||||
|
||||
# Check if record exists
|
||||
existing = conn.execute(
|
||||
'SELECT id FROM language_import_config ORDER BY id DESC LIMIT 1'
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
# Update existing record
|
||||
conn.execute(
|
||||
'''
|
||||
UPDATE language_import_config
|
||||
SET score = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (score, existing['id']))
|
||||
else:
|
||||
# Insert new record
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT INTO language_import_config (score, updated_at)
|
||||
VALUES (?, CURRENT_TIMESTAMP)
|
||||
''', (score,))
|
||||
|
||||
conn.commit()
|
||||
if current_score is not None:
|
||||
logger.info(f"Language import score updated from {current_score} to {score}")
|
||||
else:
|
||||
logger.info(f"Language import score set to: {score}")
|
||||
|
||||
@@ -4,6 +4,7 @@ from flask import Blueprint, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import secrets
|
||||
from ..db import get_db
|
||||
from ..db.queries.settings import get_language_import_score, update_language_import_score
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -124,3 +125,34 @@ def reset_api_key():
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to reset API key: {str(e)}')
|
||||
return jsonify({'error': 'Failed to reset API key'}), 500
|
||||
|
||||
|
||||
@bp.route('/language-import-score', methods=['GET'])
|
||||
def get_language_import_score_route():
|
||||
try:
|
||||
score = get_language_import_score()
|
||||
return jsonify({'score': score})
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get language import score: {str(e)}')
|
||||
return jsonify({'error': 'Failed to get language import score'}), 500
|
||||
|
||||
|
||||
@bp.route('/language-import-score', methods=['PUT'])
|
||||
def update_language_import_score_route():
|
||||
data = request.get_json()
|
||||
score = data.get('score')
|
||||
|
||||
if score is None:
|
||||
return jsonify({'error': 'Score is required'}), 400
|
||||
|
||||
try:
|
||||
score = int(score)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'Score must be an integer'}), 400
|
||||
|
||||
try:
|
||||
update_language_import_score(score)
|
||||
return jsonify({'message': 'Language import score updated successfully'})
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to update language import score: {str(e)}')
|
||||
return jsonify({'error': 'Failed to update language import score'}), 500
|
||||
|
||||
@@ -135,3 +135,51 @@ export const resetApiKey = async currentPassword => {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLanguageImportScore = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/settings/language-import-score`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.error || 'Failed to fetch language import score';
|
||||
Alert.error(errorMessage);
|
||||
throw createHandledError(errorMessage);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (!error.isHandled) {
|
||||
Alert.error(error.message || 'Failed to fetch language import score');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateLanguageImportScore = async (score) => {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/settings/language-import-score`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({score})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.error || 'Failed to update language import score';
|
||||
Alert.error(errorMessage);
|
||||
throw createHandledError(errorMessage);
|
||||
}
|
||||
Alert.success('Language import score updated successfully');
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (!error.isHandled) {
|
||||
Alert.error(error.message || 'Failed to update language import score');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ const CategoryContainer = ({
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`w-full bg-gray-800/50 px-6 py-3.5 hover:bg-gray-700/50 transition-[background-color] ${
|
||||
expanded ? 'border-b border-gray-700' : ''
|
||||
expanded ? 'border-b border-gray-700 rounded-t-lg' : 'rounded-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Eye, EyeOff, Copy, RefreshCw, Check} from 'lucide-react';
|
||||
import {resetApiKey} from '@api/settings';
|
||||
import Alert from '@ui/Alert';
|
||||
import CategoryContainer from './CategoryContainer';
|
||||
|
||||
const ApiSettingsContainer = ({apiKey, onApiKeyUpdate}) => {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showApiKeyCurrentPassword, setShowApiKeyCurrentPassword] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [apiKeyCurrentPassword, setApiKeyCurrentPassword] = useState('');
|
||||
|
||||
const handleCopyApiKey = async () => {
|
||||
await navigator.clipboard.writeText(apiKey);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 1000);
|
||||
};
|
||||
|
||||
const handleResetApiKey = async () => {
|
||||
if (!apiKeyCurrentPassword) {
|
||||
Alert.error('Please enter your current password to reset the API key.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to reset your API key? This action cannot be undone and your current key will stop working immediately.'
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
const response = await resetApiKey(apiKeyCurrentPassword);
|
||||
onApiKeyUpdate(response.api_key);
|
||||
setApiKeyCurrentPassword('');
|
||||
} catch (error) {
|
||||
console.error('Error resetting API key:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CategoryContainer title='API Settings'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium text-gray-300'>
|
||||
API Key
|
||||
</label>
|
||||
<div className='flex space-x-2'>
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors'
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showApiKey ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopyApiKey}
|
||||
className='px-3 py-2.5 bg-gray-700/50 border border-gray-700 rounded-lg hover:bg-gray-700 text-gray-200 flex items-center justify-center transition-colors'
|
||||
title='Copy API key'>
|
||||
{copySuccess ? (
|
||||
<Check size={18} className='text-green-500' />
|
||||
) : (
|
||||
<Copy size={18} className='text-white' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetApiKey}
|
||||
className='px-3 py-2.5 bg-gray-700/50 border border-gray-700 rounded-lg hover:bg-gray-700 text-gray-200 flex items-center justify-center transition-colors'
|
||||
title='Reset API key - this will invalidate your current key'>
|
||||
<RefreshCw size={18} className='text-white' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='relative'>
|
||||
<input
|
||||
type={showApiKeyCurrentPassword ? 'text' : 'password'}
|
||||
value={apiKeyCurrentPassword}
|
||||
onChange={e => setApiKeyCurrentPassword(e.target.value)}
|
||||
placeholder='Enter current password to reset API key'
|
||||
className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors'
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowApiKeyCurrentPassword(!showApiKeyCurrentPassword)
|
||||
}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showApiKeyCurrentPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CategoryContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSettingsContainer;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, {useState} from 'react';
|
||||
import {ChevronDown, ChevronRight} from 'lucide-react';
|
||||
|
||||
const CategoryContainer = ({title, children, defaultExpanded = true}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div className='bg-gradient-to-b from-gray-800 to-gray-900 rounded-lg border border-gray-700 shadow-lg mb-6'>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`w-full flex items-center justify-between px-6 py-3.5 bg-gray-800/50 hover:bg-gray-700/50 transition-colors ${
|
||||
isExpanded ? 'border-b border-gray-700 rounded-t-lg' : 'rounded-lg'
|
||||
}`}>
|
||||
<h3 className='text-lg font-semibold text-gray-100'>
|
||||
{title}
|
||||
</h3>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='w-4 h-4 text-gray-400' />
|
||||
) : (
|
||||
<ChevronRight className='w-4 h-4 text-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && <div className='p-6'>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryContainer;
|
||||
@@ -1,34 +1,16 @@
|
||||
// settings/general/GeneralContainer.jsx
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Eye, EyeOff, Copy, RefreshCw, Check} from 'lucide-react';
|
||||
import {
|
||||
fetchGeneralSettings,
|
||||
updateUsername,
|
||||
updatePassword,
|
||||
resetApiKey
|
||||
} from '@api/settings';
|
||||
import Alert from '@ui/Alert';
|
||||
import {RefreshCw} from 'lucide-react';
|
||||
import {fetchGeneralSettings} from '@api/settings';
|
||||
import ApiSettingsContainer from './ApiSettingsContainer';
|
||||
import UserSettingsContainer from './UserSettingsContainer';
|
||||
import ImportSettingsContainer from './ImportSettingsContainer';
|
||||
|
||||
const GeneralContainer = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showUsernameCurrentPassword, setShowUsernameCurrentPassword] =
|
||||
useState(false);
|
||||
const [showApiKeyCurrentPassword, setShowApiKeyCurrentPassword] =
|
||||
useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
const [settings, setSettings] = useState({
|
||||
apiKey: '',
|
||||
username: '',
|
||||
usernameCurrentPassword: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
currentPassword: '',
|
||||
currentUsername: '',
|
||||
apiKeyCurrentPassword: ''
|
||||
username: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,17 +21,10 @@ const GeneralContainer = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const {username, api_key} = await fetchGeneralSettings();
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
setSettings({
|
||||
apiKey: api_key,
|
||||
username: username,
|
||||
currentUsername: username,
|
||||
usernameCurrentPassword: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
currentPassword: '',
|
||||
apiKeyCurrentPassword: ''
|
||||
}));
|
||||
username: username
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
} finally {
|
||||
@@ -57,125 +32,20 @@ const GeneralContainer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyApiKey = async () => {
|
||||
await navigator.clipboard.writeText(formData.apiKey);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 1000);
|
||||
};
|
||||
|
||||
const handleResetApiKey = async () => {
|
||||
if (!formData.apiKeyCurrentPassword) {
|
||||
Alert.error(
|
||||
'Please enter your current password to reset the API key.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to reset your API key? This action cannot be undone and your current key will stop working immediately.'
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
const response = await resetApiKey(
|
||||
formData.apiKeyCurrentPassword
|
||||
);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
apiKey: response.api_key,
|
||||
apiKeyCurrentPassword: ''
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error resetting API key:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameChange = e => {
|
||||
setFormData(prev => ({
|
||||
const handleApiKeyUpdate = newApiKey => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
username: e.target.value
|
||||
apiKey: newApiKey
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUsernameCurrentPasswordChange = e => {
|
||||
setFormData(prev => ({
|
||||
const handleUsernameUpdate = newUsername => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
usernameCurrentPassword: e.target.value
|
||||
username: newUsername
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePasswordChange = e => {
|
||||
const newPassword = e.target.value;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
password: newPassword
|
||||
}));
|
||||
};
|
||||
|
||||
const handleConfirmPasswordChange = e => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
confirmPassword: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCurrentPasswordChange = e => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
currentPassword: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApiKeyCurrentPasswordChange = e => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
apiKeyCurrentPassword: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveUsername = async () => {
|
||||
try {
|
||||
await updateUsername(
|
||||
formData.username,
|
||||
formData.usernameCurrentPassword
|
||||
);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
currentUsername: formData.username,
|
||||
usernameCurrentPassword: ''
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error updating username:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePassword = async () => {
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
Alert.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to change your password? You will need to log in again.'
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
const response = await updatePassword(
|
||||
formData.currentPassword,
|
||||
formData.password
|
||||
);
|
||||
|
||||
if (response.requireRelogin) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center h-32'>
|
||||
@@ -184,279 +54,17 @@ const GeneralContainer = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const hasUsernameChanges = formData.username !== formData.currentUsername;
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-4'>
|
||||
<h2 className='text-xl font-bold text-gray-100'>
|
||||
API Settings
|
||||
</h2>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium text-gray-300'>
|
||||
API Key
|
||||
</label>
|
||||
<div className='flex space-x-2'>
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={formData.apiKey}
|
||||
readOnly
|
||||
className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-gray-100'
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showApiKey ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopyApiKey}
|
||||
className='w-10 h-10 bg-gray-800 border border-gray-600 rounded-lg hover:bg-gray-600 flex items-center justify-center'
|
||||
title='Copy API key'>
|
||||
{copySuccess ? (
|
||||
<Check size={18} className='text-green-500' />
|
||||
) : (
|
||||
<Copy size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetApiKey}
|
||||
className='w-10 h-10 bg-gray-800 border border-gray-600 rounded-lg hover:bg-gray-600 flex items-center justify-center'
|
||||
title='Reset API key - this will invalidate your current key'>
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={
|
||||
showApiKeyCurrentPassword ? 'text' : 'password'
|
||||
}
|
||||
value={formData.apiKeyCurrentPassword}
|
||||
onChange={handleApiKeyCurrentPasswordChange}
|
||||
placeholder='Enter current password to reset API key'
|
||||
className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-gray-100'
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowApiKeyCurrentPassword(
|
||||
!showApiKeyCurrentPassword
|
||||
)
|
||||
}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showApiKeyCurrentPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<h2 className='text-xl font-bold text-gray-100'>
|
||||
User Settings
|
||||
</h2>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium text-gray-300'>
|
||||
Username
|
||||
</label>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex space-x-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={formData.username}
|
||||
onChange={handleUsernameChange}
|
||||
className='flex-1 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasUsernameChanges && (
|
||||
<div className='flex gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={
|
||||
showUsernameCurrentPassword
|
||||
? 'text'
|
||||
: 'password'
|
||||
}
|
||||
value={
|
||||
formData.usernameCurrentPassword
|
||||
}
|
||||
onChange={
|
||||
handleUsernameCurrentPasswordChange
|
||||
}
|
||||
placeholder='Enter current password'
|
||||
className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-gray-100'
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowUsernameCurrentPassword(
|
||||
!showUsernameCurrentPassword
|
||||
)
|
||||
}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showUsernameCurrentPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveUsername}
|
||||
disabled={
|
||||
!formData.usernameCurrentPassword
|
||||
}
|
||||
title={
|
||||
!formData.usernameCurrentPassword
|
||||
? 'Enter current password'
|
||||
: 'Save changes'
|
||||
}
|
||||
className={`px-4 h-10 bg-gray-800 border rounded-lg flex items-center justify-center ${
|
||||
formData.usernameCurrentPassword
|
||||
? 'border-gray-600 hover:bg-gray-600 cursor-pointer'
|
||||
: 'border-gray-700 text-gray-500 cursor-not-allowed'
|
||||
}`}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium text-gray-300'>
|
||||
Password
|
||||
</label>
|
||||
<div className='space-y-2'>
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder='Enter new password'
|
||||
className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-gray-100'
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowPassword(!showPassword)
|
||||
}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.password && (
|
||||
<>
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={
|
||||
showConfirmPassword
|
||||
? 'text'
|
||||
: 'password'
|
||||
}
|
||||
value={formData.confirmPassword}
|
||||
onChange={
|
||||
handleConfirmPasswordChange
|
||||
}
|
||||
placeholder='Confirm new password'
|
||||
className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-gray-100'
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(
|
||||
!showConfirmPassword
|
||||
)
|
||||
}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={
|
||||
showCurrentPassword
|
||||
? 'text'
|
||||
: 'password'
|
||||
}
|
||||
value={formData.currentPassword}
|
||||
onChange={
|
||||
handleCurrentPasswordChange
|
||||
}
|
||||
placeholder='Enter current password'
|
||||
className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-gray-100'
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowCurrentPassword(
|
||||
!showCurrentPassword
|
||||
)
|
||||
}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showCurrentPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSavePassword}
|
||||
disabled={
|
||||
!formData.password ||
|
||||
!formData.confirmPassword ||
|
||||
!formData.currentPassword ||
|
||||
formData.password !==
|
||||
formData.confirmPassword
|
||||
}
|
||||
title={
|
||||
!formData.password
|
||||
? 'Enter a new password'
|
||||
: !formData.confirmPassword
|
||||
? 'Confirm your new password'
|
||||
: !formData.currentPassword
|
||||
? 'Enter your current password'
|
||||
: formData.password !==
|
||||
formData.confirmPassword
|
||||
? 'Passwords do not match'
|
||||
: 'Save new password'
|
||||
}
|
||||
className={`px-4 h-10 bg-gray-800 border rounded-lg flex items-center justify-center ${
|
||||
formData.password &&
|
||||
formData.confirmPassword &&
|
||||
formData.currentPassword &&
|
||||
formData.password ===
|
||||
formData.confirmPassword
|
||||
? 'border-gray-600 hover:bg-gray-600 cursor-pointer'
|
||||
: 'border-gray-700 text-gray-500 cursor-not-allowed'
|
||||
}`}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ApiSettingsContainer
|
||||
apiKey={settings.apiKey}
|
||||
onApiKeyUpdate={handleApiKeyUpdate}
|
||||
/>
|
||||
<UserSettingsContainer
|
||||
username={settings.username}
|
||||
onUsernameUpdate={handleUsernameUpdate}
|
||||
/>
|
||||
<ImportSettingsContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Save, Check} from 'lucide-react';
|
||||
import {fetchLanguageImportScore, updateLanguageImportScore} from '@api/settings';
|
||||
import Alert from '@ui/Alert';
|
||||
import NumberInput from '@ui/NumberInput';
|
||||
import CategoryContainer from './CategoryContainer';
|
||||
|
||||
const ImportSettingsContainer = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
languageImportScore: 0
|
||||
});
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchScore();
|
||||
}, []);
|
||||
|
||||
const fetchScore = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const {score} = await fetchLanguageImportScore();
|
||||
setFormData({
|
||||
languageImportScore: score
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching language import score:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScoreChange = value => {
|
||||
setFormData(prev => ({...prev, languageImportScore: value}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const score = formData.languageImportScore;
|
||||
|
||||
if (score >= 0) {
|
||||
Alert.error('Score must be negative');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLanguageImportScore(score);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 1000);
|
||||
} catch (error) {
|
||||
console.error('Error updating language import score:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CategoryContainer title='Import Settings'>
|
||||
<div className='flex items-center justify-center h-16'>
|
||||
<div className='w-4 h-4 bg-gray-400 rounded-full animate-pulse'></div>
|
||||
</div>
|
||||
</CategoryContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CategoryContainer title='Import Settings'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium text-gray-300'>
|
||||
Language Import Score
|
||||
</label>
|
||||
<p className='text-xs text-gray-400'>
|
||||
Default score assigned to language-specific custom formats when importing profiles. Must be negative.
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<NumberInput
|
||||
value={formData.languageImportScore}
|
||||
onChange={handleScoreChange}
|
||||
className='flex-1'
|
||||
step={1000}
|
||||
max={-1}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
title='Save language import score'
|
||||
className='px-3 py-1.5 bg-gray-700/50 border border-gray-700 rounded-lg hover:bg-gray-700 text-gray-200 text-sm flex items-center gap-2 transition-colors'>
|
||||
{saveSuccess ? (
|
||||
<Check size={16} className='text-green-500' />
|
||||
) : (
|
||||
<Save size={16} className='text-blue-500' />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CategoryContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportSettingsContainer;
|
||||
@@ -0,0 +1,263 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Eye, EyeOff, Save, Check} from 'lucide-react';
|
||||
import {updateUsername, updatePassword} from '@api/settings';
|
||||
import Alert from '@ui/Alert';
|
||||
import CategoryContainer from './CategoryContainer';
|
||||
|
||||
const UserSettingsContainer = ({username, onUsernameUpdate}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: username,
|
||||
usernameCurrentPassword: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
currentPassword: ''
|
||||
});
|
||||
const [showPasswords, setShowPasswords] = useState({
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
currentPassword: false,
|
||||
usernameCurrentPassword: false
|
||||
});
|
||||
const [saveSuccess, setSaveSuccess] = useState({
|
||||
username: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const hasUsernameChanges = formData.username !== username;
|
||||
|
||||
const handleUsernameChange = e => {
|
||||
setFormData(prev => ({...prev, username: e.target.value}));
|
||||
};
|
||||
|
||||
const handleSaveUsername = async () => {
|
||||
try {
|
||||
await updateUsername(formData.username, formData.usernameCurrentPassword);
|
||||
onUsernameUpdate(formData.username);
|
||||
setFormData(prev => ({...prev, usernameCurrentPassword: ''}));
|
||||
setSaveSuccess(prev => ({...prev, username: true}));
|
||||
setTimeout(() => setSaveSuccess(prev => ({...prev, username: false})), 1000);
|
||||
} catch (error) {
|
||||
console.error('Error updating username:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePassword = async () => {
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
Alert.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to change your password? You will need to log in again.'
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
const response = await updatePassword(
|
||||
formData.currentPassword,
|
||||
formData.password
|
||||
);
|
||||
|
||||
if (response.requireRelogin) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
setSaveSuccess(prev => ({...prev, password: true}));
|
||||
setTimeout(() => setSaveSuccess(prev => ({...prev, password: false})), 1000);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
currentPassword: ''
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CategoryContainer title='User Settings'>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium text-gray-300'>
|
||||
Username
|
||||
</label>
|
||||
<div className='space-y-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={formData.username}
|
||||
onChange={handleUsernameChange}
|
||||
className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors'
|
||||
/>
|
||||
|
||||
{hasUsernameChanges && (
|
||||
<div className='flex gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={showPasswords.usernameCurrentPassword ? 'text' : 'password'}
|
||||
value={formData.usernameCurrentPassword}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
usernameCurrentPassword: e.target.value
|
||||
}))}
|
||||
placeholder='Enter current password'
|
||||
className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors'
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPasswords(prev => ({
|
||||
...prev,
|
||||
usernameCurrentPassword: !prev.usernameCurrentPassword
|
||||
}))}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showPasswords.usernameCurrentPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveUsername}
|
||||
disabled={!formData.usernameCurrentPassword}
|
||||
title={!formData.usernameCurrentPassword ? 'Enter current password' : 'Save changes'}
|
||||
className={`px-3 py-1.5 bg-gray-700/50 border border-gray-700 rounded-lg hover:bg-gray-700 text-gray-200 text-sm flex items-center gap-2 transition-colors ${
|
||||
!formData.usernameCurrentPassword ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}>
|
||||
{saveSuccess.username ? (
|
||||
<Check size={16} className='text-green-500' />
|
||||
) : (
|
||||
<Save size={16} className='text-blue-500' />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium text-gray-300'>
|
||||
Password
|
||||
</label>
|
||||
<div className='space-y-2'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type={showPasswords.password ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
password: e.target.value
|
||||
}))}
|
||||
placeholder='Enter new password'
|
||||
className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors'
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPasswords(prev => ({
|
||||
...prev,
|
||||
password: !prev.password
|
||||
}))}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showPasswords.password ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.password && (
|
||||
<>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type={showPasswords.confirmPassword ? 'text' : 'password'}
|
||||
value={formData.confirmPassword}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
confirmPassword: e.target.value
|
||||
}))}
|
||||
placeholder='Confirm new password'
|
||||
className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors'
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPasswords(prev => ({
|
||||
...prev,
|
||||
confirmPassword: !prev.confirmPassword
|
||||
}))}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showPasswords.confirmPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<input
|
||||
type={showPasswords.currentPassword ? 'text' : 'password'}
|
||||
value={formData.currentPassword}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
currentPassword: e.target.value
|
||||
}))}
|
||||
placeholder='Enter current password'
|
||||
className='w-full px-4 py-2.5 bg-gray-900/50 border border-gray-700/50 rounded-lg text-gray-200 font-mono text-[13px] placeholder-gray-500 hover:border-gray-600 hover:bg-gray-900/70 focus:bg-gray-900 focus:border-blue-400 focus:outline-none transition-colors'
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPasswords(prev => ({
|
||||
...prev,
|
||||
currentPassword: !prev.currentPassword
|
||||
}))}
|
||||
className='absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 w-6 h-6 flex items-center justify-center'>
|
||||
{showPasswords.currentPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSavePassword}
|
||||
disabled={
|
||||
!formData.password ||
|
||||
!formData.confirmPassword ||
|
||||
!formData.currentPassword ||
|
||||
formData.password !== formData.confirmPassword
|
||||
}
|
||||
title={
|
||||
!formData.password
|
||||
? 'Enter a new password'
|
||||
: !formData.confirmPassword
|
||||
? 'Confirm your new password'
|
||||
: !formData.currentPassword
|
||||
? 'Enter your current password'
|
||||
: formData.password !== formData.confirmPassword
|
||||
? 'Passwords do not match'
|
||||
: 'Save new password'
|
||||
}
|
||||
className={`px-3 py-1.5 bg-gray-700/50 border border-gray-700 rounded-lg hover:bg-gray-700 text-gray-200 text-sm flex items-center gap-2 transition-colors ${
|
||||
!(formData.password &&
|
||||
formData.confirmPassword &&
|
||||
formData.currentPassword &&
|
||||
formData.password === formData.confirmPassword) ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}>
|
||||
{saveSuccess.password ? (
|
||||
<Check size={16} className='text-green-500' />
|
||||
) : (
|
||||
<Save size={16} className='text-blue-500' />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CategoryContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSettingsContainer;
|
||||
Reference in New Issue
Block a user