feat(nfo): implement NFO diagnostics and repair
- Add NFO API endpoints: diagnostics, repair, validate, needs-repair - Create /settings/nfo page with full NFO management UI - Add NFO status section to edit modal with repair functionality - Add anime details API for edit modal pre-fill data - Fix auth test fixtures in test_nfo_diagnostics_repair.py Implements NFO diagnostics when editing anime series via right-click menu. Users can now check NFO status, see missing tags, and repair NFOs directly from the edit modal or the dedicated NFO settings page.
This commit is contained in:
@@ -469,4 +469,101 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Series Info Grid (Read-only display) */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-sm) var(--spacing-lg);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item .info-value {
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Input with action button */
|
||||
.input-with-action {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-with-action .input-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-fetch-tmdb {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* TMDB Search Results */
|
||||
.tmdb-search-results {
|
||||
margin-top: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-surface);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tmdb-result-item {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tmdb-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tmdb-result-item:hover {
|
||||
background: var(--color-background-subtle);
|
||||
}
|
||||
|
||||
.tmdb-result-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tmdb-result-overview {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tmdb-error,
|
||||
.tmdb-no-results,
|
||||
.tmdb-selected {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tmdb-error {
|
||||
color: var(--color-error, #e74c3c);
|
||||
}
|
||||
|
||||
.tmdb-selected {
|
||||
color: var(--color-success, #2ecc71);
|
||||
}
|
||||
|
||||
|
||||
|
||||
427
src/server/web/static/css/pages/nfo-settings.css
Normal file
427
src/server/web/static/css/pages/nfo-settings.css
Normal file
@@ -0,0 +1,427 @@
|
||||
/* NFO Settings Page Styles */
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.header-left .back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.header-left .back-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.header-center h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Stats Bar */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-missing .stat-value {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.stat-incomplete .stat-value {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.stat-complete .stat-value {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.search-box i {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-box .input-field {
|
||||
width: 100%;
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-btn .count {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-btn:not(.active) .count {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Series List */
|
||||
.series-list-container {
|
||||
flex: 1;
|
||||
padding: 1rem 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.series-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-state i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
color: var(--color-success);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Series Card */
|
||||
.series-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.series-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.series-card.missing {
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
|
||||
.series-card.incomplete {
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.series-card.complete {
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.series-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.series-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.series-folder {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.nfo-status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nfo-status-badge.missing,
|
||||
.nfo-status-badge.no-nfo {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.nfo-status-badge.incomplete {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.nfo-status-badge.complete {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.missing-tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.missing-tag-chip {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-background);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 500px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.detail-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.status-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nfo-path {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-buttons .btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nfo-preview {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nfo-preview code {
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
min-width: 100px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.series-list-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.series-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ var AniWorld = window.AniWorld || {};
|
||||
AniWorld.EditModal = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants ? AniWorld.Constants.API : {};
|
||||
|
||||
let modalElement = null;
|
||||
let originalData = null;
|
||||
let currentKey = null;
|
||||
@@ -34,13 +36,26 @@ AniWorld.EditModal = (function() {
|
||||
hideKeyWarning();
|
||||
|
||||
try {
|
||||
// Find series data from the local series list
|
||||
const seriesData = findSeriesData(seriesKey);
|
||||
// Try to find series data from the local series list first
|
||||
let seriesData = findSeriesData(seriesKey);
|
||||
|
||||
// If not found locally, fetch from API
|
||||
if (!seriesData) {
|
||||
seriesData = await fetchSeriesDetails(seriesKey);
|
||||
}
|
||||
|
||||
originalData = {
|
||||
key: seriesKey,
|
||||
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
||||
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
||||
name: seriesData ? seriesData.name : seriesKey,
|
||||
year: seriesData ? seriesData.year : null,
|
||||
status: seriesData ? seriesData.status : null,
|
||||
genres: seriesData ? (seriesData.genres || []) : [],
|
||||
studio: seriesData ? (seriesData.studio || []) : [],
|
||||
premiered: seriesData ? seriesData.premiered : null,
|
||||
rating: seriesData ? seriesData.rating : null,
|
||||
rating_votes: seriesData ? seriesData.rating_votes : null,
|
||||
};
|
||||
|
||||
// Populate form fields
|
||||
@@ -48,6 +63,12 @@ AniWorld.EditModal = (function() {
|
||||
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
||||
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
||||
|
||||
// Populate display fields
|
||||
populateDisplayFields(originalData);
|
||||
|
||||
// Show/hide TMDB fetch button based on whether TMDB ID exists
|
||||
updateTmdbFetchButtonState();
|
||||
|
||||
// Load NFO diagnostics
|
||||
await loadDiagnostics(seriesKey);
|
||||
|
||||
@@ -62,6 +83,128 @@ AniWorld.EditModal = (function() {
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch series details from the API.
|
||||
* @param {string} key - Series key
|
||||
* @returns {Promise<Object|null>} Series details or null on error
|
||||
*/
|
||||
async function fetchSeriesDetails(key) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(
|
||||
'/api/anime/' + encodeURIComponent(key) + '/details'
|
||||
);
|
||||
if (response && response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch series details:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate display-only fields in the modal.
|
||||
* @param {Object} data - Series data
|
||||
*/
|
||||
function populateDisplayFields(data) {
|
||||
const fields = [
|
||||
{ id: 'edit-name', value: data.name || '' },
|
||||
{ id: 'edit-year', value: data.year || '' },
|
||||
{ id: 'edit-status', value: data.status || '' },
|
||||
{ id: 'edit-genres', value: (data.genres || []).join(', ') },
|
||||
{ id: 'edit-studio', value: (data.studio || []).join(', ') },
|
||||
{ id: 'edit-premiered', value: data.premiered || '' },
|
||||
{ id: 'edit-rating', value: data.rating ? data.rating.toFixed(1) + '/10' : '' },
|
||||
];
|
||||
|
||||
fields.forEach(function(field) {
|
||||
const el = document.getElementById(field.id);
|
||||
if (el) el.textContent = field.value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the TMDB fetch button visibility.
|
||||
*/
|
||||
function updateTmdbFetchButtonState() {
|
||||
const btn = document.getElementById('btn-fetch-tmdb');
|
||||
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||
|
||||
if (btn) {
|
||||
btn.style.display = tmdbValue ? 'none' : 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch TMDB data for auto-fill.
|
||||
*/
|
||||
async function fetchTmdbData() {
|
||||
const btn = document.getElementById('btn-fetch-tmdb');
|
||||
const resultsContainer = document.getElementById('tmdb-search-results');
|
||||
if (!btn || !resultsContainer) return;
|
||||
|
||||
// Show loading state
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
|
||||
resultsContainer.innerHTML = '';
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(
|
||||
'/api/anime/' + encodeURIComponent(currentKey) + '/tmdb-search'
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-error">Connection error</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 400) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-error">TMDB not configured</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
|
||||
if (results.length === 0) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-no-results">No matches found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render results
|
||||
resultsContainer.innerHTML = results.slice(0, 5).map(function(r) {
|
||||
const year = r.year ? ' (' + r.year + ')' : '';
|
||||
const rating = r.vote_average ? ' ★' + r.vote_average.toFixed(1) : '';
|
||||
return '<div class="tmdb-result-item" data-tmdb-id="' + r.tmdb_id + '">' +
|
||||
'<span class="tmdb-result-title">' + escapeHtml(r.title) + year + rating + '</span>' +
|
||||
'<span class="tmdb-result-overview">' + escapeHtml(r.overview || '') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// Attach click handlers to results
|
||||
resultsContainer.querySelectorAll('.tmdb-result-item').forEach(function(item) {
|
||||
item.addEventListener('click', function() {
|
||||
const tmdbId = this.getAttribute('data-tmdb-id');
|
||||
setFieldValue('edit-tmdb-id', tmdbId);
|
||||
resultsContainer.innerHTML = '<p class="tmdb-selected">TMDB ID ' + tmdbId + ' selected</p>';
|
||||
updateTmdbFetchButtonState();
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
|
||||
console.error('TMDB search error:', err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-search"></i> Fetch from TMDB';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the edit modal and reset state.
|
||||
*/
|
||||
@@ -272,6 +415,7 @@ AniWorld.EditModal = (function() {
|
||||
function updateRepairButtonState() {
|
||||
const btn = document.getElementById('btn-repair-nfo');
|
||||
const hint = document.getElementById('repair-hint');
|
||||
const diagnosticsLink = document.getElementById('btn-open-nfo-diagnostics');
|
||||
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||
|
||||
if (btn) {
|
||||
@@ -281,6 +425,11 @@ AniWorld.EditModal = (function() {
|
||||
if (hint) {
|
||||
hint.style.display = tmdbValue ? 'none' : 'block';
|
||||
}
|
||||
// Show link to full diagnostics page
|
||||
if (diagnosticsLink && currentKey) {
|
||||
diagnosticsLink.href = '/settings/nfo?key=' + encodeURIComponent(currentKey);
|
||||
diagnosticsLink.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
@@ -395,8 +544,10 @@ AniWorld.EditModal = (function() {
|
||||
const saveBtn = document.getElementById('btn-save-metadata');
|
||||
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
||||
const repairBtn = document.getElementById('btn-repair-nfo');
|
||||
const fetchTmdbBtn = document.getElementById('btn-fetch-tmdb');
|
||||
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
||||
const keyInput = document.getElementById('edit-key');
|
||||
const tmdbInput = document.getElementById('edit-tmdb-id');
|
||||
|
||||
if (saveBtn) {
|
||||
var saveFn = function() { save(); };
|
||||
@@ -416,6 +567,12 @@ AniWorld.EditModal = (function() {
|
||||
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
||||
}
|
||||
|
||||
if (fetchTmdbBtn) {
|
||||
var fetchTmdbFn = function() { fetchTmdbData(); };
|
||||
fetchTmdbBtn.addEventListener('click', fetchTmdbFn);
|
||||
listeners.push({ el: fetchTmdbBtn, event: 'click', fn: fetchTmdbFn });
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
var overlayFn = function() { close(); };
|
||||
overlay.addEventListener('click', overlayFn);
|
||||
@@ -432,6 +589,14 @@ AniWorld.EditModal = (function() {
|
||||
keyInput.addEventListener('input', keyFn);
|
||||
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
||||
}
|
||||
|
||||
if (tmdbInput) {
|
||||
var tmdbFn = function() {
|
||||
updateTmdbFetchButtonState();
|
||||
};
|
||||
tmdbInput.addEventListener('input', tmdbFn);
|
||||
listeners.push({ el: tmdbInput, event: 'input', fn: tmdbFn });
|
||||
}
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
|
||||
587
src/server/web/static/js/pages/nfo-settings.js
Normal file
587
src/server/web/static/js/pages/nfo-settings.js
Normal file
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* AniWorld - NFO Settings Page
|
||||
*
|
||||
* Diagnostics and repair interface for NFO metadata files.
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.NfoSettings = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants ? AniWorld.Constants.API : {};
|
||||
|
||||
// State
|
||||
let allSeries = [];
|
||||
let filteredSeries = [];
|
||||
let currentFilter = 'all';
|
||||
let currentSeries = null;
|
||||
let searchQuery = '';
|
||||
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
seriesList: document.getElementById('series-list'),
|
||||
loadingState: document.getElementById('loading-state'),
|
||||
emptyState: document.getElementById('empty-state'),
|
||||
searchInput: document.getElementById('search-input'),
|
||||
detailPanel: document.getElementById('detail-panel'),
|
||||
panelTitle: document.getElementById('panel-title'),
|
||||
panelStatusBadge: document.getElementById('panel-status-badge'),
|
||||
panelNfoPath: document.getElementById('panel-nfo-path'),
|
||||
panelMissingTags: document.getElementById('panel-missing-tags'),
|
||||
missingTagsSection: document.getElementById('missing-tags-section'),
|
||||
nfoPreviewSection: document.getElementById('nfo-preview-section'),
|
||||
nfoPreviewContent: document.getElementById('nfo-preview-content'),
|
||||
btnScanAll: document.getElementById('btn-scan-all'),
|
||||
btnRepairAll: document.getElementById('btn-repair-all'),
|
||||
btnClosePanel: document.getElementById('btn-close-panel'),
|
||||
btnRepairSingle: document.getElementById('btn-repair-single'),
|
||||
btnValidate: document.getElementById('btn-validate'),
|
||||
btnViewNfo: document.getElementById('btn-view-nfo'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the page
|
||||
*/
|
||||
async function init() {
|
||||
// Check auth first
|
||||
if (!AniWorld.Auth || !AniWorld.Auth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
attachEventListeners();
|
||||
await loadSeriesNeedingRepair();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners
|
||||
*/
|
||||
function attachEventListeners() {
|
||||
// Search
|
||||
if (elements.searchInput) {
|
||||
elements.searchInput.addEventListener('input', debounce(handleSearch, 300));
|
||||
}
|
||||
|
||||
// Filter buttons
|
||||
document.querySelectorAll('.filter-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const filter = this.getAttribute('data-filter');
|
||||
setFilter(filter);
|
||||
});
|
||||
});
|
||||
|
||||
// Bulk actions
|
||||
if (elements.btnScanAll) {
|
||||
elements.btnScanAll.addEventListener('click', handleScanAll);
|
||||
}
|
||||
if (elements.btnRepairAll) {
|
||||
elements.btnRepairAll.addEventListener('click', handleRepairAll);
|
||||
}
|
||||
|
||||
// Panel close
|
||||
if (elements.btnClosePanel) {
|
||||
elements.btnClosePanel.addEventListener('click', closePanel);
|
||||
}
|
||||
|
||||
// Panel overlay click
|
||||
const overlay = elements.detailPanel?.querySelector('.panel-overlay');
|
||||
if (overlay) {
|
||||
overlay.addEventListener('click', closePanel);
|
||||
}
|
||||
|
||||
// Single series actions
|
||||
if (elements.btnRepairSingle) {
|
||||
elements.btnRepairSingle.addEventListener('click', handleRepairSingle);
|
||||
}
|
||||
if (elements.btnValidate) {
|
||||
elements.btnValidate.addEventListener('click', handleValidate);
|
||||
}
|
||||
if (elements.btnViewNfo) {
|
||||
elements.btnViewNfo.addEventListener('click', handleViewNfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all series needing repair
|
||||
*/
|
||||
async function loadSeriesNeedingRepair() {
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.NFO_NEEDS_REPAIR || '/api/nfo/needs-repair');
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to load NFO status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
processNeedsRepairResponse(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load series:', err);
|
||||
AniWorld.UI.showToast('Failed to load series data', 'error');
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the needs-repair response
|
||||
*/
|
||||
function processNeedsRepairResponse(data) {
|
||||
allSeries = data.series || [];
|
||||
|
||||
// Calculate complete count
|
||||
const total = data.total || 0;
|
||||
const missing = data.missing_nfo_count || 0;
|
||||
const incomplete = data.incomplete_nfo_count || 0;
|
||||
const complete = total - missing - incomplete;
|
||||
|
||||
// Update stats
|
||||
updateStat('total-series', total);
|
||||
updateStat('missing-nfo', missing);
|
||||
updateStat('incomplete-nfo', incomplete);
|
||||
updateStat('complete-nfo', complete);
|
||||
|
||||
// Update filter counts
|
||||
updateFilterCount('all', total);
|
||||
updateFilterCount('missing', missing);
|
||||
updateFilterCount('incomplete', incomplete);
|
||||
updateFilterCount('complete', complete);
|
||||
|
||||
// Apply filter and render
|
||||
applyFilter();
|
||||
showLoading(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a stat value
|
||||
*/
|
||||
function updateStat(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filter button count
|
||||
*/
|
||||
function updateFilterCount(filter, count) {
|
||||
const el = document.getElementById('filter-' + filter + '-count');
|
||||
if (el) el.textContent = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide loading state
|
||||
*/
|
||||
function showLoading(show) {
|
||||
if (elements.loadingState) {
|
||||
elements.loadingState.classList.toggle('hidden', !show);
|
||||
}
|
||||
if (elements.seriesList) {
|
||||
elements.seriesList.style.display = show ? 'none' : 'grid';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide empty state
|
||||
*/
|
||||
function showEmpty(show) {
|
||||
if (elements.emptyState) {
|
||||
elements.emptyState.classList.toggle('hidden', !show);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search input
|
||||
*/
|
||||
function handleSearch() {
|
||||
searchQuery = (elements.searchInput?.value || '').toLowerCase().trim();
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active filter
|
||||
*/
|
||||
function setFilter(filter) {
|
||||
currentFilter = filter;
|
||||
|
||||
// Update active button
|
||||
document.querySelectorAll('.filter-btn').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-filter') === filter);
|
||||
});
|
||||
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current filter and search to series list
|
||||
*/
|
||||
function applyFilter() {
|
||||
filteredSeries = allSeries.filter(function(series) {
|
||||
// Apply filter
|
||||
let matchesFilter = false;
|
||||
if (currentFilter === 'all') {
|
||||
matchesFilter = true;
|
||||
} else if (currentFilter === 'missing') {
|
||||
matchesFilter = !series.has_nfo;
|
||||
} else if (currentFilter === 'incomplete') {
|
||||
matchesFilter = series.has_nfo && series.missing_tags && series.missing_tags.length > 0;
|
||||
} else if (currentFilter === 'complete') {
|
||||
matchesFilter = series.has_nfo && (!series.missing_tags || series.missing_tags.length === 0);
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (matchesFilter && searchQuery) {
|
||||
const matchesSearch =
|
||||
series.name.toLowerCase().includes(searchQuery) ||
|
||||
series.folder.toLowerCase().includes(searchQuery) ||
|
||||
series.key.toLowerCase().includes(searchQuery);
|
||||
matchesFilter = matchesSearch;
|
||||
}
|
||||
|
||||
return matchesFilter;
|
||||
});
|
||||
|
||||
renderSeriesList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the series list
|
||||
*/
|
||||
function renderSeriesList() {
|
||||
if (!elements.seriesList) return;
|
||||
|
||||
if (filteredSeries.length === 0) {
|
||||
showEmpty(true);
|
||||
// Clear any existing cards but keep loading/empty states
|
||||
elements.seriesList.querySelectorAll('.series-card').forEach(function(el) { el.remove(); });
|
||||
return;
|
||||
}
|
||||
|
||||
showEmpty(false);
|
||||
|
||||
// Build HTML
|
||||
const html = filteredSeries.map(function(series) {
|
||||
const statusClass = !series.has_nfo ? 'missing' :
|
||||
(series.missing_tags && series.missing_tags.length > 0) ? 'incomplete' : 'complete';
|
||||
const statusText = !series.has_nfo ? 'No NFO' :
|
||||
(series.missing_tags && series.missing_tags.length > 0) ?
|
||||
series.missing_tags.length + ' Missing' : 'Complete';
|
||||
|
||||
return '<div class="series-card ' + statusClass + '" data-key="' + escapeHtml(series.key) + '">' +
|
||||
'<div class="series-card-header">' +
|
||||
'<div>' +
|
||||
'<h4 class="series-name">' + escapeHtml(series.name) + '</h4>' +
|
||||
'<div class="series-folder">' + escapeHtml(series.folder) + '</div>' +
|
||||
'</div>' +
|
||||
'<span class="nfo-status-badge ' + statusClass + '">' + statusText + '</span>' +
|
||||
'</div>' +
|
||||
(series.missing_tags && series.missing_tags.length > 0 ?
|
||||
'<div class="missing-tags-list">' +
|
||||
series.missing_tags.slice(0, 3).map(function(tag) {
|
||||
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
||||
}).join('') +
|
||||
(series.missing_tags.length > 3 ? '<span class="missing-tag-chip">+' + (series.missing_tags.length - 3) + '</span>' : '') +
|
||||
'</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// Clear existing cards and add new ones
|
||||
elements.seriesList.querySelectorAll('.series-card').forEach(function(el) { el.remove(); });
|
||||
elements.seriesList.insertAdjacentHTML('beforeend', html);
|
||||
|
||||
// Attach click handlers
|
||||
elements.seriesList.querySelectorAll('.series-card').forEach(function(card) {
|
||||
card.addEventListener('click', function() {
|
||||
const key = this.getAttribute('data-key');
|
||||
openDetailPanel(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open detail panel for a series
|
||||
*/
|
||||
async function openDetailPanel(key) {
|
||||
currentSeries = filteredSeries.find(function(s) { return s.key === key; });
|
||||
if (!currentSeries) return;
|
||||
|
||||
// Show panel
|
||||
elements.detailPanel.classList.remove('hidden');
|
||||
|
||||
// Populate header
|
||||
elements.panelTitle.textContent = currentSeries.name;
|
||||
|
||||
// Populate status
|
||||
updateDetailStatus();
|
||||
|
||||
// Load diagnostics for this series
|
||||
await loadDiagnosticsForSeries(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update detail panel status display
|
||||
*/
|
||||
function updateDetailStatus() {
|
||||
if (!currentSeries) return;
|
||||
|
||||
const badge = elements.panelStatusBadge;
|
||||
const pathEl = elements.panelNfoPath;
|
||||
|
||||
if (!currentSeries.has_nfo) {
|
||||
badge.className = 'nfo-status-badge missing';
|
||||
badge.textContent = 'No NFO File';
|
||||
pathEl.textContent = '';
|
||||
} else if (currentSeries.missing_tags && currentSeries.missing_tags.length > 0) {
|
||||
badge.className = 'nfo-status-badge incomplete';
|
||||
badge.textContent = currentSeries.missing_tags.length + ' Missing Tags';
|
||||
pathEl.textContent = currentSeries.nfo_path || '';
|
||||
} else {
|
||||
badge.className = 'nfo-status-badge complete';
|
||||
badge.textContent = 'Complete';
|
||||
pathEl.textContent = currentSeries.nfo_path || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load diagnostics for a specific series
|
||||
*/
|
||||
async function loadDiagnosticsForSeries(key) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get('/api/nfo/' + encodeURIComponent(key) + '/diagnostics');
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to load diagnostics');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update current series with fresh data
|
||||
if (currentSeries && currentSeries.key === key) {
|
||||
currentSeries.has_nfo = data.has_nfo;
|
||||
currentSeries.nfo_path = data.nfo_path;
|
||||
currentSeries.missing_tags = data.missing_tags;
|
||||
updateDetailStatus();
|
||||
renderMissingTags();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load diagnostics:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render missing tags in detail panel
|
||||
*/
|
||||
function renderMissingTags() {
|
||||
if (!currentSeries || !currentSeries.missing_tags || currentSeries.missing_tags.length === 0) {
|
||||
elements.missingTagsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.missingTagsSection.style.display = 'block';
|
||||
elements.panelMissingTags.innerHTML = currentSeries.missing_tags.map(function(tag) {
|
||||
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close detail panel
|
||||
*/
|
||||
function closePanel() {
|
||||
elements.detailPanel.classList.add('hidden');
|
||||
currentSeries = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scan all button
|
||||
*/
|
||||
async function handleScanAll() {
|
||||
AniWorld.UI.showToast('Scanning all series for NFO issues...', 'info');
|
||||
// Reload the data
|
||||
await loadSeriesNeedingRepair();
|
||||
AniWorld.UI.showToast('Scan complete', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle repair all button
|
||||
*/
|
||||
async function handleRepairAll() {
|
||||
const missingSeries = allSeries.filter(function(s) { return !s.has_nfo; });
|
||||
const incompleteSeries = allSeries.filter(function(s) {
|
||||
return s.has_nfo && s.missing_tags && s.missing_tags.length > 0;
|
||||
});
|
||||
|
||||
const total = missingSeries.length + incompleteSeries.length;
|
||||
if (total === 0) {
|
||||
AniWorld.UI.showToast('All NFO files are complete', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await AniWorld.UI.showConfirmModal(
|
||||
'Repair All NFO',
|
||||
'This will repair ' + total + ' series. Continue?'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
let repaired = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const series of missingSeries.concat(incompleteSeries)) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(
|
||||
'/api/nfo/' + encodeURIComponent(series.key) + '/repair',
|
||||
{}
|
||||
);
|
||||
|
||||
if (response && response.ok) {
|
||||
repaired++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
AniWorld.UI.showToast(
|
||||
'Repair complete: ' + repaired + ' repaired, ' + failed + ' failed',
|
||||
failed > 0 ? 'warning' : 'success'
|
||||
);
|
||||
|
||||
// Reload data
|
||||
await loadSeriesNeedingRepair();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle repair single series
|
||||
*/
|
||||
async function handleRepairSingle() {
|
||||
if (!currentSeries) return;
|
||||
|
||||
elements.btnRepairSingle.disabled = true;
|
||||
elements.btnRepairSingle.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Repairing...';
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(
|
||||
'/api/nfo/' + encodeURIComponent(currentSeries.key) + '/repair',
|
||||
{}
|
||||
);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const err = await response?.json();
|
||||
AniWorld.UI.showToast(err?.detail || 'Failed to repair NFO', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
AniWorld.UI.showToast(result.message, 'success');
|
||||
|
||||
// Reload diagnostics and list
|
||||
await loadDiagnosticsForSeries(currentSeries.key);
|
||||
await loadSeriesNeedingRepair();
|
||||
|
||||
// Refresh detail panel
|
||||
if (currentSeries) {
|
||||
currentSeries = filteredSeries.find(function(s) { return s.key === currentSeries.key; });
|
||||
}
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Connection error during repair', 'error');
|
||||
} finally {
|
||||
elements.btnRepairSingle.disabled = false;
|
||||
elements.btnRepairSingle.innerHTML = '<i class="fas fa-wrench"></i> Repair NFO';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle validate button
|
||||
*/
|
||||
async function handleValidate() {
|
||||
if (!currentSeries) return;
|
||||
|
||||
elements.btnValidate.disabled = true;
|
||||
elements.btnValidate.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Validating...';
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(
|
||||
'/api/nfo/' + encodeURIComponent(currentSeries.key) + '/validate'
|
||||
);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
AniWorld.UI.showToast('Failed to validate NFO', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.valid) {
|
||||
AniWorld.UI.showToast('NFO XML is valid', 'success');
|
||||
} else {
|
||||
AniWorld.UI.showToast('NFO XML is invalid: ' + (result.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Connection error during validation', 'error');
|
||||
} finally {
|
||||
elements.btnValidate.disabled = false;
|
||||
elements.btnValidate.innerHTML = '<i class="fas fa-check"></i> Validate XML';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle view NFO button
|
||||
*/
|
||||
async function handleViewNfo() {
|
||||
if (!currentSeries || !currentSeries.nfo_path) {
|
||||
AniWorld.UI.showToast('No NFO file to view', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
elements.btnViewNfo.disabled = true;
|
||||
elements.btnViewNfo.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||
|
||||
try {
|
||||
// Fetch NFO content via API
|
||||
const response = await fetch(currentSeries.nfo_path);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch NFO file');
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
elements.nfoPreviewContent.querySelector('code').textContent = content;
|
||||
elements.nfoPreviewSection.style.display = 'block';
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Failed to load NFO content', 'error');
|
||||
} finally {
|
||||
elements.btnViewNfo.disabled = false;
|
||||
elements.btnViewNfo.innerHTML = '<i class="fas fa-file-code"></i> View NFO';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = function() {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
AniWorld.NfoSettings.init();
|
||||
});
|
||||
Reference in New Issue
Block a user