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:
5
main.js
5
main.js
@@ -73,6 +73,7 @@ function createWindow() {
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
initDiscordRPC();
|
||||
headlessBrowser.init()
|
||||
app.on('activate', function () {
|
||||
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: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:addFavorite', dbHandlers.addFavorite);
|
||||
ipcMain.handle('db:removeFavorite', dbHandlers.removeFavorite);
|
||||
|
||||
@@ -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) {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
container.innerHTML =
|
||||
'<p class="text-gray-400">No tags available for this image.</p>';
|
||||
container.innerHTML = '<p style="color:var(--text-tertiary)">No tags available.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
tags.forEach((tag) => {
|
||||
if (tag) {
|
||||
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);
|
||||
}
|
||||
tags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = tag;
|
||||
container.appendChild(span);
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -1,27 +1,22 @@
|
||||
export async function populateSources(sourceList) {
|
||||
console.log('Requesting sources from main process...');
|
||||
export async function populateSources(sourceList, targetType = 'image-board') {
|
||||
console.log(`Requesting sources for type: ${targetType}...`);
|
||||
const sources = await window.api.getSources();
|
||||
sourceList.innerHTML = '';
|
||||
let initialSource = '';
|
||||
|
||||
console.log("Raw sources received from backend:", sources);
|
||||
console.log("Raw sources received:", sources);
|
||||
|
||||
if (sources && sources.length > 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
|
||||
button.className = 'source-button hover:bg-gray-700 hover:text-white transition-all duration-200';
|
||||
|
||||
button.dataset.source = source.name;
|
||||
button.title = source.name;
|
||||
|
||||
@@ -81,7 +76,7 @@ export async function populateSources(sourceList) {
|
||||
firstButton.classList.add('active');
|
||||
initialSource = firstButton.dataset.source;
|
||||
} else {
|
||||
console.warn("All sources were filtered out. Check 'type' property in your extensions.");
|
||||
console.warn(`No sources found for type: ${targetType}`);
|
||||
}
|
||||
|
||||
setupCarousel(sourceList);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const fetchPath = require.resolve('node-fetch');
|
||||
const cheerioPath = require.resolve('cheerio');
|
||||
const fetch = require(fetchPath);
|
||||
|
||||
function peekProperty(filePath, propertyName) {
|
||||
try {
|
||||
@@ -17,7 +18,6 @@ module.exports = function (availableScrapers, headlessBrowser) {
|
||||
|
||||
Object.keys(availableScrapers).forEach(name => {
|
||||
const scraper = availableScrapers[name];
|
||||
|
||||
if (!scraper.url) {
|
||||
if (scraper.instance && scraper.instance.baseUrl) {
|
||||
scraper.url = scraper.instance.baseUrl;
|
||||
@@ -25,78 +25,128 @@ module.exports = function (availableScrapers, headlessBrowser) {
|
||||
scraper.url = peekProperty(scraper.path, 'baseUrl');
|
||||
}
|
||||
}
|
||||
|
||||
if (!scraper.type) {
|
||||
if (scraper.instance && scraper.instance.type) {
|
||||
scraper.type = scraper.instance.type;
|
||||
} else {
|
||||
const typeFromFile = peekProperty(scraper.path, 'type');
|
||||
if (typeFromFile) {
|
||||
console.log(`[API] Recovered type for ${name} via static analysis: ${typeFromFile}`);
|
||||
scraper.type = typeFromFile;
|
||||
}
|
||||
if (typeFromFile) scraper.type = typeFromFile;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
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 getScraperInstance = (source) => {
|
||||
const scraperData = availableScrapers[source];
|
||||
|
||||
if (!scraperData) {
|
||||
return { success: false, error: `Source ${source} not found.` };
|
||||
}
|
||||
if (!scraperData) throw new Error(`Source ${source} not found.`);
|
||||
|
||||
if (!scraperData.instance) {
|
||||
console.log(`[LazyLoad] Initializing scraper: ${source}...`);
|
||||
try {
|
||||
const scraperModule = require(scraperData.path);
|
||||
const className = Object.keys(scraperModule)[0];
|
||||
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);
|
||||
scraperData.instance = instance;
|
||||
|
||||
if (instance.type) scraperData.type = instance.type;
|
||||
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 {
|
||||
const results = await scraperData.instance.fetchSearchResult(query, page);
|
||||
const instance = getScraperInstance(source);
|
||||
const results = await instance.fetchSearchResult(query, page);
|
||||
return { success: true, data: results };
|
||||
} catch (err) {
|
||||
console.error(`Error during search in ${source}:`, err);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -27,7 +27,9 @@ export async function performSearch(
|
||||
contentGallery.innerHTML = '';
|
||||
updateHeader();
|
||||
|
||||
if (searchModal) {
|
||||
searchModal.classList.add('hidden');
|
||||
}
|
||||
|
||||
await loadMoreResults(currentSource, 1, currentLayout, domRefs, callbacks);
|
||||
}
|
||||
@@ -42,9 +44,7 @@ export async function loadMoreResults(
|
||||
const { loadingSpinner, infiniteLoadingSpinner, contentGallery } = domRefs;
|
||||
const { applyLayoutToGallery, createImageCard } = callbacks;
|
||||
|
||||
if (isLoading || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
if (isLoading || !hasNextPage) return;
|
||||
|
||||
isLoading = true;
|
||||
|
||||
@@ -64,16 +64,11 @@ export async function loadMoreResults(
|
||||
if (loadingSpinner) loadingSpinner.classList.add('hidden');
|
||||
if (infiniteLoadingSpinner) infiniteLoadingSpinner.classList.add('hidden');
|
||||
|
||||
if (
|
||||
!result.success ||
|
||||
!result.data.results ||
|
||||
result.data.results.length === 0
|
||||
) {
|
||||
if (!result.success || !result.data.results || result.data.results.length === 0) {
|
||||
hasNextPage = false;
|
||||
if (page === 1) {
|
||||
applyLayoutToGallery(contentGallery, currentLayout);
|
||||
contentGallery.innerHTML =
|
||||
'<p class="text-gray-400 text-center text-lg">No results found. Please try another search term.</p>';
|
||||
contentGallery.innerHTML = '<p class="text-gray-400 text-center text-lg">No results found.</p>';
|
||||
}
|
||||
isLoading = false;
|
||||
return;
|
||||
@@ -85,8 +80,7 @@ export async function loadMoreResults(
|
||||
if (page === 1) {
|
||||
hasNextPage = false;
|
||||
applyLayoutToGallery(contentGallery, currentLayout);
|
||||
contentGallery.innerHTML =
|
||||
'<p class="text-gray-400 text-center text-lg">Found results, but none had valid images.</p>';
|
||||
contentGallery.innerHTML = '<p class="text-gray-400 text-center text-lg">No valid images found.</p>';
|
||||
}
|
||||
isLoading = false;
|
||||
return;
|
||||
@@ -96,21 +90,23 @@ export async function loadMoreResults(
|
||||
validResults.forEach((item) => {
|
||||
const thumbnailUrl = item.image;
|
||||
const displayUrl = item.sampleImageUrl || item.fullImageUrl || thumbnailUrl;
|
||||
const title = item.title || '';
|
||||
|
||||
const card = createImageCard(
|
||||
item.id.toString(),
|
||||
item.tags,
|
||||
item.tags || [],
|
||||
displayUrl,
|
||||
thumbnailUrl,
|
||||
'browse'
|
||||
item.type || 'browse'
|
||||
);
|
||||
|
||||
if (title) card.dataset.title = title;
|
||||
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
|
||||
contentGallery.appendChild(fragment);
|
||||
|
||||
applyLayoutToGallery(contentGallery, currentLayout);
|
||||
|
||||
hasNextPage = result.data.hasNextPage;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
addFavorite: (fav) => ipcRenderer.invoke('db:addFavorite', fav),
|
||||
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),
|
||||
toggleDevTools: () => ipcRenderer.send('toggle-dev-tools'),
|
||||
|
||||
|
||||
353
src/renderer.js
353
src/renderer.js
@@ -13,197 +13,286 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
let currentSource = '';
|
||||
let currentPage = 1;
|
||||
let isFetching = false;
|
||||
const favoriteIds = new Set();
|
||||
|
||||
function showMessage(message, type = 'success') {
|
||||
if (domRefs.messageBar) {
|
||||
uiShowMessage(domRefs.messageBar, message, type);
|
||||
try {
|
||||
if (window.api && window.api.getFavorites) {
|
||||
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) {
|
||||
if (domRefs.tagInfoContent && domRefs.tagInfoModal) {
|
||||
populateTagModal(domRefs.tagInfoContent, tags);
|
||||
domRefs.tagInfoModal.classList.remove('hidden');
|
||||
}
|
||||
slice.forEach(chapter => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'chapter-row';
|
||||
|
||||
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) {
|
||||
return createImageCard(id, tags, imageUrl, thumbnailUrl, type, {
|
||||
currentLayout,
|
||||
showMessage,
|
||||
showTagModal,
|
||||
applyLayoutToGallery,
|
||||
favoritesGallery: document.getElementById('favorites-gallery')
|
||||
row.innerHTML = `<span class="chapter-main-text">${mainText}</span>`;
|
||||
row.onclick = () => openReader(chapter.id);
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
|
||||
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() {
|
||||
if (!domRefs.headerContext) return;
|
||||
domRefs.headerContext.classList.add('hidden');
|
||||
}
|
||||
async function openBookDetails(id, imageUrl, title, tags) {
|
||||
const detailsView = document.getElementById('book-details-view');
|
||||
const browseView = document.getElementById('browse-page');
|
||||
if (!detailsView || !browseView) return;
|
||||
|
||||
const callbacks = {
|
||||
showMessage,
|
||||
applyLayoutToGallery,
|
||||
updateHeader,
|
||||
createImageCard: localCreateImageCard
|
||||
browseView.classList.add('hidden');
|
||||
detailsView.classList.remove('hidden');
|
||||
|
||||
detailsView.innerHTML = `
|
||||
<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) {
|
||||
setupGlobalKeybinds(domRefs.searchModal);
|
||||
let highResCover = null;
|
||||
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) {
|
||||
domRefs.tagInfoCloseButton.addEventListener('click', () => {
|
||||
domRefs.tagInfoModal.classList.add('hidden');
|
||||
});
|
||||
domRefs.tagInfoModal.addEventListener('click', (e) => {
|
||||
if (e.target === domRefs.tagInfoModal) {
|
||||
domRefs.tagInfoModal.classList.add('hidden');
|
||||
if (highResCover) {
|
||||
const posterEl = document.getElementById('book-details-poster');
|
||||
if (posterEl) posterEl.src = highResCover;
|
||||
}
|
||||
|
||||
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) {
|
||||
domRefs.searchIconButton.addEventListener('click', () => {
|
||||
domRefs.searchModal.classList.remove('hidden');
|
||||
if(domRefs.searchInput) {
|
||||
domRefs.searchInput.focus();
|
||||
domRefs.searchInput.select();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showMessage('Failed to load content', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
domRefs.searchCloseButton.addEventListener('click', () => {
|
||||
domRefs.searchModal.classList.add('hidden');
|
||||
});
|
||||
if (domRefs.searchModal) setupGlobalKeybinds(domRefs.searchModal);
|
||||
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.contentGallery) {
|
||||
applyLayoutToGallery(domRefs.contentGallery, currentLayout);
|
||||
}
|
||||
if (domRefs.contentGallery) applyLayoutToGallery(domRefs.contentGallery, currentLayout);
|
||||
|
||||
let initialSource = '';
|
||||
if (window.api && window.api.getSources) {
|
||||
initialSource = await populateSources(domRefs.sourceList);
|
||||
} else {
|
||||
initialSource = await populateSources(domRefs.sourceList);
|
||||
}
|
||||
const isBooksPage = window.location.pathname.includes('books.html');
|
||||
const contentType = isBooksPage ? 'book-board' : 'image-board';
|
||||
|
||||
let initialSource = await populateSources(domRefs.sourceList, contentType);
|
||||
currentSource = initialSource;
|
||||
updateHeader();
|
||||
|
||||
domRefs.sourceList.addEventListener('click', (e) => {
|
||||
const button = e.target.closest('.source-button');
|
||||
if (button) {
|
||||
domRefs.sourceList
|
||||
.querySelectorAll('.source-button')
|
||||
.forEach((btn) => btn.classList.remove('active'));
|
||||
domRefs.sourceList.querySelectorAll('.source-button').forEach(b => b.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
|
||||
currentSource = button.dataset.source;
|
||||
updateHeader();
|
||||
|
||||
currentPage = 1;
|
||||
|
||||
if (domRefs.searchInput && domRefs.searchInput.value.trim()) {
|
||||
performSearch(currentSource, domRefs.searchInput, currentLayout, domRefs, callbacks);
|
||||
} else if (domRefs.searchInput) {
|
||||
performSearch(currentSource, { value: "" }, currentLayout, domRefs, callbacks);
|
||||
}
|
||||
if (domRefs.searchInput?.value.trim()) 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');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.addEventListener('scroll', async () => {
|
||||
if (
|
||||
scrollContainer.scrollTop + scrollContainer.clientHeight >=
|
||||
scrollContainer.scrollHeight - 600
|
||||
) {
|
||||
if (scrollContainer.scrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 600) {
|
||||
if (isFetching) return;
|
||||
isFetching = true;
|
||||
|
||||
currentPage++;
|
||||
|
||||
if (domRefs.infiniteLoadingSpinner) {
|
||||
domRefs.infiniteLoadingSpinner.classList.remove('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.infiniteLoadingSpinner) domRefs.infiniteLoadingSpinner.classList.remove('hidden');
|
||||
try { await loadMoreResults(currentSource, currentPage, currentLayout, domRefs, callbacks); }
|
||||
catch (error) { currentPage--; }
|
||||
finally { isFetching = false; if (domRefs.infiniteLoadingSpinner) domRefs.infiniteLoadingSpinner.classList.add('hidden'); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (domRefs.searchButton && domRefs.searchInput) {
|
||||
domRefs.searchButton.addEventListener('click', () => {
|
||||
currentPage = 1;
|
||||
performSearch(currentSource, domRefs.searchInput, currentLayout, domRefs, callbacks);
|
||||
});
|
||||
if (domRefs.searchButton) {
|
||||
domRefs.searchButton.onclick = () => { currentPage = 1; performSearch(currentSource, domRefs.searchInput, currentLayout, domRefs, callbacks); };
|
||||
}
|
||||
}
|
||||
|
||||
if (document.getElementById('favorites-gallery')) {
|
||||
const favGallery = document.getElementById('favorites-gallery');
|
||||
|
||||
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();
|
||||
|
||||
const rawFavorites = await window.api.getFavorites();
|
||||
favGallery.innerHTML = '';
|
||||
|
||||
if (!rawFavorites || rawFavorites.length === 0) {
|
||||
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 {
|
||||
if (!rawFavorites || rawFavorites.length === 0) favGallery.innerHTML = '<div class="loading-state"><p>No favorites found.</p></div>';
|
||||
else {
|
||||
rawFavorites.forEach(row => {
|
||||
const id = row.id;
|
||||
const imageUrl = row.image_url;
|
||||
const thumbnailUrl = row.thumbnail_url;
|
||||
|
||||
let tags = [];
|
||||
if (typeof row.tags === 'string') {
|
||||
tags = row.tags.split(',').filter(t => t.trim() !== '');
|
||||
} else if (Array.isArray(row.tags)) {
|
||||
tags = row.tags;
|
||||
}
|
||||
|
||||
const card = localCreateImageCard(
|
||||
id,
|
||||
tags,
|
||||
imageUrl,
|
||||
thumbnailUrl,
|
||||
'image'
|
||||
);
|
||||
if (typeof row.tags === 'string') tags = row.tags.split(',').filter(t=>t);
|
||||
else if (Array.isArray(row.tags)) tags = row.tags;
|
||||
const card = localCreateImageCard(row.id, tags, row.image_url, row.thumbnail_url, 'image');
|
||||
card.dataset.title = row.title;
|
||||
favGallery.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
applyLayoutToGallery(favGallery, currentLayout);
|
||||
}
|
||||
});
|
||||
@@ -1,17 +1,37 @@
|
||||
const { BrowserWindow } = require('electron');
|
||||
const { BrowserWindow, session } = require('electron');
|
||||
|
||||
class HeadlessBrowser {
|
||||
async scrape(url, evalFunc, options = {}) {
|
||||
const {
|
||||
waitSelector = null,
|
||||
timeout = 15000,
|
||||
args = [],
|
||||
scrollToBottom = false,
|
||||
renderWaitTime = 2000,
|
||||
loadImages = true
|
||||
} = options;
|
||||
constructor() {
|
||||
this.win = null;
|
||||
this.currentConfig = null;
|
||||
}
|
||||
|
||||
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,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
@@ -22,36 +42,75 @@ class HeadlessBrowser {
|
||||
images: loadImages,
|
||||
webgl: 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/120.0.0.0 Safari/537.36';
|
||||
win.webContents.setUserAgent(userAgent);
|
||||
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';
|
||||
this.win.webContents.setUserAgent(userAgent);
|
||||
|
||||
const session = win.webContents.session;
|
||||
session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
const ses = this.win.webContents.session;
|
||||
|
||||
ses.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
const url = details.url.toLowerCase();
|
||||
const blockExtensions = [
|
||||
'.woff', '.woff2', '.ttf', '.eot',
|
||||
'google-analytics', 'doubleclick', 'facebook', 'twitter', 'adsystem'
|
||||
const type = details.resourceType;
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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) {
|
||||
try {
|
||||
await this.waitForSelector(win, waitSelector, timeout);
|
||||
} catch (e) {
|
||||
console.warn(`[Headless] Timeout waiting for ${waitSelector}, proceeding anyway...`);
|
||||
console.warn(`[Headless] Timeout waiting for ${waitSelector}, proceeding...`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scrollToBottom) {
|
||||
await this.smoothScrollToBottom(win);
|
||||
await this.turboScroll(win);
|
||||
}
|
||||
|
||||
if (renderWaitTime > 0) {
|
||||
@@ -66,28 +125,26 @@ class HeadlessBrowser {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Headless Scrape Error:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!win.isDestroyed()) {
|
||||
win.destroy();
|
||||
// Force recreation next time if something crashed
|
||||
if (this.win) {
|
||||
try { this.win.destroy(); } catch(e){}
|
||||
this.win = null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForSelector(win, selector, timeout) {
|
||||
const script = `
|
||||
new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Timeout waiting for selector: ${selector}'));
|
||||
}, ${timeout});
|
||||
|
||||
const start = Date.now();
|
||||
const check = () => {
|
||||
const el = document.querySelector('${selector}');
|
||||
if (el) {
|
||||
clearTimeout(timer);
|
||||
if (document.querySelector('${selector}')) {
|
||||
resolve(true);
|
||||
} else if (Date.now() - start > ${timeout}) {
|
||||
reject(new Error('Timeout'));
|
||||
} else {
|
||||
setTimeout(check, 200);
|
||||
requestAnimationFrame(check);
|
||||
}
|
||||
};
|
||||
check();
|
||||
@@ -96,24 +153,24 @@ class HeadlessBrowser {
|
||||
await win.webContents.executeJavaScript(script);
|
||||
}
|
||||
|
||||
async smoothScrollToBottom(win) {
|
||||
async turboScroll(win) {
|
||||
const script = `
|
||||
new Promise((resolve) => {
|
||||
let totalHeight = 0;
|
||||
const distance = 400;
|
||||
const maxScrolls = 200;
|
||||
let currentScrolls = 0;
|
||||
|
||||
let lastHeight = 0;
|
||||
let sameHeightCount = 0;
|
||||
const timer = setInterval(() => {
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
currentScrolls++;
|
||||
|
||||
if(totalHeight >= scrollHeight - window.innerHeight || currentScrolls >= maxScrolls){
|
||||
window.scrollTo(0, scrollHeight);
|
||||
if (scrollHeight === lastHeight) {
|
||||
sameHeightCount++;
|
||||
if (sameHeightCount >= 5) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
sameHeightCount = 0;
|
||||
lastHeight = scrollHeight;
|
||||
}
|
||||
}, 20);
|
||||
});
|
||||
`;
|
||||
|
||||
126
views/books.html
Normal file
126
views/books.html
Normal 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>
|
||||
@@ -22,6 +22,14 @@
|
||||
<span>Image Boards</span>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<!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:; 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" />
|
||||
<title>Waifu Board</title>
|
||||
<link rel="stylesheet" href="styles/home.css">
|
||||
@@ -22,9 +24,19 @@
|
||||
<span>Image Boards</span>
|
||||
</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">
|
||||
<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>
|
||||
<span>Favorites</span>
|
||||
</a>
|
||||
@@ -34,7 +46,9 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -44,11 +58,13 @@
|
||||
<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);">
|
||||
<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 sources..." style="padding-left: 2.5rem;" />
|
||||
<input type="text" id="search-input" placeholder="Search across sources..."
|
||||
style="padding-left: 2.5rem;" />
|
||||
</div>
|
||||
<button id="search-button" class="hidden"></button>
|
||||
<button id="search-icon-button" class="hidden"></button>
|
||||
@@ -59,10 +75,14 @@
|
||||
|
||||
<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;">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>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
@@ -73,12 +93,16 @@
|
||||
<p>Select a 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 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 images...</p>
|
||||
</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>
|
||||
</div>
|
||||
</main>
|
||||
@@ -113,4 +137,5 @@
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -22,6 +22,14 @@
|
||||
<span>Image Boards</span>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
214
views/styles/books.css
Normal file
214
views/styles/books.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user