Enhanced setup and settings pages with full configuration

- Extended SetupRequest model to include all configuration fields
- Updated setup API endpoint to handle comprehensive configuration
- Created new setup.html with organized configuration sections
- Enhanced config modal in index.html with all settings
- Updated JavaScript modules to use unified config API
- Added backup configuration section
- Documented new features in features.md and instructions.md
This commit is contained in:
2026-01-17 18:01:15 +01:00
parent d676cb7dca
commit 4e29c4ed80
12 changed files with 1807 additions and 680 deletions

View File

@@ -29,8 +29,8 @@ optional_bearer = HTTPBearer(auto_error=False)
async def setup_auth(req: SetupRequest):
"""Initial setup endpoint to configure the master password.
This endpoint also initializes the configuration with default values
and saves the anime directory and master password hash.
This endpoint also initializes the configuration with all provided values
and saves them to config.json.
"""
if auth_service.is_configured():
raise HTTPException(
@@ -44,26 +44,77 @@ async def setup_auth(req: SetupRequest):
req.master_password
)
# Initialize or update config with master password hash
# and anime directory
# Initialize or update config with all provided values
config_service = get_config_service()
try:
config = config_service.load_config()
except Exception:
# If config doesn't exist, create default
from src.server.models.config import (
SchedulerConfig,
LoggingConfig,
BackupConfig,
NFOConfig,
)
config = AppConfig()
# Update basic settings
if req.name:
config.name = req.name
if req.data_dir:
config.data_dir = req.data_dir
# Update scheduler configuration
if req.scheduler_enabled is not None:
config.scheduler.enabled = req.scheduler_enabled
if req.scheduler_interval_minutes is not None:
config.scheduler.interval_minutes = req.scheduler_interval_minutes
# Update logging configuration
if req.logging_level:
config.logging.level = req.logging_level.upper()
if req.logging_file is not None:
config.logging.file = req.logging_file
if req.logging_max_bytes is not None:
config.logging.max_bytes = req.logging_max_bytes
if req.logging_backup_count is not None:
config.logging.backup_count = req.logging_backup_count
# Update backup configuration
if req.backup_enabled is not None:
config.backup.enabled = req.backup_enabled
if req.backup_path:
config.backup.path = req.backup_path
if req.backup_keep_days is not None:
config.backup.keep_days = req.backup_keep_days
# Update NFO configuration
if req.nfo_tmdb_api_key is not None:
config.nfo.tmdb_api_key = req.nfo_tmdb_api_key
if req.nfo_auto_create is not None:
config.nfo.auto_create = req.nfo_auto_create
if req.nfo_update_on_scan is not None:
config.nfo.update_on_scan = req.nfo_update_on_scan
if req.nfo_download_poster is not None:
config.nfo.download_poster = req.nfo_download_poster
if req.nfo_download_logo is not None:
config.nfo.download_logo = req.nfo_download_logo
if req.nfo_download_fanart is not None:
config.nfo.download_fanart = req.nfo_download_fanart
if req.nfo_image_size:
config.nfo.image_size = req.nfo_image_size.lower()
# Store master password hash in config's other field
config.other['master_password_hash'] = password_hash
# Store anime directory in config's other field if provided
anime_directory = None
if hasattr(req, 'anime_directory') and req.anime_directory:
if req.anime_directory:
anime_directory = req.anime_directory.strip()
if anime_directory:
config.other['anime_directory'] = anime_directory
# Save the config with the password hash and anime directory
# Save the config with all updates
config_service.save_config(config, create_backup=False)
# Sync series from data files to database if anime directory is set

View File

@@ -36,14 +36,106 @@ class LoginResponse(BaseModel):
class SetupRequest(BaseModel):
"""Request to initialize the master password during first-time setup."""
"""Request to initialize the master password during first-time setup.
This request includes all configuration fields needed to set up the application.
"""
# Required fields
master_password: str = Field(
..., min_length=8, description="New master password"
)
anime_directory: Optional[str] = Field(
None, description="Optional anime directory path"
)
# Application settings
name: Optional[str] = Field(
default="Aniworld", description="Application name"
)
data_dir: Optional[str] = Field(
default="data", description="Data directory path"
)
# Scheduler configuration
scheduler_enabled: Optional[bool] = Field(
default=True, description="Enable/disable scheduler"
)
scheduler_interval_minutes: Optional[int] = Field(
default=60, ge=1, description="Scheduler interval in minutes"
)
# Logging configuration
logging_level: Optional[str] = Field(
default="INFO", description="Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"
)
logging_file: Optional[str] = Field(
default=None, description="Log file path"
)
logging_max_bytes: Optional[int] = Field(
default=None, ge=0, description="Max log file size in bytes"
)
logging_backup_count: Optional[int] = Field(
default=3, ge=0, description="Number of backup log files"
)
# Backup configuration
backup_enabled: Optional[bool] = Field(
default=False, description="Enable/disable backups"
)
backup_path: Optional[str] = Field(
default="data/backups", description="Backup directory path"
)
backup_keep_days: Optional[int] = Field(
default=30, ge=0, description="Days to keep backups"
)
# NFO configuration
nfo_tmdb_api_key: Optional[str] = Field(
default=None, description="TMDB API key"
)
nfo_auto_create: Optional[bool] = Field(
default=True, description="Auto-create NFO files"
)
nfo_update_on_scan: Optional[bool] = Field(
default=True, description="Update NFO on scan"
)
nfo_download_poster: Optional[bool] = Field(
default=True, description="Download poster images"
)
nfo_download_logo: Optional[bool] = Field(
default=True, description="Download logo images"
)
nfo_download_fanart: Optional[bool] = Field(
default=True, description="Download fanart images"
)
nfo_image_size: Optional[str] = Field(
default="original", description="Image size preference (original or w500)"
)
@field_validator("logging_level")
@classmethod
def validate_logging_level(cls, v: Optional[str]) -> Optional[str]:
"""Validate logging level."""
if v is None:
return v
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
lvl = v.upper()
if lvl not in allowed:
raise ValueError(f"Invalid logging level: {v}. Must be one of {allowed}")
return lvl
@field_validator("nfo_image_size")
@classmethod
def validate_image_size(cls, v: Optional[str]) -> Optional[str]:
"""Validate image size."""
if v is None:
return v
allowed = {"original", "w500"}
size = v.lower()
if size not in allowed:
raise ValueError(f"Invalid image size: {v}. Must be 'original' or 'w500'")
return size
class AuthStatus(BaseModel):

View File

@@ -56,6 +56,9 @@ AniWorld.ConfigManager = (function() {
// NFO configuration
bindNFOEvents();
// Backup configuration
bindBackupEvents();
// Status panel
const closeStatus = document.getElementById('close-status');
if (closeStatus) {
@@ -118,8 +121,16 @@ AniWorld.ConfigManager = (function() {
}
}
/**
* Bind NFO config events
/** * Bind backup config events
*/
function bindBackupEvents() {
const saveBackup = document.getElementById('save-backup-config');
if (saveBackup) {
saveBackup.addEventListener('click', AniWorld.MainConfig.saveBackupConfig);
}
}
/** * Bind NFO config events
*/
function bindNFOEvents() {
const saveNFO = document.getElementById('save-nfo-config');

View File

@@ -113,28 +113,25 @@ AniWorld.LoggingConfig = (function() {
*/
async function save() {
try {
const config = {
log_level: document.getElementById('log-level').value,
enable_console_logging: document.getElementById('enable-console-logging').checked,
enable_console_progress: document.getElementById('enable-console-progress').checked,
enable_fail2ban_logging: document.getElementById('enable-fail2ban-logging').checked
// Get current config
const configResponse = await AniWorld.ApiClient.get(AniWorld.Constants.API.CONFIG);
if (!configResponse) return;
const config = await configResponse.json();
// Update logging settings
config.logging = {
level: document.getElementById('log-level').value.toUpperCase(),
file: document.getElementById('log-file').value.trim() || null,
max_bytes: document.getElementById('log-max-bytes').value ? parseInt(document.getElementById('log-max-bytes').value) : null,
backup_count: parseInt(document.getElementById('log-backup-count').value) || 3
};
const response = await AniWorld.ApiClient.request(API.LOGGING_CONFIG, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
// Save updated config
const response = await AniWorld.ApiClient.put(AniWorld.Constants.API.CONFIG, config);
if (!response) return;
const data = await response.json();
if (data.success) {
AniWorld.UI.showToast('Logging configuration saved successfully', 'success');
await load();
} else {
AniWorld.UI.showToast('Failed to save logging config: ' + data.error, 'error');
}
AniWorld.UI.showToast('Logging configuration saved successfully', 'success');
await load();
} catch (error) {
console.error('Error saving logging config:', error);
AniWorld.UI.showToast('Failed to save logging configuration', 'error');

View File

@@ -20,31 +20,55 @@ AniWorld.MainConfig = (function() {
async function save() {
try {
const animeDirectory = document.getElementById('anime-directory-input').value.trim();
const appName = document.getElementById('app-name-input').value.trim();
const dataDir = document.getElementById('data-dir-input').value.trim();
if (!animeDirectory) {
AniWorld.UI.showToast('Please enter an anime directory path', 'error');
return;
}
const response = await AniWorld.ApiClient.post(API.CONFIG_DIRECTORY, {
directory: animeDirectory
});
// Get current config
const currentConfig = await loadCurrentConfig();
if (!currentConfig) {
AniWorld.UI.showToast('Failed to load current configuration', 'error');
return;
}
// Update fields
if (appName) currentConfig.name = appName;
if (dataDir) currentConfig.data_dir = dataDir;
if (!currentConfig.other) currentConfig.other = {};
currentConfig.other.anime_directory = animeDirectory;
// Save updated config
const response = await AniWorld.ApiClient.put(AniWorld.Constants.API.CONFIG, currentConfig);
if (!response) return;
const data = await response.json();
if (data.success) {
AniWorld.UI.showToast('Main configuration saved successfully', 'success');
await refreshStatus();
} else {
AniWorld.UI.showToast('Failed to save configuration: ' + data.error, 'error');
}
AniWorld.UI.showToast('Main configuration saved successfully', 'success');
await refreshStatus();
} catch (error) {
console.error('Error saving main config:', error);
AniWorld.UI.showToast('Failed to save main configuration', 'error');
}
}
/**
* Load current configuration from API
*/
async function loadCurrentConfig() {
try {
const response = await AniWorld.ApiClient.get(AniWorld.Constants.API.CONFIG);
if (!response) return null;
return await response.json();
} catch (error) {
console.error('Error loading config:', error);
return null;
}
}
/**
* Reset main configuration
*/
@@ -109,12 +133,45 @@ AniWorld.MainConfig = (function() {
*/
async function refreshStatus() {
try {
const response = await AniWorld.ApiClient.get(API.ANIME_STATUS);
if (!response) return;
const data = await response.json();
// Load full configuration
const config = await loadCurrentConfig();
if (!config) return;
document.getElementById('anime-directory-input').value = data.directory || '';
document.getElementById('series-count-input').value = data.series_count || '0';
// Populate general settings
document.getElementById('app-name-input').value = config.name || 'Aniworld';
document.getElementById('data-dir-input').value = config.data_dir || 'data';
document.getElementById('anime-directory-input').value = config.other?.anime_directory || '';
// Populate scheduler settings
document.getElementById('scheduled-rescan-enabled').checked = config.scheduler?.enabled || false;
document.getElementById('scheduled-rescan-interval').value = config.scheduler?.interval_minutes || 60;
// Populate logging settings
document.getElementById('log-level').value = config.logging?.level || 'INFO';
document.getElementById('log-file').value = config.logging?.file || '';
document.getElementById('log-max-bytes').value = config.logging?.max_bytes || '';
document.getElementById('log-backup-count').value = config.logging?.backup_count || 3;
// Populate backup settings
document.getElementById('backup-enabled').checked = config.backup?.enabled || false;
document.getElementById('backup-path').value = config.backup?.path || 'data/backups';
document.getElementById('backup-keep-days').value = config.backup?.keep_days || 30;
// Populate NFO settings
document.getElementById('tmdb-api-key').value = config.nfo?.tmdb_api_key || '';
document.getElementById('nfo-auto-create').checked = config.nfo?.auto_create || false;
document.getElementById('nfo-update-on-scan').checked = config.nfo?.update_on_scan || false;
document.getElementById('nfo-download-poster').checked = config.nfo?.download_poster !== false;
document.getElementById('nfo-download-logo').checked = config.nfo?.download_logo !== false;
document.getElementById('nfo-download-fanart').checked = config.nfo?.download_fanart !== false;
document.getElementById('nfo-image-size').value = config.nfo?.image_size || 'original';
// Get series count from status endpoint
const statusResponse = await AniWorld.ApiClient.get(API.ANIME_STATUS);
if (statusResponse) {
const statusData = await statusResponse.json();
document.getElementById('series-count-input').value = statusData.series_count || '0';
}
} catch (error) {
console.error('Error refreshing status:', error);
}
@@ -278,6 +335,36 @@ AniWorld.MainConfig = (function() {
}
}
/**
* Save backup configuration
*/
async function saveBackupConfig() {
try {
const config = await loadCurrentConfig();
if (!config) {
AniWorld.UI.showToast('Failed to load current configuration', 'error');
return;
}
// Update backup settings
config.backup = {
enabled: document.getElementById('backup-enabled').checked,
path: document.getElementById('backup-path').value.trim(),
keep_days: parseInt(document.getElementById('backup-keep-days').value) || 30
};
// Save updated config
const response = await AniWorld.ApiClient.put(AniWorld.Constants.API.CONFIG, config);
if (!response) return;
AniWorld.UI.showToast('Backup configuration saved successfully', 'success');
} catch (error) {
console.error('Error saving backup config:', error);
AniWorld.UI.showToast('Failed to save backup configuration', 'error');
}
}
// Public API
return {
save: save,
@@ -289,6 +376,7 @@ AniWorld.MainConfig = (function() {
viewBackups: viewBackups,
exportConfig: exportConfig,
validateConfig: validateConfig,
resetAllConfig: resetAllConfig
resetAllConfig: resetAllConfig,
saveBackupConfig: saveBackupConfig
};
})();

View File

@@ -92,8 +92,16 @@ AniWorld.NFOConfig = (function() {
return;
}
const nfoConfig = {
tmdb_api_key: tmdbKey ? tmdbKey.value.trim() : null,
// Get current config
const configResponse = await AniWorld.ApiClient.get(AniWorld.Constants.API.CONFIG);
if (!configResponse) {
throw new Error('Failed to load current configuration');
}
const config = await configResponse.json();
// Update NFO settings
config.nfo = {
tmdb_api_key: tmdbKey ? tmdbKey.value.trim() || null : null,
auto_create: autoCreate ? autoCreate.checked : false,
update_on_scan: updateOnScan ? updateOnScan.checked : false,
download_poster: downloadPoster ? downloadPoster.checked : true,
@@ -103,13 +111,7 @@ AniWorld.NFOConfig = (function() {
};
// Save configuration
const response = await AniWorld.ApiClient.request(
API.CONFIG,
{
method: 'PUT',
body: JSON.stringify({ nfo: nfoConfig })
}
);
const response = await AniWorld.ApiClient.put(AniWorld.Constants.API.CONFIG, config);
if (response) {
AniWorld.UI.showToast('NFO configuration saved successfully', 'success');

View File

@@ -55,24 +55,25 @@ AniWorld.SchedulerConfig = (function() {
async function save() {
try {
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
const time = document.getElementById('scheduled-rescan-time').value;
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
const interval = parseInt(document.getElementById('scheduled-rescan-interval').value) || 60;
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, {
// Get current config
const configResponse = await AniWorld.ApiClient.get(AniWorld.Constants.API.CONFIG);
if (!configResponse) return;
const config = await configResponse.json();
// Update scheduler settings
config.scheduler = {
enabled: enabled,
time: time,
auto_download_after_rescan: autoDownload
});
interval_minutes: interval
};
// Save updated config
const response = await AniWorld.ApiClient.put(AniWorld.Constants.API.CONFIG, config);
if (!response) return;
const data = await response.json();
if (data.success) {
AniWorld.UI.showToast('Scheduler configuration saved successfully', 'success');
await load();
} else {
AniWorld.UI.showToast('Failed to save config: ' + data.error, 'error');
}
AniWorld.UI.showToast('Scheduler configuration saved successfully', 'success');
await load();
} catch (error) {
console.error('Error saving scheduler config:', error);
AniWorld.UI.showToast('Failed to save scheduler configuration', 'error');

View File

@@ -186,6 +186,23 @@
</button>
</div>
<div class="modal-body">
<!-- General Settings -->
<div class="config-section">
<h4 data-text="general-settings">General Settings</h4>
<div class="config-item">
<label for="app-name-input" data-text="app-name">Application Name:</label>
<input type="text" id="app-name-input" class="input-field"
placeholder="Aniworld">
</div>
<div class="config-item">
<label for="data-dir-input" data-text="data-directory">Data Directory:</label>
<input type="text" id="data-dir-input" class="input-field"
placeholder="data">
</div>
</div>
<div class="config-item">
<label for="anime-directory-input" data-text="anime-directory">Anime Directory:</label>
<div class="input-group">
@@ -233,10 +250,18 @@
<label class="checkbox-label">
<input type="checkbox" id="scheduled-rescan-enabled">
<span class="checkbox-custom"></span>
<span data-text="enable-scheduled-rescan">Enable Daily Rescan</span>
<span data-text="enable-scheduled-rescan">Enable Scheduler</span>
</label>
</div>
<div class="config-item" id="rescan-interval-config">
<label for="scheduled-rescan-interval" data-text="rescan-interval">Check Interval (minutes):</label>
<input type="number" id="scheduled-rescan-interval" value="60" min="1" class="input-field">
<small class="config-hint" data-text="rescan-interval-hint">
How often to check for new episodes (minimum 1 minute)
</small>
</div>
<div class="config-item" id="rescan-time-config">
<label for="scheduled-rescan-time" data-text="rescan-time">Rescan Time (24h format):</label>
<input type="time" id="scheduled-rescan-time" value="03:00" class="input-field">
@@ -295,6 +320,30 @@
</select>
</div>
<div class="config-item">
<label for="log-file" data-text="log-file">Log File Path (optional):</label>
<input type="text" id="log-file" placeholder="logs/app.log" class="input-field">
<small class="config-hint" data-text="log-file-hint">
Leave empty to log to console only
</small>
</div>
<div class="config-item">
<label for="log-max-bytes" data-text="log-max-bytes">Max File Size (bytes):</label>
<input type="number" id="log-max-bytes" placeholder="10485760" min="0" class="input-field">
<small class="config-hint" data-text="log-max-bytes-hint">
Maximum size before log rotation (leave empty for no rotation)
</small>
</div>
<div class="config-item">
<label for="log-backup-count" data-text="log-backup-count">Backup Count:</label>
<input type="number" id="log-backup-count" value="3" min="0" class="input-field">
<small class="config-hint" data-text="log-backup-count-hint">
Number of rotated log files to keep
</small>
</div>
<div class="config-item">
<div class="checkbox-container">
<input type="checkbox" id="enable-console-logging">
@@ -428,6 +477,45 @@
</div>
</div>
<!-- Backup Configuration -->
<div class="config-section">
<h4 data-text="backup-config">Backup Configuration</h4>
<div class="config-item">
<label class="checkbox-label">
<input type="checkbox" id="backup-enabled">
<span class="checkbox-custom"></span>
<span data-text="backup-enabled">Enable Automatic Backups</span>
</label>
<small class="config-hint" data-text="backup-enabled-hint">
Automatically backup configuration files
</small>
</div>
<div class="config-item">
<label for="backup-path" data-text="backup-path">Backup Path:</label>
<input type="text" id="backup-path" value="data/backups" class="input-field">
<small class="config-hint" data-text="backup-path-hint">
Directory where backups will be stored
</small>
</div>
<div class="config-item">
<label for="backup-keep-days" data-text="backup-keep-days">Keep Backups (days):</label>
<input type="number" id="backup-keep-days" value="30" min="0" class="input-field">
<small class="config-hint" data-text="backup-keep-days-hint">
How many days to keep old backups
</small>
</div>
<div class="config-actions">
<button id="save-backup-config" class="btn btn-primary">
<i class="fas fa-save"></i>
<span data-text="save-backup-config">Save Backup Settings</span>
</button>
</div>
</div>
<!-- Configuration Management -->
<div class="config-section">
<h4 data-text="config-management">Configuration Management</h4>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,561 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Setup</title>
<link rel="stylesheet" href="/static/css/styles.css">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.setup-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
padding: 1rem;
}
.setup-card {
background: var(--color-surface);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 500px;
border: 1px solid var(--color-border);
}
.setup-header {
text-align: center;
margin-bottom: 2rem;
}
.setup-header .logo {
font-size: 3rem;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.setup-header h1 {
margin: 0;
color: var(--color-text);
font-size: 1.8rem;
font-weight: 600;
}
.setup-header p {
margin: 1rem 0 0 0;
color: var(--color-text-secondary);
font-size: 1rem;
line-height: 1.5;
}
.setup-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 500;
color: var(--color-text);
font-size: 0.9rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
background: var(--color-background);
color: var(--color-text);
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
}
.password-input-group {
position: relative;
}
.password-input {
padding-right: 3rem;
}
.password-toggle {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
transition: color 0.2s ease;
}
.password-toggle:hover {
color: var(--color-primary);
}
.password-strength {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;
}
.strength-bar {
flex: 1;
height: 4px;
background: var(--color-border);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.strength-bar.active.weak {
background: var(--color-error);
}
.strength-bar.active.fair {
background: var(--color-warning);
}
.strength-bar.active.good {
background: var(--color-info);
}
.strength-bar.active.strong {
background: var(--color-success);
}
.strength-text {
font-size: 0.8rem;
color: var(--color-text-secondary);
margin-top: 0.25rem;
}
.form-help {
font-size: 0.8rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.setup-button {
width: 100%;
padding: 0.75rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.setup-button:hover:not(:disabled) {
background: var(--color-primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3);
}
.setup-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.error-message {
background: var(--color-error-light);
color: var(--color-error);
padding: 0.75rem;
border-radius: 8px;
border: 1px solid var(--color-error);
font-size: 0.9rem;
}
.success-message {
background: var(--color-success-light);
color: var(--color-success);
padding: 0.75rem;
border-radius: 8px;
border: 1px solid var(--color-success);
font-size: 0.9rem;
}
.security-tips {
margin-top: 1.5rem;
padding: 1rem;
background: var(--color-info-light);
border: 1px solid var(--color-info);
border-radius: 8px;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.security-tips h4 {
margin: 0 0 0.5rem 0;
color: var(--color-info);
font-size: 0.9rem;
}
.security-tips ul {
margin: 0;
padding-left: 1.2rem;
line-height: 1.4;
}
.theme-toggle {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.loading-spinner {
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="setup-container">
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<i class="fas fa-moon"></i>
</button>
<div class="setup-card">
<div class="setup-header">
<div class="logo">
<i class="fas fa-play-circle"></i>
</div>
<h1>Welcome to AniWorld Manager</h1>
<p>Let's set up your master password to secure your anime collection.</p>
</div>
<form class="setup-form" id="setup-form">
<div class="form-group">
<label for="directory" class="form-label">Anime Directory</label>
<input type="text" id="directory" name="directory" class="form-input" placeholder="C:\Anime"
value="{{ current_directory }}" required>
<div class="form-help">
The directory where your anime series are stored. This can be changed later in settings.
</div>
</div>
<div class="form-group">
<label for="password" class="form-label">Master Password</label>
<div class="password-input-group">
<input type="password" id="password" name="password" class="form-input password-input"
placeholder="Create a strong password" required minlength="8">
<button type="button" class="password-toggle" id="password-toggle" tabindex="-1">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="password-strength">
<div class="strength-bar" id="strength-1"></div>
<div class="strength-bar" id="strength-2"></div>
<div class="strength-bar" id="strength-3"></div>
<div class="strength-bar" id="strength-4"></div>
</div>
<div class="strength-text" id="strength-text">Password strength will be shown here</div>
</div>
<div class="form-group">
<label for="confirm-password" class="form-label">Confirm Password</label>
<div class="password-input-group">
<input type="password" id="confirm-password" name="confirm-password"
class="form-input password-input" placeholder="Confirm your password" required
minlength="8">
<button type="button" class="password-toggle" id="confirm-password-toggle" tabindex="-1">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div id="message-container"></div>
<button type="submit" class="setup-button" id="setup-button">
<i class="fas fa-check"></i>
<span>Complete Setup</span>
</button>
</form>
<div class="security-tips">
<h4><i class="fas fa-shield-alt"></i> Security Tips</h4>
<ul>
<li>Use a password with at least 12 characters</li>
<li>Include uppercase, lowercase, numbers, and symbols</li>
<li>Don't use personal information or common words</li>
<li>Consider using a password manager</li>
</ul>
</div>
</div>
</div>
<script>
// Theme toggle functionality
const themeToggle = document.getElementById('theme-toggle');
const htmlElement = document.documentElement;
const savedTheme = localStorage.getItem('theme') || 'light';
htmlElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
themeToggle.addEventListener('click', () => {
const currentTheme = htmlElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
htmlElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
});
function updateThemeIcon(theme) {
const icon = themeToggle.querySelector('i');
icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
// Password visibility toggles
document.querySelectorAll('.password-toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
const input = toggle.parentElement.querySelector('input');
const type = input.getAttribute('type');
const newType = type === 'password' ? 'text' : 'password';
const icon = toggle.querySelector('i');
input.setAttribute('type', newType);
icon.className = newType === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
});
});
// Password strength checker
const passwordInput = document.getElementById('password');
const strengthBars = document.querySelectorAll('.strength-bar');
const strengthText = document.getElementById('strength-text');
passwordInput.addEventListener('input', () => {
const password = passwordInput.value;
const strength = calculatePasswordStrength(password);
updatePasswordStrength(strength);
});
function calculatePasswordStrength(password) {
let score = 0;
let feedback = [];
// Length check
if (password.length >= 8) score++;
if (password.length >= 12) score++;
// Character variety
if (/[a-z]/.test(password)) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
// Penalties
if (password.length < 8) {
feedback.push('Too short');
score = Math.max(0, score - 2);
}
if (!/[A-Z]/.test(password)) feedback.push('Add uppercase');
if (!/[0-9]/.test(password)) feedback.push('Add numbers');
if (!/[^A-Za-z0-9]/.test(password)) feedback.push('Add symbols');
const strengthLevels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong'];
const strengthLevel = Math.min(Math.floor(score / 1.2), 5);
return {
score: Math.min(score, 6),
level: strengthLevel,
text: strengthLevels[strengthLevel],
feedback
};
}
function updatePasswordStrength(strength) {
const colors = ['weak', 'weak', 'fair', 'good', 'strong', 'strong'];
const color = colors[strength.level];
strengthBars.forEach((bar, index) => {
bar.className = 'strength-bar';
if (index < strength.score) {
bar.classList.add('active', color);
}
});
if (passwordInput.value) {
let text = `Password strength: ${strength.text}`;
if (strength.feedback.length > 0) {
text += ` (${strength.feedback.join(', ')})`;
}
strengthText.textContent = text;
strengthText.style.color = strength.level >= 3 ? 'var(--color-success)' : 'var(--color-warning)';
} else {
strengthText.textContent = 'Password strength will be shown here';
strengthText.style.color = 'var(--color-text-secondary)';
}
}
// Form submission
const setupForm = document.getElementById('setup-form');
const setupButton = document.getElementById('setup-button');
const messageContainer = document.getElementById('message-container');
const confirmPasswordInput = document.getElementById('confirm-password');
const directoryInput = document.getElementById('directory');
// Real-time password confirmation
confirmPasswordInput.addEventListener('input', validatePasswordMatch);
passwordInput.addEventListener('input', validatePasswordMatch);
function validatePasswordMatch() {
const password = passwordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (confirmPassword && password !== confirmPassword) {
confirmPasswordInput.setCustomValidity('Passwords do not match');
confirmPasswordInput.style.borderColor = 'var(--color-error)';
} else {
confirmPasswordInput.setCustomValidity('');
confirmPasswordInput.style.borderColor = 'var(--color-border)';
}
}
setupForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = passwordInput.value;
const confirmPassword = confirmPasswordInput.value;
const directory = directoryInput.value.trim();
if (password !== confirmPassword) {
showMessage('Passwords do not match', 'error');
return;
}
const strength = calculatePasswordStrength(password);
if (strength.level < 2) {
showMessage('Password is too weak. Please use a stronger password.', 'error');
return;
}
if (!directory) {
showMessage('Please enter a valid anime directory', 'error');
return;
}
setLoading(true);
try {
const response = await fetch('/api/auth/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
master_password: password,
anime_directory: directory
})
});
const data = await response.json();
if (response.ok && data.status === 'ok') {
showMessage('Setup completed successfully! Redirecting to login...', 'success');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
} else {
const errorMessage = data.detail || data.message || 'Setup failed';
showMessage(errorMessage, 'error');
}
} catch (error) {
showMessage('Setup failed. Please try again.', 'error');
console.error('Setup error:', error);
} finally {
setLoading(false);
}
});
function showMessage(message, type) {
messageContainer.innerHTML = `
<div class="${type}-message">
${message}
</div>
`;
}
function setLoading(loading) {
setupButton.disabled = loading;
const buttonText = setupButton.querySelector('span');
const buttonIcon = setupButton.querySelector('i');
if (loading) {
buttonIcon.className = 'loading-spinner';
buttonText.textContent = 'Setting up...';
} else {
buttonIcon.className = 'fas fa-check';
buttonText.textContent = 'Complete Setup';
}
}
// Clear message on input
[passwordInput, confirmPasswordInput, directoryInput].forEach(input => {
input.addEventListener('input', () => {
messageContainer.innerHTML = '';
});
});
</script>
</body>
</html>