enhanced server settings section
This commit is contained in:
@@ -1,43 +1,53 @@
|
||||
import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import {getConfig, setConfig} from '../../shared/config';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getConfig, setConfig } from '../../shared/config';
|
||||
|
||||
export async function getFullConfig(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
return getConfig();
|
||||
} catch (err) {
|
||||
const { values, schema } = getConfig();
|
||||
return { values, schema };
|
||||
} catch {
|
||||
return { error: "Error loading config" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigSection(req: FastifyRequest<{ Params: { section: string } }>, reply: FastifyReply) {
|
||||
export async function getConfigSection(
|
||||
req: FastifyRequest<{ Params: { section: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { section } = req.params;
|
||||
const config = getConfig();
|
||||
const { values } = getConfig();
|
||||
|
||||
if (config[section] === undefined) {
|
||||
if (values[section] === undefined) {
|
||||
return { error: "Section not found" };
|
||||
}
|
||||
|
||||
return { [section]: config[section] };
|
||||
} catch (err) {
|
||||
return { [section]: values[section] };
|
||||
} catch {
|
||||
return { error: "Error loading config section" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConfig(req: FastifyRequest<{ Body: any }>, reply: FastifyReply) {
|
||||
export async function updateConfig(
|
||||
req: FastifyRequest<{ Body: any }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
return setConfig(req.body);
|
||||
} catch (err) {
|
||||
return setConfig(req.body); // schema nunca se toca
|
||||
} catch {
|
||||
return { error: "Error updating config" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConfigSection(req: FastifyRequest<{ Params: { section: string }, Body: any }>, reply: FastifyReply) {
|
||||
export async function updateConfigSection(
|
||||
req: FastifyRequest<{ Params: { section: string }, Body: any }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { section } = req.params;
|
||||
const updatedConfig = setConfig({ [section]: req.body });
|
||||
return { [section]: updatedConfig[section] };
|
||||
} catch (err) {
|
||||
const updatedValues = setConfig({ [section]: req.body });
|
||||
return { [section]: updatedValues[section] };
|
||||
} catch {
|
||||
return { error: "Error updating config section" };
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,8 @@ async function getOrCreateEntry(
|
||||
throw new Error('METADATA_NOT_FOUND');
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const basePath = config.library?.[type];
|
||||
const { values } = loadConfig();
|
||||
const basePath = values.library?.[type];
|
||||
|
||||
if (!basePath) {
|
||||
throw new Error(`NO_LIBRARY_PATH_FOR_${type.toUpperCase()}`);
|
||||
|
||||
@@ -127,9 +127,9 @@ export async function resolveEntryMetadata(entry: any, type: string) {
|
||||
}
|
||||
|
||||
export async function performLibraryScan(mode: 'full' | 'incremental' = 'incremental') {
|
||||
const config = loadConfig();
|
||||
const { values } = loadConfig();
|
||||
|
||||
if (!config.library) {
|
||||
if (!values.library) {
|
||||
throw new Error('NO_LIBRARY_CONFIGURED');
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function performLibraryScan(mode: 'full' | 'incremental' = 'increme
|
||||
await run(`DELETE FROM local_entries`, [], 'local_library');
|
||||
}
|
||||
|
||||
for (const [type, basePath] of Object.entries(config.library)) {
|
||||
for (const [type, basePath] of Object.entries(values.library)) {
|
||||
if (!basePath || !fs.existsSync(<PathLike>basePath)) continue;
|
||||
|
||||
const dirs = fs.readdirSync(<string>basePath, { withFileTypes: true }).filter(d => d.isDirectory());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const API_BASE = '/api/config';
|
||||
let currentConfig = {};
|
||||
let currentSchema = {};
|
||||
let activeSection = '';
|
||||
let modal, navContainer, formContent, form;
|
||||
|
||||
@@ -9,96 +10,74 @@ window.toggleSettingsModal = async (forceClose = false) => {
|
||||
formContent = document.getElementById('config-section-content');
|
||||
form = document.getElementById('config-form');
|
||||
|
||||
if (!modal) {
|
||||
console.error('Modal not found');
|
||||
return;
|
||||
}
|
||||
if (!modal) return;
|
||||
|
||||
if (forceClose) {
|
||||
modal.classList.add('hidden');
|
||||
} else {
|
||||
const isHidden = modal.classList.contains('hidden');
|
||||
|
||||
if (isHidden) {
|
||||
// Abrir modal
|
||||
modal.classList.remove('hidden');
|
||||
await loadSettings();
|
||||
} else {
|
||||
// Cerrar modal
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function loadSettings() {
|
||||
if (!formContent) {
|
||||
console.error('Form content not found');
|
||||
return;
|
||||
}
|
||||
if (!formContent) return;
|
||||
|
||||
// Mostrar loading
|
||||
// Loading State
|
||||
formContent.innerHTML = `
|
||||
<div class="skeleton-loader">
|
||||
<div class="skeleton title-skeleton"></div>
|
||||
<div class="skeleton text-skeleton"></div>
|
||||
<div class="skeleton text-skeleton"></div>
|
||||
<div class="skeleton field-skeleton"></div>
|
||||
<div class="skeleton field-skeleton"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const res = await fetch(API_BASE);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
currentConfig = data;
|
||||
// --- CORRECCIÓN AQUI ---
|
||||
// Tu JSON devuelve "values", el código original buscaba "config"
|
||||
currentConfig = data.values || data.config || data;
|
||||
currentSchema = data.schema || {};
|
||||
|
||||
renderNav();
|
||||
|
||||
// Seleccionar la primera sección si no hay ninguna activa
|
||||
if (!activeSection || !currentConfig[activeSection]) {
|
||||
activeSection = Object.keys(currentConfig)[0];
|
||||
}
|
||||
|
||||
switchSection(activeSection);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading settings:', err);
|
||||
formContent.innerHTML = `
|
||||
<div style="padding: 2rem; text-align: center;">
|
||||
<p style="color: var(--color-danger); margin-bottom: 1rem;">Failed to load settings</p>
|
||||
<p style="color: var(--color-text-muted); font-size: 0.9rem;">${err.message}</p>
|
||||
<p style="color: #ef4444; margin-bottom: 1rem;">Failed to load settings</p>
|
||||
<p style="color: #888; font-size: 0.9rem;">${err.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
if (!navContainer) return;
|
||||
|
||||
navContainer.innerHTML = '';
|
||||
Object.keys(currentConfig).forEach(section => {
|
||||
const btn = document.createElement('div');
|
||||
btn.className = `nav-item ${section === activeSection ? 'active' : ''}`;
|
||||
btn.textContent = section;
|
||||
btn.onclick = () => switchSection(section);
|
||||
navContainer.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function switchSection(section) {
|
||||
if (!currentConfig[section]) return;
|
||||
|
||||
activeSection = section;
|
||||
renderNav();
|
||||
|
||||
const sectionData = currentConfig[section];
|
||||
const sectionSchema = currentSchema[section] || {};
|
||||
|
||||
formContent.innerHTML = `
|
||||
<h2 class="section-title" style="margin-bottom: 2rem; text-transform: capitalize;">
|
||||
<h2 class="section-title" style="margin-bottom: 2rem; text-transform: capitalize; font-size: 1.8rem;">
|
||||
${section.replace(/_/g, ' ')}
|
||||
</h2>
|
||||
`;
|
||||
@@ -109,21 +88,34 @@ function switchSection(section) {
|
||||
|
||||
const isBool = typeof value === 'boolean';
|
||||
const inputId = `input-${section}-${key}`;
|
||||
const label = key.replace(/_/g, ' ');
|
||||
const labelText = key.replace(/_/g, ' ');
|
||||
|
||||
// Obtener descripción
|
||||
const description = sectionSchema[key]?.description || '';
|
||||
const descHtml = description
|
||||
? `<p class="config-description">${description}</p>`
|
||||
: '';
|
||||
|
||||
if (isBool) {
|
||||
group.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="${inputId}" name="${key}" ${value ? 'checked' : ''}>
|
||||
<label for="${inputId}" style="margin: 0; cursor: pointer;">${label}</label>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 0.8rem;">
|
||||
<input type="checkbox" id="${inputId}" name="${key}" ${value ? 'checked' : ''}
|
||||
style="width: 20px; height: 20px; accent-color: var(--accent);">
|
||||
<label for="${inputId}" style="margin: 0; cursor: pointer; font-size: 1rem;">${labelText}</label>
|
||||
</div>
|
||||
${descHtml} </div>
|
||||
`;
|
||||
} else {
|
||||
// --- CAMBIO PRINCIPAL AQUI ---
|
||||
// Movimos ${descHtml} para que esté DESPUÉS del input
|
||||
group.innerHTML = `
|
||||
<label for="${inputId}">${label}</label>
|
||||
<label for="${inputId}">${labelText}</label>
|
||||
<input class="config-input" id="${inputId}" name="${key}"
|
||||
type="${typeof value === 'number' ? 'number' : 'text'}"
|
||||
value="${value}">
|
||||
value="${value || ''}"
|
||||
placeholder="Not set">
|
||||
${descHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -131,9 +123,27 @@ function switchSection(section) {
|
||||
});
|
||||
}
|
||||
|
||||
// Setup form submit handler
|
||||
function renderNav() {
|
||||
if (!navContainer) return;
|
||||
navContainer.innerHTML = '';
|
||||
|
||||
Object.keys(currentConfig).forEach(section => {
|
||||
const btn = document.createElement('div');
|
||||
btn.className = `nav-item ${section === activeSection ? 'active' : ''}`;
|
||||
|
||||
// Icono opcional según la sección (puedes personalizar esto)
|
||||
let icon = '';
|
||||
if(section === 'library') icon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>';
|
||||
if(section === 'paths') icon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><folder></folder><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>';
|
||||
|
||||
btn.innerHTML = `${icon} ${section}`;
|
||||
btn.onclick = () => switchSection(section);
|
||||
navContainer.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
// Handler de guardado
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Usar delegación de eventos ya que el form se carga dinámicamente
|
||||
document.addEventListener('submit', async (e) => {
|
||||
if (e.target.id === 'config-form') {
|
||||
e.preventDefault();
|
||||
@@ -146,8 +156,9 @@ async function saveSettings() {
|
||||
if (!form || !activeSection) return;
|
||||
|
||||
const updatedData = {};
|
||||
const sectionConfig = currentConfig[activeSection];
|
||||
|
||||
Object.keys(currentConfig[activeSection]).forEach(key => {
|
||||
Object.keys(sectionConfig).forEach(key => {
|
||||
const input = form.elements[key];
|
||||
if (!input) return;
|
||||
|
||||
@@ -168,51 +179,35 @@ async function saveSettings() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
currentConfig[activeSection] = updatedData;
|
||||
|
||||
// Mostrar notificación de éxito
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: var(--color-success, #10b981);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 10000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
notification.textContent = 'Settings saved successfully!';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideOut 0.3s ease-out';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 2000);
|
||||
currentConfig[activeSection] = updatedData; // Actualizamos localmente
|
||||
showNotification('Settings saved successfully!');
|
||||
} else {
|
||||
throw new Error('Failed to save settings');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
alert('Error saving settings: ' + err.message);
|
||||
console.error(err);
|
||||
showNotification('Error saving settings', true);
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir estilos para las animaciones (solo si no existen)
|
||||
if (!document.getElementById('settings-animations')) {
|
||||
const animationStyles = document.createElement('style');
|
||||
animationStyles.id = 'settings-animations';
|
||||
animationStyles.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(400px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(400px); opacity: 0; }
|
||||
}
|
||||
function showNotification(msg, isError = false) {
|
||||
const notification = document.createElement('div');
|
||||
const bg = isError ? '#ef4444' : '#10b981';
|
||||
|
||||
notification.style.cssText = `
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
background: ${bg}; color: white;
|
||||
padding: 1rem 1.5rem; border-radius: 8px; font-weight: 600;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5); z-index: 20000;
|
||||
animation: slideIn 0.3s ease-out; display: flex; align-items: center; gap: 10px;
|
||||
`;
|
||||
document.head.appendChild(animationStyles);
|
||||
notification.innerHTML = isError
|
||||
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> ${msg}`
|
||||
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> ${msg}`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideOut 0.3s ease-out';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
@@ -11,6 +11,22 @@ const DEFAULT_CONFIG = {
|
||||
anime: null,
|
||||
manga: null,
|
||||
novels: null
|
||||
},
|
||||
paths: {
|
||||
mpv: null,
|
||||
ffmpeg: null
|
||||
}
|
||||
};
|
||||
|
||||
export const CONFIG_SCHEMA = {
|
||||
library: {
|
||||
anime: { description: "Path where anime is stored" },
|
||||
manga: { description: "Path where manga is stored" },
|
||||
novels: { description: "Path where novels are stored" }
|
||||
},
|
||||
paths: {
|
||||
mpv: { description: "Required to open anime episodes in mpv on desktop version." },
|
||||
ffmpeg: { description: "Required for downloading anime episodes." }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,7 +47,12 @@ function ensureConfigFile() {
|
||||
export function getConfig() {
|
||||
ensureConfigFile();
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
return yaml.load(raw) || DEFAULT_CONFIG;
|
||||
const loaded = yaml.load(raw) || {};
|
||||
|
||||
return {
|
||||
values: deepMerge(structuredClone(DEFAULT_CONFIG), loaded),
|
||||
schema: CONFIG_SCHEMA
|
||||
};
|
||||
}
|
||||
|
||||
export function setConfig(partialConfig) {
|
||||
|
||||
Reference in New Issue
Block a user