Updated headless browser (Should be as fast as it was before)

Added in Book Boards! (NEW!)
Updated rendering logic
Updated search logic
Updated extension loading logic
Updated image handling logic
This commit is contained in:
2025-11-22 10:55:27 -05:00
parent dca07a26f8
commit 652db0586b
13 changed files with 975 additions and 391 deletions

View File

@@ -73,6 +73,7 @@ function createWindow() {
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
initDiscordRPC(); initDiscordRPC();
headlessBrowser.init()
app.on('activate', function () { app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow(); if (BrowserWindow.getAllWindows().length === 0) createWindow();
}); });
@@ -93,6 +94,10 @@ const dbHandlers = require('./src/ipc/db-handlers')(db);
ipcMain.handle('api:getSources', apiHandlers.getSources); ipcMain.handle('api:getSources', apiHandlers.getSources);
ipcMain.handle('api:search', apiHandlers.search); ipcMain.handle('api:search', apiHandlers.search);
ipcMain.handle('api:getChapters', apiHandlers.getChapters);
ipcMain.handle('api:getPages', apiHandlers.getPages);
ipcMain.handle('api:getMetadata', apiHandlers.getMetadata);
ipcMain.handle('db:getFavorites', dbHandlers.getFavorites); ipcMain.handle('db:getFavorites', dbHandlers.getFavorites);
ipcMain.handle('db:addFavorite', dbHandlers.addFavorite); ipcMain.handle('db:addFavorite', dbHandlers.addFavorite);
ipcMain.handle('db:removeFavorite', dbHandlers.removeFavorite); ipcMain.handle('db:removeFavorite', dbHandlers.removeFavorite);

View File

@@ -1,115 +1,123 @@
import { createAddFavoriteButton, createRemoveFavoriteButton } from '../favorites/favorites-handler.js'; export function createImageCard(id, tags, imageUrl, thumbnailUrl, type, options = {}) {
const {
showMessage,
showTagModal,
favoriteIds = new Set()
} = options;
const card = document.createElement('div');
card.className = 'image-entry';
card.dataset.id = id;
card.dataset.type = type;
card.title = tags.join(', ');
const img = document.createElement('img');
img.src = thumbnailUrl || imageUrl;
img.loading = 'lazy';
img.alt = tags.join(' ');
img.onload = () => img.classList.add('loaded');
card.appendChild(img);
if (type === 'book') {
const readOverlay = document.createElement('div');
readOverlay.className = 'book-read-overlay';
readOverlay.innerHTML = `
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
</svg>
<span>Click To Read</span>
`;
card.appendChild(readOverlay);
return card;
}
const buttonsOverlay = document.createElement('div');
buttonsOverlay.className = 'image-buttons';
const favBtn = document.createElement('button');
favBtn.className = 'heart-button';
favBtn.dataset.id = id;
const isFavorited = favoriteIds.has(String(id));
updateHeartIcon(favBtn, isFavorited);
favBtn.onclick = async (e) => {
e.stopPropagation();
e.preventDefault();
const currentlyFavorited = favoriteIds.has(String(id));
if (currentlyFavorited) {
const success = await window.api.removeFavorite(id);
if (success) {
favoriteIds.delete(String(id));
updateHeartIcon(favBtn, false);
showMessage('Removed from favorites', 'success');
if (window.location.pathname.includes('favorites.html')) {
card.remove();
if (options.applyLayoutToGallery && options.favoritesGallery) {
options.applyLayoutToGallery(options.favoritesGallery, options.currentLayout);
}
}
} else {
showMessage('Failed to remove favorite', 'error');
}
} else {
const favoriteData = {
id: String(id),
image_url: imageUrl,
thumbnail_url: thumbnailUrl,
tags: tags.join(','),
title: card.title || 'Unknown'
};
const success = await window.api.addFavorite(favoriteData);
if (success) {
favoriteIds.add(String(id));
updateHeartIcon(favBtn, true);
showMessage('Added to favorites', 'success');
} else {
showMessage('Failed to save favorite', 'error');
}
}
};
const tagBtn = document.createElement('button');
tagBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path><line x1="7" y1="7" x2="7.01" y2="7"></line></svg>`;
tagBtn.title = "View Tags";
tagBtn.onclick = (e) => {
e.stopPropagation();
showTagModal(tags);
};
buttonsOverlay.appendChild(tagBtn);
buttonsOverlay.appendChild(favBtn);
card.appendChild(buttonsOverlay);
return card;
}
function updateHeartIcon(btn, isFavorited) {
if (isFavorited) {
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="#ef4444" stroke="#ef4444" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>`;
btn.title = "Remove from Favorites";
} else {
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>`;
btn.title = "Add to Favorites";
}
}
export function populateTagModal(container, tags) { export function populateTagModal(container, tags) {
container.innerHTML = ''; container.innerHTML = '';
if (!tags || tags.length === 0) { if (!tags || tags.length === 0) {
container.innerHTML = container.innerHTML = '<p style="color:var(--text-tertiary)">No tags available.</p>';
'<p class="text-gray-400">No tags available for this image.</p>';
return; return;
} }
tags.forEach(tag => {
const fragment = document.createDocumentFragment(); const span = document.createElement('span');
tags.forEach((tag) => { span.textContent = tag;
if (tag) { container.appendChild(span);
const tagPill = document.createElement('span');
tagPill.className =
'px-2.5 py-1 bg-gray-700 text-gray-300 text-xs font-medium rounded-full';
tagPill.textContent = tag.replace(/_/g, ' ');
fragment.appendChild(tagPill);
}
}); });
container.appendChild(fragment);
}
function createInfoButton(safeTags, showTagModalCallback) {
const button = document.createElement('button');
button.title = 'Show Info';
button.className =
'p-2 rounded-full bg-black/50 text-white hover:bg-blue-600 backdrop-blur-sm transition-colors';
button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>`;
button.onclick = (e) => {
e.stopPropagation();
showTagModalCallback(safeTags);
};
return button;
}
export function createImageCard(id, tags, imageUrl, thumbnailUrl, type, context) {
const {
currentLayout,
showMessage,
showTagModal,
applyLayoutToGallery,
favoritesGallery
} = context;
const safeTags = Array.isArray(tags) ? tags : [];
const entry = document.createElement('div');
entry.dataset.id = id;
entry.className = `image-entry group relative bg-gray-800 rounded-lg shadow-lg overflow-hidden transition-all duration-300`;
if (currentLayout === 'compact') {
entry.classList.add('aspect-square');
}
const imageContainer = document.createElement('div');
imageContainer.className = 'w-full bg-gray-700 animate-pulse relative';
if (currentLayout === 'compact') {
imageContainer.classList.add('h-full');
} else {
imageContainer.classList.add('min-h-[200px]');
}
entry.appendChild(imageContainer);
const img = document.createElement('img');
img.src = imageUrl;
img.alt = safeTags.join(', ');
img.className = 'w-full h-auto object-contain bg-gray-900 opacity-0';
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
if (currentLayout === 'compact') {
img.className = 'w-full h-full object-cover bg-gray-900 opacity-0';
}
img.onload = () => {
imageContainer.classList.remove('animate-pulse', 'bg-gray-700');
img.classList.remove('opacity-0');
img.classList.add('transition-opacity', 'duration-500');
};
img.onerror = () => {
console.warn(`Failed to load full image: ${imageUrl}. Falling back to thumbnail.`);
img.src = thumbnailUrl;
imageContainer.classList.remove('animate-pulse', 'bg-gray-700');
img.classList.remove('opacity-0');
img.classList.add('transition-opacity', 'duration-500');
img.onerror = null;
};
imageContainer.appendChild(img);
const buttonContainer = document.createElement('div');
buttonContainer.className =
'image-buttons absolute top-3 right-3 flex flex-col space-y-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200';
buttonContainer.appendChild(createInfoButton(safeTags, showTagModal));
if (type === 'browse') {
buttonContainer.appendChild(
createAddFavoriteButton(id, safeTags, imageUrl, thumbnailUrl, showMessage)
);
} else {
buttonContainer.appendChild(
createRemoveFavoriteButton(id, favoritesGallery, showMessage, applyLayoutToGallery, currentLayout)
);
}
imageContainer.appendChild(buttonContainer);
return entry;
} }

View File

@@ -1,27 +1,22 @@
export async function populateSources(sourceList) { export async function populateSources(sourceList, targetType = 'image-board') {
console.log('Requesting sources from main process...'); console.log(`Requesting sources for type: ${targetType}...`);
const sources = await window.api.getSources(); const sources = await window.api.getSources();
sourceList.innerHTML = ''; sourceList.innerHTML = '';
let initialSource = ''; let initialSource = '';
console.log("Raw sources received from backend:", sources); console.log("Raw sources received:", sources);
if (sources && sources.length > 0) { if (sources && sources.length > 0) {
sources.forEach((source) => { sources.forEach((source) => {
if (source.name.toLowerCase().includes('tenor')) {
if (source.type !== 'image-board') {
console.error(`CRITICAL: Tenor extension found, but type is "${source.type}". It will be hidden.`);
} else {
console.log("SUCCESS: Tenor extension passed type check.");
}
}
if (source.type !== 'image-board') { if (targetType !== 'all' && source.type !== targetType) {
return; return;
} }
const button = document.createElement('button'); const button = document.createElement('button');
button.className = 'source-button hover:bg-gray-700 hover:text-white transition-all duration-200'; button.className = 'source-button hover:bg-gray-700 hover:text-white transition-all duration-200';
button.dataset.source = source.name; button.dataset.source = source.name;
button.title = source.name; button.title = source.name;
@@ -81,7 +76,7 @@ export async function populateSources(sourceList) {
firstButton.classList.add('active'); firstButton.classList.add('active');
initialSource = firstButton.dataset.source; initialSource = firstButton.dataset.source;
} else { } else {
console.warn("All sources were filtered out. Check 'type' property in your extensions."); console.warn(`No sources found for type: ${targetType}`);
} }
setupCarousel(sourceList); setupCarousel(sourceList);

View File

@@ -1,6 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const fetchPath = require.resolve('node-fetch'); const fetchPath = require.resolve('node-fetch');
const cheerioPath = require.resolve('cheerio'); const cheerioPath = require.resolve('cheerio');
const fetch = require(fetchPath);
function peekProperty(filePath, propertyName) { function peekProperty(filePath, propertyName) {
try { try {
@@ -17,7 +18,6 @@ module.exports = function (availableScrapers, headlessBrowser) {
Object.keys(availableScrapers).forEach(name => { Object.keys(availableScrapers).forEach(name => {
const scraper = availableScrapers[name]; const scraper = availableScrapers[name];
if (!scraper.url) { if (!scraper.url) {
if (scraper.instance && scraper.instance.baseUrl) { if (scraper.instance && scraper.instance.baseUrl) {
scraper.url = scraper.instance.baseUrl; scraper.url = scraper.instance.baseUrl;
@@ -25,78 +25,128 @@ module.exports = function (availableScrapers, headlessBrowser) {
scraper.url = peekProperty(scraper.path, 'baseUrl'); scraper.url = peekProperty(scraper.path, 'baseUrl');
} }
} }
if (!scraper.type) { if (!scraper.type) {
if (scraper.instance && scraper.instance.type) { if (scraper.instance && scraper.instance.type) {
scraper.type = scraper.instance.type; scraper.type = scraper.instance.type;
} else { } else {
const typeFromFile = peekProperty(scraper.path, 'type'); const typeFromFile = peekProperty(scraper.path, 'type');
if (typeFromFile) { if (typeFromFile) scraper.type = typeFromFile;
console.log(`[API] Recovered type for ${name} via static analysis: ${typeFromFile}`);
scraper.type = typeFromFile;
}
} }
} }
}); });
return { const getScraperInstance = (source) => {
getSources: () => {
console.log("[API] Handling getSources request...");
const results = Object.keys(availableScrapers).map((name) => {
const scraper = availableScrapers[name];
const typeToReturn = scraper.type || null;
console.log(`[API] Processing ${name}: Type found = "${typeToReturn}"`);
return {
name: name,
url: scraper.url || name,
type: typeToReturn
};
});
return results;
},
search: async (event, source, query, page) => {
const scraperData = availableScrapers[source]; const scraperData = availableScrapers[source];
if (!scraperData) throw new Error(`Source ${source} not found.`);
if (!scraperData) {
return { success: false, error: `Source ${source} not found.` };
}
if (!scraperData.instance) { if (!scraperData.instance) {
console.log(`[LazyLoad] Initializing scraper: ${source}...`); console.log(`[LazyLoad] Initializing scraper: ${source}...`);
try {
const scraperModule = require(scraperData.path); const scraperModule = require(scraperData.path);
const className = Object.keys(scraperModule)[0]; const className = Object.keys(scraperModule)[0];
const ScraperClass = scraperModule[className]; const ScraperClass = scraperModule[className];
if (!ScraperClass || typeof ScraperClass !== 'function') {
throw new Error(`File ${scraperData.path} does not export a valid class.`);
}
const instance = new ScraperClass(fetchPath, cheerioPath, headlessBrowser); const instance = new ScraperClass(fetchPath, cheerioPath, headlessBrowser);
scraperData.instance = instance; scraperData.instance = instance;
if (instance.type) scraperData.type = instance.type; if (instance.type) scraperData.type = instance.type;
if (instance.baseUrl) scraperData.url = instance.baseUrl; if (instance.baseUrl) scraperData.url = instance.baseUrl;
} catch (err) {
console.error(`Failed to lazy load ${source}:`, err);
return { success: false, error: `Failed to load extension: ${err.message}` };
}
} }
return scraperData.instance;
};
return {
getSources: () => {
return Object.keys(availableScrapers).map((name) => {
const scraper = availableScrapers[name];
return {
name: name,
url: scraper.url || name,
type: scraper.type || (scraper.instance ? scraper.instance.type : null)
};
});
},
search: async (event, source, query, page) => {
try { try {
const results = await scraperData.instance.fetchSearchResult(query, page); const instance = getScraperInstance(source);
const results = await instance.fetchSearchResult(query, page);
return { success: true, data: results }; return { success: true, data: results };
} catch (err) { } catch (err) {
console.error(`Error during search in ${source}:`, err); console.error(`Error during search in ${source}:`, err);
return { success: false, error: err.message }; return { success: false, error: err.message };
} }
},
getChapters: async (event, source, mangaId) => {
try {
const instance = getScraperInstance(source);
if (!instance.findChapters) throw new Error("Extension does not support chapters.");
const result = await instance.findChapters(mangaId);
if (Array.isArray(result)) {
return { success: true, data: result };
} else if (result && result.chapters) {
return { success: true, data: result.chapters, extra: { cover: result.cover } };
}
return { success: true, data: [] };
} catch (err) {
console.error(`Error fetching chapters from ${source}:`, err);
return { success: false, error: err.message };
}
},
getPages: async (event, source, chapterId) => {
try {
const instance = getScraperInstance(source);
if (!instance.findChapterPages) throw new Error("Extension does not support reading pages.");
const pages = await instance.findChapterPages(chapterId);
return { success: true, data: pages };
} catch (err) {
console.error(`Error fetching pages from ${source}:`, err);
return { success: false, error: err.message };
}
},
getMetadata: async (event, title) => {
let cleanTitle = title.replace(/(\[.*?\]|\(.*?\))/g, '').trim().replace(/\s+/g, ' ');
console.log(`[AniList] Searching for: "${cleanTitle}"`);
const query = `
query ($search: String, $type: MediaType) {
Media (search: $search, type: $type, sort: SEARCH_MATCH) {
id
title { romaji english native }
description(asHtml: false)
averageScore
genres
coverImage { extraLarge large }
characters(page: 1, perPage: 10, sort: ROLE) {
edges {
role
node { id name { full } image { medium } }
}
}
}
}
`;
try {
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ query, variables: { search: cleanTitle, type: 'MANGA' } })
});
const json = await response.json();
if (json.errors || !json.data || !json.data.Media) {
return { success: false, error: "No media found" };
}
return { success: true, data: json.data.Media };
} catch (err) {
return { success: false, error: err.message };
}
} }
}; };
}; };

View File

@@ -27,7 +27,9 @@ export async function performSearch(
contentGallery.innerHTML = ''; contentGallery.innerHTML = '';
updateHeader(); updateHeader();
if (searchModal) {
searchModal.classList.add('hidden'); searchModal.classList.add('hidden');
}
await loadMoreResults(currentSource, 1, currentLayout, domRefs, callbacks); await loadMoreResults(currentSource, 1, currentLayout, domRefs, callbacks);
} }
@@ -42,9 +44,7 @@ export async function loadMoreResults(
const { loadingSpinner, infiniteLoadingSpinner, contentGallery } = domRefs; const { loadingSpinner, infiniteLoadingSpinner, contentGallery } = domRefs;
const { applyLayoutToGallery, createImageCard } = callbacks; const { applyLayoutToGallery, createImageCard } = callbacks;
if (isLoading || !hasNextPage) { if (isLoading || !hasNextPage) return;
return;
}
isLoading = true; isLoading = true;
@@ -64,16 +64,11 @@ export async function loadMoreResults(
if (loadingSpinner) loadingSpinner.classList.add('hidden'); if (loadingSpinner) loadingSpinner.classList.add('hidden');
if (infiniteLoadingSpinner) infiniteLoadingSpinner.classList.add('hidden'); if (infiniteLoadingSpinner) infiniteLoadingSpinner.classList.add('hidden');
if ( if (!result.success || !result.data.results || result.data.results.length === 0) {
!result.success ||
!result.data.results ||
result.data.results.length === 0
) {
hasNextPage = false; hasNextPage = false;
if (page === 1) { if (page === 1) {
applyLayoutToGallery(contentGallery, currentLayout); applyLayoutToGallery(contentGallery, currentLayout);
contentGallery.innerHTML = contentGallery.innerHTML = '<p class="text-gray-400 text-center text-lg">No results found.</p>';
'<p class="text-gray-400 text-center text-lg">No results found. Please try another search term.</p>';
} }
isLoading = false; isLoading = false;
return; return;
@@ -85,8 +80,7 @@ export async function loadMoreResults(
if (page === 1) { if (page === 1) {
hasNextPage = false; hasNextPage = false;
applyLayoutToGallery(contentGallery, currentLayout); applyLayoutToGallery(contentGallery, currentLayout);
contentGallery.innerHTML = contentGallery.innerHTML = '<p class="text-gray-400 text-center text-lg">No valid images found.</p>';
'<p class="text-gray-400 text-center text-lg">Found results, but none had valid images.</p>';
} }
isLoading = false; isLoading = false;
return; return;
@@ -96,21 +90,23 @@ export async function loadMoreResults(
validResults.forEach((item) => { validResults.forEach((item) => {
const thumbnailUrl = item.image; const thumbnailUrl = item.image;
const displayUrl = item.sampleImageUrl || item.fullImageUrl || thumbnailUrl; const displayUrl = item.sampleImageUrl || item.fullImageUrl || thumbnailUrl;
const title = item.title || '';
const card = createImageCard( const card = createImageCard(
item.id.toString(), item.id.toString(),
item.tags, item.tags || [],
displayUrl, displayUrl,
thumbnailUrl, thumbnailUrl,
'browse' item.type || 'browse'
); );
if (title) card.dataset.title = title;
fragment.appendChild(card); fragment.appendChild(card);
}); });
contentGallery.appendChild(fragment); contentGallery.appendChild(fragment);
applyLayoutToGallery(contentGallery, currentLayout); applyLayoutToGallery(contentGallery, currentLayout);
hasNextPage = result.data.hasNextPage; hasNextPage = result.data.hasNextPage;
} catch (error) { } catch (error) {

View File

@@ -6,6 +6,9 @@ contextBridge.exposeInMainWorld('api', {
addFavorite: (fav) => ipcRenderer.invoke('db:addFavorite', fav), addFavorite: (fav) => ipcRenderer.invoke('db:addFavorite', fav),
removeFavorite: (id) => ipcRenderer.invoke('db:removeFavorite', id), removeFavorite: (id) => ipcRenderer.invoke('db:removeFavorite', id),
getChapters: (source, mangaId) => ipcRenderer.invoke('api:getChapters', source, mangaId),
getPages: (source, chapterId) => ipcRenderer.invoke('api:getPages', source, chapterId),
search: (source, query, page) => ipcRenderer.invoke('api:search', source, query, page), search: (source, query, page) => ipcRenderer.invoke('api:search', source, query, page),
toggleDevTools: () => ipcRenderer.send('toggle-dev-tools'), toggleDevTools: () => ipcRenderer.send('toggle-dev-tools'),

View File

@@ -13,197 +13,286 @@ document.addEventListener('DOMContentLoaded', async () => {
let currentSource = ''; let currentSource = '';
let currentPage = 1; let currentPage = 1;
let isFetching = false; let isFetching = false;
const favoriteIds = new Set();
function showMessage(message, type = 'success') { try {
if (domRefs.messageBar) { if (window.api && window.api.getFavorites) {
uiShowMessage(domRefs.messageBar, message, type); const favs = await window.api.getFavorites();
favs.forEach(f => favoriteIds.add(String(f.id)));
} }
} catch (e) { console.error(e); }
function showMessage(msg, type = 'success') { if (domRefs.messageBar) uiShowMessage(domRefs.messageBar, msg, type); }
function showTagModal(tags) { if (domRefs.tagInfoContent) { populateTagModal(domRefs.tagInfoContent, tags); domRefs.tagInfoModal.classList.remove('hidden'); } }
function localCreateImageCard(id, tags, img, thumb, type) {
return createImageCard(id, tags, img, thumb, type, { currentLayout, showMessage, showTagModal, applyLayoutToGallery, favoritesGallery: document.getElementById('favorites-gallery'), favoriteIds });
}
function updateHeader() { if (domRefs.headerContext) domRefs.headerContext.classList.add('hidden'); }
const callbacks = { showMessage, applyLayoutToGallery, updateHeader, createImageCard: localCreateImageCard };
let currentChapters = [];
let currentChapterPage = 1;
const CHAPTERS_PER_PAGE = 10;
function renderChapterPage() {
const listContainer = document.getElementById('chapter-list-container');
if (!listContainer) return;
listContainer.innerHTML = '';
const start = (currentChapterPage - 1) * CHAPTERS_PER_PAGE;
const end = start + CHAPTERS_PER_PAGE;
const slice = currentChapters.slice(start, end);
if (slice.length === 0) {
listContainer.innerHTML = '<div style="padding:1.5rem; text-align:center; color:var(--text-tertiary)">No chapters available.</div>';
return;
} }
function showTagModal(tags) { slice.forEach(chapter => {
if (domRefs.tagInfoContent && domRefs.tagInfoModal) { const row = document.createElement('div');
populateTagModal(domRefs.tagInfoContent, tags); row.className = 'chapter-row';
domRefs.tagInfoModal.classList.remove('hidden');
} let mainText = chapter.chapter && chapter.chapter !== '0' ? `Chapter ${chapter.chapter}` : 'Read';
if(chapter.title && !chapter.title.includes(chapter.chapter)) {
mainText = chapter.title;
} }
function localCreateImageCard(id, tags, imageUrl, thumbnailUrl, type) { row.innerHTML = `<span class="chapter-main-text">${mainText}</span>`;
return createImageCard(id, tags, imageUrl, thumbnailUrl, type, { row.onclick = () => openReader(chapter.id);
currentLayout, listContainer.appendChild(row);
showMessage,
showTagModal,
applyLayoutToGallery,
favoritesGallery: document.getElementById('favorites-gallery')
}); });
const controls = document.getElementById('pagination-controls');
if (controls) {
controls.innerHTML = '';
if (currentChapters.length > CHAPTERS_PER_PAGE) {
const prev = document.createElement('button');
prev.className = 'page-btn';
prev.textContent = '← Prev';
prev.disabled = currentChapterPage === 1;
prev.onclick = () => { currentChapterPage--; renderChapterPage(); };
const next = document.createElement('button');
next.className = 'page-btn';
next.textContent = 'Next →';
next.disabled = end >= currentChapters.length;
next.onclick = () => { currentChapterPage++; renderChapterPage(); };
const label = document.createElement('span');
label.style.color = 'var(--text-secondary)';
label.style.fontSize = '0.9rem';
label.textContent = `Page ${currentChapterPage} of ${Math.ceil(currentChapters.length / CHAPTERS_PER_PAGE)}`;
controls.appendChild(prev);
controls.appendChild(label);
controls.appendChild(next);
}
}
} }
function updateHeader() { async function openBookDetails(id, imageUrl, title, tags) {
if (!domRefs.headerContext) return; const detailsView = document.getElementById('book-details-view');
domRefs.headerContext.classList.add('hidden'); const browseView = document.getElementById('browse-page');
} if (!detailsView || !browseView) return;
const callbacks = { browseView.classList.add('hidden');
showMessage, detailsView.classList.remove('hidden');
applyLayoutToGallery,
updateHeader, detailsView.innerHTML = `
createImageCard: localCreateImageCard <div class="book-top-nav">
<div class="back-btn-large" id="back-to-library">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back to Library
</div>
</div>
<div class="book-layout-grid">
<div class="book-left-col">
<img src="${imageUrl}" class="book-poster-large" id="book-details-poster" />
<h1 class="book-title-sidebar">${title}</h1>
</div>
<div class="book-chapters-column">
<div class="chapter-table-container" id="chapter-list-container">
<div class="loading-state" style="padding:2rem;"><p>Loading chapters...</p></div>
</div>
<div id="pagination-controls" class="pagination-bar"></div>
</div>
</div>
`;
document.getElementById('back-to-library').onclick = () => {
detailsView.classList.add('hidden');
browseView.classList.remove('hidden');
}; };
if (domRefs.searchModal) { let highResCover = null;
setupGlobalKeybinds(domRefs.searchModal); try {
const aniRes = await window.api.getMetadata(title);
if (aniRes.success && aniRes.data && aniRes.data.coverImage.extraLarge) {
highResCover = aniRes.data.coverImage.extraLarge;
}
} catch (e) {}
try {
const response = await window.api.getChapters(currentSource, id);
currentChapters = response.success ? response.data : [];
currentChapterPage = 1;
if (!highResCover && response.extra && response.extra.cover) {
highResCover = response.extra.cover;
} }
if (domRefs.tagInfoCloseButton && domRefs.tagInfoModal) { if (highResCover) {
domRefs.tagInfoCloseButton.addEventListener('click', () => { const posterEl = document.getElementById('book-details-poster');
domRefs.tagInfoModal.classList.add('hidden'); if (posterEl) posterEl.src = highResCover;
});
domRefs.tagInfoModal.addEventListener('click', (e) => {
if (e.target === domRefs.tagInfoModal) {
domRefs.tagInfoModal.classList.add('hidden');
} }
renderChapterPage();
} catch (err) {
const chContainer = document.getElementById('chapter-list-container');
if(chContainer) chContainer.innerHTML = '<div style="padding:1.5rem; text-align:center; color:#ef4444">Failed to load chapters.</div>';
}
}
async function openReader(chapterId) {
const detailsView = document.getElementById('book-details-view');
const readerView = document.getElementById('reader-view');
const readerContent = document.getElementById('reader-content');
if (!detailsView || !readerView) return;
detailsView.classList.add('hidden');
readerView.classList.remove('hidden');
readerContent.innerHTML = '<div class="loading-state"><p style="color:white;">Loading content...</p></div>';
const existingBackBtn = readerView.querySelector('.reader-close-btn');
if(existingBackBtn) existingBackBtn.remove();
const backBtn = document.createElement('div');
backBtn.className = 'reader-close-btn';
backBtn.innerHTML = '<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg> Close Reader';
backBtn.onclick = () => {
readerView.classList.add('hidden');
detailsView.classList.remove('hidden');
readerContent.innerHTML = '';
};
readerView.appendChild(backBtn);
try {
const response = await window.api.getPages(currentSource, chapterId);
readerContent.innerHTML = '';
if (!response.success || response.data.length === 0) {
readerContent.innerHTML = '<p style="color:white;">No content found.</p>';
return;
}
const isTextMode = response.data[0].type === 'text';
if (isTextMode) {
const pageData = response.data[0];
const textDiv = document.createElement('div');
textDiv.className = 'reader-text-content';
textDiv.innerHTML = pageData.content;
readerContent.appendChild(textDiv);
} else {
response.data.forEach(page => {
const img = document.createElement('img');
img.className = 'reader-page-img';
img.src = page.url;
img.loading = "lazy";
readerContent.appendChild(img);
}); });
} }
if (domRefs.searchIconButton && domRefs.searchModal) { } catch (err) {
domRefs.searchIconButton.addEventListener('click', () => { console.error(err);
domRefs.searchModal.classList.remove('hidden'); showMessage('Failed to load content', 'error');
if(domRefs.searchInput) { }
domRefs.searchInput.focus();
domRefs.searchInput.select();
} }
});
domRefs.searchCloseButton.addEventListener('click', () => { if (domRefs.searchModal) setupGlobalKeybinds(domRefs.searchModal);
domRefs.searchModal.classList.add('hidden'); if (domRefs.tagInfoCloseButton) domRefs.tagInfoCloseButton.onclick = () => domRefs.tagInfoModal.classList.add('hidden');
}); if (domRefs.searchIconButton) {
domRefs.searchIconButton.onclick = () => { domRefs.searchModal.classList.remove('hidden'); domRefs.searchInput?.focus(); };
domRefs.searchCloseButton.onclick = () => domRefs.searchModal.classList.add('hidden');
} }
if (domRefs.sourceList) { if (domRefs.sourceList) {
if (domRefs.contentGallery) { if (domRefs.contentGallery) applyLayoutToGallery(domRefs.contentGallery, currentLayout);
applyLayoutToGallery(domRefs.contentGallery, currentLayout);
}
let initialSource = ''; const isBooksPage = window.location.pathname.includes('books.html');
if (window.api && window.api.getSources) { const contentType = isBooksPage ? 'book-board' : 'image-board';
initialSource = await populateSources(domRefs.sourceList);
} else {
initialSource = await populateSources(domRefs.sourceList);
}
let initialSource = await populateSources(domRefs.sourceList, contentType);
currentSource = initialSource; currentSource = initialSource;
updateHeader(); updateHeader();
domRefs.sourceList.addEventListener('click', (e) => { domRefs.sourceList.addEventListener('click', (e) => {
const button = e.target.closest('.source-button'); const button = e.target.closest('.source-button');
if (button) { if (button) {
domRefs.sourceList domRefs.sourceList.querySelectorAll('.source-button').forEach(b => b.classList.remove('active'));
.querySelectorAll('.source-button')
.forEach((btn) => btn.classList.remove('active'));
button.classList.add('active'); button.classList.add('active');
currentSource = button.dataset.source; currentSource = button.dataset.source;
updateHeader(); updateHeader();
currentPage = 1; currentPage = 1;
if (domRefs.searchInput?.value.trim()) performSearch(currentSource, domRefs.searchInput, currentLayout, domRefs, callbacks);
if (domRefs.searchInput && domRefs.searchInput.value.trim()) { else if (domRefs.searchInput) performSearch(currentSource, { value: "" }, currentLayout, domRefs, callbacks);
performSearch(currentSource, domRefs.searchInput, currentLayout, domRefs, callbacks);
} else if (domRefs.searchInput) {
performSearch(currentSource, { value: "" }, currentLayout, domRefs, callbacks);
}
} }
}); });
if (domRefs.contentGallery) {
domRefs.contentGallery.addEventListener('click', (e) => {
const card = e.target.closest('.image-entry');
if (card && isBooksPage) {
if (e.target.closest('button')) return;
e.preventDefault(); e.stopPropagation();
const bookId = card.dataset.id;
const img = card.querySelector('img');
const title = card.dataset.title || "Unknown";
if (bookId) openBookDetails(bookId, img ? img.src : '', title, []);
}
});
}
const scrollContainer = document.querySelector('.content-view'); const scrollContainer = document.querySelector('.content-view');
if (scrollContainer) { if (scrollContainer) {
scrollContainer.addEventListener('scroll', async () => { scrollContainer.addEventListener('scroll', async () => {
if ( if (scrollContainer.scrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 600) {
scrollContainer.scrollTop + scrollContainer.clientHeight >=
scrollContainer.scrollHeight - 600
) {
if (isFetching) return; if (isFetching) return;
isFetching = true; isFetching = true;
currentPage++; currentPage++;
if (domRefs.infiniteLoadingSpinner) domRefs.infiniteLoadingSpinner.classList.remove('hidden');
if (domRefs.infiniteLoadingSpinner) { try { await loadMoreResults(currentSource, currentPage, currentLayout, domRefs, callbacks); }
domRefs.infiniteLoadingSpinner.classList.remove('hidden'); catch (error) { currentPage--; }
} finally { isFetching = false; if (domRefs.infiniteLoadingSpinner) domRefs.infiniteLoadingSpinner.classList.add('hidden'); }
try {
await loadMoreResults(currentSource, currentPage, currentLayout, domRefs, callbacks);
} catch (error) {
console.error("Failed to load more results:", error);
currentPage--;
} finally {
isFetching = false;
if (domRefs.infiniteLoadingSpinner) {
domRefs.infiniteLoadingSpinner.classList.add('hidden');
}
}
} }
}); });
} }
if (domRefs.searchButton && domRefs.searchInput) { if (domRefs.searchButton) {
domRefs.searchButton.addEventListener('click', () => { domRefs.searchButton.onclick = () => { currentPage = 1; performSearch(currentSource, domRefs.searchInput, currentLayout, domRefs, callbacks); };
currentPage = 1;
performSearch(currentSource, domRefs.searchInput, currentLayout, domRefs, callbacks);
});
} }
} }
if (document.getElementById('favorites-gallery')) { if (document.getElementById('favorites-gallery')) {
const favGallery = document.getElementById('favorites-gallery'); const favGallery = document.getElementById('favorites-gallery');
const rawFavorites = await window.api.getFavorites();
const fetchFavorites = async () => {
try {
if (window.api && window.api.getFavorites) {
return await window.api.getFavorites();
} else {
console.error("window.api.getFavorites is missing.");
return [];
}
} catch (err) {
console.error("Error fetching favorites via IPC:", err);
return [];
}
};
const rawFavorites = await fetchFavorites();
favGallery.innerHTML = ''; favGallery.innerHTML = '';
if (!rawFavorites || rawFavorites.length === 0) favGallery.innerHTML = '<div class="loading-state"><p>No favorites found.</p></div>';
if (!rawFavorites || rawFavorites.length === 0) { else {
const emptyState = document.createElement('div');
emptyState.className = 'loading-state';
emptyState.style.gridColumn = '1 / -1';
emptyState.innerHTML = '<p>No favorites found.</p>';
favGallery.appendChild(emptyState);
} else {
rawFavorites.forEach(row => { rawFavorites.forEach(row => {
const id = row.id;
const imageUrl = row.image_url;
const thumbnailUrl = row.thumbnail_url;
let tags = []; let tags = [];
if (typeof row.tags === 'string') { if (typeof row.tags === 'string') tags = row.tags.split(',').filter(t=>t);
tags = row.tags.split(',').filter(t => t.trim() !== ''); else if (Array.isArray(row.tags)) tags = row.tags;
} else if (Array.isArray(row.tags)) { const card = localCreateImageCard(row.id, tags, row.image_url, row.thumbnail_url, 'image');
tags = row.tags; card.dataset.title = row.title;
}
const card = localCreateImageCard(
id,
tags,
imageUrl,
thumbnailUrl,
'image'
);
favGallery.appendChild(card); favGallery.appendChild(card);
}); });
} }
applyLayoutToGallery(favGallery, currentLayout); applyLayoutToGallery(favGallery, currentLayout);
} }
}); });

View File

@@ -1,17 +1,37 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow, session } = require('electron');
class HeadlessBrowser { class HeadlessBrowser {
async scrape(url, evalFunc, options = {}) { constructor() {
const { this.win = null;
waitSelector = null, this.currentConfig = null;
timeout = 15000, }
args = [],
scrollToBottom = false,
renderWaitTime = 2000,
loadImages = true
} = options;
const win = new BrowserWindow({ /**
* Pre-loads the browser window on app startup.
*/
async init() {
console.log('[Headless] Pre-warming browser instance...');
await this.getWindow(true); // Default to loading images
console.log('[Headless] Browser ready.');
}
/**
* Gets an existing window or creates a new one if config changes/window missing.
*/
async getWindow(loadImages) {
// If window exists and config matches, reuse it (FAST PATH)
if (this.win && !this.win.isDestroyed() && this.currentConfig === loadImages) {
return this.win;
}
// Otherwise, destroy old window and create new one (SLOW PATH)
if (this.win && !this.win.isDestroyed()) {
this.win.destroy();
}
this.currentConfig = loadImages;
this.win = new BrowserWindow({
show: false, show: false,
width: 1920, width: 1920,
height: 1080, height: 1080,
@@ -22,36 +42,75 @@ class HeadlessBrowser {
images: loadImages, images: loadImages,
webgl: false, webgl: false,
backgroundThrottling: false, backgroundThrottling: false,
autoplayPolicy: 'no-user-gesture-required',
disableHtmlFullscreenWindowResize: true
}, },
}); });
try { const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36';
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; this.win.webContents.setUserAgent(userAgent);
win.webContents.setUserAgent(userAgent);
const session = win.webContents.session; const ses = this.win.webContents.session;
session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => {
ses.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => {
const url = details.url.toLowerCase(); const url = details.url.toLowerCase();
const blockExtensions = [ const type = details.resourceType;
'.woff', '.woff2', '.ttf', '.eot',
'google-analytics', 'doubleclick', 'facebook', 'twitter', 'adsystem' if (
type === 'font' ||
type === 'stylesheet' ||
type === 'media' ||
type === 'websocket' ||
type === 'manifest'
) {
return callback({ cancel: true });
}
const blockList = [
'google-analytics', 'doubleclick', 'facebook', 'twitter', 'adsystem',
'analytics', 'tracker', 'pixel', 'quantserve', 'newrelic'
]; ];
if (blockExtensions.some(ext => url.includes(ext))) return callback({ cancel: true });
if (blockList.some(keyword => url.includes(keyword))) return callback({ cancel: true });
if (!loadImages && (type === 'image' || url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/))) {
return callback({ cancel: true });
}
return callback({ cancel: false }); return callback({ cancel: false });
}); });
await win.loadURL(url, { userAgent }); // Load a blank page to keep the process alive and ready
await this.win.loadURL('about:blank');
return this.win;
}
async scrape(url, evalFunc, options = {}) {
const {
waitSelector = null,
timeout = 10000,
args = [],
scrollToBottom = false,
renderWaitTime = 0,
loadImages = true
} = options;
try {
const win = await this.getWindow(loadImages);
await win.loadURL(url);
if (waitSelector) { if (waitSelector) {
try { try {
await this.waitForSelector(win, waitSelector, timeout); await this.waitForSelector(win, waitSelector, timeout);
} catch (e) { } catch (e) {
console.warn(`[Headless] Timeout waiting for ${waitSelector}, proceeding anyway...`); console.warn(`[Headless] Timeout waiting for ${waitSelector}, proceeding...`);
} }
} }
if (scrollToBottom) { if (scrollToBottom) {
await this.smoothScrollToBottom(win); await this.turboScroll(win);
} }
if (renderWaitTime > 0) { if (renderWaitTime > 0) {
@@ -66,28 +125,26 @@ class HeadlessBrowser {
} catch (error) { } catch (error) {
console.error('Headless Scrape Error:', error.message); console.error('Headless Scrape Error:', error.message);
throw error; // Force recreation next time if something crashed
} finally { if (this.win) {
if (!win.isDestroyed()) { try { this.win.destroy(); } catch(e){}
win.destroy(); this.win = null;
} }
throw error;
} }
} }
async waitForSelector(win, selector, timeout) { async waitForSelector(win, selector, timeout) {
const script = ` const script = `
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const timer = setTimeout(() => { const start = Date.now();
reject(new Error('Timeout waiting for selector: ${selector}'));
}, ${timeout});
const check = () => { const check = () => {
const el = document.querySelector('${selector}'); if (document.querySelector('${selector}')) {
if (el) {
clearTimeout(timer);
resolve(true); resolve(true);
} else if (Date.now() - start > ${timeout}) {
reject(new Error('Timeout'));
} else { } else {
setTimeout(check, 200); requestAnimationFrame(check);
} }
}; };
check(); check();
@@ -96,24 +153,24 @@ class HeadlessBrowser {
await win.webContents.executeJavaScript(script); await win.webContents.executeJavaScript(script);
} }
async smoothScrollToBottom(win) { async turboScroll(win) {
const script = ` const script = `
new Promise((resolve) => { new Promise((resolve) => {
let totalHeight = 0; let lastHeight = 0;
const distance = 400; let sameHeightCount = 0;
const maxScrolls = 200;
let currentScrolls = 0;
const timer = setInterval(() => { const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight; const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance); window.scrollTo(0, scrollHeight);
totalHeight += distance; if (scrollHeight === lastHeight) {
currentScrolls++; sameHeightCount++;
if (sameHeightCount >= 5) {
if(totalHeight >= scrollHeight - window.innerHeight || currentScrolls >= maxScrolls){
clearInterval(timer); clearInterval(timer);
resolve(); resolve();
} }
} else {
sameHeightCount = 0;
lastHeight = scrollHeight;
}
}, 20); }, 20);
}); });
`; `;

126
views/books.html Normal file
View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; connect-src 'self' https:;" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Waifu Board - Books</title>
<link rel="stylesheet" href="styles/home.css">
<link rel="stylesheet" href="styles/books.css">
</head>
<body>
<aside class="sidebar">
<br>
<nav style="display: flex; flex-direction: column; gap: 0.5rem;">
<a href="index.html" class="nav-button" title="Image Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<span>Image Boards</span>
</a>
<a href="books.html" class="nav-button active" title="Book Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>Book Boards</span>
</a>
<a href="favorites.html" class="nav-button" title="Favorites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span>Favorites</span>
</a>
</nav>
<nav style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: auto;">
<a href="settings.html" class="nav-button" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Settings</span>
</a>
</nav>
</aside>
<div class="main-wrapper">
<header class="top-header">
<div style="position: relative;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position: absolute; left: 15px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--text-tertiary);">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="search-input" placeholder="Search across books..." style="padding-left: 2.5rem;" />
</div>
<button id="search-button" class="hidden"></button>
<button id="search-icon-button" class="hidden"></button>
<button id="search-close-button" class="hidden"></button>
<div id="header-context" class="hidden"></div>
<h1 id="page-title" class="hidden">Book Boards</h1>
</header>
<div class="content-view">
<div id="browse-page" class="page">
<h3 style="color: var(--text-tertiary); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 1rem;">Book Sources</h3>
<div id="source-list"></div>
<h3 style="color: var(--text-tertiary); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin: 2rem 0 1rem 0;">Library</h3>
<main id="content-gallery" class="gallery-masonry">
<div id="gallery-placeholder" class="loading-state" style="width: 100%; grid-column: 1 / -1;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#52525b" stroke-width="1">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<p>Select a book source above to load content</p>
</div>
<div id="loading-spinner" class="hidden loading-state" style="width: 100%; grid-column: 1 / -1;">
<svg style="width:32px; height:32px; color: var(--accent); animation: spin 1s linear infinite;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"></path>
</svg>
<p>Fetching books...</p>
</div>
<div id="infinite-loading-spinner" class="hidden loading-state" style="width: 100%; grid-column: 1 / -1;">
<p>Loading more...</p>
</div>
</main>
</div>
<div id="book-details-view" class="page hidden">
</div>
<div id="reader-view" class="hidden">
<div id="reader-content" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
</div>
</div>
</div>
</div>
<div id="message-bar" class="toast hidden">Message</div>
<div id="updateToast" class="toast hidden" style="border-left-color: #eab308;">
<p>Update Available: <span id="latestVersionDisplay">v1.x</span></p>
</div>
<script type="module" src="../src/renderer.js"></script>
<script type="module" src="../scripts/main.js"></script>
<script src="../src/updateNotification.js"></script>
<script>
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-button');
if(searchInput && searchBtn) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchBtn.click();
});
}
</script>
</body>
</html>

View File

@@ -22,6 +22,14 @@
<span>Image Boards</span> <span>Image Boards</span>
</a> </a>
<a href="books.html" class="nav-button" title="Book Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>Book Boards</span>
</a>
<a href="favorites.html" class="nav-button active" title="Favorites"> <a href="favorites.html" class="nav-button active" title="Favorites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>

View File

@@ -1,8 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https:;" /> <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https:;" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Waifu Board</title> <title>Waifu Board</title>
<link rel="stylesheet" href="styles/home.css"> <link rel="stylesheet" href="styles/home.css">
@@ -22,9 +24,19 @@
<span>Image Boards</span> <span>Image Boards</span>
</a> </a>
<a href="books.html" class="nav-button" title="Book Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>Book Boards</span>
</a>
<a href="favorites.html" class="nav-button" title="Favorites"> <a href="favorites.html" class="nav-button" title="Favorites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> <path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z">
</path>
</svg> </svg>
<span>Favorites</span> <span>Favorites</span>
</a> </a>
@@ -34,7 +46,9 @@
<a href="settings.html" class="nav-button" title="Settings"> <a href="settings.html" class="nav-button" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path> <path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z">
</path>
</svg> </svg>
<span>Settings</span> <span>Settings</span>
</a> </a>
@@ -44,11 +58,13 @@
<div class="main-wrapper"> <div class="main-wrapper">
<header class="top-header"> <header class="top-header">
<div style="position: relative;"> <div style="position: relative;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position: absolute; left: 15px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--text-tertiary);"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="position: absolute; left: 15px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--text-tertiary);">
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
<input type="text" id="search-input" placeholder="Search across sources..." style="padding-left: 2.5rem;" /> <input type="text" id="search-input" placeholder="Search across sources..."
style="padding-left: 2.5rem;" />
</div> </div>
<button id="search-button" class="hidden"></button> <button id="search-button" class="hidden"></button>
<button id="search-icon-button" class="hidden"></button> <button id="search-icon-button" class="hidden"></button>
@@ -59,10 +75,14 @@
<div class="content-view"> <div class="content-view">
<div id="browse-page" class="page"> <div id="browse-page" class="page">
<h3 style="color: var(--text-tertiary); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 1rem;">Sources</h3> <h3
style="color: var(--text-tertiary); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 1rem;">
Sources</h3>
<div id="source-list"></div> <div id="source-list"></div>
<h3 style="color: var(--text-tertiary); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin: 2rem 0 1rem 0;">Library</h3> <h3
style="color: var(--text-tertiary); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin: 2rem 0 1rem 0;">
Library</h3>
<main id="content-gallery" class="gallery-masonry"> <main id="content-gallery" class="gallery-masonry">
<div id="gallery-placeholder" class="loading-state" style="width: 100%; grid-column: 1 / -1;"> <div id="gallery-placeholder" class="loading-state" style="width: 100%; grid-column: 1 / -1;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#52525b" stroke-width="1"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#52525b" stroke-width="1">
@@ -73,12 +93,16 @@
<p>Select a source above to load content</p> <p>Select a source above to load content</p>
</div> </div>
<div id="loading-spinner" class="hidden loading-state" style="width: 100%; grid-column: 1 / -1;"> <div id="loading-spinner" class="hidden loading-state" style="width: 100%; grid-column: 1 / -1;">
<svg style="width:32px; height:32px; color: var(--accent); animation: spin 1s linear infinite;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg style="width:32px; height:32px; color: var(--accent); animation: spin 1s linear infinite;"
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"></path> viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83">
</path>
</svg> </svg>
<p>Fetching images...</p> <p>Fetching images...</p>
</div> </div>
<div id="infinite-loading-spinner" class="hidden loading-state" style="width: 100%; grid-column: 1 / -1;"> <div id="infinite-loading-spinner" class="hidden loading-state"
style="width: 100%; grid-column: 1 / -1;">
<p>Loading more...</p> <p>Loading more...</p>
</div> </div>
</main> </main>
@@ -106,11 +130,12 @@
<script> <script>
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-button'); const searchBtn = document.getElementById('search-button');
if(searchInput && searchBtn) { if (searchInput && searchBtn) {
searchInput.addEventListener('keydown', (e) => { searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchBtn.click(); if (e.key === 'Enter') searchBtn.click();
}); });
} }
</script> </script>
</body> </body>
</html> </html>

View File

@@ -22,6 +22,14 @@
<span>Image Boards</span> <span>Image Boards</span>
</a> </a>
<a href="books.html" class="nav-button" title="Book Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>Book Boards</span>
</a>
<a href="favorites.html" class="nav-button" title="Favorites"> <a href="favorites.html" class="nav-button" title="Favorites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>

214
views/styles/books.css Normal file
View File

@@ -0,0 +1,214 @@
#book-details-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding-bottom: 4rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
.book-top-nav {
display: flex;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.back-btn-large {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
transition: 0.2s;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
}
.back-btn-large:hover {
color: var(--text-primary);
background: var(--bg-surface-hover);
}
.book-layout-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 3rem;
align-items: start;
}
.book-left-col {
display: flex;
flex-direction: column;
gap: 1.5rem;
position: sticky;
top: 20px;
}
.book-poster-large {
width: 100%;
border-radius: var(--radius-lg);
box-shadow: 0 15px 40px rgba(0,0,0,0.6);
aspect-ratio: 2/3;
object-fit: cover;
border: 1px solid var(--border);
background: #111;
}
.book-title-sidebar {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.3;
margin: 0;
color: var(--text-primary);
text-align: center;
word-wrap: break-word;
}
.book-chapters-column {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
.chapter-table-container {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.chapter-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.2s;
}
.chapter-row:last-child { border-bottom: none; }
.chapter-row:hover { background: var(--bg-surface-hover); }
.chapter-main-text { font-weight: 600; font-size: 1rem; color: var(--text-primary); }
.chapter-sub-text { font-size: 0.9rem; color: var(--text-tertiary); }
.pagination-bar {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
margin-top: 1rem;
padding-top: 1rem;
}
.page-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.5rem 1.5rem;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 500;
transition: 0.2s;
}
.page-btn:hover:not(:disabled) { background: var(--bg-surface-hover); border-color: var(--accent); }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#reader-view {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
z-index: 200; background: #0d0d0d; overflow-y: auto;
display: flex; flex-direction: column; align-items: center; padding-top: 60px;
}
.reader-page-img { max-width: 100%; width: auto; display: block; margin-bottom: 0; box-shadow: 0 0 20px rgba(0,0,0,0.5); }
.reader-text-content {
max-width: 900px;
width: 95%;
color: #e4e4e7;
font-size: 1.1rem;
line-height: 1.8;
padding: 2rem;
background: #18181b;
margin-bottom: 4rem;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.reader-text-content p {
margin-bottom: 1.5rem;
}
.reader-close-btn {
position: fixed; top: 20px; left: 20px; z-index: 210;
background: rgba(0,0,0,0.8); color: white; border: 1px solid rgba(255,255,255,0.2);
padding: 10px 20px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-weight: 600; backdrop-filter: blur(4px);
}
.reader-close-btn:hover { background: var(--accent); border-color: var(--accent); }
.loading-state { text-align: center; padding: 4rem; color: var(--text-tertiary); }
.image-entry[data-type="book"] {
aspect-ratio: 2/3;
background: #1a1a1a;
display: block;
position: relative;
cursor: pointer;
overflow: hidden;
}
.image-entry[data-type="book"] img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
transition: filter 0.3s ease, transform 0.3s ease;
}
.image-entry[data-type="book"]:hover img {
filter: blur(4px) brightness(0.7);
transform: scale(1.05);
}
.book-read-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
color: white;
z-index: 10;
}
.image-entry[data-type="book"]:hover .book-read-overlay {
opacity: 1;
}
.book-read-overlay span {
font-weight: 700;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
}
.book-read-overlay svg {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.8));
color: var(--accent);
}
.image-entry[data-type="book"]::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid rgba(255,255,255,0.1);
border-radius: inherit;
pointer-events: none;
}