new dashboard page
This commit is contained in:
468
docker/src/scripts/dashboard.js
Normal file
468
docker/src/scripts/dashboard.js
Normal file
@@ -0,0 +1,468 @@
|
||||
const API_BASE = '/api';
|
||||
let currentList = [];
|
||||
let filteredList = [];
|
||||
let currentUserId = null;
|
||||
|
||||
// Configuración de paginación
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
let visibleCount = ITEMS_PER_PAGE;
|
||||
|
||||
// Inicialización
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await initUser();
|
||||
await loadList();
|
||||
setupEventListeners();
|
||||
setupTabSystem();
|
||||
});
|
||||
|
||||
async function initUser() {
|
||||
try {
|
||||
const headers = window.AuthUtils.getSimpleAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/me`, { headers });
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
document.getElementById('user-username').textContent = data.username;
|
||||
document.getElementById('setting-username').value = data.username;
|
||||
|
||||
if (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');
|
||||
if (token) {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
currentUserId = payload.id;
|
||||
await checkIntegrations(currentUserId);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error loading user profile:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkIntegrations(userId) {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/${userId}/integration`);
|
||||
let data = { connected: false };
|
||||
if (res.ok) data = await res.json();
|
||||
|
||||
const statusEl = document.getElementById('anilist-status');
|
||||
const btn = document.getElementById('anilist-action-btn');
|
||||
const headerBadge = document.getElementById('header-anilist-link');
|
||||
|
||||
if (data.connected) {
|
||||
if (headerBadge) {
|
||||
headerBadge.style.display = 'flex';
|
||||
headerBadge.href = `https://anilist.co/user/${data.anilistUserId}`;
|
||||
headerBadge.title = `Connected as ${data.anilistUserId}`;
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `Connected as ID: ${data.anilistUserId}`;
|
||||
statusEl.style.color = 'var(--color-success)';
|
||||
}
|
||||
if (btn) {
|
||||
btn.textContent = 'Disconnect';
|
||||
btn.classList.add('btn-danger-outline');
|
||||
btn.classList.remove('btn-blur');
|
||||
btn.onclick = () => disconnectAniList(userId);
|
||||
}
|
||||
} else {
|
||||
if (headerBadge) headerBadge.style.display = 'none';
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Not connected';
|
||||
statusEl.style.color = 'var(--color-text-secondary)';
|
||||
}
|
||||
if (btn) {
|
||||
btn.textContent = 'Connect';
|
||||
btn.classList.remove('btn-danger-outline');
|
||||
btn.classList.add('btn-blur');
|
||||
btn.onclick = () => redirectToAniListLogin();
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error("Integration check error:", e); }
|
||||
}
|
||||
|
||||
async function redirectToAniListLogin() {
|
||||
if (!currentUserId) return;
|
||||
try {
|
||||
const clientId = 32898;
|
||||
const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
|
||||
const state = encodeURIComponent(currentUserId);
|
||||
|
||||
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'); }
|
||||
}
|
||||
|
||||
async function disconnectAniList(userId) {
|
||||
if(!confirm("Disconnect AniList?")) return;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await fetch(`${API_BASE}/users/${userId}/integration`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
checkIntegrations(userId);
|
||||
} catch (e) { alert("Failed to disconnect"); }
|
||||
}
|
||||
|
||||
function setupTabSystem() {
|
||||
const tabs = document.querySelectorAll('.nav-pill');
|
||||
const sections = document.querySelectorAll('.tab-section');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
const targetId = `section-${tab.dataset.target}`;
|
||||
sections.forEach(sec => {
|
||||
sec.classList.remove('active');
|
||||
if (sec.id === targetId) sec.classList.add('active');
|
||||
});
|
||||
|
||||
if (tab.dataset.target === 'local') loadLocalStats();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLocalStats() {
|
||||
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 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();
|
||||
reader.onload = evt => {
|
||||
document.getElementById('user-avatar').src = evt.target.result;
|
||||
document.getElementById('setting-avatar-url').value = '';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = [];
|
||||
if (repeatCount > 0) extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${repeatCount}</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 || '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 () => {
|
||||
// Recuperamos los datos que seteamos en el onclick del botón editar
|
||||
const data = window.ListModalManager.currentData;
|
||||
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 source = data.source || 'anilist';
|
||||
|
||||
// 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 () => {
|
||||
const data = window.ListModalManager.currentData;
|
||||
if (!data) return;
|
||||
|
||||
const idToDelete = data.entry_id || data.id;
|
||||
const source = data.source || 'anilist';
|
||||
|
||||
await window.ListModalManager.delete(idToDelete, source);
|
||||
|
||||
// IMPORTANTE: Recargar la lista
|
||||
await loadList();
|
||||
};
|
||||
|
||||
window.closeAddToListModal = () => {
|
||||
window.ListModalManager.close();
|
||||
};
|
||||
@@ -1,380 +0,0 @@
|
||||
const API_BASE = '/api';
|
||||
let currentList = [];
|
||||
let filteredList = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadList();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
function getEntryLink(item) {
|
||||
const isAnime = item.entry_type?.toUpperCase() === 'ANIME';
|
||||
const baseRoute = isAnime ? '/anime' : '/book';
|
||||
const source = item.source || 'anilist';
|
||||
|
||||
if (source === 'anilist') {
|
||||
return `${baseRoute}/${item.entry_id}`;
|
||||
} else {
|
||||
return `${baseRoute}/${source}/${item.entry_id}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function populateSourceFilter() {
|
||||
const select = document.getElementById('source-filter');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = `
|
||||
<option value="all">All Sources</option>
|
||||
<option value="anilist">AniList</option>
|
||||
`;
|
||||
|
||||
try {
|
||||
const [animeRes, bookRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/extensions/anime`),
|
||||
fetch(`${API_BASE}/extensions/book`)
|
||||
]);
|
||||
|
||||
const extensions = new Set();
|
||||
|
||||
if (animeRes.ok) {
|
||||
const data = await animeRes.json();
|
||||
(data.extensions || []).forEach(ext => extensions.add(ext));
|
||||
}
|
||||
|
||||
if (bookRes.ok) {
|
||||
const data = await bookRes.json();
|
||||
(data.extensions || []).forEach(ext => extensions.add(ext));
|
||||
}
|
||||
|
||||
extensions.forEach(extName => {
|
||||
const lower = extName.toLowerCase();
|
||||
if (lower !== 'anilist' && lower !== 'local') {
|
||||
const option = document.createElement('option');
|
||||
option.value = extName;
|
||||
option.textContent = extName.charAt(0).toUpperCase() + extName.slice(1);
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading extensions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocalList(entryData, action) {
|
||||
const entryId = entryData.entry_id;
|
||||
const source = entryData.source;
|
||||
|
||||
const findIndex = (list) => list.findIndex(e =>
|
||||
e.entry_id === entryId && e.source === source
|
||||
);
|
||||
|
||||
const currentIndex = findIndex(currentList);
|
||||
if (currentIndex !== -1) {
|
||||
if (action === 'update') {
|
||||
|
||||
currentList[currentIndex] = { ...currentList[currentIndex], ...entryData };
|
||||
} else if (action === 'delete') {
|
||||
currentList.splice(currentIndex, 1);
|
||||
}
|
||||
} else if (action === 'update') {
|
||||
|
||||
currentList.push(entryData);
|
||||
}
|
||||
|
||||
filteredList = [...currentList];
|
||||
|
||||
updateStats();
|
||||
applyFilters();
|
||||
window.ListModalManager.close();
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('status-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('source-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('type-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('sort-filter').addEventListener('change', applyFilters);
|
||||
|
||||
document.querySelector('.search-input').addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
if (query) {
|
||||
filteredList = currentList.filter(item =>
|
||||
item.title?.toLowerCase().includes(query)
|
||||
);
|
||||
} else {
|
||||
filteredList = [...currentList];
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
document.getElementById('modal-save-btn')?.addEventListener('click', async () => {
|
||||
|
||||
const entryToSave = window.ListModalManager.currentEntry || window.ListModalManager.currentData;
|
||||
|
||||
if (!entryToSave) return;
|
||||
|
||||
const success = await window.ListModalManager.save(entryToSave.entry_id, entryToSave.source);
|
||||
|
||||
if (success) {
|
||||
|
||||
const updatedEntry = window.ListModalManager.currentEntry;
|
||||
updatedEntry.updated_at = new Date().toISOString();
|
||||
|
||||
updateLocalList(updatedEntry, 'update');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
document.getElementById('modal-delete-btn')?.addEventListener('click', async () => {
|
||||
const entryToDelete = window.ListModalManager.currentEntry || window.ListModalManager.currentData;
|
||||
|
||||
if (!entryToDelete) return;
|
||||
|
||||
const success = await window.ListModalManager.delete(entryToDelete.entry_id, entryToDelete.source);
|
||||
|
||||
if (success) {
|
||||
updateLocalList(entryToDelete, 'delete');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
document.getElementById('add-list-modal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'add-list-modal') {
|
||||
window.ListModalManager.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const loadingState = document.getElementById('loading-state');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const container = document.getElementById('list-container');
|
||||
|
||||
await populateSourceFilter();
|
||||
|
||||
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 to load list');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
currentList = data.results || [];
|
||||
filteredList = [...currentList];
|
||||
|
||||
loadingState.style.display = 'none';
|
||||
|
||||
if (currentList.length === 0) {
|
||||
emptyState.style.display = 'flex';
|
||||
} else {
|
||||
updateStats();
|
||||
applyFilters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading list:', error);
|
||||
loadingState.style.display = 'none';
|
||||
if (window.NotificationUtils) {
|
||||
window.NotificationUtils.error('Failed to load your list. Please try again.');
|
||||
} else {
|
||||
alert('Failed to load your list. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
|
||||
const total = currentList.length;
|
||||
const watching = currentList.filter(item => item.status === 'WATCHING').length;
|
||||
const completed = currentList.filter(item => item.status === 'COMPLETED').length;
|
||||
const planning = currentList.filter(item => item.status === 'PLANNING').length;
|
||||
|
||||
document.getElementById('total-count').textContent = total;
|
||||
document.getElementById('watching-count').textContent = watching;
|
||||
document.getElementById('completed-count').textContent = completed;
|
||||
document.getElementById('planned-count').textContent = planning;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const statusFilter = document.getElementById('status-filter').value;
|
||||
const sourceFilter = document.getElementById('source-filter').value;
|
||||
const typeFilter = document.getElementById('type-filter').value;
|
||||
const sortFilter = document.getElementById('sort-filter').value;
|
||||
|
||||
let filtered = [...filteredList];
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.status === statusFilter);
|
||||
}
|
||||
|
||||
if (sourceFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.source === sourceFilter);
|
||||
}
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.entry_type === typeFilter);
|
||||
}
|
||||
|
||||
switch (sortFilter) {
|
||||
case 'title':
|
||||
filtered.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
break;
|
||||
case 'score':
|
||||
filtered.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
break;
|
||||
case 'progress':
|
||||
filtered.sort((a, b) => (b.progress || 0) - (a.progress || 0));
|
||||
break;
|
||||
case 'updated':
|
||||
default:
|
||||
|
||||
filtered.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||
break;
|
||||
}
|
||||
|
||||
renderList(filtered);
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
const container = document.getElementById('list-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (items.length === 0) {
|
||||
|
||||
if (currentList.length === 0) {
|
||||
document.getElementById('empty-state').style.display = 'flex';
|
||||
} else {
|
||||
|
||||
container.innerHTML = '<div class="empty-state"><p>No entries match your filters</p></div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
|
||||
items.forEach(item => {
|
||||
const element = createListItem(item);
|
||||
container.appendChild(element);
|
||||
});
|
||||
}
|
||||
|
||||
function createListItem(item) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'list-item';
|
||||
|
||||
const itemLink = getEntryLink(item);
|
||||
|
||||
const posterUrl = item.poster || '/public/assets/placeholder.png';
|
||||
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();
|
||||
let unitLabel = 'units';
|
||||
if (entryType === 'ANIME') {
|
||||
unitLabel = 'episodes';
|
||||
} else if (entryType === 'MANGA') {
|
||||
unitLabel = 'chapters';
|
||||
} else if (entryType === 'NOVEL') {
|
||||
unitLabel = 'chapters/volumes';
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
'CURRENT': entryType === 'ANIME' ? 'Watching' : 'Reading',
|
||||
'COMPLETED': 'Completed',
|
||||
'PLANNING': 'Planning',
|
||||
'PAUSED': 'Paused',
|
||||
'DROPPED': 'Dropped',
|
||||
'REPEATING': entryType === 'ANIME' ? 'Rewatching' : 'Rereading'
|
||||
};
|
||||
|
||||
const extraInfo = [];
|
||||
if (repeatCount > 0) {
|
||||
extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${repeatCount}</span>`);
|
||||
}
|
||||
if (item.is_private) {
|
||||
extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>');
|
||||
}
|
||||
|
||||
const entryDataString = JSON.stringify(item).replace(/'/g, ''');
|
||||
|
||||
div.innerHTML = `
|
||||
<a href="${itemLink}" class="item-poster-link">
|
||||
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.png'">
|
||||
</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" data-entry='${entryDataString}'>
|
||||
<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>
|
||||
`;
|
||||
|
||||
const editBtn = div.querySelector('.edit-icon-btn');
|
||||
editBtn.addEventListener('click', (e) => {
|
||||
try {
|
||||
const entryData = JSON.parse(e.currentTarget.dataset.entry);
|
||||
|
||||
window.ListModalManager.isInList = true;
|
||||
window.ListModalManager.currentEntry = entryData;
|
||||
window.ListModalManager.currentData = entryData;
|
||||
|
||||
window.ListModalManager.open(entryData, entryData.source);
|
||||
} catch (error) {
|
||||
console.error('Error parsing entry data for modal:', error);
|
||||
if (window.NotificationUtils) {
|
||||
window.NotificationUtils.error('Could not open modal. Check HTML form IDs.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
@@ -1038,7 +1038,7 @@ async function performLogin(userId, password = null) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem('token', data.token);
|
||||
|
||||
window.location.href = '/anime';
|
||||
window.location.href = '/dashboard';
|
||||
} catch (err) {
|
||||
console.error('Login error', err);
|
||||
showUserToast(err.message || 'Login failed', 'error');
|
||||
|
||||
@@ -12,7 +12,7 @@ function getNavbarHTML(activePage: string, showSearch: boolean = true): string {
|
||||
|
||||
let navbar = cachedNavbar;
|
||||
|
||||
const pages = ['anime', 'books', 'gallery', 'schedule', 'my-list', 'marketplace'];
|
||||
const pages = ['dashboard', 'anime', 'books', 'gallery', 'schedule' , 'marketplace'];
|
||||
pages.forEach(page => {
|
||||
const regex = new RegExp(`(<button class="nav-button[^"]*)"\\s+data-page="${page}"`, 'g');
|
||||
if (page === activePage) {
|
||||
@@ -51,10 +51,10 @@ async function viewsRoutes(fastify: FastifyInstance) {
|
||||
reply.type('text/html').send(htmlWithNavbar);
|
||||
});
|
||||
|
||||
fastify.get('/my-list', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'list.html');
|
||||
fastify.get('/dashboard', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'dashboard.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
const htmlWithNavbar = injectNavbar(html, 'my-list', false);
|
||||
const htmlWithNavbar = injectNavbar(html, 'dashboard', false);
|
||||
reply.type('text/html').send(htmlWithNavbar);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
</a>
|
||||
|
||||
<div class="nav-center">
|
||||
<button class="nav-button" data-page="dashboard" onclick="window.location.href='/dashboard'">Dashboard</button>
|
||||
<button class="nav-button" data-page="anime" onclick="window.location.href='/anime'">Anime</button>
|
||||
<button class="nav-button" data-page="books" onclick="window.location.href='/books'">Books</button>
|
||||
<button class="nav-button" data-page="gallery" onclick="window.location.href='/gallery'">Gallery</button>
|
||||
<button class="nav-button" data-page="schedule" onclick="window.location.href='/schedule'">Schedule</button>
|
||||
<button class="nav-button" data-page="my-list" onclick="window.location.href='/my-list'">My List</button>
|
||||
<button class="nav-button" data-page="marketplace" onclick="window.location.href='/marketplace'">Marketplace</button>
|
||||
</div>
|
||||
|
||||
|
||||
1031
docker/views/css/dashboard.css
Normal file
1031
docker/views/css/dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,507 +0,0 @@
|
||||
.container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 3rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 2rem;
|
||||
background: linear-gradient(135deg, var(--color-primary), #a78bfa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
transition:
|
||||
transform 0.3s,
|
||||
box-shadow 0.3s;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 35px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
flex-wrap: wrap;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a1a1aa'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z' clip-rule='evenodd'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.7rem center;
|
||||
background-size: 1.2em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 10px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.7rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5rem 0;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(139, 92, 246, 0.1);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5rem 0;
|
||||
gap: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.8rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.list-grid.list-view {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background: var(--color-bg-elevated-hover);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 15px 30px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
.list-grid.list-view .list-item {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-right: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.list-grid.list-view .list-item:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 20px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
.item-poster-link {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-poster {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.list-grid.list-view .item-poster {
|
||||
width: 120px;
|
||||
height: 180px;
|
||||
aspect-ratio: auto;
|
||||
border-radius: 8px;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.list-grid.list-view .item-content {
|
||||
padding: 1rem 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.list-grid.list-view .item-content > div:first-child {
|
||||
flex-basis: 75%;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.list-grid.list-view .item-title {
|
||||
font-size: 1.3rem;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.meta-pill {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--color-success);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.source-pill {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.repeat-pill {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
text-transform: none;
|
||||
}
|
||||
.private-pill {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #facc15;
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary), #a78bfa);
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-weight: 700;
|
||||
color: #facc15;
|
||||
background: rgba(250, 204, 21, 0.1);
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-icon-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 50;
|
||||
background: rgba(18, 18, 21, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.list-item:hover .edit-icon-btn {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.edit-icon-btn:hover {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.list-grid.list-view .edit-icon-btn {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin-left: auto;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.list-grid.list-view .list-item:hover .edit-icon-btn {
|
||||
opacity: 1;
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.8rem;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1rem;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.meta-pill {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
|
||||
.edit-icon-btn {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
195
docker/views/dashboard.html
Normal file
195
docker/views/dashboard.html
Normal file
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<title>Dashboard - WaifuBoard</title>
|
||||
<link rel="stylesheet" href="/views/css/globals.css">
|
||||
<link rel="stylesheet" href="/views/css/components/navbar.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/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
</head>
|
||||
<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">
|
||||
<section class="profile-header">
|
||||
<div class="profile-banner"></div> <div class="profile-body">
|
||||
<div class="profile-avatar-wrapper">
|
||||
<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 class="profile-details">
|
||||
<div class="profile-text">
|
||||
<div class="username-wrapper">
|
||||
<h1 id="user-username">Loading...</h1>
|
||||
<a id="header-anilist-link" href="#" target="_blank" class="header-anilist-badge" style="display: none;">
|
||||
<img src="https://anilist.co/img/icons/android-chrome-512x512.png" alt="AniList">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="total-stat">0</span>
|
||||
<span class="stat-label">Total Entries</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="anime-stat">-</span>
|
||||
<span class="stat-label">Anime</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="manga-stat">-</span>
|
||||
<span class="stat-label">Manga</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-navigation-modern">
|
||||
<button class="nav-pill active" data-target="tracking">Tracking List</button>
|
||||
<button class="nav-pill" data-target="local">Local Library</button>
|
||||
<button class="nav-pill" data-target="settings">Settings</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="content-container">
|
||||
|
||||
<div id="section-tracking" class="tab-section active">
|
||||
<div class="toolbar">
|
||||
<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>
|
||||
<input type="text" class="search-input" placeholder="Search your list...">
|
||||
</div>
|
||||
<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="type-filter" class="minimal-select"><option value="all">Type: All</option><option value="ANIME">Anime</option><option value="MANGA">Manga</option><option value="NOVEL">Novel</option></select>
|
||||
<select id="sort-filter" class="minimal-select"><option value="updated">Latest</option><option value="score">Score</option><option value="title">A-Z</option></select>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" data-view="grid">⊞</button>
|
||||
<button class="view-btn" data-view="list">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading-state" class="loading-state"><div class="spinner"></div></div>
|
||||
<div id="empty-state" class="empty-state" style="display: none;">
|
||||
<p>No content found</p>
|
||||
<a href="/anime" class="btn-blur">Discover Content</a>
|
||||
</div>
|
||||
<div id="list-container" class="list-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="section-local" class="tab-section">
|
||||
<div class="section-header-row">
|
||||
<h2>Local Library</h2>
|
||||
<div class="actions">
|
||||
<button id="scan-incremental-btn" class="btn-primary">Update Library</button>
|
||||
<button id="scan-full-btn" class="btn-blur">Full Rescan</button>
|
||||
</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="spinner small"></div> <span id="scan-status-text">Scanning...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="section-settings" class="tab-section">
|
||||
<div class="settings-layout">
|
||||
<div class="settings-card">
|
||||
<h3>Edit Profile</h3>
|
||||
<form id="profile-form">
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" id="setting-username" class="input-modern" placeholder="Username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Avatar Source</label>
|
||||
<div class="avatar-options">
|
||||
<button type="button" class="btn-secondary small" onclick="document.getElementById('avatar-upload').click()">Upload File</button>
|
||||
<span class="divider-text">OR</span>
|
||||
<input type="text" id="setting-avatar-url" class="input-modern" placeholder="Paste Image URL (https://...)">
|
||||
</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>
|
||||
<button type="submit" class="btn-primary full-width">Save Profile Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-stack">
|
||||
<div class="settings-card">
|
||||
<h3>Security</h3>
|
||||
<form id="password-form">
|
||||
<div class="form-group">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="current-password" class="input-modern" placeholder="••••••">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="new-password" class="input-modern" placeholder="••••••">
|
||||
</div>
|
||||
<button type="submit" class="btn-blur full-width">Update Password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Integrations</h3>
|
||||
<div class="integration-item">
|
||||
<div class="int-icon anilist-bg"><img src="https://anilist.co/img/icons/android-chrome-512x512.png" alt="AL"></div>
|
||||
<div class="int-details">
|
||||
<strong>AniList</strong>
|
||||
<span id="anilist-status">Checking...</span>
|
||||
</div>
|
||||
<button id="anilist-action-btn" class="btn-sm">Connect</button>
|
||||
</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>
|
||||
|
||||
<div id="updateToast" class="hidden">
|
||||
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
|
||||
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">Download</a>
|
||||
</div>
|
||||
|
||||
<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/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
<script src="/src/scripts/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,132 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<title>My Lists - WaifuBoard</title>
|
||||
<link rel="stylesheet" href="/views/css/globals.css">
|
||||
<link rel="stylesheet" href="/views/css/components/navbar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/list.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
<h1 class="page-title">My List</h1>
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="total-count">0</span>
|
||||
<span class="stat-label">Total Entries</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="watching-count">0</span>
|
||||
<span class="stat-label">Watching</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="completed-count">0</span>
|
||||
<span class="stat-label">Completed</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="planned-count">0</span>
|
||||
<span class="stat-label">Planning</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-section">
|
||||
<div class="filter-group">
|
||||
<label>Status</label>
|
||||
<select id="status-filter" class="filter-select">
|
||||
<option value="all">All Status</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>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Source</label>
|
||||
<select id="source-filter" class="filter-select">
|
||||
<option value="all">All Sources</option>
|
||||
<option value="anilist">AniList</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Type</label>
|
||||
<select id="type-filter" class="filter-select">
|
||||
<option value="all">All Types</option>
|
||||
<option value="ANIME">Anime</option>
|
||||
<option value="MANGA">Manga</option>
|
||||
<option value="NOVEL">Novel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Sort By</label>
|
||||
<select id="sort-filter" class="filter-select">
|
||||
<option value="updated">Last Updated</option>
|
||||
<option value="title">Title (A-Z)</option>
|
||||
<option value="score">Score (High-Low)</option>
|
||||
<option value="progress">Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>View</label>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" data-view="grid">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="view-btn" data-view="list">
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="5" width="18" height="2" rx="1"/>
|
||||
<rect x="3" y="11" width="18" height="2" rx="1"/>
|
||||
<rect x="3" y="17" width="18" height="2" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading-state" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading your list...</p>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty-state" style="display: none;">
|
||||
<svg width="120" height="120" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
<h2>Your list is empty</h2>
|
||||
<p>Start adding anime to track your progress</p>
|
||||
<button class="btn-primary" onclick="window.location.href='/anime'">Browse Content</button>
|
||||
</div>
|
||||
|
||||
<div id="list-container" class="list-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="updateToast" class="hidden">
|
||||
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
|
||||
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">
|
||||
Click To Download
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/updateNotifier.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||
<script src="/src/scripts/list.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user