fixed a bug & replicated all changes to docker version

This commit is contained in:
2025-12-27 19:23:22 +01:00
parent cc0b0a891e
commit bc74aa8116
25 changed files with 1876 additions and 509 deletions

View File

@@ -1,6 +1,7 @@
let animeData = null;
let extensionName = null;
let animeId = null;
let isLocal = false;
const episodePagination = Object.create(PaginationManager);
episodePagination.init(12, renderEpisodes);
@@ -13,6 +14,29 @@ document.addEventListener('DOMContentLoaded', () => {
setupEpisodeSearch();
});
function markAsLocal() {
isLocal = true;
const pill = document.getElementById('local-pill');
if (!pill) return;
pill.textContent = 'Local';
pill.style.display = 'inline-flex';
pill.style.background = 'rgba(34,197,94,.2)';
pill.style.color = '#22c55e';
pill.style.borderColor = 'rgba(34,197,94,.3)';
}
async function checkLocalLibraryEntry() {
try {
const res = await fetch(`/api/library/anime/${animeId}`);
if (!res.ok) return;
markAsLocal();
} catch (e) {
}
}
async function loadAnime() {
try {
@@ -24,6 +48,7 @@ async function loadAnime() {
extensionName = urlData.extensionName;
animeId = urlData.entityId;
await checkLocalLibraryEntry();
const fetchUrl = extensionName
? `/api/anime/${animeId}?source=${extensionName}`
@@ -142,8 +167,8 @@ function setupWatchButton() {
const watchBtn = document.getElementById('watch-btn');
if (watchBtn) {
watchBtn.onclick = () => {
const url = URLUtils.buildWatchUrl(animeId, 1, extensionName);
window.location.href = url;
const source = isLocal ? 'local' : (extensionName || 'anilist');
window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
};
}
}
@@ -226,8 +251,8 @@ function createEpisodeButton(num, container) {
btn.className = 'episode-btn';
btn.innerText = `Ep ${num}`;
btn.onclick = () => {
const url = URLUtils.buildWatchUrl(animeId, num, extensionName);
window.location.href = url;
const source = isLocal ? 'local' : (extensionName || 'anilist');
window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
};
container.appendChild(btn);
}

View File

@@ -7,6 +7,7 @@ let currentExtension = '';
let plyrInstance;
let hlsInstance;
let totalEpisodes = 0;
let animeTitle = "";
let aniSkipData = null;
let isAnilist = false;
@@ -17,13 +18,28 @@ const firstKey = params.keys().next().value;
let extName;
if (firstKey) extName = firstKey;
const href = extName
// URL de retroceso: Si es local, volvemos a la vista de Anilist normal
const href = (extName && extName !== 'local')
? `/anime/${extName}/${animeId}`
: `/anime/${animeId}`;
document.getElementById('back-link').href = href;
document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`;
let localEntryId = null;
async function checkLocal() {
try {
const res = await fetch(`/api/library/anime/${animeId}`);
if (!res.ok) return;
const data = await res.json();
localEntryId = data.id;
} catch {}
}
async function loadAniSkip(malId, episode, duration) {
try {
const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`);
@@ -37,9 +53,10 @@ async function loadAniSkip(malId, episode, duration) {
}
async function loadMetadata() {
checkLocal();
try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist";
const res = await fetch(`/api/anime/${animeId}${extQuery}`);
const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`;
const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`);
const data = await res.json();
if (data.error) {
@@ -49,13 +66,7 @@ async function loadMetadata() {
const isAnilistFormat = data.title && (data.title.romaji || data.title.english);
let title = '';
let description = '';
let coverImage = '';
let averageScore = '';
let format = '';
let seasonYear = '';
let season = '';
let title = '', description = '', coverImage = '', averageScore = '', format = '', seasonYear = '', season = '';
if (isAnilistFormat) {
title = data.title.romaji || data.title.english || data.title.native || 'Anime Title';
@@ -85,6 +96,7 @@ async function loadMetadata() {
document.getElementById('anime-title-details').innerText = title;
document.getElementById('anime-title-details2').innerText = title;
animeTitle = title;
document.title = `Watching ${title} - Ep ${currentEpisode}`;
const tempDiv = document.createElement('div');
@@ -96,7 +108,8 @@ async function loadMetadata() {
document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--');
document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg';
if (extName) {
// Solo cargamos episodios de extensión si hay extensión real y no es local
if (extName && extName !== 'local') {
await loadExtensionEpisodes();
} else {
if (data.nextAiringEpisode?.episode) {
@@ -108,12 +121,7 @@ async function loadMetadata() {
}
const simpleEpisodes = [];
for (let i = 1; i <= totalEpisodes; i++) {
simpleEpisodes.push({
number: i,
title: null,
thumbnail: null,
isDub: false
});
simpleEpisodes.push({ number: i, title: null, thumbnail: null, isDub: false });
}
populateEpisodeCarousel(simpleEpisodes);
}
@@ -128,72 +136,30 @@ async function loadMetadata() {
}
async function applyAniSkip(video) {
if (!isAnilist || !malId) {
console.log('AniSkip disabled: isAnilist=' + isAnilist + ', malId=' + malId);
return;
}
if (!isAnilist || !malId) return;
console.log('Loading AniSkip for MAL ID:', malId, 'Episode:', currentEpisode);
aniSkipData = await loadAniSkip(malId, currentEpisode, Math.floor(video.duration));
aniSkipData = await loadAniSkip(
malId,
currentEpisode,
Math.floor(video.duration)
);
if (!aniSkipData || aniSkipData.length === 0) return;
console.log('AniSkip data received:', aniSkipData);
if (!aniSkipData || aniSkipData.length === 0) {
console.log('No AniSkip data available');
return;
}
let op, ed;
const markers = [];
aniSkipData.forEach(item => {
const { startTime, endTime } = item.interval;
if (item.skipType === 'op') {
op = { start: startTime, end: endTime };
markers.push({
start: startTime,
end: endTime,
label: 'Opening'
});
console.log('Opening found:', startTime, '-', endTime);
}
if (item.skipType === 'ed') {
ed = { start: startTime, end: endTime };
markers.push({
start: startTime,
end: endTime,
label: 'Ending'
});
console.log('Ending found:', startTime, '-', endTime);
}
markers.push({
start: startTime,
end: endTime,
label: item.skipType === 'op' ? 'Opening' : 'Ending'
});
});
// Crear markers visuales en el DOM
if (plyrInstance && markers.length > 0) {
console.log('Creating visual markers:', markers);
// Esperar a que el player esté completamente cargado
setTimeout(() => {
const progressContainer = document.querySelector('.plyr__progress');
if (!progressContainer) {
console.error('Progress container not found');
return;
}
if (!progressContainer) return;
// Eliminar markers anteriores si existen
const oldMarkers = progressContainer.querySelector('.plyr__markers');
if (oldMarkers) oldMarkers.remove();
// Crear contenedor de markers
const markersContainer = document.createElement('div');
markersContainer.className = 'plyr__markers';
@@ -215,56 +181,36 @@ async function applyAniSkip(video) {
markersContainer.appendChild(markerElement);
});
progressContainer.appendChild(markersContainer);
console.log('Visual markers created successfully');
}, 500);
}
}
async function loadExtensionEpisodes() {
try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist";
const res = await fetch(`/api/anime/${animeId}/episodes${extQuery}`);
const res = await fetch(`/api/anime/${animeId}/episodes?source=${extName}`);
const data = await res.json();
totalEpisodes = Array.isArray(data) ? data.length : 0;
if (Array.isArray(data) && data.length > 0) {
populateEpisodeCarousel(data);
} else {
const fallback = [];
for (let i = 1; i <= totalEpisodes; i++) {
fallback.push({ number: i, title: null, thumbnail: null });
}
populateEpisodeCarousel(fallback);
}
populateEpisodeCarousel(Array.isArray(data) ? data : []);
} catch (e) {
console.error("Error cargando episodios por extensión:", e);
totalEpisodes = 0;
console.error("Error cargando episodios:", e);
}
}
function populateEpisodeCarousel(episodesData) {
const carousel = document.getElementById('episode-carousel');
if (!carousel) return;
carousel.innerHTML = '';
episodesData.forEach((ep, index) => {
const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1);
if (!epNumber) return;
const extParam = extName ? `?${extName}` : "";
const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== '';
const link = document.createElement('a');
link.href = `/watch/${animeId}/${epNumber}${extParam}`;
link.classList.add('carousel-item');
link.dataset.episode = epNumber;
if (!hasThumbnail) link.classList.add('no-thumbnail');
if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel');
const imgContainer = document.createElement('div');
@@ -272,21 +218,15 @@ function populateEpisodeCarousel(episodesData) {
if (hasThumbnail) {
const img = document.createElement('img');
img.classList.add('carousel-item-img');
img.src = ep.thumbnail;
img.alt = `Episode ${epNumber} Thumbnail`;
img.classList.add('carousel-item-img');
imgContainer.appendChild(img);
}
link.appendChild(imgContainer);
const info = document.createElement('div');
info.classList.add('carousel-item-info');
const title = document.createElement('p');
title.innerText = `Ep ${epNumber}: ${ep.title || 'Untitled'}`;
info.appendChild(title);
info.innerHTML = `<p>Ep ${epNumber}: ${ep.title || 'Untitled'}</p>`;
link.appendChild(info);
carousel.appendChild(link);
});
@@ -297,28 +237,27 @@ async function loadExtensions() {
const res = await fetch('/api/extensions/anime');
const data = await res.json();
const select = document.getElementById('extension-select');
let extensions = data.extensions || [];
if (data.extensions && data.extensions.length > 0) {
select.innerHTML = '';
data.extensions.forEach(ext => {
const opt = document.createElement('option');
opt.value = opt.innerText = ext;
select.appendChild(opt);
});
if (typeof extName === 'string' && data.extensions.includes(extName)) {
select.value = extName;
} else {
select.selectedIndex = 0;
}
currentExtension = select.value;
onExtensionChange();
} else {
select.innerHTML = '<option>No Extensions</option>';
select.disabled = true;
setLoading("No anime extensions found.");
if (extName === 'local' && !extensions.includes('local')) {
extensions.push('local');
}
select.innerHTML = '';
extensions.forEach(ext => {
const opt = document.createElement('option');
opt.value = opt.innerText = ext;
select.appendChild(opt);
});
if (extName && extensions.includes(extName)) {
select.value = extName;
} else if (extensions.length > 0) {
select.value = extensions[0];
}
currentExtension = select.value;
onExtensionChange();
} catch (error) {
console.error("Extension Error:", error);
}
@@ -327,83 +266,69 @@ async function loadExtensions() {
async function onExtensionChange() {
const select = document.getElementById('extension-select');
currentExtension = select.value;
setLoading("Fetching extension settings...");
if (currentExtension === 'local') {
document.getElementById('sd-toggle').style.display = 'none';
document.getElementById('server-select').style.display = 'none';
loadStream();
return;
}
setLoading("Fetching extension settings...");
try {
const res = await fetch(`/api/extensions/${currentExtension}/settings`);
const settings = await res.json();
const toggle = document.getElementById('sd-toggle');
if (settings.supportsDub) {
toggle.style.display = 'flex';
setAudioMode('sub');
} else {
toggle.style.display = 'none';
setAudioMode('sub');
}
toggle.style.display = settings.supportsDub ? 'flex' : 'none';
setAudioMode('sub');
const serverSelect = document.getElementById('server-select');
serverSelect.innerHTML = '';
if (settings.episodeServers && settings.episodeServers.length > 0) {
if (settings.episodeServers?.length > 0) {
settings.episodeServers.forEach(srv => {
const opt = document.createElement('option');
opt.value = srv;
opt.innerText = srv;
opt.value = opt.innerText = srv;
serverSelect.appendChild(opt);
});
serverSelect.style.display = 'block';
} else {
serverSelect.style.display = 'none';
}
loadStream();
} catch (error) {
console.error(error);
setLoading("Failed to load extension settings.");
setLoading("Failed to load settings.");
}
}
function toggleAudioMode() {
const newMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(newMode);
loadStream();
}
function setAudioMode(mode) {
audioMode = mode;
const toggle = document.getElementById('sd-toggle');
const subOpt = document.getElementById('opt-sub');
const dubOpt = document.getElementById('opt-dub');
toggle.setAttribute('data-state', mode);
subOpt.classList.toggle('active', mode === 'sub');
dubOpt.classList.toggle('active', mode === 'dub');
}
async function loadStream() {
if (!currentExtension) return;
if (currentExtension === 'local') {
if (!localEntryId) {
setLoading("No existe en local");
return;
}
const localUrl = `/api/library/stream/anime/${localEntryId}/${currentEpisode}`;
playVideo(localUrl, []);
document.getElementById('loading-overlay').style.display = 'none';
return;
}
const serverSelect = document.getElementById('server-select');
const server = serverSelect.value || "default";
setLoading(`Loading stream (${audioMode})...`);
try {
let sourc = "&source=anilist";
if (extName){
sourc = `&source=${extName}`;
}
const sourc = (extName && extName !== 'local') ? `&source=${extName}` : "&source=anilist";
const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
setLoading(`Error: ${data.error}`);
return;
}
if (!data.videoSources || data.videoSources.length === 0) {
setLoading("No video sources found.");
if (data.error || !data.videoSources?.length) {
setLoading(data.error || "No video sources.");
return;
}
@@ -415,33 +340,31 @@ async function loadStream() {
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
playVideo(proxyUrl, data.videoSources[0].subtitles || data.subtitles);
playVideo(proxyUrl, source.subtitles || data.subtitles || []);
document.getElementById('loading-overlay').style.display = 'none';
} catch (error) {
setLoading("Stream error. Check console.");
console.error(error);
setLoading("Stream error.");
}
}
function playVideo(url, subtitles = []) {
const video = document.getElementById('player');
const isLocal = url.includes('/api/library/stream/');
if (Hls.isSupported()) {
if (!isLocal && Hls.isSupported()) {
if (hlsInstance) hlsInstance.destroy();
hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false });
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
} else {
if (hlsInstance) hlsInstance.destroy();
video.src = url;
}
if (plyrInstance) plyrInstance.destroy();
const existingTracks = video.querySelectorAll('track');
existingTracks.forEach(track => track.remove());
while (video.textTracks.length > 0) video.removeChild(video.textTracks[0]);
subtitles.forEach(sub => {
if (!sub.url) return;
const track = document.createElement('track');
track.kind = 'captions';
track.label = sub.language || 'Unknown';
@@ -454,74 +377,23 @@ function playVideo(url, subtitles = []) {
plyrInstance = new Plyr(video, {
captions: { active: true, update: true, language: 'en' },
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
settings: ['captions', 'quality', 'speed'],
markers: {
enabled: true,
points: []
}
settings: ['captions', 'quality', 'speed']
});
video.addEventListener('loadedmetadata', () => {
applyAniSkip(video);
});
let alreadyTriggered = false;
video.addEventListener('timeupdate', () => {
if (!video.duration) return;
const percent = (video.currentTime / video.duration) * 100;
if (percent >= 80 && !alreadyTriggered) {
alreadyTriggered = true;
sendProgress();
}
});
video.play().catch(() => console.log("Autoplay blocked"));
}
function setLoading(message) {
const overlay = document.getElementById('loading-overlay');
const text = document.getElementById('loading-text');
overlay.style.display = 'flex';
text.innerText = message;
}
const extParam = extName ? `?${extName}` : "";
document.getElementById('prev-btn').onclick = () => {
if (currentEpisode > 1) {
window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
}
};
document.getElementById('next-btn').onclick = () => {
if (currentEpisode < totalEpisodes || totalEpisodes === 0) {
window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
}
};
if (currentEpisode <= 1) {
document.getElementById('prev-btn').disabled = true;
video.addEventListener('loadedmetadata', () => applyAniSkip(video));
}
async function sendProgress() {
const token = localStorage.getItem('token');
if (!token) return;
const source = extName
? extName
: "anilist";
const source = (extName && extName !== 'local') ? extName : "anilist";
const body = {
entry_id: animeId,
source: source,
entry_type: "ANIME",
status: 'CURRENT',
progress: source === 'anilist'
? Math.floor(currentEpisode)
: currentEpisode
progress: currentEpisode
};
try {
@@ -538,5 +410,38 @@ async function sendProgress() {
}
}
// Botones y Toggle
document.getElementById('sd-toggle').onclick = () => {
audioMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(audioMode);
loadStream();
};
function setAudioMode(mode) {
const toggle = document.getElementById('sd-toggle');
toggle.setAttribute('data-state', mode);
document.getElementById('opt-sub').classList.toggle('active', mode === 'sub');
document.getElementById('opt-dub').classList.toggle('active', mode === 'dub');
}
function setLoading(message) {
document.getElementById('loading-text').innerText = message;
document.getElementById('loading-overlay').style.display = 'flex';
}
const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
document.getElementById('prev-btn').onclick = () => {
if (currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
};
document.getElementById('next-btn').onclick = () => {
if (currentEpisode < totalEpisodes || totalEpisodes === 0) window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
};
if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
setInterval(() => {
if (plyrInstance && !plyrInstance.paused) sendProgress();
}, 60000);
loadMetadata();
loadExtensions();

View File

@@ -43,6 +43,43 @@ async function loadMeUI() {
}
}
// Variable para saber si el modal ya fue cargado
let settingsModalLoaded = false;
document.getElementById('nav-settings').addEventListener('click', openSettings)
async function openSettings() {
if (!settingsModalLoaded) {
try {
const res = await fetch('/views/components/settings-modal.html')
const html = await res.text()
document.body.insertAdjacentHTML('beforeend', html)
settingsModalLoaded = true;
// Esperar un momento para que el DOM se actualice
await new Promise(resolve => setTimeout(resolve, 50));
// Ahora cargar los settings
if (window.toggleSettingsModal) {
await window.toggleSettingsModal(false);
}
} catch (err) {
console.error('Error loading settings modal:', err);
}
} else {
if (window.toggleSettingsModal) {
await window.toggleSettingsModal(false);
}
}
}
function closeSettings() {
const modal = document.getElementById('settings-modal');
if (modal) {
modal.classList.add('hidden');
}
}
function setupDropdown() {
const userAvatarBtn = document.querySelector(".user-avatar-btn")
const navDropdown = document.getElementById("nav-dropdown")

View File

@@ -0,0 +1,209 @@
let activeFilter = 'all';
let activeSort = 'az';
let isLocalMode = false;
let localEntries = [];
function toggleLibraryMode() {
isLocalMode = !isLocalMode;
const btn = document.getElementById('library-mode-btn');
const onlineContent = document.getElementById('online-content');
const localContent = document.getElementById('local-content');
const svg = btn.querySelector('svg');
const label = btn.querySelector('span');
if (isLocalMode) {
// LOCAL MODE
btn.classList.add('active');
onlineContent.classList.add('hidden');
localContent.classList.remove('hidden');
loadLocalEntries();
svg.innerHTML = `
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
`;
} else {
// ONLINE MODE
btn.classList.remove('active');
onlineContent.classList.remove('hidden');
localContent.classList.add('hidden');
svg.innerHTML = `
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
`;
}
}
async function loadLocalEntries() {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(8);
try {
const response = await fetch('/api/library/anime');
const entries = await response.json();
localEntries = entries;
if (entries.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No anime found in your local library. Click "Scan Library" to scan your folders.</p>';
return;
}
// Renderizar grid
grid.innerHTML = entries.map(entry => {
const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id;
const cover = entry.metadata?.coverImage?.extraLarge || entry.metadata?.coverImage?.large || '/public/assets/placeholder.jpg';
const score = entry.metadata?.averageScore || '--';
const episodes = entry.metadata?.episodes || '??';
return `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${score}% • ${episodes} Eps
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
} catch (err) {
console.error('Error loading local entries:', err);
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local library. Make sure the backend is running.</p>';
}
}
async function scanLocalLibrary() {
const btnText = document.getElementById('scan-text');
const originalText = btnText.innerText;
btnText.innerText = "Scanning...";
try {
const response = await fetch('/api/library/scan?mode=incremental', {
method: 'POST'
});
if (response.ok) {
await loadLocalEntries();
// Mostrar notificación de éxito si tienes sistema de notificaciones
if (window.NotificationUtils) {
NotificationUtils.show('Library scanned successfully!', 'success');
}
} else {
throw new Error('Scan failed');
}
} catch (err) {
console.error("Scan failed", err);
alert("Failed to scan library. Check console for details.");
// Mostrar notificación de error si tienes sistema de notificaciones
if (window.NotificationUtils) {
NotificationUtils.show('Failed to scan library', 'error');
}
} finally {
btnText.innerText = originalText;
}
}
function viewLocalEntry(anilistId) {
if (!anilistId) {
console.warn('Anime not linked');
return;
}
window.location.href = `/anime/${anilistId}`;
}
function renderLocalEntries(entries) {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = entries.map(entry => {
const title = entry.metadata?.title?.romaji
|| entry.metadata?.title?.english
|| entry.id;
const cover =
entry.metadata?.coverImage?.extraLarge
|| entry.metadata?.coverImage?.large
|| '/public/assets/placeholder.jpg';
const score = entry.metadata?.averageScore || '--';
const episodes = entry.metadata?.episodes || '??';
return `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${score}% • ${episodes} Eps
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
}
function applyLocalFilters() {
let filtered = [...localEntries];
if (activeFilter === 'linked') {
filtered = filtered.filter(e => e.matched);
}
if (activeFilter === 'unlinked') {
filtered = filtered.filter(e => !e.matched);
}
if (activeSort === 'az') {
filtered.sort((a, b) =>
(a.metadata?.title?.romaji || a.id)
.localeCompare(b.metadata?.title?.romaji || b.id)
);
}
if (activeSort === 'za') {
filtered.sort((a, b) =>
(b.metadata?.title?.romaji || b.id)
.localeCompare(a.metadata?.title?.romaji || a.id)
);
}
renderLocalEntries(filtered);
}
document.addEventListener('click', e => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
if (btn.dataset.filter) {
activeFilter = btn.dataset.filter;
}
if (btn.dataset.sort) {
activeSort = btn.dataset.sort;
}
btn
.closest('.local-filters')
.querySelectorAll('.filter-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyLocalFilters();
});

View File

@@ -0,0 +1,218 @@
const API_BASE = '/api/config';
let currentConfig = {};
let activeSection = '';
let modal, navContainer, formContent, form;
window.toggleSettingsModal = async (forceClose = false) => {
modal = document.getElementById('settings-modal');
navContainer = document.getElementById('config-nav');
formContent = document.getElementById('config-section-content');
form = document.getElementById('config-form');
if (!modal) {
console.error('Modal not found');
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;
}
// Mostrar loading
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>
`;
try {
const res = await fetch(API_BASE);
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;
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>
</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];
formContent.innerHTML = `
<h2 class="section-title" style="margin-bottom: 2rem; text-transform: capitalize;">
${section.replace(/_/g, ' ')}
</h2>
`;
Object.entries(sectionData).forEach(([key, value]) => {
const group = document.createElement('div');
group.className = 'config-group';
const isBool = typeof value === 'boolean';
const inputId = `input-${section}-${key}`;
const label = key.replace(/_/g, ' ');
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>
`;
} else {
group.innerHTML = `
<label for="${inputId}">${label}</label>
<input class="config-input" id="${inputId}" name="${key}"
type="${typeof value === 'number' ? 'number' : 'text'}"
value="${value}">
`;
}
formContent.appendChild(group);
});
}
// Setup form submit handler
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();
await saveSettings();
}
});
});
async function saveSettings() {
if (!form || !activeSection) return;
const updatedData = {};
Object.keys(currentConfig[activeSection]).forEach(key => {
const input = form.elements[key];
if (!input) return;
if (input.type === 'checkbox') {
updatedData[key] = input.checked;
} else if (input.type === 'number') {
updatedData[key] = Number(input.value);
} else {
updatedData[key] = input.value;
}
});
try {
const res = await fetch(`${API_BASE}/${activeSection}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedData)
});
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);
} else {
throw new Error('Failed to save settings');
}
} catch (err) {
console.error('Error saving settings:', err);
alert('Error saving settings: ' + err.message);
}
}
// 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; }
}
`;
document.head.appendChild(animationStyles);
}