diff --git a/main.js b/main.js index c70921f..9cee976 100644 --- a/main.js +++ b/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); diff --git a/src/content/image-handler.js b/src/content/image-handler.js index a81e819..974242a 100644 --- a/src/content/image-handler.js +++ b/src/content/image-handler.js @@ -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 = ` + + + + + Click To Read + `; + 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 = ``; + 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 = ``; + btn.title = "Remove from Favorites"; + } else { + btn.innerHTML = ``; + btn.title = "Add to Favorites"; + } +} export function populateTagModal(container, tags) { - container.innerHTML = ''; - - if (!tags || tags.length === 0) { - container.innerHTML = - '

No tags available for this image.

'; - 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); + container.innerHTML = ''; + if (!tags || tags.length === 0) { + container.innerHTML = '

No tags available.

'; + return; } - }); - 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 = ` - - `; - 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; + tags.forEach(tag => { + const span = document.createElement('span'); + span.textContent = tag; + container.appendChild(span); + }); } \ No newline at end of file diff --git a/src/extensions/load-extensions.js b/src/extensions/load-extensions.js index 717c52a..fb131a6 100644 --- a/src/extensions/load-extensions.js +++ b/src/extensions/load-extensions.js @@ -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); diff --git a/src/ipc/api-handlers.js b/src/ipc/api-handlers.js index 3937a6d..a27c601 100644 --- a/src/ipc/api-handlers.js +++ b/src/ipc/api-handlers.js @@ -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 { + console.log(`[LazyLoad] Initializing scraper: ${source}...`); 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 }; + } } }; }; \ No newline at end of file diff --git a/src/modules/search-handler.js b/src/modules/search-handler.js index 103d327..6724132 100644 --- a/src/modules/search-handler.js +++ b/src/modules/search-handler.js @@ -27,7 +27,9 @@ export async function performSearch( contentGallery.innerHTML = ''; updateHeader(); - searchModal.classList.add('hidden'); + 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 = - '

No results found. Please try another search term.

'; + contentGallery.innerHTML = '

No results found.

'; } isLoading = false; return; @@ -85,8 +80,7 @@ export async function loadMoreResults( if (page === 1) { hasNextPage = false; applyLayoutToGallery(contentGallery, currentLayout); - contentGallery.innerHTML = - '

Found results, but none had valid images.

'; + contentGallery.innerHTML = '

No valid images found.

'; } 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) { diff --git a/src/preload.js b/src/preload.js index 227646d..8b356b4 100644 --- a/src/preload.js +++ b/src/preload.js @@ -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'), diff --git a/src/renderer.js b/src/renderer.js index a1d03c2..4650584 100644 --- a/src/renderer.js +++ b/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'); } - function showTagModal(tags) { - if (domRefs.tagInfoContent && domRefs.tagInfoModal) { - populateTagModal(domRefs.tagInfoContent, tags); - domRefs.tagInfoModal.classList.remove('hidden'); - } - } + const callbacks = { showMessage, applyLayoutToGallery, updateHeader, createImageCard: localCreateImageCard }; - function localCreateImageCard(id, tags, imageUrl, thumbnailUrl, type) { - return createImageCard(id, tags, imageUrl, thumbnailUrl, type, { - currentLayout, - showMessage, - showTagModal, - applyLayoutToGallery, - favoritesGallery: document.getElementById('favorites-gallery') - }); - } + let currentChapters = []; + let currentChapterPage = 1; + const CHAPTERS_PER_PAGE = 10; - function updateHeader() { - if (!domRefs.headerContext) return; - domRefs.headerContext.classList.add('hidden'); - } + function renderChapterPage() { + const listContainer = document.getElementById('chapter-list-container'); + if (!listContainer) return; + listContainer.innerHTML = ''; - const callbacks = { - showMessage, - applyLayoutToGallery, - updateHeader, - createImageCard: localCreateImageCard - }; + const start = (currentChapterPage - 1) * CHAPTERS_PER_PAGE; + const end = start + CHAPTERS_PER_PAGE; + const slice = currentChapters.slice(start, end); - if (domRefs.searchModal) { - setupGlobalKeybinds(domRefs.searchModal); - } + if (slice.length === 0) { + listContainer.innerHTML = '
No chapters available.
'; + return; + } - 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'); - } + 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; + } + + row.innerHTML = `${mainText}`; + 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); + } + } } - if (domRefs.searchIconButton && domRefs.searchModal) { - domRefs.searchIconButton.addEventListener('click', () => { - domRefs.searchModal.classList.remove('hidden'); - if(domRefs.searchInput) { - domRefs.searchInput.focus(); - domRefs.searchInput.select(); - } - }); + async function openBookDetails(id, imageUrl, title, tags) { + const detailsView = document.getElementById('book-details-view'); + const browseView = document.getElementById('browse-page'); + if (!detailsView || !browseView) return; + + browseView.classList.add('hidden'); + detailsView.classList.remove('hidden'); - domRefs.searchCloseButton.addEventListener('click', () => { - domRefs.searchModal.classList.add('hidden'); - }); + detailsView.innerHTML = ` +
+
+ + Back to Library +
+
+ +
+
+ +

${title}

+
+ +
+
+

Loading chapters...

+
+
+
+
+ `; + + document.getElementById('back-to-library').onclick = () => { + detailsView.classList.add('hidden'); + browseView.classList.remove('hidden'); + }; + + 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 (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 = '
Failed to load chapters.
'; + } + } + + 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 = '

Loading content...

'; + + const existingBackBtn = readerView.querySelector('.reader-close-btn'); + if(existingBackBtn) existingBackBtn.remove(); + + const backBtn = document.createElement('div'); + backBtn.className = 'reader-close-btn'; + backBtn.innerHTML = ' 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 = '

No content found.

'; + 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); + }); + } + + } catch (err) { + console.error(err); + showMessage('Failed to load content', 'error'); + } + } + + 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 = '

No favorites found.

'; - favGallery.appendChild(emptyState); - } else { + if (!rawFavorites || rawFavorites.length === 0) favGallery.innerHTML = '

No favorites found.

'; + 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); } }); \ No newline at end of file diff --git a/src/utils/headless-browser.js b/src/utils/headless-browser.js index e143375..9b39ef9 100644 --- a/src/utils/headless-browser.js +++ b/src/utils/headless-browser.js @@ -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 }, }); + 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 ses = this.win.webContents.session; + + ses.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => { + const url = details.url.toLowerCase(); + 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 (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 }); + }); + + // 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 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 win = await this.getWindow(loadImages); - const session = win.webContents.session; - session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => { - const url = details.url.toLowerCase(); - const blockExtensions = [ - '.woff', '.woff2', '.ttf', '.eot', - 'google-analytics', 'doubleclick', 'facebook', 'twitter', 'adsystem' - ]; - if (blockExtensions.some(ext => url.includes(ext))) return callback({ cancel: true }); - return callback({ cancel: false }); - }); - - await win.loadURL(url, { userAgent }); + 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,23 +153,23 @@ 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){ - clearInterval(timer); - resolve(); + window.scrollTo(0, scrollHeight); + if (scrollHeight === lastHeight) { + sameHeightCount++; + if (sameHeightCount >= 5) { + clearInterval(timer); + resolve(); + } + } else { + sameHeightCount = 0; + lastHeight = scrollHeight; } }, 20); }); diff --git a/views/books.html b/views/books.html new file mode 100644 index 0000000..20a3375 --- /dev/null +++ b/views/books.html @@ -0,0 +1,126 @@ + + + + + + + Waifu Board - Books + + + + + + + +
+
+
+ + + + + +
+ + + + +

Book Boards

+
+ +
+ +
+

Book Sources

+
+ +

Library

+
+ + + +
+
+ + + + + +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/views/favorites.html b/views/favorites.html index 8bd0828..e10a62d 100644 --- a/views/favorites.html +++ b/views/favorites.html @@ -22,6 +22,14 @@ Image Boards + + + + + + Book Boards + + diff --git a/views/index.html b/views/index.html index 6bc89f1..cad9775 100644 --- a/views/index.html +++ b/views/index.html @@ -1,8 +1,10 @@ + - + Waifu Board @@ -22,9 +24,19 @@ Image Boards + + + + + + Book Boards + + - + + Favorites @@ -34,7 +46,9 @@ - + + Settings @@ -44,11 +58,13 @@
- + - +
@@ -59,10 +75,14 @@
-

Sources

+

+ Sources

-

Library

+

+ Library

-
@@ -106,11 +130,12 @@ + \ No newline at end of file diff --git a/views/settings.html b/views/settings.html index af19b76..b15e3fd 100644 --- a/views/settings.html +++ b/views/settings.html @@ -22,6 +22,14 @@ Image Boards + + + + + + Book Boards + + diff --git a/views/styles/books.css b/views/styles/books.css new file mode 100644 index 0000000..65bd9cf --- /dev/null +++ b/views/styles/books.css @@ -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; +} \ No newline at end of file