diff --git a/main.js b/main.js index a09ea5a..35752be 100644 --- a/main.js +++ b/main.js @@ -1,37 +1,28 @@ -/* - main.js (Electron Main Process) - MODIFIED: Swapped 'better-sqlite3' for 'sqlite3' to remove C++ dependency. -*/ const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); const fs = require('fs'); const sqlite3 = require('sqlite3').verbose(); -// --- NEW: Get paths for *both* dependencies --- + const fetchPath = require.resolve('node-fetch'); const cheerioPath = require.resolve('cheerio'); -// --- END NEW --- -// --- Core paths --- const waifuBoardsPath = path.join(app.getPath('home'), 'WaifuBoards'); const pluginsPath = path.join(waifuBoardsPath, 'extensions'); const dbPath = path.join(waifuBoardsPath, 'favorites.db'); -// --- Ensure directories exist --- try { if (!fs.existsSync(waifuBoardsPath)) { fs.mkdirSync(waifuBoardsPath); } if (!fs.existsSync(pluginsPath)) { - // Use recursive: true in case WaifuBoards doesn't exist yet + fs.mkdirSync(pluginsPath, { recursive: true }); } } catch (error) { console.error('Failed to create directories:', error); - // We can probably continue, but loading/saving will fail. + } -// --- API Scraper Loader --- -// This will hold our instantiated scraper classes, e.g. { 'Gelbooru': new Gelbooru() } const loadedScrapers = {}; function loadScrapers() { @@ -44,22 +35,19 @@ function loadScrapers() { .forEach((file) => { const filePath = path.join(pluginsPath, file); try { - // Dynamically require the scraper file + const scraperModule = require(filePath); - // We assume the export is an object like { Gelbooru: class... } + const className = Object.keys(scraperModule)[0]; const ScraperClass = scraperModule[className]; - // Basic check to see if it's a valid scraper class if ( typeof ScraperClass === 'function' && ScraperClass.prototype.fetchSearchResult ) { - // --- MODIFIED: Inject *both* paths --- + const instance = new ScraperClass(fetchPath, cheerioPath); - // --- END MODIFIED --- - - // Store the instance and its baseUrl + loadedScrapers[className] = { instance: instance, baseUrl: instance.baseUrl, @@ -75,25 +63,21 @@ function loadScrapers() { } }); } -// -------------------- -// Load scrapers at startup loadScrapers(); -// --- MODIFIED: Initialize sqlite3 (async) --- const db = new sqlite3.Database(dbPath, (err) => { if (err) { console.error('Error opening database:', err.message); } else { console.log('Connected to the favorites database.'); - runDatabaseMigrations(); // Run migrations after connecting + runDatabaseMigrations(); } }); -// --- MODIFIED: Database functions are now async --- function runDatabaseMigrations() { db.serialize(() => { - // Create the 'favorites' table + db.run( ` CREATE TABLE IF NOT EXISTS favorites ( @@ -109,7 +93,6 @@ function runDatabaseMigrations() { } ); - // --- Migration (Add thumbnail_url) --- console.log('Checking database schema for "thumbnail_url"...'); db.all('PRAGMA table_info(favorites)', (err, columns) => { if (err) { @@ -137,7 +120,6 @@ function runDatabaseMigrations() { } }); - // --- Migration (Add tags) --- console.log('Checking database schema for "tags" column...'); db.all('PRAGMA table_info(favorites)', (err, columns) => { if (err) { @@ -163,42 +145,33 @@ function runDatabaseMigrations() { } function createWindow() { - // Create the browser window. + const mainWindow = new BrowserWindow({ width: 1000, height: 800, webPreferences: { - // Attach the 'preload.js' script to the window - // This is the secure way to expose Node.js functions to the renderer (frontend) - preload: path.join(__dirname, 'preload.js'), - // contextIsolation is true by default and is a critical security feature + + preload: path.join(__dirname, '/scripts/preload.js'), + contextIsolation: true, - // nodeIntegration should be false + nodeIntegration: false, }, }); - // Load the index.html file into the window - mainWindow.loadFile('index.html'); + mainWindow.loadFile('views/index.html'); - // --- Add this line to remove the menu bar --- mainWindow.setMenu(null); } -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. app.whenReady().then(() => { - // loadScrapers(); // MOVED: This is now called at the top createWindow(); app.on('activate', function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); -// Quit when all windows are closed, except on macOS. app.on('window-all-closed', function () { if (process.platform !== 'darwin') { db.close((err) => { @@ -208,12 +181,7 @@ app.on('window-all-closed', function () { } }); -// --- IPC Handlers (Backend Functions) --- -// These functions listen for calls from the 'preload.js' script - -// NEW: Send the list of loaded scrapers to the frontend ipcMain.handle('api:getSources', () => { - // Returns an array of objects: [{ name: 'Gelbooru', url: 'https://gelbooru.com' }, ...] return Object.keys(loadedScrapers).map((name) => { return { name: name, @@ -222,12 +190,9 @@ ipcMain.handle('api:getSources', () => { }); }); -// MODIFIED: Generic search handler now accepts a page number ipcMain.handle('api:search', async (event, source, query, page) => { try { - // Check if the requested source was successfully loaded if (loadedScrapers[source] && loadedScrapers[source].instance) { - // Pass the page number to the scraper const results = await loadedScrapers[source].instance.fetchSearchResult( query, page @@ -241,16 +206,12 @@ ipcMain.handle('api:search', async (event, source, query, page) => { return { success: false, error: error.message }; } }); - -// --- MODIFIED: All db handlers are now async Promises --- - -// Handle request to get all favorites ipcMain.handle('db:getFavorites', () => { return new Promise((resolve, reject) => { db.all('SELECT * FROM favorites', [], (err, rows) => { if (err) { console.error('Error getting favorites:', err.message); - resolve([]); // Resolve with empty array on error + resolve([]); } else { resolve(rows); } @@ -258,7 +219,6 @@ ipcMain.handle('db:getFavorites', () => { }); }); -// Handle request to add a favorite ipcMain.handle('db:addFavorite', (event, fav) => { return new Promise((resolve) => { const stmt = @@ -267,7 +227,7 @@ ipcMain.handle('db:addFavorite', (event, fav) => { stmt, [fav.id, fav.title, fav.imageUrl, fav.thumbnailUrl, fav.tags], function (err) { - // Must use 'function' to get 'this' + if (err) { if (err.code.includes('SQLITE_CONSTRAINT')) { resolve({ success: false, error: 'Item is already a favorite.' }); @@ -283,12 +243,11 @@ ipcMain.handle('db:addFavorite', (event, fav) => { }); }); -// Handle request to remove a favorite ipcMain.handle('db:removeFavorite', (event, id) => { return new Promise((resolve) => { const stmt = 'DELETE FROM favorites WHERE id = ?'; db.run(stmt, id, function (err) { - // Must use 'function' to get 'this' + if (err) { console.error('Error removing favorite:', err.message); resolve({ success: false, error: err.message }); diff --git a/package.json b/package.json index a7e4431..5b4de92 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "productName": "Waifu Board", "win": { "target": "portable", - "icon": "build/waifuboards.ico" + "icon": "public/waifuboards.ico" }, "files": [ "**/*", diff --git a/preload.js b/preload.js deleted file mode 100644 index 460af87..0000000 --- a/preload.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - preload.js - This script runs in a special, isolated context before the web page (index.html) - is loaded. It uses 'contextBridge' to securely expose specific functions - from the main process (like database access) to the renderer process (frontend). -*/ -const { contextBridge, ipcRenderer } = require('electron'); - -// Expose a 'db' object to the global 'window' object in the renderer -contextBridge.exposeInMainWorld('api', { - // --- Database Functions --- - getFavorites: () => ipcRenderer.invoke('db:getFavorites'), - addFavorite: (fav) => ipcRenderer.invoke('db:addFavorite', fav), - removeFavorite: (id) => ipcRenderer.invoke('db:removeFavorite', id), - - // --- API Function --- - // This is now a generic search function that takes the source - search: (source, query) => ipcRenderer.invoke('api:search', source, query), - // NEW: This function gets the list of available sources from main.js - getSources: () => ipcRenderer.invoke('api:getSources'), -}); \ No newline at end of file diff --git a/build/waifuboards.ico b/public/waifuboards.ico similarity index 100% rename from build/waifuboards.ico rename to public/waifuboards.ico diff --git a/scripts/preload.js b/scripts/preload.js new file mode 100644 index 0000000..1c19fb3 --- /dev/null +++ b/scripts/preload.js @@ -0,0 +1,12 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('api', { + + getFavorites: () => ipcRenderer.invoke('db:getFavorites'), + addFavorite: (fav) => ipcRenderer.invoke('db:addFavorite', fav), + removeFavorite: (id) => ipcRenderer.invoke('db:removeFavorite', id), + + search: (source, query) => ipcRenderer.invoke('api:search', source, query), + + getSources: () => ipcRenderer.invoke('api:getSources'), +}); \ No newline at end of file diff --git a/renderer.js b/scripts/renderer.js similarity index 70% rename from renderer.js rename to scripts/renderer.js index e2f735b..5d3935b 100644 --- a/renderer.js +++ b/scripts/renderer.js @@ -1,65 +1,48 @@ -/* - renderer.js - MODIFIED: Now includes infinite scrolling -*/ document.addEventListener('DOMContentLoaded', () => { - // --- Page Elements --- const browseButton = document.getElementById('browse-button'); const favoritesButton = document.getElementById('favorites-button'); const settingsButton = document.getElementById('settings-button'); const browsePage = document.getElementById('browse-page'); - const favoritesPage = document.getElementById('favorites-page'); - const settingsPage = document.getElementById('settings-page'); const pageTitle = document.getElementById('page-title'); const headerContext = document.getElementById('header-context'); - // --- Search Modal Elements --- const searchIconButton = document.getElementById('search-icon-button'); const searchModal = document.getElementById('search-modal'); const searchCloseButton = document.getElementById('search-close-button'); const searchInput = document.getElementById('search-input'); const searchButton = document.getElementById('search-button'); - // --- Gallery Elements --- const sourceList = document.getElementById('source-list'); const contentGallery = document.getElementById('content-gallery'); const favoritesGallery = document.getElementById('favorites-gallery'); const loadingSpinner = document.getElementById('loading-spinner'); - // NEW: Get the infinite loading spinner + const infiniteLoadingSpinner = document.getElementById( 'infinite-loading-spinner' ); const messageBar = document.getElementById('message-bar'); const galleryPlaceholder = document.getElementById('gallery-placeholder'); - // --- Settings Elements --- const layoutRadios = document.querySelectorAll('input[name="layout"]'); - const layoutScroll = document.getElementById('layout-scroll'); - const layoutGrid = document.getElementById('layout-grid'); - const layoutCompact = document.getElementById('layout-compact'); - // --- Tag Info Modal Elements --- const tagInfoModal = document.getElementById('tag-info-modal'); const tagInfoCloseButton = document.getElementById( 'tag-info-close-button' ); const tagInfoContent = document.getElementById('tag-info-content'); - // --- App State --- - let currentFavorites = []; // Cache for favorites + let currentFavorites = []; let currentSource = ''; let currentQuery = ''; - let currentLayout = 'scroll'; // Default layout - // --- NEW: State for infinite scroll --- + let currentLayout = 'scroll'; let currentPage = 1; let isLoading = false; let hasNextPage = true; - // --- Populate Sources Sidebar --- async function populateSources() { console.log('Requesting sources from main process...'); - const sources = await window.api.getSources(); // e.g., [{ name: 'Gelbooru', url: '...' }] - sourceList.innerHTML = ''; // Clear "Loading..." + const sources = await window.api.getSources(); + sourceList.innerHTML = ''; if (sources && sources.length > 0) { sources.forEach((source) => { @@ -69,32 +52,25 @@ document.addEventListener('DOMContentLoaded', () => { button.dataset.source = source.name; button.title = source.name; - // Create and add favicon const favicon = document.createElement('img'); favicon.className = 'w-8 h-8 rounded'; - // Parse main domain from URL to get correct favicon - let mainDomain = source.url; // Default to the full URL + let mainDomain = source.url; try { - const hostname = new URL(source.url).hostname; // e.g., 'api.waifu.pics' + const hostname = new URL(source.url).hostname; const parts = hostname.split('.'); if (parts.length > 2 && ['api', 'www'].includes(parts[0])) { - // Get the last two parts (e.g., 'waifu.pics' from 'api.waifu.pics') mainDomain = parts.slice(1).join('.'); } else { - // It's already a main domain (e.g., 'gelbooru.com') mainDomain = hostname; } } catch (e) { console.warn(`Could not parse domain from ${source.url}:`, e); mainDomain = source.name; } - // --- END NEW --- - - // Use Google's favicon service. sz=32 requests a 32x32 icon. + favicon.src = `https://www.google.com/s2/favicons?domain=${mainDomain}&sz=32`; favicon.alt = source.name; - // Fallback in case favicon fails to load favicon.onerror = () => { button.innerHTML = `${source.name.substring( 0, @@ -108,7 +84,6 @@ document.addEventListener('DOMContentLoaded', () => { }); console.log('Sources populated:', sources); - // Set first source as active by default if (sourceList.children.length > 0) { const firstButton = sourceList.children[0]; firstButton.classList.add('active'); @@ -120,11 +95,9 @@ document.addEventListener('DOMContentLoaded', () => { } } - // --- Source Selection --- sourceList.addEventListener('click', (e) => { const button = e.target.closest('.source-button'); if (button) { - // ... (remove/add active class) ... sourceList .querySelectorAll('.source-button') .forEach((btn) => btn.classList.remove('active')); @@ -133,46 +106,41 @@ document.addEventListener('DOMContentLoaded', () => { currentSource = button.dataset.source; console.log('Source changed to:', currentSource); updateHeader(); - // Automatically re-search when changing source if a query exists if (currentQuery) { - // This will reset the gallery and start a new search performSearch(); } } }); - // --- Tab Switching Logic (Sidebar) --- function showPage(pageId) { - // Hide all pages + document.querySelectorAll('.page').forEach((page) => { page.classList.add('hidden'); }); - // De-activate all icon buttons + document.querySelectorAll('.nav-button').forEach((tab) => { tab.classList.remove('bg-indigo-600', 'text-white'); tab.classList.add('text-gray-400', 'hover:bg-gray-700'); }); - // Show the active page const activePage = document.getElementById(pageId); activePage.classList.remove('hidden'); - // Highlight the active icon button let activeTab; if (pageId === 'browse-page') { activeTab = browseButton; pageTitle.textContent = 'Browse'; - updateHeader(); // Update header context + updateHeader(); } else if (pageId === 'favorites-page') { activeTab = favoritesButton; pageTitle.textContent = 'Favorites'; - headerContext.textContent = ''; // Clear context - // When switching to favorites, refresh the list + headerContext.textContent = ''; + loadFavorites(); } else if (pageId === 'settings-page') { activeTab = settingsButton; pageTitle.textContent = 'Settings'; - headerContext.textContent = ''; // Clear context + headerContext.textContent = ''; } activeTab.classList.add('bg-indigo-600', 'text-white'); activeTab.classList.remove('text-gray-400', 'hover:bg-gray-700'); @@ -182,41 +150,36 @@ document.addEventListener('DOMContentLoaded', () => { favoritesButton.addEventListener('click', () => showPage('favorites-page')); settingsButton.addEventListener('click', () => showPage('settings-page')); - // --- Search Modal Logic --- searchIconButton.addEventListener('click', () => { searchModal.classList.remove('hidden'); - searchInput.focus(); // Auto-focus the search bar + searchInput.focus(); searchInput.select(); }); searchCloseButton.addEventListener('click', () => { searchModal.classList.add('hidden'); }); searchButton.addEventListener('click', () => { - // Sanitize search query to allow multiple tags - // MODIFIED: This just calls performSearch, which now handles its own state performSearch(); }); - // Close search modal on Escape key + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { searchModal.classList.add('hidden'); } }); - // --- Tag Info Modal Logic --- tagInfoCloseButton.addEventListener('click', () => { tagInfoModal.classList.add('hidden'); }); - // Close tag modal by clicking the backdrop + tagInfoModal.addEventListener('click', (e) => { if (e.target === tagInfoModal) { tagInfoModal.classList.add('hidden'); } }); - // Function to show the tag info modal function showTagModal(tags) { - tagInfoContent.innerHTML = ''; // Clear old tags + tagInfoContent.innerHTML = ''; if (!tags || tags.length === 0) { tagInfoContent.innerHTML = @@ -231,7 +194,7 @@ document.addEventListener('DOMContentLoaded', () => { 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, ' '); // Replace underscores + tagPill.textContent = tag.replace(/_/g, ' '); fragment.appendChild(tagPill); } }); @@ -239,7 +202,6 @@ document.addEventListener('DOMContentLoaded', () => { tagInfoModal.classList.remove('hidden'); } - // --- Header Update --- function updateHeader() { if (currentSource) { headerContext.textContent = `Source: ${currentSource}`; @@ -248,56 +210,48 @@ document.addEventListener('DOMContentLoaded', () => { } } - // --- Search Function --- async function performSearch() { if (!currentSource) { showMessage('Please select a source from the sidebar.', 'error'); return; } - // --- NEW: Reset state for a new search --- currentPage = 1; hasNextPage = true; isLoading = false; currentQuery = searchInput.value.trim().replace(/[, ]+/g, ' '); if (galleryPlaceholder) galleryPlaceholder.classList.add('hidden'); - // Clear and apply layout classes - applyLayoutToGallery(contentGallery, currentLayout); - contentGallery.innerHTML = ''; // Clear previous results - updateHeader(); // Update header to show source - // Close modal after search + applyLayoutToGallery(contentGallery, currentLayout); + contentGallery.innerHTML = ''; + updateHeader(); + searchModal.classList.add('hidden'); - - // Load the first page of results + loadMoreResults(); } - // --- NEW: Infinite Scroll Loader --- async function loadMoreResults() { - // Don't load if we're already loading or if there are no more pages + if (isLoading || !hasNextPage) { return; } isLoading = true; - // Show the correct spinner if (currentPage === 1) { - loadingSpinner.classList.remove('hidden'); // Show main spinner + loadingSpinner.classList.remove('hidden'); } else { - infiniteLoadingSpinner.classList.remove('hidden'); // Show bottom spinner + infiniteLoadingSpinner.classList.remove('hidden'); } - // Use the new API function with the current page const result = await window.api.search( currentSource, currentQuery, currentPage ); - // Hide all spinners loadingSpinner.classList.add('hidden'); infiniteLoadingSpinner.classList.add('hidden'); @@ -306,14 +260,14 @@ document.addEventListener('DOMContentLoaded', () => { !result.data.results || result.data.results.length === 0 ) { - hasNextPage = false; // Stop trying to load more + hasNextPage = false; if (currentPage === 1) { - // If it's the first page and no results, show "No results" + applyLayoutToGallery(contentGallery, currentLayout); contentGallery.innerHTML = '

No results found. Please try another search term.

'; } - // If it's not the first page, we just stop loading (no message needed) + isLoading = false; return; } @@ -321,7 +275,7 @@ document.addEventListener('DOMContentLoaded', () => { const validResults = result.data.results.filter((item) => item.image); if (validResults.length === 0) { - hasNextPage = false; // Stop trying to load more + hasNextPage = false; if (currentPage === 1) { applyLayoutToGallery(contentGallery, currentLayout); contentGallery.innerHTML = @@ -331,66 +285,57 @@ document.addEventListener('DOMContentLoaded', () => { return; } - // Use a DocumentFragment for performance const fragment = document.createDocumentFragment(); validResults.forEach((item) => { const thumbnailUrl = item.image; - // const fullImageUrl = getFullImageUrl(thumbnailUrl, currentSource); + const displayUrl = item.sampleImageUrl || item.fullImageUrl || thumbnailUrl; const card = createImageCard( item.id.toString(), - item.tags, // Pass the whole tags array - displayUrl, // Pass the new *real* URL - thumbnailUrl, // Pass the *real* thumbnail as a fallback + item.tags, + displayUrl, + thumbnailUrl, 'browse' ); fragment.appendChild(card); }); - // Append the new results instead of overwriting + contentGallery.appendChild(fragment); - // Update state for the next scroll hasNextPage = result.data.hasNextPage; currentPage++; isLoading = false; } - // --- NEW: Scroll Event Listener for Browse Page --- browsePage.addEventListener('scroll', () => { - // Check if user is near the bottom of the scrollable area if ( browsePage.scrollTop + browsePage.clientHeight >= - browsePage.scrollHeight - 600 // Load 600px before the end + browsePage.scrollHeight - 600 ) { loadMoreResults(); } }); - // --- Favorites Logic --- async function loadFavorites() { - // Apply layout classes applyLayoutToGallery(favoritesGallery, currentLayout); favoritesGallery.innerHTML = '

Loading favorites...

'; currentFavorites = await window.api.getFavorites(); if (currentFavorites.length === 0) { - // Apply layout classes applyLayoutToGallery(favoritesGallery, currentLayout); favoritesGallery.innerHTML = '

You haven\'t saved any favorites yet.

'; return; } - // Apply layout classes applyLayoutToGallery(favoritesGallery, currentLayout); - favoritesGallery.innerHTML = ''; // Clear loading message + favoritesGallery.innerHTML = ''; const fragment = document.createDocumentFragment(); currentFavorites.forEach((fav) => { const card = createImageCard( fav.id, - // Read from the new 'tags' column instead of 'title' fav.tags ? fav.tags.split(',') : [], fav.image_url, fav.thumbnail_url, @@ -402,11 +347,8 @@ document.addEventListener('DOMContentLoaded', () => { } async function handleAddFavorite(id, tags, imageUrl, thumbnailUrl) { - // Ensure 'tags' is an array before using array methods const safeTags = Array.isArray(tags) ? tags : []; - // Title is just the first tag (or a default), for simplicity const title = safeTags.length > 0 ? safeTags[0] : 'Favorite'; - // Create a string of all tags to store const allTags = safeTags.join(','); const result = await window.api.addFavorite({ @@ -414,7 +356,7 @@ document.addEventListener('DOMContentLoaded', () => { title, imageUrl, thumbnailUrl, - tags: allTags, // Pass all tags to the backend + tags: allTags, }); if (result.success) { showMessage('Added to favorites!', 'success'); @@ -427,7 +369,6 @@ document.addEventListener('DOMContentLoaded', () => { const result = await window.api.removeFavorite(id); if (result.success) { showMessage('Removed from favorites.', 'success'); - // Find the card to remove, regardless of layout const cardToRemove = document.querySelector( `#favorites-gallery [data-id='${id}']` ); @@ -436,50 +377,34 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { cardToRemove.remove(); if (favoritesGallery.children.length === 0) { - // Apply layout classes applyLayoutToGallery(favoritesGallery, currentLayout); favoritesGallery.innerHTML = '

You haven\'t saved any favorites yet.

'; } - }, 300); // Wait for animation + }, 300); } } else { showMessage(result.error, 'error'); } } - // --- UI Helpers --- - - /** - * REWRITTEN: Creates a professional image card based on current layout. - * @param {string} id - The unique ID of the artwork. - * @param {string[]} tags - An array of tags. - * @param {string} imageUrl - The full URL of the image to display. - * @param {string} thumbnailUrl - The fallback thumbnail URL. - * @param {'browse' | 'fav'} type - The type of card to create. - * @returns {HTMLElement} The card element. - */ function createImageCard(id, tags, imageUrl, thumbnailUrl, type) { - // Ensure 'tags' is an array before using array methods + const safeTags = Array.isArray(tags) ? tags : []; - // --- All layouts use this as the base card --- 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`; - // --- "Compact" layout gets a special style --- if (currentLayout === 'compact') { - // Use aspect-ratio to keep cards square and uniform + entry.classList.add('aspect-square'); } - // Image container with pulse animation for loading const imageContainer = document.createElement('div'); imageContainer.className = 'w-full bg-gray-700 animate-pulse relative'; - - // For "Compact" layout, image container is also square + if (currentLayout === 'compact') { imageContainer.classList.add('h-full'); } else { @@ -489,13 +414,12 @@ document.addEventListener('DOMContentLoaded', () => { entry.appendChild(imageContainer); const img = document.createElement('img'); - img.src = imageUrl; // Try to load the full-res image first - img.alt = safeTags.join(', '); // Use safeTags - img.className = 'w-full h-auto object-contain bg-gray-900 opacity-0'; // Start hidden + 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'; - - // "Compact" layout uses "object-cover" to fill the square + if (currentLayout === 'compact') { img.className = 'w-full h-full object-cover bg-gray-900 opacity-0'; } @@ -508,20 +432,18 @@ document.addEventListener('DOMContentLoaded', () => { img.onerror = () => { console.warn(`Failed to load full image: ${imageUrl}. Falling back to thumbnail.`); - img.src = thumbnailUrl; // Fallback + 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; // Prevent infinite loop + img.onerror = null; }; imageContainer.appendChild(img); - // --- Add buttons (overlay on hover) --- 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'; - // Add Info Button buttonContainer.appendChild(createInfoButton(safeTags)); if (type === 'browse') { @@ -531,49 +453,38 @@ document.addEventListener('DOMContentLoaded', () => { } else { buttonContainer.appendChild(createRemoveFavoriteButton(id)); } - imageContainer.appendChild(buttonContainer); // Add buttons to image container + imageContainer.appendChild(buttonContainer); return entry; } - /** - * Tries to guess the full-resolution image URL from a thumbnail URL. - * This is a "best guess" based on common patterns. - * @param {string} thumbnailUrl - The URL of the thumbnail. - * @param {string} source - The name of the source (e.g., 'Gelbooru', 'Rule34'). - * @returns {string} The guessed full-resolution URL. - */ function getFullImageUrl(thumbnailUrl, source) { if (!thumbnailUrl) return ''; try { - // Waifu.pics API already provides the full URL + if (source === 'WaifuPics') { return thumbnailUrl; } - // Rule34 (API): preview_url -> file_url if (source === 'Rule34' && thumbnailUrl.includes('thumbnail_')) { return thumbnailUrl .replace('/thumbnails/', '/images/') .replace('thumbnail_', ''); } - - // Gelbooru (Scraper): /thumbnails/ -> /images/ + if (source === 'Gelbooru' && thumbnailUrl.includes('/thumbnails/')) { return thumbnailUrl .replace('/thumbnails/', '/images/') .replace('thumbnail_', ''); } - - // Safebooru (Scraper): /thumbnails/ -> /images/ + if (source === 'Safebooru' && thumbnailUrl.includes('/thumbnails/')) { return thumbnailUrl .replace('/thumbnails/', '/images/') .replace('thumbnail_', ''); } - - // Fallback for unknown scrapers + if (thumbnailUrl.includes('/thumbnails/')) { return thumbnailUrl .replace('/thumbnails/', '/images/') @@ -583,11 +494,10 @@ document.addEventListener('DOMContentLoaded', () => { } catch (e) { console.error('Error parsing full image URL:', e); } - // If no rules match, just return the thumbnail URL + return thumbnailUrl; } - // --- Button Creation Helpers --- function createInfoButton(safeTags) { const button = document.createElement('button'); button.title = 'Show Info'; @@ -597,7 +507,7 @@ document.addEventListener('DOMContentLoaded', () => { `; button.onclick = (e) => { - e.stopPropagation(); // Prevent card click + e.stopPropagation(); showTagModal(safeTags); }; return button; @@ -612,7 +522,7 @@ document.addEventListener('DOMContentLoaded', () => { `; button.onclick = (e) => { - e.stopPropagation(); // Prevent card click + e.stopPropagation(); handleAddFavorite(id, safeTags, imageUrl, thumbnailUrl); }; return button; @@ -627,23 +537,16 @@ document.addEventListener('DOMContentLoaded', () => { `; button.onclick = (e) => { - e.stopPropagation(); // Prevent card click + e.stopPropagation(); handleRemoveFavorite(id); }; return button; } - // --- END NEW: Button Creation Helpers --- - /** - * Shows a green/red message bar at the bottom of the screen. - * @param {string} message - The text to display. - * @param {'success' | 'error'} type - The type of message. - */ function showMessage(message, type = 'success') { if (!messageBar) return; messageBar.textContent = message; - // Set color if (type === 'error') { messageBar.classList.remove('bg-green-600'); messageBar.classList.add('bg-red-600'); @@ -652,28 +555,24 @@ document.addEventListener('DOMContentLoaded', () => { messageBar.classList.add('bg-green-600'); } - // Show messageBar.classList.remove('hidden', 'translate-y-16'); - // Hide after 3 seconds setTimeout(() => { messageBar.classList.add('hidden', 'translate-y-16'); }, 3000); } - // --- NEW: Settings Logic --- function loadSettings() { const savedLayout = localStorage.getItem('waifuBoardLayout') || 'scroll'; currentLayout = savedLayout; - - // Check if the saved layout element exists before trying to check it + const savedRadio = document.querySelector( `input[name="layout"][value="${savedLayout}"]` ); if (savedRadio) { savedRadio.checked = true; } else { - // Fallback if saved layout is invalid + document.getElementById('layout-scroll').checked = true; currentLayout = 'scroll'; localStorage.setItem('waifuBoardLayout', 'scroll'); @@ -686,14 +585,12 @@ document.addEventListener('DOMContentLoaded', () => { currentLayout = newLayout; console.log('Layout changed to:', newLayout); - // Re-render the current view if (browsePage.classList.contains('hidden')) { - loadFavorites(); // Re-render favorites + loadFavorites(); } else { - // --- FIX --- - // Only re-run the search if there was a query. + if (currentQuery) { - performSearch(); // Re-render browse (will reset to page 1) + performSearch(); } else { applyLayoutToGallery(contentGallery, currentLayout); } @@ -701,16 +598,13 @@ document.addEventListener('DOMContentLoaded', () => { } function applyLayoutToGallery(galleryElement, layout) { - // Reset all layout classes - galleryElement.className = 'p-4 w-full'; // Base classes + galleryElement.className = 'p-4 w-full'; if (layout === 'scroll') { galleryElement.classList.add('max-w-3xl', 'mx-auto', 'space-y-8'); } else if (layout === 'grid') { - // Use the Masonry layout class galleryElement.classList.add('gallery-masonry'); } else if (layout === 'compact') { - // Use the standard grid layout (formerly 'gallery-grid') galleryElement.classList.add('gallery-grid'); } } @@ -718,10 +612,8 @@ document.addEventListener('DOMContentLoaded', () => { layoutRadios.forEach((radio) => { radio.addEventListener('change', handleLayoutChange); }); - // --- END NEW: Settings Logic --- - // --- Initial Load --- - loadSettings(); // NEW: Load settings on startup - populateSources(); // Load the sources into the dropdown on startup - showPage('browse-page'); // Show the browse page on startup + loadSettings(); + populateSources(); + showPage('browse-page'); }); \ No newline at end of file diff --git a/scripts/updateNotification.js b/scripts/updateNotification.js new file mode 100644 index 0000000..660f5b2 --- /dev/null +++ b/scripts/updateNotification.js @@ -0,0 +1,77 @@ +const GITHUB_OWNER = 'ItsSkaiya'; +const GITHUB_REPO = 'WaifuBoard'; +const CURRENT_VERSION = '1.0.0'; + +let currentVersionDisplay; +let latestVersionDisplay; +let updateToast; + +document.addEventListener('DOMContentLoaded', () => { + + currentVersionDisplay = document.getElementById('currentVersionDisplay'); + latestVersionDisplay = document.getElementById('latestVersionDisplay'); + updateToast = document.getElementById('updateToast'); + + if (currentVersionDisplay) { + currentVersionDisplay.textContent = CURRENT_VERSION; + } + + checkForUpdates(); +}); + +function showToast(latestVersion) { + + if (latestVersionDisplay && updateToast) { + latestVersionDisplay.textContent = latestVersion; + updateToast.classList.add('update-available'); + updateToast.classList.remove('hidden'); + + } else { + console.error("Error: Cannot display toast because one or more DOM elements were not found."); + } +} + +function isVersionOutdated(versionA, versionB) { + + const vA = versionA.replace(/^v/, '').split('.').map(Number); + const vB = versionB.replace(/^v/, '').split('.').map(Number); + + for (let i = 0; i < Math.max(vA.length, vB.length); i++) { + const numA = vA[i] || 0; + const numB = vB[i] || 0; + + if (numA < numB) return true; + if (numA > numB) return false; + } + + return false; +} + +async function checkForUpdates() { + console.log(`Checking for updates for ${GITHUB_OWNER}/${GITHUB_REPO}...`); + const apiUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`; + + try { + const response = await fetch(apiUrl); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + const data = await response.json(); + + const latestVersion = data.tag_name; + console.log(`Latest GitHub Release: ${latestVersion}`); + + if (isVersionOutdated(CURRENT_VERSION, latestVersion)) { + console.warn('Update available!'); + showToast(latestVersion); + } else { + console.info('Package is up to date.'); + hideToast(); + } + + } catch (error) { + console.error('Failed to fetch GitHub release:', error); + } +} \ No newline at end of file diff --git a/updateNotification.js b/updateNotification.js deleted file mode 100644 index 71653a8..0000000 --- a/updateNotification.js +++ /dev/null @@ -1,93 +0,0 @@ -// --- Configuration --- -const GITHUB_OWNER = 'ItsSkaiya'; // e.g., 'google' -const GITHUB_REPO = 'WaifuBoard'; // e.g., 'gemini-api-cookbook' -const CURRENT_VERSION = '1.0.0'; // Manually set this, or pull from a package.json/config file - -// --- DOM Elements --- -const currentVersionDisplay = document.getElementById('currentVersionDisplay'); -const latestVersionDisplay = document.getElementById('latestVersionDisplay'); -const updateToast = document.getElementById('updateToast'); - -// Display the current version on load -document.addEventListener('DOMContentLoaded', () => { - currentVersionDisplay.textContent = CURRENT_VERSION; -}); - - -/** - * Shows the update notification toast. - * @param {string} latestVersion - The latest version string from GitHub. - */ -function showToast(latestVersion) { - latestVersionDisplay.textContent = latestVersion; - updateToast.classList.add('update-available'); - updateToast.classList.remove('hidden'); - // NOTE: The toast will NOT close until the user clicks 'X' -} - -/** - * Hides the update notification toast. - */ -function hideToast() { - updateToast.classList.add('hidden'); - updateToast.classList.remove('update-available'); -} - -/** - * Compares two semantic version strings (e.g., "1.2.3" vs "1.2.4"). - * Returns true if version A is older than version B. - * @param {string} versionA - The current version. - * @param {string} versionB - The latest version. - * @returns {boolean} True if A is older than B. - */ -function isVersionOutdated(versionA, versionB) { - // Clean up version strings (e.g., remove 'v' prefix) and split by '.' - const vA = versionA.replace(/^v/, '').split('.').map(Number); - const vB = versionB.replace(/^v/, '').split('.').map(Number); - - for (let i = 0; i < Math.max(vA.length, vB.length); i++) { - const numA = vA[i] || 0; - const numB = vB[i] || 0; - - if (numA < numB) return true; // A is older - if (numA > numB) return false; // A is newer - } - - return false; // Versions are the same or incomparable -} - -/** - * Main function to fetch the latest GitHub release and check for updates. - */ -async function checkForUpdates() { - console.log(`Checking for updates for ${GITHUB_OWNER}/${GITHUB_REPO}...`); - const apiUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`; - - try { - const response = await fetch(apiUrl); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - const data = await response.json(); - - // The tag_name often contains the version (e.g., "v1.0.1") - const latestVersion = data.tag_name; - console.log(`Latest GitHub Release: ${latestVersion}`); - - if (isVersionOutdated(CURRENT_VERSION, latestVersion)) { - // Package is out of date! Issue the red toast notification. - console.warn('Update available!'); - showToast(latestVersion); - } else { - // Package is up to date or newer. Do not show the toast. - console.info('Package is up to date.'); - hideToast(); // Ensure it's hidden in case a previous check showed it - } - - } catch (error) { - console.error('Failed to fetch GitHub release:', error); - // You might want a different toast here for a failure notification - } -} \ No newline at end of file diff --git a/index.html b/views/index.html similarity index 76% rename from index.html rename to views/index.html index d5f8435..21ceb8e 100644 --- a/index.html +++ b/views/index.html @@ -12,14 +12,12 @@ " /> Waifu Board - - -
-

Browse

- -
-
- - -
- - -