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:
Samuel Chau
2025-06-16 11:46:12 +09:30
committed by GitHub
parent 876df945e4
commit 5281752269
11 changed files with 690 additions and 422 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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