code organisation & refactor

This commit is contained in:
2025-11-27 20:23:00 +01:00
parent 03636fee99
commit 2b29beeeb5
33 changed files with 2099 additions and 1947 deletions

178
src/scripts/anime/anime.js Normal file
View File

@@ -0,0 +1,178 @@
const animeId = window.location.pathname.split('/').pop();
let player;
let totalEpisodes = 0;
let currentPage = 1;
const itemsPerPage = 12;
var tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
async function loadAnime() {
try {
const res = await fetch(`/api/anime/${animeId}`);
const data = await res.json();
if(data.error) {
document.getElementById('title').innerText = "Anime Not Found";
return;
}
const title = data.title.english || data.title.romaji;
document.title = `${title} | WaifuBoard`;
document.getElementById('title').innerText = title;
document.getElementById('poster').src = data.coverImage.extraLarge;
const rawDesc = data.description || "No description available.";
handleDescription(rawDesc);
document.getElementById('score').innerText = (data.averageScore || '?') + '% Score';
document.getElementById('year').innerText = data.seasonYear || '????';
document.getElementById('genres').innerText = data.genres ? data.genres.slice(0, 3).join(' • ') : '';
document.getElementById('format').innerText = data.format || 'TV';
document.getElementById('episodes').innerText = data.episodes || '?';
document.getElementById('status').innerText = data.status || 'Unknown';
document.getElementById('season').innerText = `${data.season || ''} ${data.seasonYear || ''}`;
if (data.studios && data.studios.nodes.length > 0) {
document.getElementById('studio').innerText = data.studios.nodes[0].name;
}
if (data.characters && data.characters.nodes) {
const charContainer = document.getElementById('char-list');
data.characters.nodes.slice(0, 5).forEach(char => {
charContainer.innerHTML += `
<div class="character-item">
<div class="char-dot"></div> ${char.name.full}
</div>`;
});
}
document.getElementById('watch-btn').onclick = () => {
window.location.href = `/watch/${animeId}/1`;
};
if (data.trailer && data.trailer.site === 'youtube') {
window.onYouTubeIframeAPIReady = function() {
player = new YT.Player('player', {
height: '100%',
width: '100%',
videoId: data.trailer.id,
playerVars: {
'autoplay': 1, 'controls': 0, 'mute': 1,
'loop': 1, 'playlist': data.trailer.id,
'showinfo': 0, 'modestbranding': 1, 'disablekb': 1
},
events: { 'onReady': (e) => e.target.playVideo() }
});
};
} else {
const banner = data.bannerImage || data.coverImage.extraLarge;
document.querySelector('.video-background').innerHTML = `<img src="${banner}" style="width:100%; height:100%; object-fit:cover;">`;
}
if (data.nextAiringEpisode) {
totalEpisodes = data.nextAiringEpisode.episode - 1;
} else {
totalEpisodes = data.episodes || 12;
}
totalEpisodes = Math.min(Math.max(totalEpisodes, 1), 5000);
renderEpisodes();
} catch (err) {
console.error(err);
}
}
function handleDescription(text) {
const tmp = document.createElement("DIV");
tmp.innerHTML = text;
const cleanText = tmp.textContent || tmp.innerText || "";
const sentences = cleanText.match(/[^\.!\?]+[\.!\?]+/g) || [cleanText];
document.getElementById('full-description').innerHTML = text;
if (sentences.length > 4) {
const shortText = sentences.slice(0, 4).join(' ');
document.getElementById('description-preview').innerText = shortText + '...';
document.getElementById('read-more-btn').style.display = 'inline-flex';
} else {
document.getElementById('description-preview').innerHTML = text;
document.getElementById('read-more-btn').style.display = 'none';
}
}
function openModal() {
document.getElementById('desc-modal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeModal() {
document.getElementById('desc-modal').classList.remove('active');
document.body.style.overflow = '';
}
document.getElementById('desc-modal').addEventListener('click', (e) => {
if (e.target.id === 'desc-modal') closeModal();
});
function renderEpisodes() {
const grid = document.getElementById('episodes-grid');
grid.innerHTML = '';
const start = (currentPage - 1) * itemsPerPage + 1;
const end = Math.min(start + itemsPerPage - 1, totalEpisodes);
for(let i = start; i <= end; i++) {
createEpisodeButton(i, grid);
}
updatePaginationControls();
}
function createEpisodeButton(num, container) {
const btn = document.createElement('div');
btn.className = 'episode-btn';
btn.innerText = `Ep ${num}`;
btn.onclick = () => window.location.href = `/watch/${animeId}/${num}`;
container.appendChild(btn);
}
function updatePaginationControls() {
const totalPages = Math.ceil(totalEpisodes / itemsPerPage);
document.getElementById('page-info').innerText = `Page ${currentPage} of ${totalPages}`;
document.getElementById('prev-page').disabled = currentPage === 1;
document.getElementById('next-page').disabled = currentPage === totalPages;
document.getElementById('pagination-controls').style.display = 'flex';
}
function changePage(delta) {
currentPage += delta;
renderEpisodes();
}
const searchInput = document.getElementById('ep-search');
searchInput.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
const grid = document.getElementById('episodes-grid');
if (val > 0 && val <= totalEpisodes) {
grid.innerHTML = '';
createEpisodeButton(val, grid);
document.getElementById('pagination-controls').style.display = 'none';
} else if (!e.target.value) {
renderEpisodes();
} else {
grid.innerHTML = '<div style="color:#666; width:100%; text-align:center;">Episode not found</div>';
document.getElementById('pagination-controls').style.display = 'none';
}
});
loadAnime();

210
src/scripts/anime/animes.js Normal file
View File

@@ -0,0 +1,210 @@
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
clearTimeout(searchTimeout);
if (query.length < 2) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
searchInput.style.borderRadius = '99px';
return;
}
searchTimeout = setTimeout(() => {
fetchLocalSearch(query);
}, 300);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-wrapper')) {
searchResults.classList.remove('active');
searchInput.style.borderRadius = '99px';
}
});
async function fetchLocalSearch(query) {
try {
const res = await fetch(`/api/search/local?q=${encodeURIComponent(query)}`);
const data = await res.json();
renderSearchResults(data.results);
} catch (err) { console.error("Search Error:", err); }
}
function renderSearchResults(results) {
searchResults.innerHTML = '';
if (results.length === 0) {
searchResults.innerHTML = '<div style="padding:1rem; color:#888; text-align:center">No results found</div>';
} else {
results.forEach(anime => {
const title = getTitle(anime);
const img = anime.coverImage.medium || anime.coverImage.large;
const rating = anime.averageScore ? `${anime.averageScore}%` : 'N/A';
const year = anime.seasonYear || '';
const format = anime.format || 'TV';
const item = document.createElement('a');
item.className = 'search-item';
item.href = `/anime/${anime.id}`;
item.innerHTML = `
<img src="${img}" class="search-poster" alt="${title}">
<div class="search-info">
<div class="search-title">${title}</div>
<div class="search-meta">
<span class="rating-pill">${rating}</span>
<span>• ${year}</span>
<span>• ${format}</span>
</div>
</div>
`;
searchResults.appendChild(item);
});
}
searchResults.classList.add('active');
searchInput.style.borderRadius = '12px 12px 0 0';
}
function scrollCarousel(id, direction) {
const container = document.getElementById(id);
if(container) {
const scrollAmount = container.clientWidth * 0.75;
container.scrollBy({ left: direction * scrollAmount, behavior: 'smooth' });
}
}
let trendingAnimes = [];
let currentHeroIndex = 0;
let player;
let heroInterval;
var tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
function onYouTubeIframeAPIReady() {
player = new YT.Player('player', {
height: '100%', width: '100%',
playerVars: { 'autoplay': 1, 'controls': 0, 'mute': 1, 'loop': 1, 'showinfo': 0, 'modestbranding': 1 },
events: { 'onReady': (e) => { e.target.mute(); if(trendingAnimes.length) updateHeroVideo(trendingAnimes[currentHeroIndex]); } }
});
}
async function fetchContent(isUpdate = false) {
try {
const trendingRes = await fetch('/api/trending');
const trendingData = await trendingRes.json();
if (trendingData.results && trendingData.results.length > 0) {
trendingAnimes = trendingData.results;
if (!isUpdate) {
updateHeroUI(trendingAnimes[0]);
startHeroCycle();
}
renderList('trending', trendingAnimes);
} else if (!isUpdate) {
setTimeout(() => fetchContent(false), 2000);
}
const topRes = await fetch('/api/top-airing');
const topData = await topRes.json();
if (topData.results && topData.results.length > 0) {
renderList('top-airing', topData.results);
}
} catch (e) {
console.error("Fetch Error:", e);
if(!isUpdate) setTimeout(() => fetchContent(false), 5000);
}
}
function startHeroCycle() {
if(heroInterval) clearInterval(heroInterval);
heroInterval = setInterval(() => {
if(trendingAnimes.length > 0) {
currentHeroIndex = (currentHeroIndex + 1) % trendingAnimes.length;
updateHeroUI(trendingAnimes[currentHeroIndex]);
}
}, 10000);
}
function getTitle(anime) {
return anime.title.english || anime.title.romaji || "Unknown Title";
}
function updateHeroUI(anime) {
if(!anime) return;
const title = getTitle(anime);
const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A';
const year = anime.seasonYear || '';
const type = anime.format || 'TV';
const desc = anime.description || 'No description available.';
const poster = anime.coverImage ? anime.coverImage.extraLarge : '';
const banner = anime.bannerImage || poster;
document.getElementById('hero-title').innerText = title;
document.getElementById('hero-desc').innerHTML = desc;
document.getElementById('hero-score').innerText = score;
document.getElementById('hero-year').innerText = year;
document.getElementById('hero-type').innerText = type;
document.getElementById('hero-poster').src = poster;
const watchBtn = document.getElementById('watch-btn');
if(watchBtn) watchBtn.onclick = () => window.location.href = `/anime/${anime.id}`;
const bgImg = document.getElementById('hero-bg-media');
if(bgImg && bgImg.src !== banner) bgImg.src = banner;
updateHeroVideo(anime);
document.getElementById('hero-loading-ui').style.display = 'none';
document.getElementById('hero-real-ui').style.display = 'block';
}
function updateHeroVideo(anime) {
if (!player || !player.loadVideoById) return;
const videoContainer = document.getElementById('player');
if (anime.trailer && anime.trailer.site === 'youtube' && anime.trailer.id) {
if(player.getVideoData && player.getVideoData().video_id !== anime.trailer.id) {
player.loadVideoById(anime.trailer.id);
player.mute();
}
videoContainer.style.opacity = "1";
} else {
videoContainer.style.opacity = "0";
player.stopVideo();
}
}
function renderList(id, list) {
const container = document.getElementById(id);
const firstId = list.length > 0 ? list[0].id : null;
const currentFirstId = container.firstElementChild?.dataset?.id;
if (currentFirstId && parseInt(currentFirstId) === firstId && container.children.length === list.length) {
return;
}
container.innerHTML = '';
list.forEach(anime => {
const title = getTitle(anime);
const cover = anime.coverImage ? anime.coverImage.large : '';
const ep = anime.nextAiringEpisode ? 'Ep ' + anime.nextAiringEpisode.episode : (anime.episodes ? anime.episodes + ' Eps' : 'TV');
const score = anime.averageScore || '--';
const el = document.createElement('div');
el.className = 'card';
el.dataset.id = anime.id;
el.onclick = () => window.location.href = `/anime/${anime.id}`;
el.innerHTML = `
<div class="card-img-wrap"><img src="${cover}" loading="lazy"></div>
<div class="card-content">
<h3>${title}</h3>
<p>${score}% • ${ep}</p>
</div>
`;
container.appendChild(el);
});
}
fetchContent();
setInterval(() => fetchContent(true), 60000);

211
src/scripts/anime/player.js Normal file
View File

@@ -0,0 +1,211 @@
const pathParts = window.location.pathname.split('/');
const animeId = pathParts[2];
const currentEpisode = parseInt(pathParts[3]);
let audioMode = 'sub';
let currentExtension = '';
let plyrInstance;
let hlsInstance;
document.getElementById('back-link').href = `/anime/${animeId}`;
document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`;
async function loadMetadata() {
try {
const res = await fetch(`/api/anime/${animeId}`);
const data = await res.json();
if(!data.error) {
const title = data.title.english || data.title.romaji;
document.getElementById('anime-title').innerText = title;
document.title = `Watching ${title} - Ep ${currentEpisode}`;
}
} catch(e) { console.error(e); }
}
async function loadExtensions() {
try {
const res = await fetch('/api/extensions');
const data = await res.json();
const select = document.getElementById('extension-select');
if (data.extensions && data.extensions.length > 0) {
data.extensions.forEach(extName => {
const opt = document.createElement('option');
opt.value = extName;
opt.innerText = extName;
select.appendChild(opt);
});
} else {
select.innerHTML = '<option>No Extensions</option>';
select.disabled = true;
setLoading("No extensions found in WaifuBoards folder.");
}
} catch(e) { console.error("Extension Error:", e); }
}
async function onExtensionChange() {
const select = document.getElementById('extension-select');
currentExtension = select.value;
setLoading("Fetching extension settings...");
try {
const res = await fetch(`/api/extension/${currentExtension}/settings`);
const settings = await res.json();
const toggle = document.getElementById('sd-toggle');
if (settings.supportsDub) {
toggle.style.display = 'flex';
setAudioMode('sub');
} else {
toggle.style.display = 'none';
setAudioMode('sub');
}
const serverSelect = document.getElementById('server-select');
serverSelect.innerHTML = '';
if (settings.episodeServers && settings.episodeServers.length > 0) {
settings.episodeServers.forEach(srv => {
const opt = document.createElement('option');
opt.value = srv;
opt.innerText = srv;
serverSelect.appendChild(opt);
});
serverSelect.style.display = 'block';
} else {
serverSelect.style.display = 'none';
}
loadStream();
} catch (err) {
console.error(err);
setLoading("Failed to load extension settings.");
}
}
function toggleAudioMode() {
const newMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(newMode);
loadStream();
}
function setAudioMode(mode) {
audioMode = mode;
const toggle = document.getElementById('sd-toggle');
const subOpt = document.getElementById('opt-sub');
const dubOpt = document.getElementById('opt-dub');
toggle.setAttribute('data-state', mode);
if (mode === 'sub') {
subOpt.classList.add('active');
dubOpt.classList.remove('active');
} else {
subOpt.classList.remove('active');
dubOpt.classList.add('active');
}
}
async function loadStream() {
if (!currentExtension) return;
const serverSelect = document.getElementById('server-select');
const server = serverSelect.value || "default";
setLoading(`Searching & Resolving Stream (${audioMode})...`);
try {
const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
setLoading(`Error: ${data.error}`);
return;
}
if (!data.videoSources || data.videoSources.length === 0) {
setLoading("No video sources found.");
return;
}
const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0];
const headers = data.headers || {};
let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`;
if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
playVideo(proxyUrl, data.videoSources[0].subtitles);
document.getElementById('loading-overlay').style.display = 'none';
} catch (err) {
setLoading("Stream Error. Check console.");
console.error(err);
}
}
function playVideo(url, subtitles) {
const video = document.getElementById('player');
if (Hls.isSupported()) {
if (hlsInstance) hlsInstance.destroy();
hlsInstance = new Hls({
xhrSetup: (xhr, url) => {
xhr.withCredentials = false;
}
});
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
}
if (plyrInstance) plyrInstance.destroy();
while (video.firstChild) {
video.removeChild(video.firstChild);
}
if (subtitles && subtitles.length > 0) {
subtitles.forEach(sub => {
const track = document.createElement('track');
track.kind = 'captions';
track.label = sub.language;
track.srclang = sub.language.slice(0, 2).toLowerCase();
track.src = sub.url;
if (sub.default || sub.language.toLowerCase().includes('english')) {
track.default = true;
}
video.appendChild(track);
});
}
plyrInstance = new Plyr(video, {
captions: { active: true, update: true, language: 'en' },
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
settings: ['captions', 'quality', 'speed']
});
video.play().catch(e => console.log("Auto-play blocked"));
}
function setLoading(msg) {
const overlay = document.getElementById('loading-overlay');
const text = document.getElementById('loading-text');
overlay.style.display = 'flex';
text.innerText = msg;
}
document.getElementById('prev-btn').onclick = () => {
if(currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}`;
};
document.getElementById('next-btn').onclick = () => {
window.location.href = `/watch/${animeId}/${currentEpisode + 1}`;
};
if(currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
loadMetadata();
loadExtensions();

207
src/scripts/books/book.js Normal file
View File

@@ -0,0 +1,207 @@
const bookId = window.location.pathname.split('/').pop();
let allChapters = [];
let filteredChapters = [];
let currentPage = 1;
const itemsPerPage = 12;
async function init() {
try {
const res = await fetch(`/api/book/${bookId}`);
const data = await res.json();
if (data.error) {
const titleEl = document.getElementById('title');
if (titleEl) titleEl.innerText = "Book Not Found";
return;
}
const title = data.title.english || data.title.romaji;
document.title = `${title} | WaifuBoard Books`;
const titleEl = document.getElementById('title');
if (titleEl) titleEl.innerText = title;
const descEl = document.getElementById('description');
if (descEl) descEl.innerHTML = data.description || "No description available.";
const scoreEl = document.getElementById('score');
if (scoreEl) scoreEl.innerText = (data.averageScore || '?') + '% Score';
const pubEl = document.getElementById('published-date');
if (pubEl) {
if (data.startDate && data.startDate.year) {
const y = data.startDate.year;
const m = data.startDate.month ? `-${data.startDate.month.toString().padStart(2, '0')}` : '';
const d = data.startDate.day ? `-${data.startDate.day.toString().padStart(2, '0')}` : '';
pubEl.innerText = `${y}${m}${d}`;
} else {
pubEl.innerText = '????';
}
}
const statusEl = document.getElementById('status');
if (statusEl) statusEl.innerText = data.status || 'Unknown';
const formatEl = document.getElementById('format');
if (formatEl) formatEl.innerText = data.format || 'MANGA';
const chaptersEl = document.getElementById('chapters');
if (chaptersEl) chaptersEl.innerText = data.chapters || '?';
const genresEl = document.getElementById('genres');
if(genresEl && data.genres) {
genresEl.innerText = data.genres.slice(0, 3).join(' • ');
}
const img = data.coverImage.extraLarge || data.coverImage.large;
const posterEl = document.getElementById('poster');
if (posterEl) posterEl.src = img;
const heroBgEl = document.getElementById('hero-bg');
if (heroBgEl) heroBgEl.src = data.bannerImage || img;
loadChapters();
} catch (err) {
console.error("Metadata Error:", err);
}
}
async function loadChapters() {
const tbody = document.getElementById('chapters-body');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extensions for chapters...</td></tr>';
try {
const res = await fetch(`/api/book/${bookId}/chapters`);
const data = await res.json();
allChapters = data.chapters || [];
filteredChapters = [...allChapters];
const totalEl = document.getElementById('total-chapters');
if (allChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found on loaded extensions.</td></tr>';
if (totalEl) totalEl.innerText = "0 Found";
return;
}
if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
populateProviderFilter();
const readBtn = document.getElementById('read-start-btn');
if (readBtn && filteredChapters.length > 0) {
readBtn.onclick = () => openReader(filteredChapters[0].id);
}
renderTable();
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; color: #ef4444;">Error loading chapters.</td></tr>';
console.error(err);
}
}
function populateProviderFilter() {
const select = document.getElementById('provider-filter');
if (!select) return;
const providers = [...new Set(allChapters.map(ch => ch.provider))];
if (providers.length > 0) {
select.style.display = 'inline-block';
select.innerHTML = '<option value="all">All Providers</option>';
providers.forEach(prov => {
const opt = document.createElement('option');
opt.value = prov;
opt.innerText = prov;
select.appendChild(opt);
});
select.onchange = (e) => {
const selected = e.target.value;
if (selected === 'all') {
filteredChapters = [...allChapters];
} else {
filteredChapters = allChapters.filter(ch => ch.provider === selected);
}
currentPage = 1;
renderTable();
};
}
}
function renderTable() {
const tbody = document.getElementById('chapters-body');
if (!tbody) return;
tbody.innerHTML = '';
if (filteredChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters match this filter.</td></tr>';
updatePagination();
return;
}
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
const pageItems = filteredChapters.slice(start, end);
pageItems.forEach((ch, idx) => {
const realIndex = start + idx;
const row = document.createElement('tr');
row.innerHTML = `
<td>${ch.number}</td>
<td>${ch.title || 'Chapter ' + ch.number}</td>
<td><span class="pill" style="font-size:0.75rem;">${ch.provider}</span></td>
<td>
<button class="read-btn-small" onclick="openReader('${bookId}', '${realIndex}', '${ch.provider}')">
Read
</button>
</td>
`;
tbody.appendChild(row);
});
updatePagination();
}
function updatePagination() {
const totalPages = Math.ceil(filteredChapters.length / itemsPerPage);
const pagination = document.getElementById('pagination');
if (!pagination) return;
if (totalPages <= 1) {
pagination.style.display = 'none';
return;
}
pagination.style.display = 'flex';
document.getElementById('page-info').innerText = `Page ${currentPage} of ${totalPages}`;
const prevBtn = document.getElementById('prev-page');
const nextBtn = document.getElementById('next-page');
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage >= totalPages;
prevBtn.onclick = () => { currentPage--; renderTable(); };
nextBtn.onclick = () => { currentPage++; renderTable(); };
}
function openReader(bookId, chapterId, provider) {
const c = encodeURIComponent(chapterId);
const p = encodeURIComponent(provider);
window.location.href = `/read/${bookId}/${c}/${p}`;
}
init();

176
src/scripts/books/books.js Normal file
View File

@@ -0,0 +1,176 @@
let trendingBooks = [];
let currentHeroIndex = 0;
let heroInterval;
window.addEventListener('scroll', () => {
const nav = document.getElementById('navbar');
if (window.scrollY > 50) nav.classList.add('scrolled');
else nav.classList.remove('scrolled');
});
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
clearTimeout(searchTimeout);
if (query.length < 2) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
searchInput.style.borderRadius = '99px';
return;
}
searchTimeout = setTimeout(() => {
fetchBookSearch(query);
}, 300);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-wrapper')) {
searchResults.classList.remove('active');
searchInput.style.borderRadius = '99px';
}
});
async function fetchBookSearch(query) {
try {
const res = await fetch(`/api/search/books?q=${encodeURIComponent(query)}`);
const data = await res.json();
renderSearchResults(data.results || []);
} catch (err) {
console.error("Search Error:", err);
renderSearchResults([]);
}
}
function renderSearchResults(results) {
searchResults.innerHTML = '';
if (!results || results.length === 0) {
searchResults.innerHTML = '<div style="padding:1rem; color:#888; text-align:center">No results found</div>';
} else {
results.forEach(book => {
const title = book.title.english || book.title.romaji || "Unknown";
const img = (book.coverImage && (book.coverImage.medium || book.coverImage.large)) || '';
const rating = book.averageScore ? `${book.averageScore}%` : 'N/A';
const year = book.seasonYear || (book.startDate ? book.startDate.year : '') || '????';
const format = book.format || 'MANGA';
const item = document.createElement('a');
item.className = 'search-item';
item.href = `/book/${book.id}`;
item.innerHTML = `
<img src="${img}" class="search-poster" alt="${title}">
<div class="search-info">
<div class="search-title">${title}</div>
<div class="search-meta">
<span class="rating-pill">${rating}</span>
<span>• ${year}</span>
<span>• ${format}</span>
</div>
</div>
`;
searchResults.appendChild(item);
});
}
searchResults.classList.add('active');
searchInput.style.borderRadius = '12px 12px 0 0';
}
function scrollCarousel(id, direction) {
const container = document.getElementById(id);
if(container) {
const scrollAmount = container.clientWidth * 0.75;
container.scrollBy({ left: direction * scrollAmount, behavior: 'smooth' });
}
}
async function init() {
try {
const res = await fetch('/api/books/trending');
const data = await res.json();
if (data.results && data.results.length > 0) {
trendingBooks = data.results;
updateHeroUI(trendingBooks[0]);
renderList('trending', trendingBooks);
startHeroCycle();
}
const resPop = await fetch('/api/books/popular');
const dataPop = await resPop.json();
if (dataPop.results) renderList('popular', dataPop.results);
} catch (e) {
console.error("Books Error:", e);
}
}
function startHeroCycle() {
if(heroInterval) clearInterval(heroInterval);
heroInterval = setInterval(() => {
if(trendingBooks.length > 0) {
currentHeroIndex = (currentHeroIndex + 1) % trendingBooks.length;
updateHeroUI(trendingBooks[currentHeroIndex]);
}
}, 8000);
}
function updateHeroUI(book) {
if(!book) return;
const title = book.title.english || book.title.romaji;
const desc = book.description || "No description available.";
const poster = (book.coverImage && (book.coverImage.extraLarge || book.coverImage.large)) || '';
const banner = book.bannerImage || poster;
document.getElementById('hero-title').innerText = title;
document.getElementById('hero-desc').innerHTML = desc;
document.getElementById('hero-score').innerText = (book.averageScore || '?') + '% Score';
document.getElementById('hero-year').innerText = (book.startDate && book.startDate.year) ? book.startDate.year : '????';
document.getElementById('hero-type').innerText = book.format || 'MANGA';
const heroPoster = document.getElementById('hero-poster');
if(heroPoster) heroPoster.src = poster;
const bg = document.getElementById('hero-bg-media');
if(bg) bg.src = banner;
const readBtn = document.getElementById('read-btn');
if (readBtn) {
readBtn.onclick = () => window.location.href = `/book/${book.id}`;
}
}
function renderList(id, list) {
const container = document.getElementById(id);
container.innerHTML = '';
list.forEach(book => {
const title = book.title.english || book.title.romaji;
const cover = book.coverImage ? book.coverImage.large : '';
const score = book.averageScore || '--';
const type = book.format || 'Book';
const el = document.createElement('div');
el.className = 'card';
el.onclick = () => {
window.location.href = `/book/${book.id}`;
};
el.innerHTML = `
<div class="card-img-wrap"><img src="${cover}" loading="lazy"></div>
<div class="card-content">
<h3>${title}</h3>
<p>${score}% • ${type}</p>
</div>
`;
container.appendChild(el);
});
}
init();

592
src/scripts/books/reader.js Normal file
View File

@@ -0,0 +1,592 @@
const reader = document.getElementById('reader');
const panel = document.getElementById('settings-panel');
const overlay = document.getElementById('overlay');
const settingsBtn = document.getElementById('settings-btn');
const closePanel = document.getElementById('close-panel');
const chapterLabel = document.getElementById('chapter-label');
const prevBtn = document.getElementById('prev-chapter');
const nextBtn = document.getElementById('next-chapter');
const lnSettings = document.getElementById('ln-settings');
const mangaSettings = document.getElementById('manga-settings');
const config = {
ln: {
fontSize: 18,
lineHeight: 1.8,
maxWidth: 750,
fontFamily: '"Georgia", serif',
textColor: '#e5e7eb',
bg: '#14141b',
textAlign: 'justify'
},
manga: {
direction: 'rtl',
mode: 'auto',
spacing: 16,
imageFit: 'screen',
preloadCount: 3
}
};
let currentType = null;
let currentPages = [];
let observer = null;
const parts = window.location.pathname.split('/');
const bookId = parts[2];
let chapter = parts[3];
let provider = parts[4];
function loadConfig() {
try {
const saved = localStorage.getItem('readerConfig');
if (saved) {
const parsed = JSON.parse(saved);
Object.assign(config.ln, parsed.ln || {});
Object.assign(config.manga, parsed.manga || {});
}
} catch (e) {
console.error('Error loading config:', e);
}
updateUIFromConfig();
}
function saveConfig() {
try {
localStorage.setItem('readerConfig', JSON.stringify(config));
} catch (e) {
console.error('Error saving config:', e);
}
}
function updateUIFromConfig() {
document.getElementById('font-size').value = config.ln.fontSize;
document.getElementById('font-size-value').textContent = config.ln.fontSize + 'px';
document.getElementById('line-height').value = config.ln.lineHeight;
document.getElementById('line-height-value').textContent = config.ln.lineHeight;
document.getElementById('max-width').value = config.ln.maxWidth;
document.getElementById('max-width-value').textContent = config.ln.maxWidth + 'px';
document.getElementById('font-family').value = config.ln.fontFamily;
document.getElementById('text-color').value = config.ln.textColor;
document.getElementById('bg-color').value = config.ln.bg;
document.querySelectorAll('[data-align]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === config.ln.textAlign);
});
document.getElementById('display-mode').value = config.manga.mode;
document.getElementById('image-fit').value = config.manga.imageFit;
document.getElementById('page-spacing').value = config.manga.spacing;
document.getElementById('page-spacing-value').textContent = config.manga.spacing + 'px';
document.getElementById('preload-count').value = config.manga.preloadCount;
document.querySelectorAll('[data-direction]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.direction === config.manga.direction);
});
}
function applyStyles() {
if (currentType === 'ln') {
document.documentElement.style.setProperty('--ln-font-size', config.ln.fontSize + 'px');
document.documentElement.style.setProperty('--ln-line-height', config.ln.lineHeight);
document.documentElement.style.setProperty('--ln-max-width', config.ln.maxWidth + 'px');
document.documentElement.style.setProperty('--ln-font-family', config.ln.fontFamily);
document.documentElement.style.setProperty('--ln-text-color', config.ln.textColor);
document.documentElement.style.setProperty('--bg-base', config.ln.bg);
document.documentElement.style.setProperty('--ln-text-align', config.ln.textAlign);
}
if (currentType === 'manga') {
document.documentElement.style.setProperty('--page-spacing', config.manga.spacing + 'px');
document.documentElement.style.setProperty('--page-max-width', 900 + 'px');
document.documentElement.style.setProperty('--manga-max-width', 1400 + 'px');
const viewportHeight = window.innerHeight - 64 - 32;
document.documentElement.style.setProperty('--viewport-height', viewportHeight + 'px');
}
}
function updateSettingsVisibility() {
lnSettings.classList.toggle('hidden', currentType !== 'ln');
mangaSettings.classList.toggle('hidden', currentType !== 'manga');
}
async function loadChapter() {
reader.innerHTML = `
<div class="loading-container">
<div class="loading-spinner"></div>
<span>Loading chapter...</span>
</div>
`;
try {
const res = await fetch(`/api/book/${bookId}/${chapter}/${provider}`);
const data = await res.json();
if (data.title) {
chapterLabel.textContent = data.title;
document.title = data.title;
} else {
chapterLabel.textContent = `Chapter ${chapter}`;
document.title = `Chapter ${chapter}`;
}
if (data.error) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Error: ${data.error}</span>
</div>
`;
return;
}
currentType = data.type;
updateSettingsVisibility();
applyStyles();
reader.innerHTML = '';
if (data.type === 'manga') {
currentPages = data.pages || [];
loadManga(currentPages);
} else if (data.type === 'ln') {
loadLN(data.content);
}
} catch (error) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Error loading chapter: ${error.message}</span>
</div>
`;
}
}
function loadManga(pages) {
if (!pages || pages.length === 0) {
reader.innerHTML = '<div class="loading-container"><span>No pages found</span></div>';
return;
}
const container = document.createElement('div');
container.className = 'manga-container';
let isLongStrip = false;
if (config.manga.mode === 'longstrip') {
isLongStrip = true;
} else if (config.manga.mode === 'auto' && detectLongStrip(pages)) {
isLongStrip = true;
}
const useDouble = config.manga.mode === 'double' ||
(config.manga.mode === 'auto' && !isLongStrip && shouldUseDoublePage(pages));
if (useDouble) {
loadDoublePage(container, pages);
} else {
loadSinglePage(container, pages);
}
reader.appendChild(container);
setupLazyLoading();
enableMangaPageNavigation();
}
function shouldUseDoublePage(pages) {
if (pages.length <= 5) return false;
const widePages = pages.filter(p => {
if (!p.height || !p.width) return false;
const ratio = p.width / p.height;
return ratio > 1.3;
});
if (widePages.length > pages.length * 0.3) return false;
return true;
}
function loadSinglePage(container, pages) {
pages.forEach((page, index) => {
const img = createImageElement(page, index);
container.appendChild(img);
});
}
function loadDoublePage(container, pages) {
let i = 0;
while (i < pages.length) {
const currentPage = pages[i];
const nextPage = pages[i + 1];
const isWide = currentPage.width && currentPage.height &&
(currentPage.width / currentPage.height) > 1.1;
if (isWide) {
const img = createImageElement(currentPage, i);
container.appendChild(img);
i++;
} else {
const doubleContainer = document.createElement('div');
doubleContainer.className = 'double-container';
const leftPage = createImageElement(currentPage, i);
if (nextPage) {
const nextIsWide = nextPage.width && nextPage.height &&
(nextPage.width / nextPage.height) > 1.3;
if (nextIsWide) {
const singleImg = createImageElement(currentPage, i);
container.appendChild(singleImg);
i++;
} else {
const rightPage = createImageElement(nextPage, i + 1);
if (config.manga.direction === 'rtl') {
doubleContainer.appendChild(rightPage);
doubleContainer.appendChild(leftPage);
} else {
doubleContainer.appendChild(leftPage);
doubleContainer.appendChild(rightPage);
}
container.appendChild(doubleContainer);
i += 2;
}
} else {
const singleImg = createImageElement(currentPage, i);
container.appendChild(singleImg);
i++;
}
}
}
}
function createImageElement(page, index) {
const img = document.createElement('img');
img.className = 'page-img';
img.dataset.index = index;
const url = buildProxyUrl(page.url, page.headers);
if (config.manga.mode === 'longstrip' && index > 0) {
img.classList.add('longstrip-fit');
} else {
if (config.manga.imageFit === 'width') img.classList.add('fit-width');
else if (config.manga.imageFit === 'height') img.classList.add('fit-height');
else if (config.manga.imageFit === 'screen') img.classList.add('fit-screen');
}
if (index < config.manga.preloadCount) {
img.src = url;
} else {
img.dataset.src = url;
img.loading = 'lazy';
}
img.alt = `Page ${index + 1}`;
return img;
}
function buildProxyUrl(url, headers = {}) {
const params = new URLSearchParams({
url
});
if (headers.referer) params.append('referer', headers.referer);
if (headers['user-agent']) params.append('ua', headers['user-agent']);
if (headers.cookie) params.append('cookie', headers.cookie);
return `/api/proxy?${params.toString()}`;
}
function detectLongStrip(pages) {
if (!pages || pages.length === 0) return false;
const relevant = pages.slice(1);
const tall = relevant.filter(p => p.height && p.width && (p.height / p.width) > 2);
return tall.length >= 2 || (tall.length / relevant.length) > 0.3;
}
function setupLazyLoading() {
if (observer) observer.disconnect();
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
observer.unobserve(img);
}
}
});
}, {
rootMargin: '200px'
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
}
function loadLN(html) {
const div = document.createElement('div');
div.className = 'ln-content';
div.innerHTML = html;
reader.appendChild(div);
}
document.getElementById('font-size').addEventListener('input', (e) => {
config.ln.fontSize = parseInt(e.target.value);
document.getElementById('font-size-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('line-height').addEventListener('input', (e) => {
config.ln.lineHeight = parseFloat(e.target.value);
document.getElementById('line-height-value').textContent = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('max-width').addEventListener('input', (e) => {
config.ln.maxWidth = parseInt(e.target.value);
document.getElementById('max-width-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('font-family').addEventListener('change', (e) => {
config.ln.fontFamily = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('text-color').addEventListener('change', (e) => {
config.ln.textColor = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('bg-color').addEventListener('change', (e) => {
config.ln.bg = e.target.value;
applyStyles();
saveConfig();
});
document.querySelectorAll('[data-align]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-align]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
config.ln.textAlign = btn.dataset.align;
applyStyles();
saveConfig();
});
});
document.querySelectorAll('[data-preset]').forEach(btn => {
btn.addEventListener('click', () => {
const preset = btn.dataset.preset;
const presets = {
dark: { bg: '#14141b', textColor: '#e5e7eb' },
sepia: { bg: '#f4ecd8', textColor: '#5c472d' },
light: { bg: '#fafafa', textColor: '#1f2937' },
amoled: { bg: '#000000', textColor: '#ffffff' }
};
if (presets[preset]) {
Object.assign(config.ln, presets[preset]);
document.getElementById('bg-color').value = config.ln.bg;
document.getElementById('text-color').value = config.ln.textColor;
applyStyles();
saveConfig();
}
});
});
document.getElementById('display-mode').addEventListener('change', (e) => {
config.manga.mode = e.target.value;
saveConfig();
loadChapter();
});
document.getElementById('image-fit').addEventListener('change', (e) => {
config.manga.imageFit = e.target.value;
saveConfig();
loadChapter();
});
document.getElementById('page-spacing').addEventListener('input', (e) => {
config.manga.spacing = parseInt(e.target.value);
document.getElementById('page-spacing-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('preload-count').addEventListener('change', (e) => {
config.manga.preloadCount = parseInt(e.target.value);
saveConfig();
});
document.querySelectorAll('[data-direction]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-direction]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
config.manga.direction = btn.dataset.direction;
saveConfig();
loadChapter();
});
});
prevBtn.addEventListener('click', () => {
const newChapter = String(parseInt(chapter) - 1);
updateURL(newChapter);
window.scrollTo(0, 0);
loadChapter();
});
nextBtn.addEventListener('click', () => {
const newChapter = String(parseInt(chapter) + 1);
updateURL(newChapter);
window.scrollTo(0, 0);
loadChapter();
});
function updateURL(newChapter) {
chapter = newChapter;
const newUrl = `/reader/${bookId}/${chapter}/${provider}`;
window.history.pushState({}, '', newUrl);
}
document.getElementById('back-btn').addEventListener('click', () => {
const parts = window.location.pathname.split('/');
const mangaId = parts[2];
window.location.href = `/book/${mangaId}`;
});
settingsBtn.addEventListener('click', () => {
panel.classList.add('open');
overlay.classList.add('active');
});
closePanel.addEventListener('click', closeSettings);
overlay.addEventListener('click', closeSettings);
function closeSettings() {
panel.classList.remove('open');
overlay.classList.remove('active');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panel.classList.contains('open')) {
closeSettings();
}
});
function enableMangaPageNavigation() {
if (currentType !== 'manga') return;
const logicalPages = [];
document.querySelectorAll('.manga-container > *').forEach(el => {
if (el.classList.contains('double-container')) {
logicalPages.push(el);
} else if (el.tagName === 'IMG') {
logicalPages.push(el);
}
});
if (logicalPages.length === 0) return;
function scrollToLogical(index) {
if (index < 0 || index >= logicalPages.length) return;
const topBar = document.querySelector('.top-bar');
const offset = topBar ? -topBar.offsetHeight : 0;
const y = logicalPages[index].getBoundingClientRect().top
+ window.pageYOffset
+ offset;
window.scrollTo({
top: y,
behavior: 'smooth'
});
}
function getCurrentLogicalIndex() {
let closest = 0;
let minDist = Infinity;
logicalPages.forEach((el, i) => {
const rect = el.getBoundingClientRect();
const dist = Math.abs(rect.top);
if (dist < minDist) {
minDist = dist;
closest = i;
}
});
return closest;
}
const rtl = () => config.manga.direction === 'rtl';
document.addEventListener('keydown', (e) => {
if (currentType !== 'manga') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const index = getCurrentLogicalIndex();
if (e.key === 'ArrowLeft') {
scrollToLogical(rtl() ? index + 1 : index - 1);
}
if (e.key === 'ArrowRight') {
scrollToLogical(rtl() ? index - 1 : index + 1);
}
});
reader.addEventListener('click', (e) => {
if (currentType !== 'manga') return;
const bounds = reader.getBoundingClientRect();
const x = e.clientX - bounds.left;
const half = bounds.width / 2;
const index = getCurrentLogicalIndex();
if (x < half) {
scrollToLogical(rtl() ? index + 1 : index - 1);
} else {
scrollToLogical(rtl() ? index - 1 : index + 1);
}
});
}
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
applyStyles();
}, 250);
});
if (!bookId || !chapter || !provider) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Missing required parameters (bookId, chapter, provider)</span>
</div>
`;
} else {
loadConfig();
loadChapter();
}