new dashboard page

This commit is contained in:
2025-12-31 21:51:17 +01:00
parent c9c4cc074c
commit 20ea5bee9c
14 changed files with 2261 additions and 3425 deletions

View File

@@ -1,21 +1,78 @@
const API_BASE = '/api'; const API_BASE = '/api';
let currentList = [];
let filteredList = [];
let currentUserId = null;
// Configuración de paginación const DashboardApp = {
const ITEMS_PER_PAGE = 50;
let visibleCount = ITEMS_PER_PAGE;
// Inicialización State: {
document.addEventListener('DOMContentLoaded', async () => { currentList: [],
await initUser(); filteredList: [],
await loadList(); localLibraryData: [],
setupEventListeners(); currentUserId: null,
setupTabSystem(); currentLocalType: 'anime',
pagination: {
itemsPerPage: 50,
visibleCount: 50
}
},
init: async function() {
console.log('Initializing Dashboard...');
await this.User.init();
await this.Tracking.load();
this.UI.setupTabSystem();
this.initListeners();
const localInput = document.getElementById('local-search-input');
if(localInput) {
localInput.addEventListener('input', (e) => this.Library.filterContent(e.target.value));
}
},
initListeners: function() {
document.getElementById('scan-incremental-btn')?.addEventListener('click', () => this.Library.triggerScan('incremental'));
document.getElementById('scan-full-btn')?.addEventListener('click', () => this.Library.triggerScan('full'));
document.getElementById('profile-form')?.addEventListener('submit', (e) => this.User.updateProfile(e));
document.getElementById('password-form')?.addEventListener('submit', (e) => this.User.changePassword(e));
document.getElementById('logout-btn')?.addEventListener('click', () => window.AuthUtils.logout());
const fileInput = document.getElementById('avatar-upload');
if (fileInput) {
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = evt => {
document.getElementById('user-avatar').src = evt.target.result;
const urlInput = document.getElementById('setting-avatar-url');
if(urlInput) urlInput.value = '';
};
reader.readAsDataURL(file);
}
});
}
const trackingInput = document.getElementById('tracking-search-input');
if (trackingInput) trackingInput.addEventListener('input', () => this.Tracking.applyFilters());
['status-filter', 'type-filter', 'sort-filter'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => this.Tracking.applyFilters());
}); });
async function initUser() { document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
const container = document.getElementById('list-container');
view === 'list' ? container.classList.add('list-view') : container.classList.remove('list-view');
});
});
},
User: {
init: async function() {
try { try {
const headers = window.AuthUtils.getSimpleAuthHeaders(); const headers = window.AuthUtils.getSimpleAuthHeaders();
const res = await fetch(`${API_BASE}/me`, { headers }); const res = await fetch(`${API_BASE}/me`, { headers });
@@ -23,38 +80,37 @@ async function initUser() {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
document.getElementById('user-username').textContent = data.username; document.getElementById('user-username').textContent = data.username;
document.getElementById('setting-username').value = data.username; const settingUsername = document.getElementById('setting-username');
if(settingUsername) settingUsername.value = data.username;
if (data.avatar) { if (data.avatar) {
document.getElementById('user-avatar').src = data.avatar; document.getElementById('user-avatar').src = data.avatar;
if (data.avatar.startsWith('http')) {
document.getElementById('setting-avatar-url').value = data.avatar;
} else {
document.getElementById('setting-avatar-url').placeholder = "Image uploaded via file (Base64)";
document.getElementById('setting-avatar-url').value = "";
}
} }
} }
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
const payload = JSON.parse(atob(token.split('.')[1])); const payload = JSON.parse(atob(token.split('.')[1]));
currentUserId = payload.id; DashboardApp.State.currentUserId = payload.id;
await checkIntegrations(currentUserId); await this.checkIntegrations(payload.id);
} }
} catch (err) { } catch (err) {
console.error("Error loading user profile:", err); console.error("Error loading user profile:", err);
} }
} },
async function checkIntegrations(userId) { checkIntegrations: async function(userId) {
if (!userId) return; if (!userId) return;
try { try {
const res = await fetch(`${API_BASE}/users/${userId}/integration`); const res = await fetch(`${API_BASE}/users/${userId}/integration`);
let data = { connected: false }; let data = { connected: false };
if (res.ok) data = await res.json(); if (res.ok) data = await res.json();
this.updateIntegrationUI(data, userId);
} catch (e) { console.error("Integration check error:", e); }
},
updateIntegrationUI: function(data, userId) {
const statusEl = document.getElementById('anilist-status'); const statusEl = document.getElementById('anilist-status');
const btn = document.getElementById('anilist-action-btn'); const btn = document.getElementById('anilist-action-btn');
const headerBadge = document.getElementById('header-anilist-link'); const headerBadge = document.getElementById('header-anilist-link');
@@ -71,9 +127,9 @@ async function checkIntegrations(userId) {
} }
if (btn) { if (btn) {
btn.textContent = 'Disconnect'; btn.textContent = 'Disconnect';
btn.classList.add('btn-danger-outline'); btn.className = 'btn-stream-outline link-danger';
btn.classList.remove('btn-blur');
btn.onclick = () => disconnectAniList(userId); btn.onclick = () => this.disconnectAniList(userId);
} }
} else { } else {
if (headerBadge) headerBadge.style.display = 'none'; if (headerBadge) headerBadge.style.display = 'none';
@@ -83,26 +139,23 @@ async function checkIntegrations(userId) {
} }
if (btn) { if (btn) {
btn.textContent = 'Connect'; btn.textContent = 'Connect';
btn.classList.remove('btn-danger-outline'); btn.className = 'btn-stream-outline';
btn.classList.add('btn-blur'); btn.onclick = () => this.redirectToAniListLogin();
btn.onclick = () => redirectToAniListLogin();
} }
} }
} catch (e) { console.error("Integration check error:", e); } },
}
async function redirectToAniListLogin() { redirectToAniListLogin: async function() {
if (!currentUserId) return; if (!DashboardApp.State.currentUserId) return;
try { try {
const clientId = 32898; const clientId = 32898;
const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist'); const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
const state = encodeURIComponent(currentUserId); const state = encodeURIComponent(DashboardApp.State.currentUserId);
window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&state=${state}`; window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&state=${state}`;
} catch (err) { console.error(err); alert('Error starting AniList login'); } } catch (err) { console.error(err); alert('Error starting AniList login'); }
} },
async function disconnectAniList(userId) { disconnectAniList: async function(userId) {
if(!confirm("Disconnect AniList?")) return; if(!confirm("Disconnect AniList?")) return;
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -110,11 +163,370 @@ async function disconnectAniList(userId) {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
checkIntegrations(userId); this.checkIntegrations(userId);
} catch (e) { alert("Failed to disconnect"); } } catch (e) { alert("Failed to disconnect"); }
},
updateProfile: async function(e) {
e.preventDefault();
const userId = DashboardApp.State.currentUserId;
if (!userId) return;
const username = document.getElementById('setting-username').value;
const urlInput = document.getElementById('setting-avatar-url')?.value || '';
const fileInput = document.getElementById('avatar-upload');
let finalAvatar = null;
if (fileInput && fileInput.files && fileInput.files[0]) {
try {
finalAvatar = await DashboardApp.Utils.fileToBase64(fileInput.files[0]);
} catch (err) { alert("Error reading file"); return; }
} else if (urlInput.trim() !== "") {
finalAvatar = urlInput.trim();
} }
function setupTabSystem() { const bodyData = { username };
if (finalAvatar) bodyData.profilePictureUrl = finalAvatar;
try {
const res = await fetch(`${API_BASE}/users/${userId}`, {
method: 'PUT',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(bodyData)
});
if (res.ok) alert('Profile updated successfully!');
else { const err = await res.json(); alert(err.error || 'Update failed'); }
} catch (e) { console.error(e); }
},
changePassword: async function(e) {
e.preventDefault();
const userId = DashboardApp.State.currentUserId;
if (!userId) return;
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password').value;
try {
const res = await fetch(`${API_BASE}/users/${userId}/password`, {
method: 'PUT',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json();
if (res.ok) { alert("Password updated successfully"); document.getElementById('password-form').reset(); }
else alert(data.error || "Failed to update password");
} catch (e) { console.error(e); }
}
},
Tracking: {
load: async function() {
const loadingState = document.getElementById('loading-state');
const emptyState = document.getElementById('empty-state');
const container = document.getElementById('list-container');
try {
loadingState.style.display = 'flex';
emptyState.style.display = 'none';
container.innerHTML = '';
const response = await fetch(`${API_BASE}/list`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if (!response.ok) throw new Error('Failed');
const data = await response.json();
DashboardApp.State.currentList = data.results || [];
this.updateStats();
loadingState.style.display = 'none';
if (DashboardApp.State.currentList.length === 0) emptyState.style.display = 'flex';
else this.applyFilters();
} catch (error) {
console.error(error);
loadingState.style.display = 'none';
}
},
updateStats: function() {
const list = DashboardApp.State.currentList;
const animeCount = list.filter(item => item.entry_type === 'ANIME').length;
const mangaCount = list.filter(item => item.entry_type === 'MANGA').length;
document.getElementById('total-stat').textContent = list.length;
if (document.getElementById('anime-stat')) document.getElementById('anime-stat').textContent = animeCount;
if (document.getElementById('manga-stat')) document.getElementById('manga-stat').textContent = mangaCount;
},
applyFilters: function() {
const statusFilter = document.getElementById('status-filter').value;
const typeFilter = document.getElementById('type-filter').value;
const sortFilter = document.getElementById('sort-filter').value;
const searchInput = document.getElementById('tracking-search-input');
const searchQuery = searchInput ? searchInput.value.toLowerCase().trim() : '';
let result = [...DashboardApp.State.currentList];
if (searchQuery) {
result = result.filter(item => (item.title ? item.title.toLowerCase() : '').includes(searchQuery));
}
if (statusFilter !== 'all') result = result.filter(item => item.status === statusFilter);
if (typeFilter !== 'all') result = result.filter(item => item.entry_type === typeFilter);
if (sortFilter === 'title') result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
else if (sortFilter === 'score') result.sort((a, b) => (b.score || 0) - (a.score || 0));
else result.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
DashboardApp.State.filteredList = result;
DashboardApp.State.pagination.visibleCount = DashboardApp.State.pagination.itemsPerPage;
this.render();
},
render: function() {
const container = document.getElementById('list-container');
container.innerHTML = '';
const list = DashboardApp.State.filteredList;
const count = DashboardApp.State.pagination.visibleCount;
if (list.length === 0) {
container.innerHTML = '<div class="empty-text" style="grid-column: 1/-1; text-align:center; color: var(--color-text-secondary);">No matches found</div>';
return;
}
const itemsToShow = list.slice(0, count);
itemsToShow.forEach(item => container.appendChild(this.createItemElement(item)));
if (count < list.length) {
this.renderLoadMoreButton(container, list.length - count);
}
},
createItemElement: function(item) {
const div = document.createElement('div');
div.className = 'list-item';
const itemLink = this.getEntryLink(item);
const posterUrl = item.poster || '/public/assets/placeholder.svg';
const progress = item.progress || 0;
const totalUnits = item.entry_type === 'ANIME' ? item.total_episodes || 0 : item.total_chapters || 0;
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
const score = item.score ? item.score.toFixed(1) : null;
const entryType = (item.entry_type).toUpperCase();
const statusLabels = {
'CURRENT': entryType === 'ANIME' ? 'Watching' : 'Reading',
'COMPLETED': 'Completed', 'PLANNING': 'Planning', 'PAUSED': 'Paused',
'DROPPED': 'Dropped', 'REPEATING': entryType === 'ANIME' ? 'Rewatching' : 'Rereading'
};
const extraInfo = [];
if (item.repeat_count > 0) extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${item.repeat_count}</span>`);
if (item.is_private) extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>');
div.innerHTML = `
<a href="${itemLink}" class="item-poster-link">
<img src="${posterUrl}" alt="${item.title}" class="item-poster" onerror="this.src='/public/assets/placeholder.svg'">
</a>
<div class="item-content">
<div>
<a href="${itemLink}" style="text-decoration:none; color:inherit;">
<h3 class="item-title">${item.title || 'Unknown'}</h3>
</a>
<div class="item-meta">
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
<span class="meta-pill type-pill">${entryType}</span>
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
${extraInfo.join('')}
</div>
</div>
<div>
<div class="progress-bar-container"><div class="progress-bar" style="width: ${progressPercent}%"></div></div>
<div class="progress-text">
<span>${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''}</span>
${score ? `<span class="score-badge">⭐ ${score}</span>` : ''}
</div>
</div>
</div>
<button class="edit-icon-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15.232 5.232l3.536 3.536m-2.036-5.808a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L15.232 5.232z"/></svg>
</button>
`;
div.querySelector('.edit-icon-btn').onclick = (e) => {
e.preventDefault(); e.stopPropagation();
window.ListModalManager.currentData = item;
window.ListModalManager.isInList = true;
window.ListModalManager.currentEntry = item;
window.ListModalManager.open(item, item.source || 'anilist');
};
return div;
},
renderLoadMoreButton: function(container, remaining) {
const btnContainer = document.createElement('div');
Object.assign(btnContainer.style, { gridColumn: "1 / -1", display: "flex", justifyContent: "center", padding: "2rem 0" });
const loadMoreBtn = document.createElement('button');
loadMoreBtn.className = "btn-blur";
loadMoreBtn.textContent = `Show All (${remaining} more)`;
loadMoreBtn.onclick = () => {
DashboardApp.State.pagination.visibleCount = DashboardApp.State.filteredList.length;
this.render();
};
btnContainer.appendChild(loadMoreBtn);
container.appendChild(btnContainer);
},
getEntryLink: function(item) {
const baseRoute = (item.entry_type?.toUpperCase() === 'ANIME') ? '/anime' : '/book';
return `${baseRoute}/${item.entry_id}`;
}
},
Library: {
loadStats: async function() {
const types = ['anime', 'manga', 'novels'];
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
for (const type of types) {
try {
const res = await fetch(`${API_BASE}/library/${type}`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if(res.ok) {
const data = await res.json();
const el = document.getElementById(elements[type]);
if (el) el.textContent = `${data.length} items`;
}
} catch (e) { console.error(e); }
}
},
loadContent: async function(type) {
DashboardApp.State.currentLocalType = type;
const container = document.getElementById('local-list-container');
const loading = document.getElementById('local-loading');
const searchInput = document.getElementById('local-search-input');
container.innerHTML = '';
loading.style.display = 'flex';
if(searchInput) searchInput.value = '';
try {
const res = await fetch(`${API_BASE}/library/${type}`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if (!res.ok) throw new Error('Failed to load local content');
const data = await res.json();
DashboardApp.State.localLibraryData = data;
this.renderGrid(data, type);
} catch (err) {
console.error(err);
container.innerHTML = `<div class="empty-state"><p>Error loading library</p></div>`;
} finally {
loading.style.display = 'none';
}
},
renderGrid: function(entries, type) {
const container = document.getElementById('local-list-container');
container.innerHTML = '';
if (entries.length === 0) {
container.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1;">
<p>No ${type} files found.</p>
<button onclick="DashboardApp.Library.triggerScan('incremental')" class="btn-blur">Scan Now</button>
</div>`;
return;
}
entries.forEach(entry => {
const isMatched = entry.matched && entry.metadata;
const meta = entry.metadata || {};
let poster = meta.coverImage?.large || '/public/assets/placeholder.svg';
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.folder_name;
if (!isMatched) title = title.replace(/\[.*?\]|\(.*?\)|\.mkv|\.mp4/g, '').trim();
const url = isMatched ? (type === 'anime' ? `/anime/${meta.id}` : `/book/${meta.id}`) : '#';
const div = document.createElement('div');
div.className = 'list-item';
div.innerHTML = `
<div class="item-poster-link" onclick="${isMatched ? `window.location='${url}'` : ''}" style="cursor: ${isMatched ? 'pointer' : 'default'}">
<img src="${poster}" class="item-poster" loading="lazy">
${!isMatched ? `<div class="unmatched-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> UNMATCHED</div>` : ''}
</div>
<div class="item-content">
<h3 class="item-title" title="${entry.folder_name}">${title}</h3>
<div class="item-meta">
<span class="meta-pill type-pill">${entry.files ? entry.files.length : 0} FILES</span>
${isMatched ? '<span class="meta-pill status-pill">MATCHED</span>' : ''}
</div>
<div class="folder-path-tooltip">${entry.folder_name}</div>
</div>
<button class="edit-icon-btn" onclick="DashboardApp.Library.openManualMatch('${entry.id}', '${type}')" title="${isMatched ? 'Rematch' : 'Fix Match'}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
`;
container.appendChild(div);
});
},
filterContent: function(query) {
if (!DashboardApp.State.localLibraryData) return;
const lowerQuery = query.toLowerCase();
const filtered = DashboardApp.State.localLibraryData.filter(entry => {
const metaTitle = entry.metadata?.title?.english || entry.metadata?.title?.romaji || '';
const folderName = entry.folder_name || '';
return metaTitle.toLowerCase().includes(lowerQuery) || folderName.toLowerCase().includes(lowerQuery);
});
this.renderGrid(filtered, DashboardApp.State.currentLocalType);
},
triggerScan: async function(mode) {
const consoleDiv = document.getElementById('scan-console');
const statusText = document.getElementById('scan-status-text');
if(consoleDiv) consoleDiv.style.display = 'flex';
if(statusText) statusText.textContent = `Starting ${mode} scan...`;
try {
const res = await fetch(`${API_BASE}/library/scan?mode=${mode}`, {
method: 'POST', headers: window.AuthUtils.getSimpleAuthHeaders()
});
if (res.ok) {
if(statusText) statusText.textContent = "Scan completed successfully!";
setTimeout(() => { if(consoleDiv) consoleDiv.style.display = 'none'; this.loadStats(); }, 3000);
} else throw new Error('Scan failed');
} catch (e) {
if(statusText) { statusText.textContent = "Error during scan."; statusText.style.color = 'var(--color-danger)'; }
}
},
openManualMatch: function(id, type) {
const newId = prompt("Enter AniList ID to force match:");
if (newId) {
fetch(`${API_BASE}/library/${type}/${id}/match`, {
method: 'POST',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'anilist', matched_id: parseInt(newId) })
}).then(res => {
if(res.ok) { alert("Matched! Refreshing..."); this.loadContent(type); }
else { alert("Failed to match."); }
});
}
},
switchType: function(type, btnElement) {
document.querySelectorAll('.type-pill-btn').forEach(b => b.classList.remove('active'));
if(btnElement) btnElement.classList.add('active');
this.loadContent(type);
}
},
UI: {
setupTabSystem: function() {
const tabs = document.querySelectorAll('.nav-pill'); const tabs = document.querySelectorAll('.nav-pill');
const sections = document.querySelectorAll('.tab-section'); const sections = document.querySelectorAll('.tab-section');
@@ -129,340 +541,45 @@ function setupTabSystem() {
if (sec.id === targetId) sec.classList.add('active'); if (sec.id === targetId) sec.classList.add('active');
}); });
if (tab.dataset.target === 'local') loadLocalStats(); if (tab.dataset.target === 'local') {
DashboardApp.Library.loadStats();
DashboardApp.Library.loadContent('anime');
}
}); });
}); });
} }
},
async function loadLocalStats() { Utils: {
const types = ['anime', 'manga', 'novels']; fileToBase64: (file) => new Promise((resolve, reject) => {
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
for (const type of types) {
try {
const res = await fetch(`${API_BASE}/library/${type}`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if(res.ok) {
const data = await res.json();
const elId = elements[type];
if (document.getElementById(elId)) document.getElementById(elId).textContent = `${data.length} items`;
}
} catch (e) { console.error(e); }
}
}
async function triggerScan(mode) {
const consoleDiv = document.getElementById('scan-console');
const statusText = document.getElementById('scan-status-text');
consoleDiv.style.display = 'flex';
statusText.textContent = `Starting ${mode} scan...`;
try {
const res = await fetch(`${API_BASE}/library/scan?mode=${mode}`, {
method: 'POST',
headers: window.AuthUtils.getSimpleAuthHeaders()
});
if (res.ok) {
statusText.textContent = "Scan completed successfully!";
setTimeout(() => { consoleDiv.style.display = 'none'; loadLocalStats(); }, 3000);
} else throw new Error('Scan failed');
} catch (e) {
statusText.textContent = "Error during scan.";
statusText.style.color = 'var(--color-danger)';
}
}
async function handleProfileUpdate(e) {
e.preventDefault();
if (!currentUserId) return;
const username = document.getElementById('setting-username').value;
const urlInput = document.getElementById('setting-avatar-url').value;
const fileInput = document.getElementById('avatar-upload');
let finalAvatar = null;
if (fileInput.files && fileInput.files[0]) {
const toBase64 = file => new Promise((res, rej) => {
const r = new FileReader(); r.readAsDataURL(file);
r.onload = () => res(r.result); r.onerror = rej;
});
try { finalAvatar = await toBase64(fileInput.files[0]); } catch (err) { alert("Error reading file"); return; }
} else if (urlInput.trim() !== "") {
finalAvatar = urlInput.trim();
}
const bodyData = { username };
if (finalAvatar) bodyData.profilePictureUrl = finalAvatar;
try {
const res = await fetch(`${API_BASE}/users/${currentUserId}`, {
method: 'PUT',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(bodyData)
});
if (res.ok) alert('Profile updated successfully!');
else { const err = await res.json(); alert(err.error || 'Update failed'); }
} catch (e) { console.error(e); }
}
async function handlePasswordChange(e) {
e.preventDefault();
if (!currentUserId) return;
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password').value;
try {
const res = await fetch(`${API_BASE}/users/${currentUserId}/password`, {
method: 'PUT',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json();
if (res.ok) { alert("Password updated successfully"); document.getElementById('password-form').reset(); }
else alert(data.error || "Failed to update password");
} catch (e) { console.error(e); }
}
function setupEventListeners() {
document.getElementById('scan-incremental-btn')?.addEventListener('click', () => triggerScan('incremental'));
document.getElementById('scan-full-btn')?.addEventListener('click', () => triggerScan('full'));
document.getElementById('profile-form')?.addEventListener('submit', handleProfileUpdate);
document.getElementById('password-form')?.addEventListener('submit', handlePasswordChange);
document.getElementById('logout-btn')?.addEventListener('click', () => window.AuthUtils.logout());
const fileInput = document.getElementById('avatar-upload');
if (fileInput) {
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = evt => {
document.getElementById('user-avatar').src = evt.target.result;
document.getElementById('setting-avatar-url').value = '';
};
reader.readAsDataURL(file); reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
})
} }
});
}
document.querySelector('.search-input').addEventListener('input', () => applyFilters());
['status-filter', 'type-filter', 'sort-filter'].forEach(id => {
document.getElementById(id).addEventListener('change', () => applyFilters());
});
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
const container = document.getElementById('list-container');
if (view === 'list') container.classList.add('list-view');
else container.classList.remove('list-view');
});
});
}
// --- LOGICA DE LISTA ---
async function loadList() {
const loadingState = document.getElementById('loading-state');
const emptyState = document.getElementById('empty-state');
const container = document.getElementById('list-container');
try {
loadingState.style.display = 'flex';
emptyState.style.display = 'none';
container.innerHTML = '';
const response = await fetch(`${API_BASE}/list`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if (!response.ok) throw new Error('Failed');
const data = await response.json();
currentList = data.results || [];
const animeCount = currentList.filter(item => item.entry_type === 'ANIME').length;
const mangaCount = currentList.filter(item => item.entry_type === 'MANGA').length;
document.getElementById('total-stat').textContent = currentList.length;
if (document.getElementById('anime-stat')) document.getElementById('anime-stat').textContent = animeCount;
if (document.getElementById('manga-stat')) document.getElementById('manga-stat').textContent = mangaCount;
loadingState.style.display = 'none';
if (currentList.length === 0) emptyState.style.display = 'flex';
else applyFilters();
} catch (error) {
console.error(error);
loadingState.style.display = 'none';
}
}
function applyFilters() {
const statusFilter = document.getElementById('status-filter').value;
const typeFilter = document.getElementById('type-filter').value;
const sortFilter = document.getElementById('sort-filter').value;
const searchQuery = document.querySelector('.search-input').value.toLowerCase().trim();
let result = [...currentList];
if (searchQuery) result = result.filter(item => (item.title || '').toLowerCase().includes(searchQuery));
if (statusFilter !== 'all') result = result.filter(item => item.status === statusFilter);
if (typeFilter !== 'all') result = result.filter(item => item.entry_type === typeFilter);
if (sortFilter === 'title') result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
else if (sortFilter === 'score') result.sort((a, b) => (b.score || 0) - (a.score || 0));
else result.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
filteredList = result;
visibleCount = ITEMS_PER_PAGE;
renderList();
}
function renderList() {
const container = document.getElementById('list-container');
container.innerHTML = '';
if (filteredList.length === 0) {
container.innerHTML = '<div class="empty-text" style="grid-column: 1/-1; text-align:center;">No matches found</div>';
return;
}
const itemsToShow = filteredList.slice(0, visibleCount);
itemsToShow.forEach(item => container.appendChild(createListItem(item)));
if (visibleCount < filteredList.length) {
const remaining = filteredList.length - visibleCount;
const btnContainer = document.createElement('div');
btnContainer.style.gridColumn = "1 / -1";
btnContainer.style.display = "flex";
btnContainer.style.justifyContent = "center";
btnContainer.style.padding = "2rem 0";
const loadMoreBtn = document.createElement('button');
loadMoreBtn.className = "btn-blur";
loadMoreBtn.textContent = `Show All (${remaining} more)`;
loadMoreBtn.onclick = () => { visibleCount = filteredList.length; renderList(); };
btnContainer.appendChild(loadMoreBtn);
container.appendChild(btnContainer);
}
}
function createListItem(item) {
const div = document.createElement('div');
div.className = 'list-item';
const itemLink = getEntryLink(item);
const posterUrl = item.poster || '/public/assets/placeholder.svg';
const progress = item.progress || 0;
const totalUnits = item.entry_type === 'ANIME' ? item.total_episodes || 0 : item.total_chapters || 0;
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
const score = item.score ? item.score.toFixed(1) : null;
const repeatCount = item.repeat_count || 0;
const entryType = (item.entry_type).toUpperCase();
const unitLabel = entryType === 'ANIME' ? 'episodes' : 'chapters';
const statusLabels = {
'CURRENT': entryType === 'ANIME' ? 'Watching' : 'Reading',
'COMPLETED': 'Completed',
'PLANNING': 'Planning',
'PAUSED': 'Paused',
'DROPPED': 'Dropped',
'REPEATING': entryType === 'ANIME' ? 'Rewatching' : 'Rereading'
}; };
const extraInfo = []; document.addEventListener('DOMContentLoaded', () => {
if (repeatCount > 0) extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${repeatCount}</span>`); DashboardApp.init();
if (item.is_private) extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>'); });
div.innerHTML = ` window.switchLocalType = (type, btn) => DashboardApp.Library.switchType(type, btn);
<a href="${itemLink}" class="item-poster-link">
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.svg'">
</a>
<div class="item-content">
<div>
<a href="${itemLink}" style="text-decoration:none; color:inherit;">
<h3 class="item-title">${item.title || 'Unknown Title'}</h3>
</a>
<div class="item-meta">
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
<span class="meta-pill type-pill">${entryType}</span>
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
${extraInfo.join('')}
</div>
</div>
<div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${progressPercent}%"></div>
</div>
<div class="progress-text">
<span>${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel}</span> ${score ? `<span class="score-badge">⭐ ${score}</span>` : ''}
</div>
</div>
</div>
<button class="edit-icon-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path d="M15.232 5.232l3.536 3.536m-2.036-5.808a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L15.232 5.232z"/>
</svg>
</button>
`;
// Lógica para abrir el Modal (Estilo book.js: seteamos currentData)
const editBtn = div.querySelector('.edit-icon-btn');
editBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
// 1. Configuramos el modal manager con los datos de ESTE item
window.ListModalManager.currentData = item;
window.ListModalManager.isInList = true;
window.ListModalManager.currentEntry = item; // Ya tenemos los datos de la lista
// 2. Abrimos el modal
window.ListModalManager.open(item, item.source || 'anilist');
};
return div;
}
function getEntryLink(item) {
const isAnime = item.entry_type?.toUpperCase() === 'ANIME';
const baseRoute = isAnime ? '/anime' : '/book';
return `${baseRoute}/${item.entry_id}`;
}
// =========================================================
// EXPORTS GLOBALES (Estilo book.js)
// Estas funciones son llamadas por los onclick del HTML del Modal
// =========================================================
window.saveToList = async () => { window.saveToList = async () => {
// Recuperamos los datos que seteamos en el onclick del botón editar
const data = window.ListModalManager.currentData; const data = window.ListModalManager.currentData;
if (!data) return; if (!data) return;
// En la vista de lista, el ID suele ser 'entry_id', pero usamos un fallback
const idToSave = data.entry_id || data.id; const idToSave = data.entry_id || data.id;
const source = data.source || 'anilist'; await window.ListModalManager.save(idToSave, data.source || 'anilist');
await DashboardApp.Tracking.load();
// Llamamos al manager pasando ID y Source explícitamente como en book.js
await window.ListModalManager.save(idToSave, source);
// IMPORTANTE: Recargar la lista para ver los cambios
await loadList();
}; };
window.deleteFromList = async () => { window.deleteFromList = async () => {
const data = window.ListModalManager.currentData; const data = window.ListModalManager.currentData;
if (!data) return; if (!data) return;
const idToDelete = data.entry_id || data.id; const idToDelete = data.entry_id || data.id;
const source = data.source || 'anilist'; await window.ListModalManager.delete(idToDelete, data.source || 'anilist');
await DashboardApp.Tracking.load();
await window.ListModalManager.delete(idToDelete, source);
// IMPORTANTE: Recargar la lista
await loadList();
}; };
window.closeAddToListModal = () => { window.closeAddToListModal = () => window.ListModalManager.close();
window.ListModalManager.close();
};

View File

@@ -1,106 +0,0 @@
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');
if (isLocalMode) {
btn.classList.add('active');
onlineContent.classList.add('hidden');
localContent.classList.remove('hidden');
loadLocalEntries();
} else {
btn.classList.remove('active');
onlineContent.classList.remove('hidden');
localContent.classList.add('hidden');
}
}
async function loadLocalEntries() {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(6);
try {
const [mangaRes, novelRes] = await Promise.all([
fetch('/api/library/manga'),
fetch('/api/library/novels')
]);
const [manga, novel] = await Promise.all([
mangaRes.json(),
novelRes.json()
]);
localEntries = [
...manga.map(e => ({ ...e, type: 'manga' })),
...novel.map(e => ({ ...e, type: 'novel' }))
];
if (localEntries.length === 0) {
grid.innerHTML = '<p style="grid-column:1/-1;text-align:center;padding:3rem;">No books found.</p>';
return;
}
renderLocalEntries(localEntries);
} catch {
grid.innerHTML = '<p style="grid-column:1/-1;text-align:center;color:var(--color-danger);padding:3rem;">Error loading library.</p>';
}
}
function filterLocal(type) {
if (type === 'all') renderLocalEntries(localEntries);
else renderLocalEntries(localEntries.filter(e => e.type === type));
}
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 || '/public/assets/placeholder.jpg';
const chapters = entry.metadata?.chapters || '??';
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;">
${chapters} Chapters
</p>
<div class="badge">${entry.type}</div>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
}
async function scanLocalLibrary() {
const btnText = document.getElementById('scan-text');
btnText.innerText = "Scanning...";
try {
// Asumiendo que el scan de libros usa este query param
const response = await fetch('/api/library/scan?mode=incremental', { method: 'POST' });
if (response.ok) {
await loadLocalEntries();
if (window.NotificationUtils) NotificationUtils.show('Library scanned!', 'success');
}
} catch (err) {
if (window.NotificationUtils) NotificationUtils.show('Scan failed', 'error');
} finally {
btnText.innerText = "Scan Library";
}
}
function viewLocalEntry(id) {
if (id) window.location.href = `/book/${id}`;
}

View File

@@ -1,209 +0,0 @@
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

@@ -117,46 +117,6 @@
</section> </section>
</main> </main>
<!-- Local Library Mode Content -->
<main id="local-content" class="hidden">
<section class="section">
<div class="section-header">
<div class="section-title">Local Anime Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/>
<path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="watching">Watching</button>
<button class="filter-btn" data-filter="completed">Completed</button>
<button class="filter-btn" data-filter="unwatched">Unwatched</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
<button class="filter-btn" data-sort="recent">Recent</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<div id="updateToast" class="hidden"> <div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p> <p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank"> <a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">
@@ -170,7 +130,6 @@
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/utils/youtube-player-utils.js"></script> <script src="/src/scripts/utils/youtube-player-utils.js"></script>
<script src="/src/scripts/anime/animes.js"></script> <script src="/src/scripts/anime/animes.js"></script>
<script src="/src/scripts/local-library.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script> <script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>

View File

@@ -57,32 +57,6 @@
</svg> </svg>
</button> </button>
</div> </div>
<main id="local-content" class="hidden">
<section class="section">
<div class="section-header">
<div class="section-title">Local Books Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<main id="online-content"> <main id="online-content">
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
@@ -132,7 +106,6 @@
<script src="/src/scripts/utils/list-modal-manager.js"></script> <script src="/src/scripts/utils/list-modal-manager.js"></script>
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/books/books.js"></script> <script src="/src/scripts/books/books.js"></script>
<script src="/src/scripts/local-library-books.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script> <script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@
<span class="app-title">WaifuBoard</span> <span class="app-title">WaifuBoard</span>
</div> </div>
<div class="title-right"> <div class="title-right">
<button class="min"></button> <button class="min"></button>
<button class="max">🗖</button> <button class="max">🗖</button>
<button class="close"></button> <button class="close"></button>
</div> </div>
@@ -31,10 +31,6 @@
<div class="profile-banner"></div> <div class="profile-body"> <div class="profile-banner"></div> <div class="profile-body">
<div class="profile-avatar-wrapper"> <div class="profile-avatar-wrapper">
<img id="user-avatar" src="/public/assets/placeholder.svg" alt="Profile" class="avatar-img"> <img id="user-avatar" src="/public/assets/placeholder.svg" alt="Profile" class="avatar-img">
<label for="avatar-upload" class="avatar-edit-overlay">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</label>
<input type="file" id="avatar-upload" accept="image/*" style="display: none;">
</div> </div>
<div class="profile-details"> <div class="profile-details">
@@ -77,7 +73,8 @@
<div class="toolbar"> <div class="toolbar">
<div class="search-box"> <div class="search-box">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input type="text" class="search-input" placeholder="Search your list..."> <input type="text" id="tracking-search-input" class="search-input" placeholder="Search your list...">
</div> </div>
<div class="filters-inline"> <div class="filters-inline">
<select id="status-filter" class="minimal-select"><option value="all">Status: All</option><option value="CURRENT">Watching</option><option value="COMPLETED">Completed</option><option value="PLANNING">Planning</option><option value="PAUSED">Paused</option><option value="DROPPED">Dropped</option></select> <select id="status-filter" class="minimal-select"><option value="all">Status: All</option><option value="CURRENT">Watching</option><option value="COMPLETED">Completed</option><option value="PLANNING">Planning</option><option value="PAUSED">Paused</option><option value="DROPPED">Dropped</option></select>
@@ -98,81 +95,100 @@
</div> </div>
<div id="section-local" class="tab-section"> <div id="section-local" class="tab-section">
<div class="section-header-row">
<h2>Local Library</h2> <div class="toolbar local-toolbar">
<div class="actions"> <div class="search-box">
<button id="scan-incremental-btn" class="btn-primary">Update Library</button> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<button id="scan-full-btn" class="btn-blur">Full Rescan</button> <input type="text" id="local-search-input" class="search-input" placeholder="Filter local files...">
</div>
<div class="local-type-switcher">
<button class="type-pill-btn active" onclick="switchLocalType('anime', this)">Anime</button>
<button class="type-pill-btn" onclick="switchLocalType('manga', this)">Manga</button>
<button class="type-pill-btn" onclick="switchLocalType('novels', this)">Novels</button>
</div>
<div class="actions-group">
<button id="scan-incremental-btn" class="action-icon-btn" title="Update Library (Fast)">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
<button id="scan-full-btn" class="action-icon-btn danger" title="Full Rescan (Slow)">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div> </div>
</div> </div>
<div class="local-stats-grid">
<div class="local-card"><h3>Anime Files</h3><p id="local-anime-count">0</p></div>
<div class="local-card"><h3>Manga Files</h3><p id="local-manga-count">0</p></div>
<div class="local-card"><h3>Novel Files</h3><p id="local-novel-count">0</p></div>
</div>
<div class="console-output" id="scan-console" style="display: none;"> <div class="console-output" id="scan-console" style="display: none;">
<div class="spinner small"></div> <span id="scan-status-text">Scanning...</span> <div class="spinner small"></div>
<span id="scan-status-text">Scanning folders...</span>
</div>
<div id="local-list-container" class="list-grid"></div>
<div id="local-loading" class="loading-state" style="display: none;">
<div class="spinner"></div>
</div> </div>
</div> </div>
<div id="section-settings" class="tab-section"> <div id="section-settings" class="tab-section">
<div class="settings-layout"> <div class="stream-settings-container">
<div class="settings-card">
<h3>Edit Profile</h3> <div class="stream-section">
<form id="profile-form"> <h3 class="section-label">Profile</h3>
<div class="form-group"> <form id="profile-form" class="stream-profile-row">
<label>Display Name</label>
<input type="text" id="setting-username" class="input-modern" placeholder="Username"> <div class="stream-avatar-wrapper">
<img id="setting-avatar-preview" src="/public/assets/placeholder.svg" alt="Avatar">
<div class="avatar-overlay" onclick="document.getElementById('avatar-upload').click()">
<span>Edit</span>
</div>
<input type="file" id="avatar-upload" accept="image/*" style="display: none;">
</div> </div>
<div class="form-group"> <div class="stream-inputs-col">
<label>Avatar Source</label> <div class="stream-input-group">
<div class="avatar-options"> <label>Username</label>
<button type="button" class="btn-secondary small" onclick="document.getElementById('avatar-upload').click()">Upload File</button> <input type="text" id="setting-username" class="stream-input">
<span class="divider-text">OR</span> </div>
<input type="text" id="setting-avatar-url" class="input-modern" placeholder="Paste Image URL (https://...)">
<div class="stream-actions">
<button type="submit" class="btn-stream-primary">Submit</button>
</div> </div>
<small style="color: var(--color-text-muted); display:block; margin-top:5px;">
* If you upload a file, it will be converted automatically. The text box above is only for external URLs.
</small>
</div> </div>
<button type="submit" class="btn-primary full-width">Save Profile Changes</button>
</form> </form>
</div> </div>
<div class="settings-stack"> <div class="stream-section">
<div class="settings-card"> <h3 class="section-label">Change password</h3>
<h3>Security</h3> <form id="password-form" class="stream-form-row">
<form id="password-form"> <div class="stream-input-group">
<div class="form-group"> <label>Current password</label>
<label>Current Password</label> <input type="password" id="current-password" class="stream-input" placeholder="••••••">
<input type="password" id="current-password" class="input-modern" placeholder="••••••">
</div> </div>
<div class="form-group"> <div class="stream-input-group">
<label>New Password</label> <label>New password</label>
<input type="password" id="new-password" class="input-modern" placeholder="••••••"> <input type="password" id="new-password" class="stream-input" placeholder="••••••">
</div>
<div class="stream-actions-inline">
<button type="submit" class="btn-stream-ghost">Update password</button>
</div> </div>
<button type="submit" class="btn-blur full-width">Update Password</button>
</form> </form>
</div> </div>
<div class="settings-card"> <div class="stream-section">
<h3>Integrations</h3> <h3 class="section-label">Connections</h3>
<div class="integration-item"> <div class="stream-integration-row">
<div class="int-icon anilist-bg"><img src="https://anilist.co/img/icons/android-chrome-512x512.png" alt="AL"></div> <div class="int-info">
<div class="int-details"> <img src="https://anilist.co/img/icons/android-chrome-512x512.png" alt="AL" class="int-logo">
<strong>AniList</strong> <div class="int-text">
<span id="anilist-status">Checking...</span> <span class="int-name">AniList</span>
<span id="anilist-status" class="int-status">Checking...</span>
</div> </div>
<button id="anilist-action-btn" class="btn-sm">Connect</button> </div>
<button id="anilist-action-btn" class="btn-stream-outline">Manage</button>
</div> </div>
</div> </div>
<div class="settings-card danger-zone">
<h3>Danger Zone</h3>
<button id="logout-btn" class="btn-danger">Log Out</button>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -1,21 +1,78 @@
const API_BASE = '/api'; const API_BASE = '/api';
let currentList = [];
let filteredList = [];
let currentUserId = null;
// Configuración de paginación const DashboardApp = {
const ITEMS_PER_PAGE = 50;
let visibleCount = ITEMS_PER_PAGE;
// Inicialización State: {
document.addEventListener('DOMContentLoaded', async () => { currentList: [],
await initUser(); filteredList: [],
await loadList(); localLibraryData: [],
setupEventListeners(); currentUserId: null,
setupTabSystem(); currentLocalType: 'anime',
pagination: {
itemsPerPage: 50,
visibleCount: 50
}
},
init: async function() {
console.log('Initializing Dashboard...');
await this.User.init();
await this.Tracking.load();
this.UI.setupTabSystem();
this.initListeners();
const localInput = document.getElementById('local-search-input');
if(localInput) {
localInput.addEventListener('input', (e) => this.Library.filterContent(e.target.value));
}
},
initListeners: function() {
document.getElementById('scan-incremental-btn')?.addEventListener('click', () => this.Library.triggerScan('incremental'));
document.getElementById('scan-full-btn')?.addEventListener('click', () => this.Library.triggerScan('full'));
document.getElementById('profile-form')?.addEventListener('submit', (e) => this.User.updateProfile(e));
document.getElementById('password-form')?.addEventListener('submit', (e) => this.User.changePassword(e));
document.getElementById('logout-btn')?.addEventListener('click', () => window.AuthUtils.logout());
const fileInput = document.getElementById('avatar-upload');
if (fileInput) {
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = evt => {
document.getElementById('user-avatar').src = evt.target.result;
const urlInput = document.getElementById('setting-avatar-url');
if(urlInput) urlInput.value = '';
};
reader.readAsDataURL(file);
}
});
}
const trackingInput = document.getElementById('tracking-search-input');
if (trackingInput) trackingInput.addEventListener('input', () => this.Tracking.applyFilters());
['status-filter', 'type-filter', 'sort-filter'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => this.Tracking.applyFilters());
}); });
async function initUser() { document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
const container = document.getElementById('list-container');
view === 'list' ? container.classList.add('list-view') : container.classList.remove('list-view');
});
});
},
User: {
init: async function() {
try { try {
const headers = window.AuthUtils.getSimpleAuthHeaders(); const headers = window.AuthUtils.getSimpleAuthHeaders();
const res = await fetch(`${API_BASE}/me`, { headers }); const res = await fetch(`${API_BASE}/me`, { headers });
@@ -23,38 +80,37 @@ async function initUser() {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
document.getElementById('user-username').textContent = data.username; document.getElementById('user-username').textContent = data.username;
document.getElementById('setting-username').value = data.username; const settingUsername = document.getElementById('setting-username');
if(settingUsername) settingUsername.value = data.username;
if (data.avatar) { if (data.avatar) {
document.getElementById('user-avatar').src = data.avatar; document.getElementById('user-avatar').src = data.avatar;
if (data.avatar.startsWith('http')) {
document.getElementById('setting-avatar-url').value = data.avatar;
} else {
document.getElementById('setting-avatar-url').placeholder = "Image uploaded via file (Base64)";
document.getElementById('setting-avatar-url').value = "";
}
} }
} }
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
const payload = JSON.parse(atob(token.split('.')[1])); const payload = JSON.parse(atob(token.split('.')[1]));
currentUserId = payload.id; DashboardApp.State.currentUserId = payload.id;
await checkIntegrations(currentUserId); await this.checkIntegrations(payload.id);
} }
} catch (err) { } catch (err) {
console.error("Error loading user profile:", err); console.error("Error loading user profile:", err);
} }
} },
async function checkIntegrations(userId) { checkIntegrations: async function(userId) {
if (!userId) return; if (!userId) return;
try { try {
const res = await fetch(`${API_BASE}/users/${userId}/integration`); const res = await fetch(`${API_BASE}/users/${userId}/integration`);
let data = { connected: false }; let data = { connected: false };
if (res.ok) data = await res.json(); if (res.ok) data = await res.json();
this.updateIntegrationUI(data, userId);
} catch (e) { console.error("Integration check error:", e); }
},
updateIntegrationUI: function(data, userId) {
const statusEl = document.getElementById('anilist-status'); const statusEl = document.getElementById('anilist-status');
const btn = document.getElementById('anilist-action-btn'); const btn = document.getElementById('anilist-action-btn');
const headerBadge = document.getElementById('header-anilist-link'); const headerBadge = document.getElementById('header-anilist-link');
@@ -71,9 +127,9 @@ async function checkIntegrations(userId) {
} }
if (btn) { if (btn) {
btn.textContent = 'Disconnect'; btn.textContent = 'Disconnect';
btn.classList.add('btn-danger-outline'); btn.className = 'btn-stream-outline link-danger';
btn.classList.remove('btn-blur');
btn.onclick = () => disconnectAniList(userId); btn.onclick = () => this.disconnectAniList(userId);
} }
} else { } else {
if (headerBadge) headerBadge.style.display = 'none'; if (headerBadge) headerBadge.style.display = 'none';
@@ -83,26 +139,23 @@ async function checkIntegrations(userId) {
} }
if (btn) { if (btn) {
btn.textContent = 'Connect'; btn.textContent = 'Connect';
btn.classList.remove('btn-danger-outline'); btn.className = 'btn-stream-outline';
btn.classList.add('btn-blur'); btn.onclick = () => this.redirectToAniListLogin();
btn.onclick = () => redirectToAniListLogin();
} }
} }
} catch (e) { console.error("Integration check error:", e); } },
}
async function redirectToAniListLogin() { redirectToAniListLogin: async function() {
if (!currentUserId) return; if (!DashboardApp.State.currentUserId) return;
try { try {
const clientId = 32898; const clientId = 32898;
const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist'); const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
const state = encodeURIComponent(currentUserId); const state = encodeURIComponent(DashboardApp.State.currentUserId);
window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&state=${state}`; window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&state=${state}`;
} catch (err) { console.error(err); alert('Error starting AniList login'); } } catch (err) { console.error(err); alert('Error starting AniList login'); }
} },
async function disconnectAniList(userId) { disconnectAniList: async function(userId) {
if(!confirm("Disconnect AniList?")) return; if(!confirm("Disconnect AniList?")) return;
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -110,11 +163,370 @@ async function disconnectAniList(userId) {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
checkIntegrations(userId); this.checkIntegrations(userId);
} catch (e) { alert("Failed to disconnect"); } } catch (e) { alert("Failed to disconnect"); }
},
updateProfile: async function(e) {
e.preventDefault();
const userId = DashboardApp.State.currentUserId;
if (!userId) return;
const username = document.getElementById('setting-username').value;
const urlInput = document.getElementById('setting-avatar-url')?.value || '';
const fileInput = document.getElementById('avatar-upload');
let finalAvatar = null;
if (fileInput && fileInput.files && fileInput.files[0]) {
try {
finalAvatar = await DashboardApp.Utils.fileToBase64(fileInput.files[0]);
} catch (err) { alert("Error reading file"); return; }
} else if (urlInput.trim() !== "") {
finalAvatar = urlInput.trim();
} }
function setupTabSystem() { const bodyData = { username };
if (finalAvatar) bodyData.profilePictureUrl = finalAvatar;
try {
const res = await fetch(`${API_BASE}/users/${userId}`, {
method: 'PUT',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(bodyData)
});
if (res.ok) alert('Profile updated successfully!');
else { const err = await res.json(); alert(err.error || 'Update failed'); }
} catch (e) { console.error(e); }
},
changePassword: async function(e) {
e.preventDefault();
const userId = DashboardApp.State.currentUserId;
if (!userId) return;
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password').value;
try {
const res = await fetch(`${API_BASE}/users/${userId}/password`, {
method: 'PUT',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json();
if (res.ok) { alert("Password updated successfully"); document.getElementById('password-form').reset(); }
else alert(data.error || "Failed to update password");
} catch (e) { console.error(e); }
}
},
Tracking: {
load: async function() {
const loadingState = document.getElementById('loading-state');
const emptyState = document.getElementById('empty-state');
const container = document.getElementById('list-container');
try {
loadingState.style.display = 'flex';
emptyState.style.display = 'none';
container.innerHTML = '';
const response = await fetch(`${API_BASE}/list`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if (!response.ok) throw new Error('Failed');
const data = await response.json();
DashboardApp.State.currentList = data.results || [];
this.updateStats();
loadingState.style.display = 'none';
if (DashboardApp.State.currentList.length === 0) emptyState.style.display = 'flex';
else this.applyFilters();
} catch (error) {
console.error(error);
loadingState.style.display = 'none';
}
},
updateStats: function() {
const list = DashboardApp.State.currentList;
const animeCount = list.filter(item => item.entry_type === 'ANIME').length;
const mangaCount = list.filter(item => item.entry_type === 'MANGA').length;
document.getElementById('total-stat').textContent = list.length;
if (document.getElementById('anime-stat')) document.getElementById('anime-stat').textContent = animeCount;
if (document.getElementById('manga-stat')) document.getElementById('manga-stat').textContent = mangaCount;
},
applyFilters: function() {
const statusFilter = document.getElementById('status-filter').value;
const typeFilter = document.getElementById('type-filter').value;
const sortFilter = document.getElementById('sort-filter').value;
const searchInput = document.getElementById('tracking-search-input');
const searchQuery = searchInput ? searchInput.value.toLowerCase().trim() : '';
let result = [...DashboardApp.State.currentList];
if (searchQuery) {
result = result.filter(item => (item.title ? item.title.toLowerCase() : '').includes(searchQuery));
}
if (statusFilter !== 'all') result = result.filter(item => item.status === statusFilter);
if (typeFilter !== 'all') result = result.filter(item => item.entry_type === typeFilter);
if (sortFilter === 'title') result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
else if (sortFilter === 'score') result.sort((a, b) => (b.score || 0) - (a.score || 0));
else result.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
DashboardApp.State.filteredList = result;
DashboardApp.State.pagination.visibleCount = DashboardApp.State.pagination.itemsPerPage;
this.render();
},
render: function() {
const container = document.getElementById('list-container');
container.innerHTML = '';
const list = DashboardApp.State.filteredList;
const count = DashboardApp.State.pagination.visibleCount;
if (list.length === 0) {
container.innerHTML = '<div class="empty-text" style="grid-column: 1/-1; text-align:center; color: var(--color-text-secondary);">No matches found</div>';
return;
}
const itemsToShow = list.slice(0, count);
itemsToShow.forEach(item => container.appendChild(this.createItemElement(item)));
if (count < list.length) {
this.renderLoadMoreButton(container, list.length - count);
}
},
createItemElement: function(item) {
const div = document.createElement('div');
div.className = 'list-item';
const itemLink = this.getEntryLink(item);
const posterUrl = item.poster || '/public/assets/placeholder.svg';
const progress = item.progress || 0;
const totalUnits = item.entry_type === 'ANIME' ? item.total_episodes || 0 : item.total_chapters || 0;
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
const score = item.score ? item.score.toFixed(1) : null;
const entryType = (item.entry_type).toUpperCase();
const statusLabels = {
'CURRENT': entryType === 'ANIME' ? 'Watching' : 'Reading',
'COMPLETED': 'Completed', 'PLANNING': 'Planning', 'PAUSED': 'Paused',
'DROPPED': 'Dropped', 'REPEATING': entryType === 'ANIME' ? 'Rewatching' : 'Rereading'
};
const extraInfo = [];
if (item.repeat_count > 0) extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${item.repeat_count}</span>`);
if (item.is_private) extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>');
div.innerHTML = `
<a href="${itemLink}" class="item-poster-link">
<img src="${posterUrl}" alt="${item.title}" class="item-poster" onerror="this.src='/public/assets/placeholder.svg'">
</a>
<div class="item-content">
<div>
<a href="${itemLink}" style="text-decoration:none; color:inherit;">
<h3 class="item-title">${item.title || 'Unknown'}</h3>
</a>
<div class="item-meta">
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
<span class="meta-pill type-pill">${entryType}</span>
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
${extraInfo.join('')}
</div>
</div>
<div>
<div class="progress-bar-container"><div class="progress-bar" style="width: ${progressPercent}%"></div></div>
<div class="progress-text">
<span>${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''}</span>
${score ? `<span class="score-badge">⭐ ${score}</span>` : ''}
</div>
</div>
</div>
<button class="edit-icon-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15.232 5.232l3.536 3.536m-2.036-5.808a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L15.232 5.232z"/></svg>
</button>
`;
div.querySelector('.edit-icon-btn').onclick = (e) => {
e.preventDefault(); e.stopPropagation();
window.ListModalManager.currentData = item;
window.ListModalManager.isInList = true;
window.ListModalManager.currentEntry = item;
window.ListModalManager.open(item, item.source || 'anilist');
};
return div;
},
renderLoadMoreButton: function(container, remaining) {
const btnContainer = document.createElement('div');
Object.assign(btnContainer.style, { gridColumn: "1 / -1", display: "flex", justifyContent: "center", padding: "2rem 0" });
const loadMoreBtn = document.createElement('button');
loadMoreBtn.className = "btn-blur";
loadMoreBtn.textContent = `Show All (${remaining} more)`;
loadMoreBtn.onclick = () => {
DashboardApp.State.pagination.visibleCount = DashboardApp.State.filteredList.length;
this.render();
};
btnContainer.appendChild(loadMoreBtn);
container.appendChild(btnContainer);
},
getEntryLink: function(item) {
const baseRoute = (item.entry_type?.toUpperCase() === 'ANIME') ? '/anime' : '/book';
return `${baseRoute}/${item.entry_id}`;
}
},
Library: {
loadStats: async function() {
const types = ['anime', 'manga', 'novels'];
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
for (const type of types) {
try {
const res = await fetch(`${API_BASE}/library/${type}`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if(res.ok) {
const data = await res.json();
const el = document.getElementById(elements[type]);
if (el) el.textContent = `${data.length} items`;
}
} catch (e) { console.error(e); }
}
},
loadContent: async function(type) {
DashboardApp.State.currentLocalType = type;
const container = document.getElementById('local-list-container');
const loading = document.getElementById('local-loading');
const searchInput = document.getElementById('local-search-input');
container.innerHTML = '';
loading.style.display = 'flex';
if(searchInput) searchInput.value = '';
try {
const res = await fetch(`${API_BASE}/library/${type}`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if (!res.ok) throw new Error('Failed to load local content');
const data = await res.json();
DashboardApp.State.localLibraryData = data;
this.renderGrid(data, type);
} catch (err) {
console.error(err);
container.innerHTML = `<div class="empty-state"><p>Error loading library</p></div>`;
} finally {
loading.style.display = 'none';
}
},
renderGrid: function(entries, type) {
const container = document.getElementById('local-list-container');
container.innerHTML = '';
if (entries.length === 0) {
container.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1;">
<p>No ${type} files found.</p>
<button onclick="DashboardApp.Library.triggerScan('incremental')" class="btn-blur">Scan Now</button>
</div>`;
return;
}
entries.forEach(entry => {
const isMatched = entry.matched && entry.metadata;
const meta = entry.metadata || {};
let poster = meta.coverImage?.large || '/public/assets/placeholder.svg';
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.folder_name;
if (!isMatched) title = title.replace(/\[.*?\]|\(.*?\)|\.mkv|\.mp4/g, '').trim();
const url = isMatched ? (type === 'anime' ? `/anime/${meta.id}` : `/book/${meta.id}`) : '#';
const div = document.createElement('div');
div.className = 'list-item';
div.innerHTML = `
<div class="item-poster-link" onclick="${isMatched ? `window.location='${url}'` : ''}" style="cursor: ${isMatched ? 'pointer' : 'default'}">
<img src="${poster}" class="item-poster" loading="lazy">
${!isMatched ? `<div class="unmatched-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> UNMATCHED</div>` : ''}
</div>
<div class="item-content">
<h3 class="item-title" title="${entry.folder_name}">${title}</h3>
<div class="item-meta">
<span class="meta-pill type-pill">${entry.files ? entry.files.length : 0} FILES</span>
${isMatched ? '<span class="meta-pill status-pill">MATCHED</span>' : ''}
</div>
<div class="folder-path-tooltip">${entry.folder_name}</div>
</div>
<button class="edit-icon-btn" onclick="DashboardApp.Library.openManualMatch('${entry.id}', '${type}')" title="${isMatched ? 'Rematch' : 'Fix Match'}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
`;
container.appendChild(div);
});
},
filterContent: function(query) {
if (!DashboardApp.State.localLibraryData) return;
const lowerQuery = query.toLowerCase();
const filtered = DashboardApp.State.localLibraryData.filter(entry => {
const metaTitle = entry.metadata?.title?.english || entry.metadata?.title?.romaji || '';
const folderName = entry.folder_name || '';
return metaTitle.toLowerCase().includes(lowerQuery) || folderName.toLowerCase().includes(lowerQuery);
});
this.renderGrid(filtered, DashboardApp.State.currentLocalType);
},
triggerScan: async function(mode) {
const consoleDiv = document.getElementById('scan-console');
const statusText = document.getElementById('scan-status-text');
if(consoleDiv) consoleDiv.style.display = 'flex';
if(statusText) statusText.textContent = `Starting ${mode} scan...`;
try {
const res = await fetch(`${API_BASE}/library/scan?mode=${mode}`, {
method: 'POST', headers: window.AuthUtils.getSimpleAuthHeaders()
});
if (res.ok) {
if(statusText) statusText.textContent = "Scan completed successfully!";
setTimeout(() => { if(consoleDiv) consoleDiv.style.display = 'none'; this.loadStats(); }, 3000);
} else throw new Error('Scan failed');
} catch (e) {
if(statusText) { statusText.textContent = "Error during scan."; statusText.style.color = 'var(--color-danger)'; }
}
},
openManualMatch: function(id, type) {
const newId = prompt("Enter AniList ID to force match:");
if (newId) {
fetch(`${API_BASE}/library/${type}/${id}/match`, {
method: 'POST',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'anilist', matched_id: parseInt(newId) })
}).then(res => {
if(res.ok) { alert("Matched! Refreshing..."); this.loadContent(type); }
else { alert("Failed to match."); }
});
}
},
switchType: function(type, btnElement) {
document.querySelectorAll('.type-pill-btn').forEach(b => b.classList.remove('active'));
if(btnElement) btnElement.classList.add('active');
this.loadContent(type);
}
},
UI: {
setupTabSystem: function() {
const tabs = document.querySelectorAll('.nav-pill'); const tabs = document.querySelectorAll('.nav-pill');
const sections = document.querySelectorAll('.tab-section'); const sections = document.querySelectorAll('.tab-section');
@@ -129,340 +541,45 @@ function setupTabSystem() {
if (sec.id === targetId) sec.classList.add('active'); if (sec.id === targetId) sec.classList.add('active');
}); });
if (tab.dataset.target === 'local') loadLocalStats(); if (tab.dataset.target === 'local') {
DashboardApp.Library.loadStats();
DashboardApp.Library.loadContent('anime');
}
}); });
}); });
} }
},
async function loadLocalStats() { Utils: {
const types = ['anime', 'manga', 'novels']; fileToBase64: (file) => new Promise((resolve, reject) => {
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
for (const type of types) {
try {
const res = await fetch(`${API_BASE}/library/${type}`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if(res.ok) {
const data = await res.json();
const elId = elements[type];
if (document.getElementById(elId)) document.getElementById(elId).textContent = `${data.length} items`;
}
} catch (e) { console.error(e); }
}
}
async function triggerScan(mode) {
const consoleDiv = document.getElementById('scan-console');
const statusText = document.getElementById('scan-status-text');
consoleDiv.style.display = 'flex';
statusText.textContent = `Starting ${mode} scan...`;
try {
const res = await fetch(`${API_BASE}/library/scan?mode=${mode}`, {
method: 'POST',
headers: window.AuthUtils.getSimpleAuthHeaders()
});
if (res.ok) {
statusText.textContent = "Scan completed successfully!";
setTimeout(() => { consoleDiv.style.display = 'none'; loadLocalStats(); }, 3000);
} else throw new Error('Scan failed');
} catch (e) {
statusText.textContent = "Error during scan.";
statusText.style.color = 'var(--color-danger)';
}
}
async function handleProfileUpdate(e) {
e.preventDefault();
if (!currentUserId) return;
const username = document.getElementById('setting-username').value;
const urlInput = document.getElementById('setting-avatar-url').value;
const fileInput = document.getElementById('avatar-upload');
let finalAvatar = null;
if (fileInput.files && fileInput.files[0]) {
const toBase64 = file => new Promise((res, rej) => {
const r = new FileReader(); r.readAsDataURL(file);
r.onload = () => res(r.result); r.onerror = rej;
});
try { finalAvatar = await toBase64(fileInput.files[0]); } catch (err) { alert("Error reading file"); return; }
} else if (urlInput.trim() !== "") {
finalAvatar = urlInput.trim();
}
const bodyData = { username };
if (finalAvatar) bodyData.profilePictureUrl = finalAvatar;
try {
const res = await fetch(`${API_BASE}/users/${currentUserId}`, {
method: 'PUT',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(bodyData)
});
if (res.ok) alert('Profile updated successfully!');
else { const err = await res.json(); alert(err.error || 'Update failed'); }
} catch (e) { console.error(e); }
}
async function handlePasswordChange(e) {
e.preventDefault();
if (!currentUserId) return;
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password').value;
try {
const res = await fetch(`${API_BASE}/users/${currentUserId}/password`, {
method: 'PUT',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json();
if (res.ok) { alert("Password updated successfully"); document.getElementById('password-form').reset(); }
else alert(data.error || "Failed to update password");
} catch (e) { console.error(e); }
}
function setupEventListeners() {
document.getElementById('scan-incremental-btn')?.addEventListener('click', () => triggerScan('incremental'));
document.getElementById('scan-full-btn')?.addEventListener('click', () => triggerScan('full'));
document.getElementById('profile-form')?.addEventListener('submit', handleProfileUpdate);
document.getElementById('password-form')?.addEventListener('submit', handlePasswordChange);
document.getElementById('logout-btn')?.addEventListener('click', () => window.AuthUtils.logout());
const fileInput = document.getElementById('avatar-upload');
if (fileInput) {
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = evt => {
document.getElementById('user-avatar').src = evt.target.result;
document.getElementById('setting-avatar-url').value = '';
};
reader.readAsDataURL(file); reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
})
} }
});
}
document.querySelector('.search-input').addEventListener('input', () => applyFilters());
['status-filter', 'type-filter', 'sort-filter'].forEach(id => {
document.getElementById(id).addEventListener('change', () => applyFilters());
});
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
const container = document.getElementById('list-container');
if (view === 'list') container.classList.add('list-view');
else container.classList.remove('list-view');
});
});
}
// --- LOGICA DE LISTA ---
async function loadList() {
const loadingState = document.getElementById('loading-state');
const emptyState = document.getElementById('empty-state');
const container = document.getElementById('list-container');
try {
loadingState.style.display = 'flex';
emptyState.style.display = 'none';
container.innerHTML = '';
const response = await fetch(`${API_BASE}/list`, { headers: window.AuthUtils.getSimpleAuthHeaders() });
if (!response.ok) throw new Error('Failed');
const data = await response.json();
currentList = data.results || [];
const animeCount = currentList.filter(item => item.entry_type === 'ANIME').length;
const mangaCount = currentList.filter(item => item.entry_type === 'MANGA').length;
document.getElementById('total-stat').textContent = currentList.length;
if (document.getElementById('anime-stat')) document.getElementById('anime-stat').textContent = animeCount;
if (document.getElementById('manga-stat')) document.getElementById('manga-stat').textContent = mangaCount;
loadingState.style.display = 'none';
if (currentList.length === 0) emptyState.style.display = 'flex';
else applyFilters();
} catch (error) {
console.error(error);
loadingState.style.display = 'none';
}
}
function applyFilters() {
const statusFilter = document.getElementById('status-filter').value;
const typeFilter = document.getElementById('type-filter').value;
const sortFilter = document.getElementById('sort-filter').value;
const searchQuery = document.querySelector('.search-input').value.toLowerCase().trim();
let result = [...currentList];
if (searchQuery) result = result.filter(item => (item.title || '').toLowerCase().includes(searchQuery));
if (statusFilter !== 'all') result = result.filter(item => item.status === statusFilter);
if (typeFilter !== 'all') result = result.filter(item => item.entry_type === typeFilter);
if (sortFilter === 'title') result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
else if (sortFilter === 'score') result.sort((a, b) => (b.score || 0) - (a.score || 0));
else result.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
filteredList = result;
visibleCount = ITEMS_PER_PAGE;
renderList();
}
function renderList() {
const container = document.getElementById('list-container');
container.innerHTML = '';
if (filteredList.length === 0) {
container.innerHTML = '<div class="empty-text" style="grid-column: 1/-1; text-align:center;">No matches found</div>';
return;
}
const itemsToShow = filteredList.slice(0, visibleCount);
itemsToShow.forEach(item => container.appendChild(createListItem(item)));
if (visibleCount < filteredList.length) {
const remaining = filteredList.length - visibleCount;
const btnContainer = document.createElement('div');
btnContainer.style.gridColumn = "1 / -1";
btnContainer.style.display = "flex";
btnContainer.style.justifyContent = "center";
btnContainer.style.padding = "2rem 0";
const loadMoreBtn = document.createElement('button');
loadMoreBtn.className = "btn-blur";
loadMoreBtn.textContent = `Show All (${remaining} more)`;
loadMoreBtn.onclick = () => { visibleCount = filteredList.length; renderList(); };
btnContainer.appendChild(loadMoreBtn);
container.appendChild(btnContainer);
}
}
function createListItem(item) {
const div = document.createElement('div');
div.className = 'list-item';
const itemLink = getEntryLink(item);
const posterUrl = item.poster || '/public/assets/placeholder.svg';
const progress = item.progress || 0;
const totalUnits = item.entry_type === 'ANIME' ? item.total_episodes || 0 : item.total_chapters || 0;
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
const score = item.score ? item.score.toFixed(1) : null;
const repeatCount = item.repeat_count || 0;
const entryType = (item.entry_type).toUpperCase();
const unitLabel = entryType === 'ANIME' ? 'episodes' : 'chapters';
const statusLabels = {
'CURRENT': entryType === 'ANIME' ? 'Watching' : 'Reading',
'COMPLETED': 'Completed',
'PLANNING': 'Planning',
'PAUSED': 'Paused',
'DROPPED': 'Dropped',
'REPEATING': entryType === 'ANIME' ? 'Rewatching' : 'Rereading'
}; };
const extraInfo = []; document.addEventListener('DOMContentLoaded', () => {
if (repeatCount > 0) extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${repeatCount}</span>`); DashboardApp.init();
if (item.is_private) extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>'); });
div.innerHTML = ` window.switchLocalType = (type, btn) => DashboardApp.Library.switchType(type, btn);
<a href="${itemLink}" class="item-poster-link">
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.svg'">
</a>
<div class="item-content">
<div>
<a href="${itemLink}" style="text-decoration:none; color:inherit;">
<h3 class="item-title">${item.title || 'Unknown Title'}</h3>
</a>
<div class="item-meta">
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
<span class="meta-pill type-pill">${entryType}</span>
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
${extraInfo.join('')}
</div>
</div>
<div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${progressPercent}%"></div>
</div>
<div class="progress-text">
<span>${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel}</span> ${score ? `<span class="score-badge">⭐ ${score}</span>` : ''}
</div>
</div>
</div>
<button class="edit-icon-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path d="M15.232 5.232l3.536 3.536m-2.036-5.808a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L15.232 5.232z"/>
</svg>
</button>
`;
// Lógica para abrir el Modal (Estilo book.js: seteamos currentData)
const editBtn = div.querySelector('.edit-icon-btn');
editBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
// 1. Configuramos el modal manager con los datos de ESTE item
window.ListModalManager.currentData = item;
window.ListModalManager.isInList = true;
window.ListModalManager.currentEntry = item; // Ya tenemos los datos de la lista
// 2. Abrimos el modal
window.ListModalManager.open(item, item.source || 'anilist');
};
return div;
}
function getEntryLink(item) {
const isAnime = item.entry_type?.toUpperCase() === 'ANIME';
const baseRoute = isAnime ? '/anime' : '/book';
return `${baseRoute}/${item.entry_id}`;
}
// =========================================================
// EXPORTS GLOBALES (Estilo book.js)
// Estas funciones son llamadas por los onclick del HTML del Modal
// =========================================================
window.saveToList = async () => { window.saveToList = async () => {
// Recuperamos los datos que seteamos en el onclick del botón editar
const data = window.ListModalManager.currentData; const data = window.ListModalManager.currentData;
if (!data) return; if (!data) return;
// En la vista de lista, el ID suele ser 'entry_id', pero usamos un fallback
const idToSave = data.entry_id || data.id; const idToSave = data.entry_id || data.id;
const source = data.source || 'anilist'; await window.ListModalManager.save(idToSave, data.source || 'anilist');
await DashboardApp.Tracking.load();
// Llamamos al manager pasando ID y Source explícitamente como en book.js
await window.ListModalManager.save(idToSave, source);
// IMPORTANTE: Recargar la lista para ver los cambios
await loadList();
}; };
window.deleteFromList = async () => { window.deleteFromList = async () => {
const data = window.ListModalManager.currentData; const data = window.ListModalManager.currentData;
if (!data) return; if (!data) return;
const idToDelete = data.entry_id || data.id; const idToDelete = data.entry_id || data.id;
const source = data.source || 'anilist'; await window.ListModalManager.delete(idToDelete, data.source || 'anilist');
await DashboardApp.Tracking.load();
await window.ListModalManager.delete(idToDelete, source);
// IMPORTANTE: Recargar la lista
await loadList();
}; };
window.closeAddToListModal = () => { window.closeAddToListModal = () => window.ListModalManager.close();
window.ListModalManager.close();
};

View File

@@ -1,106 +0,0 @@
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');
if (isLocalMode) {
btn.classList.add('active');
onlineContent.classList.add('hidden');
localContent.classList.remove('hidden');
loadLocalEntries();
} else {
btn.classList.remove('active');
onlineContent.classList.remove('hidden');
localContent.classList.add('hidden');
}
}
async function loadLocalEntries() {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(6);
try {
const [mangaRes, novelRes] = await Promise.all([
fetch('/api/library/manga'),
fetch('/api/library/novels')
]);
const [manga, novel] = await Promise.all([
mangaRes.json(),
novelRes.json()
]);
localEntries = [
...manga.map(e => ({ ...e, type: 'manga' })),
...novel.map(e => ({ ...e, type: 'novel' }))
];
if (localEntries.length === 0) {
grid.innerHTML = '<p style="grid-column:1/-1;text-align:center;padding:3rem;">No books found.</p>';
return;
}
renderLocalEntries(localEntries);
} catch {
grid.innerHTML = '<p style="grid-column:1/-1;text-align:center;color:var(--color-danger);padding:3rem;">Error loading library.</p>';
}
}
function filterLocal(type) {
if (type === 'all') renderLocalEntries(localEntries);
else renderLocalEntries(localEntries.filter(e => e.type === type));
}
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 || '/public/assets/placeholder.jpg';
const chapters = entry.metadata?.chapters || '??';
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;">
${chapters} Chapters
</p>
<div class="badge">${entry.type}</div>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
}
async function scanLocalLibrary() {
const btnText = document.getElementById('scan-text');
btnText.innerText = "Scanning...";
try {
// Asumiendo que el scan de libros usa este query param
const response = await fetch('/api/library/scan?mode=incremental', { method: 'POST' });
if (response.ok) {
await loadLocalEntries();
if (window.NotificationUtils) NotificationUtils.show('Library scanned!', 'success');
}
} catch (err) {
if (window.NotificationUtils) NotificationUtils.show('Scan failed', 'error');
} finally {
btnText.innerText = "Scan Library";
}
}
function viewLocalEntry(id) {
if (id) window.location.href = `/book/${id}`;
}

View File

@@ -1,209 +0,0 @@
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

@@ -13,6 +13,7 @@
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
</head> </head>
<body> <body>
<div class="hero-wrapper"> <div class="hero-wrapper">
<div class="hero-background"> <div class="hero-background">
<img id="hero-bg-media" alt=""> <img id="hero-bg-media" alt="">
@@ -103,45 +104,6 @@
</section> </section>
</main> </main>
<!-- Local Library Mode Content -->
<main id="local-content" class="hidden">
<section class="section">
<div class="section-header">
<div class="section-title">Local Anime Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/>
<path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="watching">Watching</button>
<button class="filter-btn" data-filter="completed">Completed</button>
<button class="filter-btn" data-filter="unwatched">Unwatched</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
<button class="filter-btn" data-sort="recent">Recent</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<div id="updateToast" class="hidden"> <div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p> <p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
@@ -156,8 +118,8 @@
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/utils/youtube-player-utils.js"></script> <script src="/src/scripts/utils/youtube-player-utils.js"></script>
<script src="/src/scripts/anime/animes.js"></script> <script src="/src/scripts/anime/animes.js"></script>
<script src="/src/scripts/local-library.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
<script src="/src/scripts/settings.js"></script> <script src="/src/scripts/settings.js"></script>
</body> </body>

View File

@@ -37,39 +37,7 @@
</div> </div>
</div> </div>
</div> </div>
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<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"/>
</svg>
</button>
</div> </div>
<main id="local-content" class="hidden">
<section class="section">
<div class="section-header">
<div class="section-title">Local Books Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<main id="online-content"> <main id="online-content">
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
@@ -119,7 +87,6 @@
<script src="/src/scripts/utils/list-modal-manager.js"></script> <script src="/src/scripts/utils/list-modal-manager.js"></script>
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/books/books.js"></script> <script src="/src/scripts/books/books.js"></script>
<script src="/src/scripts/local-library-books.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

@@ -10,31 +10,14 @@
<link rel="stylesheet" href="/views/css/components/anilist-modal.css"> <link rel="stylesheet" href="/views/css/components/anilist-modal.css">
<link rel="stylesheet" href="/views/css/dashboard.css"> <link rel="stylesheet" href="/views/css/dashboard.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar">
<div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<div class="main-wrapper"> <div class="main-wrapper">
<section class="profile-header"> <section class="profile-header">
<div class="profile-banner"></div> <div class="profile-body"> <div class="profile-banner"></div> <div class="profile-body">
<div class="profile-avatar-wrapper"> <div class="profile-avatar-wrapper">
<img id="user-avatar" src="/public/assets/placeholder.svg" alt="Profile" class="avatar-img"> <img id="user-avatar" src="/public/assets/placeholder.svg" alt="Profile" class="avatar-img">
<label for="avatar-upload" class="avatar-edit-overlay">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</label>
<input type="file" id="avatar-upload" accept="image/*" style="display: none;">
</div> </div>
<div class="profile-details"> <div class="profile-details">
@@ -77,7 +60,8 @@
<div class="toolbar"> <div class="toolbar">
<div class="search-box"> <div class="search-box">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input type="text" class="search-input" placeholder="Search your list..."> <input type="text" id="tracking-search-input" class="search-input" placeholder="Search your list...">
</div> </div>
<div class="filters-inline"> <div class="filters-inline">
<select id="status-filter" class="minimal-select"><option value="all">Status: All</option><option value="CURRENT">Watching</option><option value="COMPLETED">Completed</option><option value="PLANNING">Planning</option><option value="PAUSED">Paused</option><option value="DROPPED">Dropped</option></select> <select id="status-filter" class="minimal-select"><option value="all">Status: All</option><option value="CURRENT">Watching</option><option value="COMPLETED">Completed</option><option value="PLANNING">Planning</option><option value="PAUSED">Paused</option><option value="DROPPED">Dropped</option></select>
@@ -98,81 +82,100 @@
</div> </div>
<div id="section-local" class="tab-section"> <div id="section-local" class="tab-section">
<div class="section-header-row">
<h2>Local Library</h2> <div class="toolbar local-toolbar">
<div class="actions"> <div class="search-box">
<button id="scan-incremental-btn" class="btn-primary">Update Library</button> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<button id="scan-full-btn" class="btn-blur">Full Rescan</button> <input type="text" id="local-search-input" class="search-input" placeholder="Filter local files...">
</div>
<div class="local-type-switcher">
<button class="type-pill-btn active" onclick="switchLocalType('anime', this)">Anime</button>
<button class="type-pill-btn" onclick="switchLocalType('manga', this)">Manga</button>
<button class="type-pill-btn" onclick="switchLocalType('novels', this)">Novels</button>
</div>
<div class="actions-group">
<button id="scan-incremental-btn" class="action-icon-btn" title="Update Library (Fast)">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
<button id="scan-full-btn" class="action-icon-btn danger" title="Full Rescan (Slow)">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div> </div>
</div> </div>
<div class="local-stats-grid">
<div class="local-card"><h3>Anime Files</h3><p id="local-anime-count">0</p></div>
<div class="local-card"><h3>Manga Files</h3><p id="local-manga-count">0</p></div>
<div class="local-card"><h3>Novel Files</h3><p id="local-novel-count">0</p></div>
</div>
<div class="console-output" id="scan-console" style="display: none;"> <div class="console-output" id="scan-console" style="display: none;">
<div class="spinner small"></div> <span id="scan-status-text">Scanning...</span> <div class="spinner small"></div>
<span id="scan-status-text">Scanning folders...</span>
</div>
<div id="local-list-container" class="list-grid"></div>
<div id="local-loading" class="loading-state" style="display: none;">
<div class="spinner"></div>
</div> </div>
</div> </div>
<div id="section-settings" class="tab-section"> <div id="section-settings" class="tab-section">
<div class="settings-layout"> <div class="stream-settings-container">
<div class="settings-card">
<h3>Edit Profile</h3> <div class="stream-section">
<form id="profile-form"> <h3 class="section-label">Profile</h3>
<div class="form-group"> <form id="profile-form" class="stream-profile-row">
<label>Display Name</label>
<input type="text" id="setting-username" class="input-modern" placeholder="Username"> <div class="stream-avatar-wrapper">
<img id="setting-avatar-preview" src="/public/assets/placeholder.svg" alt="Avatar">
<div class="avatar-overlay" onclick="document.getElementById('avatar-upload').click()">
<span>Edit</span>
</div>
<input type="file" id="avatar-upload" accept="image/*" style="display: none;">
</div> </div>
<div class="form-group"> <div class="stream-inputs-col">
<label>Avatar Source</label> <div class="stream-input-group">
<div class="avatar-options"> <label>Username</label>
<button type="button" class="btn-secondary small" onclick="document.getElementById('avatar-upload').click()">Upload File</button> <input type="text" id="setting-username" class="stream-input">
<span class="divider-text">OR</span> </div>
<input type="text" id="setting-avatar-url" class="input-modern" placeholder="Paste Image URL (https://...)">
<div class="stream-actions">
<button type="submit" class="btn-stream-primary">Submit</button>
</div> </div>
<small style="color: var(--color-text-muted); display:block; margin-top:5px;">
* If you upload a file, it will be converted automatically. The text box above is only for external URLs.
</small>
</div> </div>
<button type="submit" class="btn-primary full-width">Save Profile Changes</button>
</form> </form>
</div> </div>
<div class="settings-stack"> <div class="stream-section">
<div class="settings-card"> <h3 class="section-label">Change password</h3>
<h3>Security</h3> <form id="password-form" class="stream-form-row">
<form id="password-form"> <div class="stream-input-group">
<div class="form-group"> <label>Current password</label>
<label>Current Password</label> <input type="password" id="current-password" class="stream-input" placeholder="••••••">
<input type="password" id="current-password" class="input-modern" placeholder="••••••">
</div> </div>
<div class="form-group"> <div class="stream-input-group">
<label>New Password</label> <label>New password</label>
<input type="password" id="new-password" class="input-modern" placeholder="••••••"> <input type="password" id="new-password" class="stream-input" placeholder="••••••">
</div>
<div class="stream-actions-inline">
<button type="submit" class="btn-stream-ghost">Update password</button>
</div> </div>
<button type="submit" class="btn-blur full-width">Update Password</button>
</form> </form>
</div> </div>
<div class="settings-card"> <div class="stream-section">
<h3>Integrations</h3> <h3 class="section-label">Connections</h3>
<div class="integration-item"> <div class="stream-integration-row">
<div class="int-icon anilist-bg"><img src="https://anilist.co/img/icons/android-chrome-512x512.png" alt="AL"></div> <div class="int-info">
<div class="int-details"> <img src="https://anilist.co/img/icons/android-chrome-512x512.png" alt="AL" class="int-logo">
<strong>AniList</strong> <div class="int-text">
<span id="anilist-status">Checking...</span> <span class="int-name">AniList</span>
<span id="anilist-status" class="int-status">Checking...</span>
</div> </div>
<button id="anilist-action-btn" class="btn-sm">Connect</button> </div>
<button id="anilist-action-btn" class="btn-stream-outline">Manage</button>
</div> </div>
</div> </div>
<div class="settings-card danger-zone">
<h3>Danger Zone</h3>
<button id="logout-btn" class="btn-danger">Log Out</button>
</div>
</div>
</div> </div>
</div> </div>