added reader

This commit is contained in:
2025-11-27 02:11:17 +01:00
parent 76aa21ef14
commit a6c753085e
5 changed files with 1350 additions and 14 deletions

View File

@@ -164,14 +164,17 @@ function renderTable() {
pageItems.forEach(ch => { pageItems.forEach(ch => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td>${ch.number}</td> <td>${ch.number}</td>
<td>${ch.title || `Chapter ${ch.number}`}</td> <td>${ch.title || 'Chapter ' + ch.number}</td>
<td><span class="pill" style="font-size:0.75rem;">${ch.provider}</span></td> <td><span class="pill" style="font-size:0.75rem;">${ch.provider}</span></td>
<td> <td>
<button class="read-btn-small" onclick="openReader('${ch.id}')">Read</button> <button class="read-btn-small" onclick="openReader('${bookId}', '${ch.number - 1}', '${ch.provider}')">
</td> Read
`; </button>
</td>
`;
tbody.appendChild(row); tbody.appendChild(row);
}); });
@@ -202,9 +205,10 @@ function updatePagination() {
nextBtn.onclick = () => { currentPage++; renderTable(); }; nextBtn.onclick = () => { currentPage++; renderTable(); };
} }
function openReader(chapterId) { function openReader(bookId, chapterId, provider) {
alert("Opening Reader for Chapter ID: " + chapterId); const c = encodeURIComponent(chapterId);
// window.location.href = `/read/${bookId}/${chapterId}`; const p = encodeURIComponent(provider);
window.location.href = `/read/${bookId}/${c}/${p}`;
} }
init(); init();

555
public/reader.css Normal file
View File

@@ -0,0 +1,555 @@
:root {
--bg-base: #0a0a0f;
--bg-surface: #14141b;
--bg-elevated: #1c1c26;
--bg-hover: #252530;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--accent-light: rgba(139, 92, 246, 0.15);
--text-primary: #ffffff;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--border: rgba(255, 255, 255, 0.08);
--border-focus: rgba(139, 92, 246, 0.4);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.5);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg-base);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
overflow-x: hidden;
line-height: 1.6;
}
.hidden { display: none !important; }
/* ===== TOP BAR ===== */
.top-bar {
position: fixed;
top: 0; left: 0; right: 0;
height: 64px;
background: rgba(10, 10, 15, 0.85);
backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
z-index: 1000;
box-shadow: var(--shadow-sm);
}
.glass-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.625rem 1.25rem;
border-radius: var(--radius-full);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.glass-btn:hover {
background: var(--bg-hover);
border-color: var(--accent);
transform: translateY(-1px);
}
.glass-btn:active {
transform: translateY(0);
}
.chapter-info {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
}
.nav-arrow {
background: var(--bg-surface);
border: 1px solid var(--border);
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
}
.nav-arrow:hover {
background: var(--accent);
border-color: var(--accent);
transform: scale(1.05);
}
.nav-arrow:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ===== READER CONTAINER ===== */
#reader {
margin-top: 64px;
padding: 2rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
min-height: calc(100vh - 64px);
width: 100%;
}
/* ===== MANGA STYLES ===== */
.manga-container {
width: 100%;
max-width: var(--manga-max-width, 1200px);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--page-spacing, 16px);
}
.page-img {
width: 100%;
max-width: var(--page-max-width, 900px);
height: auto;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
transition: transform 0.3s ease;
cursor: zoom-in;
}
.page-img:hover {
transform: scale(1.01);
}
.page-img.zoomed {
cursor: zoom-out;
max-width: 100%;
position: relative;
}
.double-container {
display: flex;
gap: var(--page-spacing, 16px);
width: 100%;
max-width: var(--manga-max-width, 1400px);
justify-content: center;
}
.double-container img {
width: 48%;
max-width: 700px;
height: auto;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
object-fit: contain;
cursor: zoom-in;
transition: transform 0.3s ease;
}
.double-container img:hover {
transform: scale(1.02);
}
/* ===== LIGHT NOVEL STYLES ===== */
.ln-content {
max-width: var(--ln-max-width, 750px);
width: 100%;
margin: 0 auto;
padding: 3rem 2.5rem;
line-height: var(--ln-line-height, 1.8);
font-size: var(--ln-font-size, 18px);
font-family: var(--ln-font-family, 'Georgia', serif);
color: var(--ln-text-color, #e5e7eb);
background: var(--ln-bg, #14141b);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
text-align: var(--ln-text-align, left);
}
.ln-content p {
margin-bottom: 1.5em;
}
.ln-content h1, .ln-content h2, .ln-content h3 {
margin-top: 2em;
margin-bottom: 1em;
font-weight: 700;
}
/* ===== SETTINGS PANEL ===== */
.settings-panel {
position: fixed;
right: 0;
top: 64px;
bottom: 0;
width: 400px;
background: var(--bg-surface);
border-left: 1px solid var(--border);
padding: 0;
z-index: 1001;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-lg);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.settings-panel.open {
transform: translateX(0);
}
.panel-header {
position: sticky;
top: 0;
background: var(--bg-elevated);
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
z-index: 10;
}
.panel-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.close-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 1.25rem;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.panel-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.settings-group {
margin-bottom: 2rem;
}
.settings-group h4 {
margin: 0 0 1rem 0;
color: var(--accent);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.control {
margin-bottom: 1.25rem;
}
.control label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.625rem;
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
.control label span {
color: var(--text-primary);
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8125rem;
}
/* Range Inputs */
input[type="range"] {
width: 100%;
height: 6px;
background: var(--bg-elevated);
border-radius: var(--radius-full);
outline: none;
-webkit-appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4);
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.6);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
border: none;
transition: all 0.2s;
}
/* Select & Color Inputs */
select, input[type="color"], input[type="number"] {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.875rem;
transition: all 0.2s;
cursor: pointer;
}
select:hover, input[type="color"]:hover, input[type="number"]:hover {
border-color: var(--accent);
}
select:focus, input[type="color"]:focus, input[type="number"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}
input[type="color"] {
height: 44px;
padding: 0.25rem;
cursor: pointer;
}
/* Presets */
.presets {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.presets button {
padding: 0.75rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
font-weight: 600;
font-size: 0.875rem;
}
.presets button:hover {
background: var(--accent);
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
/* Toggle Switches */
.toggle-group {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.toggle-btn {
flex: 1;
padding: 0.5rem 1rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 0.8125rem;
font-weight: 500;
text-align: center;
}
.toggle-btn:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.toggle-btn.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.overlay.active {
opacity: 1;
pointer-events: all;
}
/* Loading State */
.loading-spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 3px solid var(--bg-elevated);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
gap: 1rem;
color: var(--text-secondary);
}
/* Divider */
.divider {
height: 1px;
background: var(--border);
margin: 1.5rem 0;
}
/* Scrollbar */
.settings-panel::-webkit-scrollbar {
width: 8px;
}
.settings-panel::-webkit-scrollbar-track {
background: var(--bg-surface);
}
.settings-panel::-webkit-scrollbar-thumb {
background: var(--bg-elevated);
border-radius: 4px;
}
.settings-panel::-webkit-scrollbar-thumb:hover {
background: var(--bg-hover);
}
/* Responsive */
@media (max-width: 768px) {
.settings-panel {
width: 100%;
max-width: 100%;
}
.top-bar {
padding: 0 1rem;
}
.glass-btn {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
}
.chapter-info {
font-size: 0.875rem;
gap: 0.75rem;
}
.double-container {
flex-direction: column;
}
.double-container img {
width: 100%;
max-width: 100%;
}
.ln-content {
padding: 2rem 1.5rem;
font-size: var(--ln-font-size, 16px);
}
}
/* Image Fit Modes */
.fit-width {
width: 100% !important;
height: auto !important;
max-width: 100% !important;
}
.fit-height {
height: var(--viewport-height, 85vh) !important;
width: auto !important;
max-width: 100% !important;
}
.fit-screen {
max-height: var(--viewport-height, 85vh) !important;
max-width: 100% !important;
width: auto !important;
height: auto !important;
object-fit: contain !important;
}

515
public/reader.js Normal file
View File

@@ -0,0 +1,515 @@
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',
maxWidth: 900,
quality: 'high',
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() {
// Light Novel
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;
// Text alignment buttons
document.querySelectorAll('[data-align]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === config.ln.textAlign);
});
// Manga
document.getElementById('display-mode').value = config.manga.mode;
document.getElementById('image-fit').value = config.manga.imageFit;
document.getElementById('manga-max-width').value = config.manga.maxWidth;
document.getElementById('manga-max-width-value').textContent = config.manga.maxWidth + 'px';
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;
// Direction buttons
document.querySelectorAll('[data-direction]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.direction === config.manga.direction);
});
// Quality buttons
document.querySelectorAll('[data-quality]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.quality === config.manga.quality);
});
}
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('--ln-bg', 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', config.manga.maxWidth + 'px');
document.documentElement.style.setProperty('--manga-max-width', config.manga.maxWidth + 'px');
const viewportHeight = window.innerHeight - 64 - 32; // header + padding
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';
const isLongStrip = config.manga.mode === 'longstrip' ||
(config.manga.mode === 'auto' && detectLongStrip(pages));
const useDouble = config.manga.mode === 'double' ||
(config.manga.mode === 'auto' && !isLongStrip && pages.length > 5);
if (useDouble) {
loadDoublePage(container, pages);
} else {
loadSinglePage(container, pages);
}
reader.appendChild(container);
setupLazyLoading();
}
function loadSinglePage(container, pages) {
pages.forEach((page, index) => {
const img = createImageElement(page.url, index);
container.appendChild(img);
});
}
function loadDoublePage(container, pages) {
for (let i = 0; i < pages.length; i += 2) {
const doubleContainer = document.createElement('div');
doubleContainer.className = 'double-container';
const leftPage = createImageElement(pages[i].url, i);
if (pages[i + 1]) {
const rightPage = createImageElement(pages[i + 1].url, i + 1);
if (config.manga.direction === 'rtl') {
doubleContainer.appendChild(rightPage);
doubleContainer.appendChild(leftPage);
} else {
doubleContainer.appendChild(leftPage);
doubleContainer.appendChild(rightPage);
}
} else {
doubleContainer.appendChild(leftPage);
}
container.appendChild(doubleContainer);
}
}
function createImageElement(url, index) {
const img = document.createElement('img');
img.className = 'page-img';
img.dataset.index = index;
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');
}
// Preload o lazy load
if (index < config.manga.preloadCount) {
img.src = buildProxyUrl(url);
} else {
img.dataset.src = buildProxyUrl(url);
img.loading = 'lazy';
}
img.alt = `Page ${index + 1}`;
return img;
}
function buildProxyUrl(url) {
return `/api/proxy?url=${encodeURIComponent(url)}&referer=https%3A%2F%2Fmangapark.net`;
}
function detectLongStrip(pages) {
if (!pages || pages.length === 0) return false;
const tallPages = pages.filter(p => {
if (!p.height || !p.width) return false;
return (p.height / p.width) > 2.5;
});
return tallPages.length >= Math.min(4, pages.length * 0.5);
}
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();
});
// Text alignment
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();
});
});
// Presets
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('manga-max-width').addEventListener('input', (e) => {
config.manga.maxWidth = parseInt(e.target.value);
document.getElementById('manga-max-width-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
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();
});
// Direction
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();
});
});
// Quality
document.querySelectorAll('[data-quality]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-quality]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
config.manga.quality = btn.dataset.quality;
saveConfig();
});
});
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', () => {
history.back();
});
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();
}
});
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
switch(e.key) {
case 'ArrowLeft':
if (config.manga.direction === 'rtl') {
nextBtn.click();
} else {
prevBtn.click();
}
break;
case 'ArrowRight':
if (config.manga.direction === 'rtl') {
prevBtn.click();
} else {
nextBtn.click();
}
break;
case 's':
case 'S':
settingsBtn.click();
break;
}
});
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();
}

View File

@@ -31,8 +31,8 @@ async function loadExtensions() {
try { try {
delete require.cache[require.resolve(filePath)]; delete require.cache[require.resolve(filePath)];
const ExtensionClass = require(filePath); const ExtensionClass = require(filePath);
const instance = typeof ExtensionClass === 'function' const instance = typeof ExtensionClass === 'function'
? new ExtensionClass() ? new ExtensionClass()
: (ExtensionClass.default ? new ExtensionClass.default() : null); : (ExtensionClass.default ? new ExtensionClass.default() : null);
if (instance && (instance.type === "anime-board" || instance.type === "book-board")) { if (instance && (instance.type === "anime-board" || instance.type === "book-board")) {
@@ -351,7 +351,7 @@ fastify.get('/api/book/:id/chapters', async (req, reply) => {
.map(async ([name, ext]) => { .map(async ([name, ext]) => {
try { try {
console.log(`[${name}] Searching chapters for: ${searchTitle}`); console.log(`[${name}] Searching chapters for: ${searchTitle}`);
// Pass strict search options // Pass strict search options
const matches = await ext.search({ const matches = await ext.search({
query: searchTitle, query: searchTitle,
@@ -366,7 +366,7 @@ fastify.get('/api/book/:id/chapters', async (req, reply) => {
// Use the first match to find chapters // Use the first match to find chapters
const best = matches[0]; const best = matches[0];
const chaps = await ext.findChapters(best.id); const chaps = await ext.findChapters(best.id);
if (chaps && chaps.length > 0) { if (chaps && chaps.length > 0) {
console.log(`[${name}] Found ${chaps.length} chapters.`); console.log(`[${name}] Found ${chaps.length} chapters.`);
chaps.forEach(ch => { chaps.forEach(ch => {
@@ -398,6 +398,73 @@ fastify.get('/api/book/:id/chapters', async (req, reply) => {
return { chapters: sortedChapters }; return { chapters: sortedChapters };
}); });
fastify.get('/api/book/:bookId/:chapter/:provider', async (req, reply) => {
const { bookId, chapter, provider } = req.params;
const ext = extensions.get(provider);
if (!ext)
return reply.code(404).send({ error: "Provider not found" });
let chapterId = decodeURIComponent(chapter);
let chapterTitle = null;
let chapterNumber = null;
const index = parseInt(chapter);
const chapterList = await fetch(
`http://localhost:3000/api/book/${bookId}/chapters`
).then(r => r.json());
if (!chapterList?.chapters)
return reply.code(404).send({ error: "Chapters not found" });
const providerChapters = chapterList.chapters.filter(
c => c.provider === provider
);
if (!providerChapters[index])
return reply.code(404).send({ error: "Chapter index out of range" });
const selected = providerChapters[index];
chapterId = selected.id;
chapterTitle = selected.title || null;
chapterNumber = selected.number || index;
try {
if (ext.mediaType === "manga") {
const pages = await ext.findChapterPages(chapterId);
return reply.send({
type: "manga",
chapterId,
title: chapterTitle,
number: chapterNumber,
provider,
pages
});
}
if (ext.mediaType === "ln") {
const content = await ext.findChapterPages(chapterId);
return reply.send({
type: "ln",
chapterId,
title: chapterTitle,
number: chapterNumber,
provider,
content
});
}
return reply.code(400).send({ error: "Unknown mediaType" });
} catch (err) {
console.error(err);
return reply.code(500).send({ error: "Error loading chapter" });
}
});
fastify.get('/api/book/:id', async (req, reply) => { fastify.get('/api/book/:id', async (req, reply) => {
const id = req.params.id; const id = req.params.id;
@@ -493,6 +560,11 @@ fastify.get('/api/top-airing', (req, reply) => {
return new Promise((resolve) => db.all("SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10", [], (err, rows) => resolve({ results: rows ? rows.map(r => JSON.parse(r.full_data)) : [] }))); return new Promise((resolve) => db.all("SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10", [], (err, rows) => resolve({ results: rows ? rows.map(r => JSON.parse(r.full_data)) : [] })));
}); });
fastify.get('/read/:id/:chapter/:provider', (req, reply) => {
const stream = fs.createReadStream(path.join(__dirname, 'views', 'reader.html'));
reply.type('text/html').send(stream);
});
const start = async () => { const start = async () => {
try { try {
await fastify.listen({ port: 3000, host: '0.0.0.0' }); await fastify.listen({ port: 3000, host: '0.0.0.0' });

190
views/reader.html Normal file
View File

@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reader</title>
<link rel="stylesheet" href="/public/reader.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<!-- Top Bar -->
<header class="top-bar">
<button id="back-btn" class="glass-btn">
X
</button>
<div class="chapter-info">
<button id="prev-chapter" class="nav-arrow"></button>
<span id="chapter-label">Loading...</span>
<button id="next-chapter" class="nav-arrow"></button>
</div>
<button id="settings-btn" class="glass-btn">
⚙ Settings
</button>
</header>
<!-- Settings Panel -->
<aside id="settings-panel" class="settings-panel">
<div class="panel-header">
<h3>Reader Settings</h3>
<button id="close-panel" class="close-btn">×</button>
</div>
<div class="panel-content">
<!-- Light Novel Settings -->
<div id="ln-settings" class="settings-group hidden">
<h4>Text Settings</h4>
<div class="control">
<label>
Font Size
<span id="font-size-value">18px</span>
</label>
<input type="range" id="font-size" min="12" max="40" value="18" step="1">
</div>
<div class="control">
<label>
Line Height
<span id="line-height-value">1.8</span>
</label>
<input type="range" id="line-height" min="1.2" max="3" step="0.1" value="1.8">
</div>
<div class="control">
<label>
Content Width
<span id="max-width-value">750px</span>
</label>
<input type="range" id="max-width" min="500" max="1200" value="750" step="50">
</div>
<div class="control">
<label>Font Family</label>
<select id="font-family">
<option value='"Georgia", serif'>Georgia (Serif)</option>
<option value='"Charter", serif'>Charter (Serif)</option>
<option value='"Merriweather", serif'>Merriweather</option>
<option value='"Inter", system-ui, sans-serif'>Inter (Sans)</option>
<option value='system-ui, sans-serif'>System UI</option>
<option value='"JetBrains Mono", monospace'>JetBrains Mono</option>
</select>
</div>
<div class="control">
<label>Text Alignment</label>
<div class="toggle-group">
<button class="toggle-btn" data-align="left">Left</button>
<button class="toggle-btn active" data-align="justify">Justify</button>
<button class="toggle-btn" data-align="center">Center</button>
</div>
</div>
<div class="divider"></div>
<h4>🎨 Color Theme</h4>
<div class="control">
<label>Text Color</label>
<input type="color" id="text-color" value="#e5e7eb">
</div>
<div class="control">
<label>Background Color</label>
<input type="color" id="bg-color" value="#14141b">
</div>
<div class="presets">
<button data-preset="dark">Dark</button>
<button data-preset="sepia">Sepia</button>
<button data-preset="light">Light</button>
<button data-preset="amoled">AMOLED</button>
</div>
</div>
<!-- Manga Settings -->
<div id="manga-settings" class="settings-group hidden">
<h4>Display Mode</h4>
<div class="control">
<label>Layout Mode</label>
<select id="display-mode">
<option value="auto">Auto Detect</option>
<option value="single">Single Page</option>
<option value="double">Double Page</option>
<option value="longstrip">Long Strip</option>
</select>
</div>
<div class="control">
<label>Reading Direction</label>
<div class="toggle-group">
<button class="toggle-btn" data-direction="ltr">← LTR</button>
<button class="toggle-btn active" data-direction="rtl">RTL →</button>
</div>
</div>
<div class="divider"></div>
<h4>Image Settings</h4>
<div class="control">
<label>Image Fit</label>
<select id="image-fit">
<option value="width">Fit Width</option>
<option value="height">Fit Height</option>
<option value="screen" selected>Fit Screen</option>
</select>
</div>
<div class="control">
<label>
Max Image Width
<span id="manga-max-width-value">900px</span>
</label>
<input type="range" id="manga-max-width" min="600" max="1600" value="900" step="50">
</div>
<div class="control">
<label>
Page Spacing
<span id="page-spacing-value">16px</span>
</label>
<input type="range" id="page-spacing" min="0" max="60" value="16" step="4">
</div>
<div class="divider"></div>
<h4>⚡ Performance</h4>
<div class="control">
<label>Image Quality</label>
<div class="toggle-group">
<button class="toggle-btn" data-quality="low">Low</button>
<button class="toggle-btn active" data-quality="high">High</button>
</div>
</div>
<div class="control">
<label>Preload Pages</label>
<input type="number" id="preload-count" min="0" max="10" value="3">
</div>
</div>
</div>
</aside>
<div id="overlay" class="overlay"></div>
<main id="reader">
<div class="loading-container">
<div class="loading-spinner"></div>
<span>Loading chapter...</span>
</div>
</main>
<script src="/public/reader.js"></script>
</body>
</html>