Organized all script files

Removed useless things from all files
Added an update notification if there is an update
Updated the directory for the app icon
This commit is contained in:
2025-11-18 13:23:50 -05:00
parent df23040017
commit 7f01bace06
9 changed files with 220 additions and 451 deletions

77
main.js
View File

@@ -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 { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require('sqlite3').verbose();
// --- NEW: Get paths for *both* dependencies ---
const fetchPath = require.resolve('node-fetch'); const fetchPath = require.resolve('node-fetch');
const cheerioPath = require.resolve('cheerio'); const cheerioPath = require.resolve('cheerio');
// --- END NEW ---
// --- Core paths ---
const waifuBoardsPath = path.join(app.getPath('home'), 'WaifuBoards'); const waifuBoardsPath = path.join(app.getPath('home'), 'WaifuBoards');
const pluginsPath = path.join(waifuBoardsPath, 'extensions'); const pluginsPath = path.join(waifuBoardsPath, 'extensions');
const dbPath = path.join(waifuBoardsPath, 'favorites.db'); const dbPath = path.join(waifuBoardsPath, 'favorites.db');
// --- Ensure directories exist ---
try { try {
if (!fs.existsSync(waifuBoardsPath)) { if (!fs.existsSync(waifuBoardsPath)) {
fs.mkdirSync(waifuBoardsPath); fs.mkdirSync(waifuBoardsPath);
} }
if (!fs.existsSync(pluginsPath)) { if (!fs.existsSync(pluginsPath)) {
// Use recursive: true in case WaifuBoards doesn't exist yet
fs.mkdirSync(pluginsPath, { recursive: true }); fs.mkdirSync(pluginsPath, { recursive: true });
} }
} catch (error) { } catch (error) {
console.error('Failed to create directories:', 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 = {}; const loadedScrapers = {};
function loadScrapers() { function loadScrapers() {
@@ -44,22 +35,19 @@ function loadScrapers() {
.forEach((file) => { .forEach((file) => {
const filePath = path.join(pluginsPath, file); const filePath = path.join(pluginsPath, file);
try { try {
// Dynamically require the scraper file
const scraperModule = require(filePath); const scraperModule = require(filePath);
// We assume the export is an object like { Gelbooru: class... }
const className = Object.keys(scraperModule)[0]; const className = Object.keys(scraperModule)[0];
const ScraperClass = scraperModule[className]; const ScraperClass = scraperModule[className];
// Basic check to see if it's a valid scraper class
if ( if (
typeof ScraperClass === 'function' && typeof ScraperClass === 'function' &&
ScraperClass.prototype.fetchSearchResult ScraperClass.prototype.fetchSearchResult
) { ) {
// --- MODIFIED: Inject *both* paths ---
const instance = new ScraperClass(fetchPath, cheerioPath);
// --- END MODIFIED ---
// Store the instance and its baseUrl const instance = new ScraperClass(fetchPath, cheerioPath);
loadedScrapers[className] = { loadedScrapers[className] = {
instance: instance, instance: instance,
baseUrl: instance.baseUrl, baseUrl: instance.baseUrl,
@@ -75,25 +63,21 @@ function loadScrapers() {
} }
}); });
} }
// --------------------
// Load scrapers at startup
loadScrapers(); loadScrapers();
// --- MODIFIED: Initialize sqlite3 (async) ---
const db = new sqlite3.Database(dbPath, (err) => { const db = new sqlite3.Database(dbPath, (err) => {
if (err) { if (err) {
console.error('Error opening database:', err.message); console.error('Error opening database:', err.message);
} else { } else {
console.log('Connected to the favorites database.'); console.log('Connected to the favorites database.');
runDatabaseMigrations(); // Run migrations after connecting runDatabaseMigrations();
} }
}); });
// --- MODIFIED: Database functions are now async ---
function runDatabaseMigrations() { function runDatabaseMigrations() {
db.serialize(() => { db.serialize(() => {
// Create the 'favorites' table
db.run( db.run(
` `
CREATE TABLE IF NOT EXISTS favorites ( CREATE TABLE IF NOT EXISTS favorites (
@@ -109,7 +93,6 @@ function runDatabaseMigrations() {
} }
); );
// --- Migration (Add thumbnail_url) ---
console.log('Checking database schema for "thumbnail_url"...'); console.log('Checking database schema for "thumbnail_url"...');
db.all('PRAGMA table_info(favorites)', (err, columns) => { db.all('PRAGMA table_info(favorites)', (err, columns) => {
if (err) { if (err) {
@@ -137,7 +120,6 @@ function runDatabaseMigrations() {
} }
}); });
// --- Migration (Add tags) ---
console.log('Checking database schema for "tags" column...'); console.log('Checking database schema for "tags" column...');
db.all('PRAGMA table_info(favorites)', (err, columns) => { db.all('PRAGMA table_info(favorites)', (err, columns) => {
if (err) { if (err) {
@@ -163,42 +145,33 @@ function runDatabaseMigrations() {
} }
function createWindow() { function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1000, width: 1000,
height: 800, height: 800,
webPreferences: { 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, '/scripts/preload.js'),
preload: path.join(__dirname, 'preload.js'),
// contextIsolation is true by default and is a critical security feature
contextIsolation: true, contextIsolation: true,
// nodeIntegration should be false
nodeIntegration: false, nodeIntegration: false,
}, },
}); });
// Load the index.html file into the window mainWindow.loadFile('views/index.html');
mainWindow.loadFile('index.html');
// --- Add this line to remove the menu bar ---
mainWindow.setMenu(null); mainWindow.setMenu(null);
} }
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
app.whenReady().then(() => { app.whenReady().then(() => {
// loadScrapers(); // MOVED: This is now called at the top
createWindow(); createWindow();
app.on('activate', function () { 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(); if (BrowserWindow.getAllWindows().length === 0) createWindow();
}); });
}); });
// Quit when all windows are closed, except on macOS.
app.on('window-all-closed', function () { app.on('window-all-closed', function () {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
db.close((err) => { 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', () => { ipcMain.handle('api:getSources', () => {
// Returns an array of objects: [{ name: 'Gelbooru', url: 'https://gelbooru.com' }, ...]
return Object.keys(loadedScrapers).map((name) => { return Object.keys(loadedScrapers).map((name) => {
return { return {
name: name, 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) => { ipcMain.handle('api:search', async (event, source, query, page) => {
try { try {
// Check if the requested source was successfully loaded
if (loadedScrapers[source] && loadedScrapers[source].instance) { if (loadedScrapers[source] && loadedScrapers[source].instance) {
// Pass the page number to the scraper
const results = await loadedScrapers[source].instance.fetchSearchResult( const results = await loadedScrapers[source].instance.fetchSearchResult(
query, query,
page page
@@ -241,16 +206,12 @@ ipcMain.handle('api:search', async (event, source, query, page) => {
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// --- MODIFIED: All db handlers are now async Promises ---
// Handle request to get all favorites
ipcMain.handle('db:getFavorites', () => { ipcMain.handle('db:getFavorites', () => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.all('SELECT * FROM favorites', [], (err, rows) => { db.all('SELECT * FROM favorites', [], (err, rows) => {
if (err) { if (err) {
console.error('Error getting favorites:', err.message); console.error('Error getting favorites:', err.message);
resolve([]); // Resolve with empty array on error resolve([]);
} else { } else {
resolve(rows); resolve(rows);
} }
@@ -258,7 +219,6 @@ ipcMain.handle('db:getFavorites', () => {
}); });
}); });
// Handle request to add a favorite
ipcMain.handle('db:addFavorite', (event, fav) => { ipcMain.handle('db:addFavorite', (event, fav) => {
return new Promise((resolve) => { return new Promise((resolve) => {
const stmt = const stmt =
@@ -267,7 +227,7 @@ ipcMain.handle('db:addFavorite', (event, fav) => {
stmt, stmt,
[fav.id, fav.title, fav.imageUrl, fav.thumbnailUrl, fav.tags], [fav.id, fav.title, fav.imageUrl, fav.thumbnailUrl, fav.tags],
function (err) { function (err) {
// Must use 'function' to get 'this'
if (err) { if (err) {
if (err.code.includes('SQLITE_CONSTRAINT')) { if (err.code.includes('SQLITE_CONSTRAINT')) {
resolve({ success: false, error: 'Item is already a favorite.' }); 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) => { ipcMain.handle('db:removeFavorite', (event, id) => {
return new Promise((resolve) => { return new Promise((resolve) => {
const stmt = 'DELETE FROM favorites WHERE id = ?'; const stmt = 'DELETE FROM favorites WHERE id = ?';
db.run(stmt, id, function (err) { db.run(stmt, id, function (err) {
// Must use 'function' to get 'this'
if (err) { if (err) {
console.error('Error removing favorite:', err.message); console.error('Error removing favorite:', err.message);
resolve({ success: false, error: err.message }); resolve({ success: false, error: err.message });

View File

@@ -28,7 +28,7 @@
"productName": "Waifu Board", "productName": "Waifu Board",
"win": { "win": {
"target": "portable", "target": "portable",
"icon": "build/waifuboards.ico" "icon": "public/waifuboards.ico"
}, },
"files": [ "files": [
"**/*", "**/*",

View File

@@ -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'),
});

View File

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

12
scripts/preload.js Normal file
View File

@@ -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'),
});

View File

@@ -1,65 +1,48 @@
/*
renderer.js
MODIFIED: Now includes infinite scrolling
*/
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// --- Page Elements ---
const browseButton = document.getElementById('browse-button'); const browseButton = document.getElementById('browse-button');
const favoritesButton = document.getElementById('favorites-button'); const favoritesButton = document.getElementById('favorites-button');
const settingsButton = document.getElementById('settings-button'); const settingsButton = document.getElementById('settings-button');
const browsePage = document.getElementById('browse-page'); 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 pageTitle = document.getElementById('page-title');
const headerContext = document.getElementById('header-context'); const headerContext = document.getElementById('header-context');
// --- Search Modal Elements ---
const searchIconButton = document.getElementById('search-icon-button'); const searchIconButton = document.getElementById('search-icon-button');
const searchModal = document.getElementById('search-modal'); const searchModal = document.getElementById('search-modal');
const searchCloseButton = document.getElementById('search-close-button'); const searchCloseButton = document.getElementById('search-close-button');
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const searchButton = document.getElementById('search-button'); const searchButton = document.getElementById('search-button');
// --- Gallery Elements ---
const sourceList = document.getElementById('source-list'); const sourceList = document.getElementById('source-list');
const contentGallery = document.getElementById('content-gallery'); const contentGallery = document.getElementById('content-gallery');
const favoritesGallery = document.getElementById('favorites-gallery'); const favoritesGallery = document.getElementById('favorites-gallery');
const loadingSpinner = document.getElementById('loading-spinner'); const loadingSpinner = document.getElementById('loading-spinner');
// NEW: Get the infinite loading spinner
const infiniteLoadingSpinner = document.getElementById( const infiniteLoadingSpinner = document.getElementById(
'infinite-loading-spinner' 'infinite-loading-spinner'
); );
const messageBar = document.getElementById('message-bar'); const messageBar = document.getElementById('message-bar');
const galleryPlaceholder = document.getElementById('gallery-placeholder'); const galleryPlaceholder = document.getElementById('gallery-placeholder');
// --- Settings Elements ---
const layoutRadios = document.querySelectorAll('input[name="layout"]'); 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 tagInfoModal = document.getElementById('tag-info-modal');
const tagInfoCloseButton = document.getElementById( const tagInfoCloseButton = document.getElementById(
'tag-info-close-button' 'tag-info-close-button'
); );
const tagInfoContent = document.getElementById('tag-info-content'); const tagInfoContent = document.getElementById('tag-info-content');
// --- App State --- let currentFavorites = [];
let currentFavorites = []; // Cache for favorites
let currentSource = ''; let currentSource = '';
let currentQuery = ''; let currentQuery = '';
let currentLayout = 'scroll'; // Default layout let currentLayout = 'scroll';
// --- NEW: State for infinite scroll ---
let currentPage = 1; let currentPage = 1;
let isLoading = false; let isLoading = false;
let hasNextPage = true; let hasNextPage = true;
// --- Populate Sources Sidebar ---
async function populateSources() { async function populateSources() {
console.log('Requesting sources from main process...'); console.log('Requesting sources from main process...');
const sources = await window.api.getSources(); // e.g., [{ name: 'Gelbooru', url: '...' }] const sources = await window.api.getSources();
sourceList.innerHTML = ''; // Clear "Loading..." sourceList.innerHTML = '';
if (sources && sources.length > 0) { if (sources && sources.length > 0) {
sources.forEach((source) => { sources.forEach((source) => {
@@ -69,32 +52,25 @@ document.addEventListener('DOMContentLoaded', () => {
button.dataset.source = source.name; button.dataset.source = source.name;
button.title = source.name; button.title = source.name;
// Create and add favicon
const favicon = document.createElement('img'); const favicon = document.createElement('img');
favicon.className = 'w-8 h-8 rounded'; favicon.className = 'w-8 h-8 rounded';
// Parse main domain from URL to get correct favicon let mainDomain = source.url;
let mainDomain = source.url; // Default to the full URL
try { try {
const hostname = new URL(source.url).hostname; // e.g., 'api.waifu.pics' const hostname = new URL(source.url).hostname;
const parts = hostname.split('.'); const parts = hostname.split('.');
if (parts.length > 2 && ['api', 'www'].includes(parts[0])) { 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('.'); mainDomain = parts.slice(1).join('.');
} else { } else {
// It's already a main domain (e.g., 'gelbooru.com')
mainDomain = hostname; mainDomain = hostname;
} }
} catch (e) { } catch (e) {
console.warn(`Could not parse domain from ${source.url}:`, e); console.warn(`Could not parse domain from ${source.url}:`, e);
mainDomain = source.name; 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.src = `https://www.google.com/s2/favicons?domain=${mainDomain}&sz=32`;
favicon.alt = source.name; favicon.alt = source.name;
// Fallback in case favicon fails to load
favicon.onerror = () => { favicon.onerror = () => {
button.innerHTML = `<span class="font-bold text-sm">${source.name.substring( button.innerHTML = `<span class="font-bold text-sm">${source.name.substring(
0, 0,
@@ -108,7 +84,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
console.log('Sources populated:', sources); console.log('Sources populated:', sources);
// Set first source as active by default
if (sourceList.children.length > 0) { if (sourceList.children.length > 0) {
const firstButton = sourceList.children[0]; const firstButton = sourceList.children[0];
firstButton.classList.add('active'); firstButton.classList.add('active');
@@ -120,11 +95,9 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
// --- Source Selection ---
sourceList.addEventListener('click', (e) => { sourceList.addEventListener('click', (e) => {
const button = e.target.closest('.source-button'); const button = e.target.closest('.source-button');
if (button) { if (button) {
// ... (remove/add active class) ...
sourceList sourceList
.querySelectorAll('.source-button') .querySelectorAll('.source-button')
.forEach((btn) => btn.classList.remove('active')); .forEach((btn) => btn.classList.remove('active'));
@@ -133,46 +106,41 @@ document.addEventListener('DOMContentLoaded', () => {
currentSource = button.dataset.source; currentSource = button.dataset.source;
console.log('Source changed to:', currentSource); console.log('Source changed to:', currentSource);
updateHeader(); updateHeader();
// Automatically re-search when changing source if a query exists
if (currentQuery) { if (currentQuery) {
// This will reset the gallery and start a new search
performSearch(); performSearch();
} }
} }
}); });
// --- Tab Switching Logic (Sidebar) ---
function showPage(pageId) { function showPage(pageId) {
// Hide all pages
document.querySelectorAll('.page').forEach((page) => { document.querySelectorAll('.page').forEach((page) => {
page.classList.add('hidden'); page.classList.add('hidden');
}); });
// De-activate all icon buttons
document.querySelectorAll('.nav-button').forEach((tab) => { document.querySelectorAll('.nav-button').forEach((tab) => {
tab.classList.remove('bg-indigo-600', 'text-white'); tab.classList.remove('bg-indigo-600', 'text-white');
tab.classList.add('text-gray-400', 'hover:bg-gray-700'); tab.classList.add('text-gray-400', 'hover:bg-gray-700');
}); });
// Show the active page
const activePage = document.getElementById(pageId); const activePage = document.getElementById(pageId);
activePage.classList.remove('hidden'); activePage.classList.remove('hidden');
// Highlight the active icon button
let activeTab; let activeTab;
if (pageId === 'browse-page') { if (pageId === 'browse-page') {
activeTab = browseButton; activeTab = browseButton;
pageTitle.textContent = 'Browse'; pageTitle.textContent = 'Browse';
updateHeader(); // Update header context updateHeader();
} else if (pageId === 'favorites-page') { } else if (pageId === 'favorites-page') {
activeTab = favoritesButton; activeTab = favoritesButton;
pageTitle.textContent = 'Favorites'; pageTitle.textContent = 'Favorites';
headerContext.textContent = ''; // Clear context headerContext.textContent = '';
// When switching to favorites, refresh the list
loadFavorites(); loadFavorites();
} else if (pageId === 'settings-page') { } else if (pageId === 'settings-page') {
activeTab = settingsButton; activeTab = settingsButton;
pageTitle.textContent = 'Settings'; pageTitle.textContent = 'Settings';
headerContext.textContent = ''; // Clear context headerContext.textContent = '';
} }
activeTab.classList.add('bg-indigo-600', 'text-white'); activeTab.classList.add('bg-indigo-600', 'text-white');
activeTab.classList.remove('text-gray-400', 'hover:bg-gray-700'); activeTab.classList.remove('text-gray-400', 'hover:bg-gray-700');
@@ -182,41 +150,36 @@ document.addEventListener('DOMContentLoaded', () => {
favoritesButton.addEventListener('click', () => showPage('favorites-page')); favoritesButton.addEventListener('click', () => showPage('favorites-page'));
settingsButton.addEventListener('click', () => showPage('settings-page')); settingsButton.addEventListener('click', () => showPage('settings-page'));
// --- Search Modal Logic ---
searchIconButton.addEventListener('click', () => { searchIconButton.addEventListener('click', () => {
searchModal.classList.remove('hidden'); searchModal.classList.remove('hidden');
searchInput.focus(); // Auto-focus the search bar searchInput.focus();
searchInput.select(); searchInput.select();
}); });
searchCloseButton.addEventListener('click', () => { searchCloseButton.addEventListener('click', () => {
searchModal.classList.add('hidden'); searchModal.classList.add('hidden');
}); });
searchButton.addEventListener('click', () => { searchButton.addEventListener('click', () => {
// Sanitize search query to allow multiple tags
// MODIFIED: This just calls performSearch, which now handles its own state
performSearch(); performSearch();
}); });
// Close search modal on Escape key
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
searchModal.classList.add('hidden'); searchModal.classList.add('hidden');
} }
}); });
// --- Tag Info Modal Logic ---
tagInfoCloseButton.addEventListener('click', () => { tagInfoCloseButton.addEventListener('click', () => {
tagInfoModal.classList.add('hidden'); tagInfoModal.classList.add('hidden');
}); });
// Close tag modal by clicking the backdrop
tagInfoModal.addEventListener('click', (e) => { tagInfoModal.addEventListener('click', (e) => {
if (e.target === tagInfoModal) { if (e.target === tagInfoModal) {
tagInfoModal.classList.add('hidden'); tagInfoModal.classList.add('hidden');
} }
}); });
// Function to show the tag info modal
function showTagModal(tags) { function showTagModal(tags) {
tagInfoContent.innerHTML = ''; // Clear old tags tagInfoContent.innerHTML = '';
if (!tags || tags.length === 0) { if (!tags || tags.length === 0) {
tagInfoContent.innerHTML = tagInfoContent.innerHTML =
@@ -231,7 +194,7 @@ document.addEventListener('DOMContentLoaded', () => {
const tagPill = document.createElement('span'); const tagPill = document.createElement('span');
tagPill.className = tagPill.className =
'px-2.5 py-1 bg-gray-700 text-gray-300 text-xs font-medium rounded-full'; '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); fragment.appendChild(tagPill);
} }
}); });
@@ -239,7 +202,6 @@ document.addEventListener('DOMContentLoaded', () => {
tagInfoModal.classList.remove('hidden'); tagInfoModal.classList.remove('hidden');
} }
// --- Header Update ---
function updateHeader() { function updateHeader() {
if (currentSource) { if (currentSource) {
headerContext.textContent = `Source: ${currentSource}`; headerContext.textContent = `Source: ${currentSource}`;
@@ -248,56 +210,48 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
// --- Search Function ---
async function performSearch() { async function performSearch() {
if (!currentSource) { if (!currentSource) {
showMessage('Please select a source from the sidebar.', 'error'); showMessage('Please select a source from the sidebar.', 'error');
return; return;
} }
// --- NEW: Reset state for a new search ---
currentPage = 1; currentPage = 1;
hasNextPage = true; hasNextPage = true;
isLoading = false; isLoading = false;
currentQuery = searchInput.value.trim().replace(/[, ]+/g, ' '); currentQuery = searchInput.value.trim().replace(/[, ]+/g, ' ');
if (galleryPlaceholder) galleryPlaceholder.classList.add('hidden'); 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'); searchModal.classList.add('hidden');
// Load the first page of results
loadMoreResults(); loadMoreResults();
} }
// --- NEW: Infinite Scroll Loader ---
async function loadMoreResults() { async function loadMoreResults() {
// Don't load if we're already loading or if there are no more pages
if (isLoading || !hasNextPage) { if (isLoading || !hasNextPage) {
return; return;
} }
isLoading = true; isLoading = true;
// Show the correct spinner
if (currentPage === 1) { if (currentPage === 1) {
loadingSpinner.classList.remove('hidden'); // Show main spinner loadingSpinner.classList.remove('hidden');
} else { } 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( const result = await window.api.search(
currentSource, currentSource,
currentQuery, currentQuery,
currentPage currentPage
); );
// Hide all spinners
loadingSpinner.classList.add('hidden'); loadingSpinner.classList.add('hidden');
infiniteLoadingSpinner.classList.add('hidden'); infiniteLoadingSpinner.classList.add('hidden');
@@ -306,14 +260,14 @@ document.addEventListener('DOMContentLoaded', () => {
!result.data.results || !result.data.results ||
result.data.results.length === 0 result.data.results.length === 0
) { ) {
hasNextPage = false; // Stop trying to load more hasNextPage = false;
if (currentPage === 1) { if (currentPage === 1) {
// If it's the first page and no results, show "No results"
applyLayoutToGallery(contentGallery, currentLayout); applyLayoutToGallery(contentGallery, currentLayout);
contentGallery.innerHTML = contentGallery.innerHTML =
'<p class="text-gray-400 text-center text-lg">No results found. Please try another search term.</p>'; '<p class="text-gray-400 text-center text-lg">No results found. Please try another search term.</p>';
} }
// If it's not the first page, we just stop loading (no message needed)
isLoading = false; isLoading = false;
return; return;
} }
@@ -321,7 +275,7 @@ document.addEventListener('DOMContentLoaded', () => {
const validResults = result.data.results.filter((item) => item.image); const validResults = result.data.results.filter((item) => item.image);
if (validResults.length === 0) { if (validResults.length === 0) {
hasNextPage = false; // Stop trying to load more hasNextPage = false;
if (currentPage === 1) { if (currentPage === 1) {
applyLayoutToGallery(contentGallery, currentLayout); applyLayoutToGallery(contentGallery, currentLayout);
contentGallery.innerHTML = contentGallery.innerHTML =
@@ -331,66 +285,57 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// Use a DocumentFragment for performance
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
validResults.forEach((item) => { validResults.forEach((item) => {
const thumbnailUrl = item.image; const thumbnailUrl = item.image;
// const fullImageUrl = getFullImageUrl(thumbnailUrl, currentSource);
const displayUrl = item.sampleImageUrl || item.fullImageUrl || thumbnailUrl; const displayUrl = item.sampleImageUrl || item.fullImageUrl || thumbnailUrl;
const card = createImageCard( const card = createImageCard(
item.id.toString(), item.id.toString(),
item.tags, // Pass the whole tags array item.tags,
displayUrl, // Pass the new *real* URL displayUrl,
thumbnailUrl, // Pass the *real* thumbnail as a fallback thumbnailUrl,
'browse' 'browse'
); );
fragment.appendChild(card); fragment.appendChild(card);
}); });
// Append the new results instead of overwriting
contentGallery.appendChild(fragment); contentGallery.appendChild(fragment);
// Update state for the next scroll
hasNextPage = result.data.hasNextPage; hasNextPage = result.data.hasNextPage;
currentPage++; currentPage++;
isLoading = false; isLoading = false;
} }
// --- NEW: Scroll Event Listener for Browse Page ---
browsePage.addEventListener('scroll', () => { browsePage.addEventListener('scroll', () => {
// Check if user is near the bottom of the scrollable area
if ( if (
browsePage.scrollTop + browsePage.clientHeight >= browsePage.scrollTop + browsePage.clientHeight >=
browsePage.scrollHeight - 600 // Load 600px before the end browsePage.scrollHeight - 600
) { ) {
loadMoreResults(); loadMoreResults();
} }
}); });
// --- Favorites Logic ---
async function loadFavorites() { async function loadFavorites() {
// Apply layout classes
applyLayoutToGallery(favoritesGallery, currentLayout); applyLayoutToGallery(favoritesGallery, currentLayout);
favoritesGallery.innerHTML = favoritesGallery.innerHTML =
'<div class="text-center p-10"><p class="text-gray-400">Loading favorites...</p></div>'; '<div class="text-center p-10"><p class="text-gray-400">Loading favorites...</p></div>';
currentFavorites = await window.api.getFavorites(); currentFavorites = await window.api.getFavorites();
if (currentFavorites.length === 0) { if (currentFavorites.length === 0) {
// Apply layout classes
applyLayoutToGallery(favoritesGallery, currentLayout); applyLayoutToGallery(favoritesGallery, currentLayout);
favoritesGallery.innerHTML = favoritesGallery.innerHTML =
'<p class="text-gray-400 text-center text-lg">You haven\'t saved any favorites yet.</p>'; '<p class="text-gray-400 text-center text-lg">You haven\'t saved any favorites yet.</p>';
return; return;
} }
// Apply layout classes
applyLayoutToGallery(favoritesGallery, currentLayout); applyLayoutToGallery(favoritesGallery, currentLayout);
favoritesGallery.innerHTML = ''; // Clear loading message favoritesGallery.innerHTML = '';
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
currentFavorites.forEach((fav) => { currentFavorites.forEach((fav) => {
const card = createImageCard( const card = createImageCard(
fav.id, fav.id,
// Read from the new 'tags' column instead of 'title'
fav.tags ? fav.tags.split(',') : [], fav.tags ? fav.tags.split(',') : [],
fav.image_url, fav.image_url,
fav.thumbnail_url, fav.thumbnail_url,
@@ -402,11 +347,8 @@ document.addEventListener('DOMContentLoaded', () => {
} }
async function handleAddFavorite(id, tags, imageUrl, thumbnailUrl) { async function handleAddFavorite(id, tags, imageUrl, thumbnailUrl) {
// Ensure 'tags' is an array before using array methods
const safeTags = Array.isArray(tags) ? tags : []; 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'; const title = safeTags.length > 0 ? safeTags[0] : 'Favorite';
// Create a string of all tags to store
const allTags = safeTags.join(','); const allTags = safeTags.join(',');
const result = await window.api.addFavorite({ const result = await window.api.addFavorite({
@@ -414,7 +356,7 @@ document.addEventListener('DOMContentLoaded', () => {
title, title,
imageUrl, imageUrl,
thumbnailUrl, thumbnailUrl,
tags: allTags, // Pass all tags to the backend tags: allTags,
}); });
if (result.success) { if (result.success) {
showMessage('Added to favorites!', 'success'); showMessage('Added to favorites!', 'success');
@@ -427,7 +369,6 @@ document.addEventListener('DOMContentLoaded', () => {
const result = await window.api.removeFavorite(id); const result = await window.api.removeFavorite(id);
if (result.success) { if (result.success) {
showMessage('Removed from favorites.', 'success'); showMessage('Removed from favorites.', 'success');
// Find the card to remove, regardless of layout
const cardToRemove = document.querySelector( const cardToRemove = document.querySelector(
`#favorites-gallery [data-id='${id}']` `#favorites-gallery [data-id='${id}']`
); );
@@ -436,50 +377,34 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => { setTimeout(() => {
cardToRemove.remove(); cardToRemove.remove();
if (favoritesGallery.children.length === 0) { if (favoritesGallery.children.length === 0) {
// Apply layout classes
applyLayoutToGallery(favoritesGallery, currentLayout); applyLayoutToGallery(favoritesGallery, currentLayout);
favoritesGallery.innerHTML = favoritesGallery.innerHTML =
'<p class="text-gray-400 text-center text-lg">You haven\'t saved any favorites yet.</p>'; '<p class="text-gray-400 text-center text-lg">You haven\'t saved any favorites yet.</p>';
} }
}, 300); // Wait for animation }, 300);
} }
} else { } else {
showMessage(result.error, 'error'); 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) { function createImageCard(id, tags, imageUrl, thumbnailUrl, type) {
// Ensure 'tags' is an array before using array methods
const safeTags = Array.isArray(tags) ? tags : []; const safeTags = Array.isArray(tags) ? tags : [];
// --- All layouts use this as the base card ---
const entry = document.createElement('div'); const entry = document.createElement('div');
entry.dataset.id = id; entry.dataset.id = id;
entry.className = `image-entry group relative bg-gray-800 rounded-lg shadow-lg overflow-hidden transition-all duration-300`; 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') { if (currentLayout === 'compact') {
// Use aspect-ratio to keep cards square and uniform
entry.classList.add('aspect-square'); entry.classList.add('aspect-square');
} }
// Image container with pulse animation for loading
const imageContainer = document.createElement('div'); const imageContainer = document.createElement('div');
imageContainer.className = imageContainer.className =
'w-full bg-gray-700 animate-pulse relative'; 'w-full bg-gray-700 animate-pulse relative';
// For "Compact" layout, image container is also square
if (currentLayout === 'compact') { if (currentLayout === 'compact') {
imageContainer.classList.add('h-full'); imageContainer.classList.add('h-full');
} else { } else {
@@ -489,13 +414,12 @@ document.addEventListener('DOMContentLoaded', () => {
entry.appendChild(imageContainer); entry.appendChild(imageContainer);
const img = document.createElement('img'); const img = document.createElement('img');
img.src = imageUrl; // Try to load the full-res image first img.src = imageUrl;
img.alt = safeTags.join(', '); // Use safeTags img.alt = safeTags.join(', ');
img.className = 'w-full h-auto object-contain bg-gray-900 opacity-0'; // Start hidden img.className = 'w-full h-auto object-contain bg-gray-900 opacity-0';
img.loading = 'lazy'; img.loading = 'lazy';
img.referrerPolicy = 'no-referrer'; img.referrerPolicy = 'no-referrer';
// "Compact" layout uses "object-cover" to fill the square
if (currentLayout === 'compact') { if (currentLayout === 'compact') {
img.className = 'w-full h-full object-cover bg-gray-900 opacity-0'; img.className = 'w-full h-full object-cover bg-gray-900 opacity-0';
} }
@@ -508,20 +432,18 @@ document.addEventListener('DOMContentLoaded', () => {
img.onerror = () => { img.onerror = () => {
console.warn(`Failed to load full image: ${imageUrl}. Falling back to thumbnail.`); 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'); imageContainer.classList.remove('animate-pulse', 'bg-gray-700');
img.classList.remove('opacity-0'); img.classList.remove('opacity-0');
img.classList.add('transition-opacity', 'duration-500'); img.classList.add('transition-opacity', 'duration-500');
img.onerror = null; // Prevent infinite loop img.onerror = null;
}; };
imageContainer.appendChild(img); imageContainer.appendChild(img);
// --- Add buttons (overlay on hover) ---
const buttonContainer = document.createElement('div'); const buttonContainer = document.createElement('div');
buttonContainer.className = 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'; '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)); buttonContainer.appendChild(createInfoButton(safeTags));
if (type === 'browse') { if (type === 'browse') {
@@ -531,49 +453,38 @@ document.addEventListener('DOMContentLoaded', () => {
} else { } else {
buttonContainer.appendChild(createRemoveFavoriteButton(id)); buttonContainer.appendChild(createRemoveFavoriteButton(id));
} }
imageContainer.appendChild(buttonContainer); // Add buttons to image container imageContainer.appendChild(buttonContainer);
return entry; 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) { function getFullImageUrl(thumbnailUrl, source) {
if (!thumbnailUrl) return ''; if (!thumbnailUrl) return '';
try { try {
// Waifu.pics API already provides the full URL
if (source === 'WaifuPics') { if (source === 'WaifuPics') {
return thumbnailUrl; return thumbnailUrl;
} }
// Rule34 (API): preview_url -> file_url
if (source === 'Rule34' && thumbnailUrl.includes('thumbnail_')) { if (source === 'Rule34' && thumbnailUrl.includes('thumbnail_')) {
return thumbnailUrl return thumbnailUrl
.replace('/thumbnails/', '/images/') .replace('/thumbnails/', '/images/')
.replace('thumbnail_', ''); .replace('thumbnail_', '');
} }
// Gelbooru (Scraper): /thumbnails/ -> /images/
if (source === 'Gelbooru' && thumbnailUrl.includes('/thumbnails/')) { if (source === 'Gelbooru' && thumbnailUrl.includes('/thumbnails/')) {
return thumbnailUrl return thumbnailUrl
.replace('/thumbnails/', '/images/') .replace('/thumbnails/', '/images/')
.replace('thumbnail_', ''); .replace('thumbnail_', '');
} }
// Safebooru (Scraper): /thumbnails/ -> /images/
if (source === 'Safebooru' && thumbnailUrl.includes('/thumbnails/')) { if (source === 'Safebooru' && thumbnailUrl.includes('/thumbnails/')) {
return thumbnailUrl return thumbnailUrl
.replace('/thumbnails/', '/images/') .replace('/thumbnails/', '/images/')
.replace('thumbnail_', ''); .replace('thumbnail_', '');
} }
// Fallback for unknown scrapers
if (thumbnailUrl.includes('/thumbnails/')) { if (thumbnailUrl.includes('/thumbnails/')) {
return thumbnailUrl return thumbnailUrl
.replace('/thumbnails/', '/images/') .replace('/thumbnails/', '/images/')
@@ -583,11 +494,10 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (e) { } catch (e) {
console.error('Error parsing full image URL:', e); console.error('Error parsing full image URL:', e);
} }
// If no rules match, just return the thumbnail URL
return thumbnailUrl; return thumbnailUrl;
} }
// --- Button Creation Helpers ---
function createInfoButton(safeTags) { function createInfoButton(safeTags) {
const button = document.createElement('button'); const button = document.createElement('button');
button.title = 'Show Info'; button.title = 'Show Info';
@@ -597,7 +507,7 @@ document.addEventListener('DOMContentLoaded', () => {
<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" /> <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>`; </svg>`;
button.onclick = (e) => { button.onclick = (e) => {
e.stopPropagation(); // Prevent card click e.stopPropagation();
showTagModal(safeTags); showTagModal(safeTags);
}; };
return button; return button;
@@ -612,7 +522,7 @@ document.addEventListener('DOMContentLoaded', () => {
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.31h5.518a.562.562 0 01.31.95l-4.203 3.03a.563.563 0 00-.182.53l1.501 4.87a.562.562 0 01-.82.624l-4.204-3.03a.563.563 0 00-.576 0l-4.204 3.03a.562.562 0 01-.82-.624l1.501-4.87a.563.563 0 00-.182-.53L2.498 9.87a.562.562 0 01.31-.95h5.518a.563.563 0 00.475-.31L11.48 3.5z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.31h5.518a.562.562 0 01.31.95l-4.203 3.03a.563.563 0 00-.182.53l1.501 4.87a.562.562 0 01-.82.624l-4.204-3.03a.563.563 0 00-.576 0l-4.204 3.03a.562.562 0 01-.82-.624l1.501-4.87a.563.563 0 00-.182-.53L2.498 9.87a.562.562 0 01.31-.95h5.518a.563.563 0 00.475-.31L11.48 3.5z" />
</svg>`; </svg>`;
button.onclick = (e) => { button.onclick = (e) => {
e.stopPropagation(); // Prevent card click e.stopPropagation();
handleAddFavorite(id, safeTags, imageUrl, thumbnailUrl); handleAddFavorite(id, safeTags, imageUrl, thumbnailUrl);
}; };
return button; return button;
@@ -627,23 +537,16 @@ document.addEventListener('DOMContentLoaded', () => {
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12.578 0a48.108 48.108 0 01-3.478-.397m15.408 0l-2.147-2.147A1.125 1.125 0 0016.34 3H7.66a1.125 1.125 0 00-.795.325L4.772 5.79m14.456 0l-2.29-2.29a1.125 1.125 0 00-.795-.324H8.455a1.125 1.125 0 00-.795.324L5.37 5.79m13.84 0L20.25 7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12.578 0a48.108 48.108 0 01-3.478-.397m15.408 0l-2.147-2.147A1.125 1.125 0 0016.34 3H7.66a1.125 1.125 0 00-.795.325L4.772 5.79m14.456 0l-2.29-2.29a1.125 1.125 0 00-.795-.324H8.455a1.125 1.125 0 00-.795.324L5.37 5.79m13.84 0L20.25 7.5" />
</svg>`; </svg>`;
button.onclick = (e) => { button.onclick = (e) => {
e.stopPropagation(); // Prevent card click e.stopPropagation();
handleRemoveFavorite(id); handleRemoveFavorite(id);
}; };
return button; 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') { function showMessage(message, type = 'success') {
if (!messageBar) return; if (!messageBar) return;
messageBar.textContent = message; messageBar.textContent = message;
// Set color
if (type === 'error') { if (type === 'error') {
messageBar.classList.remove('bg-green-600'); messageBar.classList.remove('bg-green-600');
messageBar.classList.add('bg-red-600'); messageBar.classList.add('bg-red-600');
@@ -652,28 +555,24 @@ document.addEventListener('DOMContentLoaded', () => {
messageBar.classList.add('bg-green-600'); messageBar.classList.add('bg-green-600');
} }
// Show
messageBar.classList.remove('hidden', 'translate-y-16'); messageBar.classList.remove('hidden', 'translate-y-16');
// Hide after 3 seconds
setTimeout(() => { setTimeout(() => {
messageBar.classList.add('hidden', 'translate-y-16'); messageBar.classList.add('hidden', 'translate-y-16');
}, 3000); }, 3000);
} }
// --- NEW: Settings Logic ---
function loadSettings() { function loadSettings() {
const savedLayout = localStorage.getItem('waifuBoardLayout') || 'scroll'; const savedLayout = localStorage.getItem('waifuBoardLayout') || 'scroll';
currentLayout = savedLayout; currentLayout = savedLayout;
// Check if the saved layout element exists before trying to check it
const savedRadio = document.querySelector( const savedRadio = document.querySelector(
`input[name="layout"][value="${savedLayout}"]` `input[name="layout"][value="${savedLayout}"]`
); );
if (savedRadio) { if (savedRadio) {
savedRadio.checked = true; savedRadio.checked = true;
} else { } else {
// Fallback if saved layout is invalid
document.getElementById('layout-scroll').checked = true; document.getElementById('layout-scroll').checked = true;
currentLayout = 'scroll'; currentLayout = 'scroll';
localStorage.setItem('waifuBoardLayout', 'scroll'); localStorage.setItem('waifuBoardLayout', 'scroll');
@@ -686,14 +585,12 @@ document.addEventListener('DOMContentLoaded', () => {
currentLayout = newLayout; currentLayout = newLayout;
console.log('Layout changed to:', newLayout); console.log('Layout changed to:', newLayout);
// Re-render the current view
if (browsePage.classList.contains('hidden')) { if (browsePage.classList.contains('hidden')) {
loadFavorites(); // Re-render favorites loadFavorites();
} else { } else {
// --- FIX ---
// Only re-run the search if there was a query.
if (currentQuery) { if (currentQuery) {
performSearch(); // Re-render browse (will reset to page 1) performSearch();
} else { } else {
applyLayoutToGallery(contentGallery, currentLayout); applyLayoutToGallery(contentGallery, currentLayout);
} }
@@ -701,16 +598,13 @@ document.addEventListener('DOMContentLoaded', () => {
} }
function applyLayoutToGallery(galleryElement, layout) { function applyLayoutToGallery(galleryElement, layout) {
// Reset all layout classes galleryElement.className = 'p-4 w-full';
galleryElement.className = 'p-4 w-full'; // Base classes
if (layout === 'scroll') { if (layout === 'scroll') {
galleryElement.classList.add('max-w-3xl', 'mx-auto', 'space-y-8'); galleryElement.classList.add('max-w-3xl', 'mx-auto', 'space-y-8');
} else if (layout === 'grid') { } else if (layout === 'grid') {
// Use the Masonry layout class
galleryElement.classList.add('gallery-masonry'); galleryElement.classList.add('gallery-masonry');
} else if (layout === 'compact') { } else if (layout === 'compact') {
// Use the standard grid layout (formerly 'gallery-grid')
galleryElement.classList.add('gallery-grid'); galleryElement.classList.add('gallery-grid');
} }
} }
@@ -718,10 +612,8 @@ document.addEventListener('DOMContentLoaded', () => {
layoutRadios.forEach((radio) => { layoutRadios.forEach((radio) => {
radio.addEventListener('change', handleLayoutChange); radio.addEventListener('change', handleLayoutChange);
}); });
// --- END NEW: Settings Logic ---
// --- Initial Load --- loadSettings();
loadSettings(); // NEW: Load settings on startup populateSources();
populateSources(); // Load the sources into the dropdown on startup showPage('browse-page');
showPage('browse-page'); // Show the browse page on startup
}); });

View File

@@ -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);
}
}

View File

@@ -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
}
}

View File

@@ -12,14 +12,12 @@
" /> " />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Waifu Board</title> <title>Waifu Board</title>
<!-- We'll use Tailwind CSS for a modern, professional look -->
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <style>
/* Simple scrollbar styling for the dark theme */
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background-color: #111827; background-color: #111827;
/* Tailwind gray-900 */
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -28,172 +26,149 @@
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #1f2937; background: #1f2937;
/* gray-800 */
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #4b5563; background: #4b5563;
/* gray-600 */
border-radius: 5px; border-radius: 5px;
border: 2px solid #1f2937; border: 2px solid #1f2937;
/* gray-800 */
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #6b7280; background: #6b7280;
/* gray-500 */
} }
.hidden { .hidden {
display: none; display: none;
} }
/* Simple focus ring for dark mode */
.dark-focus-ring:focus { .dark-focus-ring:focus {
outline: 2px solid #4f46e5; outline: 2px solid #4f46e5;
/* indigo-600 */
outline-offset: 2px; outline-offset: 2px;
} }
/* Style for the active source button in the sidebar */
.source-button.active { .source-button.active {
background-color: #4f46e5; background-color: #4f46e5;
/* indigo-600 */
color: white; color: white;
/* Add a ring to the active favicon */
outline: 2px solid #4f46e5; outline: 2px solid #4f46e5;
outline-offset: 2px; outline-offset: 2px;
} }
/* Group hover for showing buttons on image cards */
.image-entry:hover .image-buttons { .image-entry:hover .image-buttons {
opacity: 1; opacity: 1;
} }
/* --- MODIFIED --- */
/* This is the new "Compact" layout (formerly "Grid") */
/* It uses a standard, uniform grid */
.gallery-grid { .gallery-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem; gap: 1rem;
} }
/* --- NEW --- */
/* This is the new "Grid" layout (Masonry/Waterfall) */
/* It uses CSS columns to create a height-responsive layout */
.gallery-masonry { .gallery-masonry {
/* Set column count based on screen size */
column-count: 2; column-count: 2;
/* 2 columns on small screens */
column-gap: 1rem; column-gap: 1rem;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.gallery-masonry { .gallery-masonry {
column-count: 3; column-count: 3;
/* 3 columns on medium screens */
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.gallery-masonry { .gallery-masonry {
column-count: 4; column-count: 4;
/* 4 columns on large screens */
} }
} }
/* This tells each card in Masonry how to behave */
.gallery-masonry .image-entry { .gallery-masonry .image-entry {
display: inline-block; display: inline-block;
/* Respect the column flow */
width: 100%; width: 100%;
margin-bottom: 1rem; margin-bottom: 1rem;
/* Vertical gap */
break-inside: avoid; break-inside: avoid;
/* Prevent cards from breaking across columns */
} }
/* --- Toast Notification Styling --- */
.toast { .toast {
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
padding: 15px 25px 15px 15px; padding: 15px;
/* Extra padding on the right for the close button */
border-radius: 5px; border-radius: 5px;
color: white; color: white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
display: flex; display: flex;
/* Allows 'X' button to align to the right */ align-items: flex-start;
align-items: center;
justify-content: space-between; min-width: 350px;
min-width: 300px;
z-index: 1000; z-index: 1000;
/* Ensure it's above other elements */
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
} }
/* Red style for the 'out of date' notification */ .toast-content {
.toast.update-available { flex-grow: 1;
background-color: #e53935;
/* A strong red */
}
/* State for hiding the toast */
.toast.hidden {
opacity: 0;
pointer-events: none;
/* Prevents interaction when hidden */
} }
.toast p { .toast p {
margin: 0; margin: 0 0 5px 0;
flex-grow: 1;
/* Allows the text to take up most of the space */
} }
/* Styling for the close button */ .toast.update-available {
.close-btn { background-color: #e53935;
background: none; }
border: none;
color: white; .toast.hidden {
font-size: 16px; opacity: 0;
font-weight: bold; pointer-events: none;
cursor: pointer;
margin-left: 10px;
padding: 5px;
line-height: 1;
} }
</style> </style>
</head> </head>
<body class="text-gray-200 flex h-screen overflow-hidden"> <body class="text-gray-200 flex h-screen overflow-hidden">
<!-- Sidebar: Main Navigation -->
<nav class="w-20 bg-gray-900 flex flex-col items-center flex-shrink-0 p-4 space-y-6"> <nav class="w-20 bg-gray-900 flex flex-col items-center flex-shrink-0 p-4 space-y-6">
<!-- Home / Browse -->
<button id="browse-button" class="nav-button p-3 rounded-xl bg-indigo-600 text-white" title="Browse"> <button id="browse-button" class="nav-button p-3 rounded-xl bg-indigo-600 text-white" title="Browse">
<!-- Heroicon: Home -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6"> class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg> </svg>
</button> </button>
<!-- Favorites -->
<button id="favorites-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white" <button id="favorites-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white"
title="Favorites"> title="Favorites">
<!-- Heroicon: Star -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6"> class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.31h5.518a.562.562 0 01.31.95l-4.203 3.03a.563.563 0 00-.182.53l1.501 4.87a.562.562 0 01-.82.624l-4.204-3.03a.563.563 0 00-.576 0l-4.204 3.03a.562.562 0 01-.82-.624l1.501-4.87a.563.563 0 00-.182-.53L2.498 9.87a.562.562 0 01.31-.95h5.518a.563.563 0 00.475-.31L11.48 3.5z" /> d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.31h5.518a.562.562 0 01.31.95l-4.203 3.03a.563.563 0 00-.182.53l1.501 4.87a.562.562 0 01-.82.624l-4.204-3.03a.563.563 0 00-.576 0l-4.204 3.03a.562.562 0 01-.82-.624l1.501-4.87a.563.563 0 00-.182-.53L2.498 9.87a.562.562 0 01.31-.95h5.518a.563.563 0 00.475-.31L11.48 3.5z" />
</svg> </svg>
</button> </button>
<!-- Search -->
<button id="search-icon-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white" <button id="search-icon-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white"
title="Search"> title="Search">
<!-- Heroicon: Magnifying Glass -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6"> class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
@@ -201,10 +176,8 @@
</svg> </svg>
</button> </button>
<!-- NEW: Settings Button -->
<button id="settings-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white" <button id="settings-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white"
title="Settings"> title="Settings">
<!-- Heroicon: Cog 6 Tooth -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6"> class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
@@ -212,31 +185,21 @@
</svg> </svg>
</button> </button>
<!-- Divider -->
<div class="h-px w-10 bg-gray-700"></div> <div class="h-px w-10 bg-gray-700"></div>
<!-- Source List (Dynamically Populated) -->
<div id="source-list" class="flex flex-col items-center space-y-4" aria-label="Sources"> <div id="source-list" class="flex flex-col items-center space-y-4" aria-label="Sources">
<!-- Source buttons will be injected here by renderer.js -->
</div> </div>
</nav> </nav>
<!-- Main Content Area -->
<div class="flex-1 flex flex-col overflow-hidden"> <div class="flex-1 flex flex-col overflow-hidden">
<!-- Contextual Header -->
<header class="bg-gray-800 flex-shrink-0 flex items-center justify-between p-4 border-b border-gray-700 h-[69px]"> <header class="bg-gray-800 flex-shrink-0 flex items-center justify-between p-4 border-b border-gray-700 h-[69px]">
<h1 id="page-title" class="text-xl font-bold text-gray-100">Browse</h1> <h1 id="page-title" class="text-xl font-bold text-gray-100">Browse</h1>
<div id="header-context" class="text-sm text-gray-400"></div> <div id="header-context" class="text-sm text-gray-400"></div>
</header> </header>
<!-- Page: Browse -->
<!-- MODIFIED: Changed overflow-hidden to overflow-y-auto -->
<div id="browse-page" class="page flex-1 overflow-y-auto"> <div id="browse-page" class="page flex-1 overflow-y-auto">
<!-- Content Gallery: Layout classes will be added by JS -->
<main id="content-gallery" class="p-4 w-full" aria-live="polite"> <main id="content-gallery" class="p-4 w-full" aria-live="polite">
<!-- Loading spinner -->
<div id="loading-spinner" class="hidden text-center p-10 text-gray-400"> <div id="loading-spinner" class="hidden text-center p-10 text-gray-400">
<!-- Tailwind Spinner -->
<svg class="animate-spin h-8 w-8 text-indigo-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" <svg class="animate-spin h-8 w-8 text-indigo-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -246,14 +209,11 @@
</svg> </svg>
<p class="mt-2">Loading...</p> <p class="mt-2">Loading...</p>
</div> </div>
<!-- Search results will be injected here -->
<p id="gallery-placeholder" class="text-gray-400 text-center text-lg"> <p id="gallery-placeholder" class="text-gray-400 text-center text-lg">
Select a source and click the search icon to browse. Select a source and click the search icon to browse.
</p> </p>
<!-- NEW: Infinite Scroll Loading Spinner -->
<div id="infinite-loading-spinner" class="hidden text-center p-10 text-gray-400"> <div id="infinite-loading-spinner" class="hidden text-center p-10 text-gray-400">
<!-- Tailwind Spinner -->
<svg class="animate-spin h-8 w-8 text-indigo-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" <svg class="animate-spin h-8 w-8 text-indigo-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24"> viewBox="0 0 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -266,27 +226,20 @@
</main> </main>
</div> </div>
<!-- Page: Favorites -->
<!-- MODIFIED: Changed overflow-hidden to overflow-y-auto -->
<div id="favorites-page" class="page hidden flex-1 overflow-y-auto"> <div id="favorites-page" class="page hidden flex-1 overflow-y-auto">
<!-- Favorites Gallery: Layout classes will be added by JS -->
<main id="favorites-gallery" class="p-4 w-full"> <main id="favorites-gallery" class="p-4 w-full">
<!-- Favorite items will be injected here -->
</main> </main>
</div> </div>
<!-- NEW: Settings Page -->
<div id="settings-page" class="page hidden flex-1 overflow-y-auto p-8"> <div id="settings-page" class="page hidden flex-1 overflow-y-auto p-8">
<div class="max-w-2xl mx-auto space-y-8"> <div class="max-w-2xl mx-auto space-y-8">
<h2 class="text-2xl font-bold text-white">Settings</h2> <h2 class="text-2xl font-bold text-white">Settings</h2>
<!-- Layout Options -->
<div class="bg-gray-800 rounded-lg p-6"> <div class="bg-gray-800 rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-100 mb-4"> <h3 class="text-lg font-semibold text-gray-100 mb-4">
Gallery Layout Gallery Layout
</h3> </h3>
<fieldset class="space-y-4"> <fieldset class="space-y-4">
<!-- Scroll -->
<label for="layout-scroll" class="flex items-center p-4 bg-gray-700 rounded-lg cursor-pointer"> <label for="layout-scroll" class="flex items-center p-4 bg-gray-700 rounded-lg cursor-pointer">
<input type="radio" id="layout-scroll" name="layout" value="scroll" <input type="radio" id="layout-scroll" name="layout" value="scroll"
class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" /> class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" />
@@ -295,7 +248,6 @@
<span class="text-sm text-gray-400">A single, vertical column of large images.</span> <span class="text-sm text-gray-400">A single, vertical column of large images.</span>
</span> </span>
</label> </label>
<!-- Grid -->
<label for="layout-grid" class="flex items-center p-4 bg-gray-700 rounded-lg cursor-pointer"> <label for="layout-grid" class="flex items-center p-4 bg-gray-700 rounded-lg cursor-pointer">
<input type="radio" id="layout-grid" name="layout" value="grid" <input type="radio" id="layout-grid" name="layout" value="grid"
class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" /> class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" />
@@ -305,7 +257,6 @@
</span> </span>
</label> </label>
<!-- MODIFIED: "Table" is now "Compact" -->
<label for="layout-compact" class="flex items-center p-4 bg-gray-700 rounded-lg cursor-pointer"> <label for="layout-compact" class="flex items-center p-4 bg-gray-700 rounded-lg cursor-pointer">
<input type="radio" id="layout-compact" name="layout" value="compact" <input type="radio" id="layout-compact" name="layout" value="compact"
class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" /> class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" />
@@ -320,12 +271,10 @@
</div> </div>
</div> </div>
<!-- Search Modal -->
<div id="search-modal" <div id="search-modal"
class="hidden fixed inset-0 z-30 bg-black/70 backdrop-blur-md flex items-start justify-center p-8"> class="hidden fixed inset-0 z-30 bg-black/70 backdrop-blur-md flex items-start justify-center p-8">
<div class="bg-gray-800 p-4 rounded-lg shadow-xl w-full max-w-lg relative"> <div class="bg-gray-800 p-4 rounded-lg shadow-xl w-full max-w-lg relative">
<button id="search-close-button" class="absolute top-3 right-3 p-2 text-gray-400 hover:text-white"> <button id="search-close-button" class="absolute top-3 right-3 p-2 text-gray-400 hover:text-white">
<!-- Heroicon: X Mark -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6"> class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -343,13 +292,11 @@
</div> </div>
</div> </div>
<!-- NEW: Tag Info Modal -->
<div id="tag-info-modal" <div id="tag-info-modal"
class="hidden fixed inset-0 z-30 bg-black/70 backdrop-blur-md flex items-start justify-center p-8" class="hidden fixed inset-0 z-30 bg-black/70 backdrop-blur-md flex items-start justify-center p-8"
aria-modal="true"> aria-modal="true">
<div class="bg-gray-800 p-6 rounded-lg shadow-xl w-full max-w-lg relative"> <div class="bg-gray-800 p-6 rounded-lg shadow-xl w-full max-w-lg relative">
<button id="tag-info-close-button" class="absolute top-3 right-3 p-2 text-gray-400 hover:text-white"> <button id="tag-info-close-button" class="absolute top-3 right-3 p-2 text-gray-400 hover:text-white">
<!-- Heroicon: X Mark -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6"> class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -357,25 +304,21 @@
</button> </button>
<h2 class="text-xl font-semibold mb-4">Image Tags</h2> <h2 class="text-xl font-semibold mb-4">Image Tags</h2>
<div id="tag-info-content" class="flex flex-wrap gap-2 max-h-[60vh] overflow-y-auto"> <div id="tag-info-content" class="flex flex-wrap gap-2 max-h-[60vh] overflow-y-auto">
<!-- Tags will be injected here by renderer.js -->
</div> </div>
</div> </div>
</div> </div>
<!-- Message Bar (for confirmations) -->
<div id="message-bar" <div id="message-bar"
class="hidden fixed bottom-4 right-4 bg-green-600 text-white px-6 py-3 rounded-lg shadow-xl transition-all duration-300 transform translate-y-16"> class="hidden fixed bottom-4 right-4 bg-green-600 text-white px-6 py-3 rounded-lg shadow-xl transition-all duration-300 transform translate-y-16">
Message Message
</div> </div>
<div id="updateToast" class="toast hidden"> <div id="updateToast" class="toast hidden">
<p>🚨 A new update is available! Version <span id="latestVersionDisplay"></span></p> <p>An update is required for Waifu Board! newest version - <span id="latestVersionDisplay"></span></p>
<button class="close-btn" onclick="hideToast()">X</button>
</div> </div>
<!-- Renderer Script --> <script src="../scripts/renderer.js"></script>
<script src="./renderer.js"></script> <script src="../scripts/updateNotification.js"></script>
<script src="./updateNotification.js"></script>
</body> </body>
</html> </html>