21 Commits

Author SHA1 Message Date
0fd39fd2b3 Merge pull request 'Mobile UI Fixes' (#3) from dev into main
Reviewed-on: #3
2025-11-25 01:35:21 +01:00
8c37dc3217 Updated versioning 2025-11-24 19:33:23 -05:00
177a9cbac3 fully responsive UI 2025-11-24 23:43:51 +01:00
bdc4992d16 responsive design and better image handling 2025-11-24 20:59:50 +01:00
4d760c2ca4 Emulator navigation now shows on all pages 2025-11-24 12:49:24 -05:00
76492b492b Added in an emulator for coding extensions
no more exiting the instance just to constantly reupload your extension to see if it works.
write many times, test once. :)
2025-11-24 12:47:33 -05:00
27a98598bd Switched over to Gitea from GitHub
Added in a new Marketplace for extension installing
Updated update notification to Gitea
Fixed favoriting not working for image boards
2025-11-23 23:31:14 -05:00
42c3fff9a8 Updated README 2025-11-23 17:50:22 -05:00
e71c47f781 Updated README.md again 2025-11-23 17:33:52 -05:00
ad01b3a1c1 Updated README.md again 2025-11-23 17:27:12 -05:00
40ce55f568 Updated README.md again 2025-11-23 17:19:48 -05:00
b7decc3f98 Updated README.md 2025-11-23 17:17:59 -05:00
5e9f63ff47 Updated README.md 2025-11-23 16:29:28 -05:00
bec3423887 Added the in app marketplace 2025-11-23 10:17:21 -05:00
1d10083b86 Fixed DiscordRPC crashing the app on startup if you didn't have discord open. 2025-11-22 22:52:35 -05:00
75e695ceea Updated versioning 2025-11-22 10:59:28 -05:00
652db0586b Updated headless browser (Should be as fast as it was before)
Added in Book Boards! (NEW!)
Updated rendering logic
Updated search logic
Updated extension loading logic
Updated image handling logic
2025-11-22 10:55:27 -05:00
dca07a26f8 Updated version error 2025-11-21 21:25:45 -05:00
04f37218de Updated headless browser to support dynamic sites
Removed tabs and moved over to pages
Updated the rendering system
Fixed multiple pages not loading on scroll and re-rending or not rendering anything or just page 1.
Fixed the search bar not taking in spaces for each query
Updated how extensions are made
Updated how extensions are loaded
2025-11-21 11:48:07 -05:00
c3de5af1f2 Added dev tools support 2025-11-20 14:53:32 -05:00
aa5ac304bd Updated UI style to be more modern
Updated headless browser to get images, videos and gifs!
2025-11-20 14:25:23 -05:00
31 changed files with 4316 additions and 706 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/node_modules /node_modules
/dist /dist
.env .env
/public/banner.png

BIN
README.md

Binary file not shown.

26
main.js
View File

@@ -56,23 +56,24 @@ const db = initDatabase(dbPath);
function createWindow() { function createWindow() {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1000, width: 1324,
height: 800, height: 868,
webPreferences: { webPreferences: {
preload: path.join(__dirname, '/src/preload.js'), preload: path.join(__dirname, '/src/preload.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
enableRemoteModule: true
}, },
}); });
mainWindow.loadFile('views/index.html'); mainWindow.loadFile('views/index.html');
mainWindow.setMenu(null); mainWindow.setMenu(null);
// mainWindow.webContents.openDevTools();
} }
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
initDiscordRPC(); initDiscordRPC();
headlessBrowser.init()
app.on('activate', function () { app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow(); if (BrowserWindow.getAllWindows().length === 0) createWindow();
}); });
@@ -93,6 +94,23 @@ const dbHandlers = require('./src/ipc/db-handlers')(db);
ipcMain.handle('api:getSources', apiHandlers.getSources); ipcMain.handle('api:getSources', apiHandlers.getSources);
ipcMain.handle('api:search', apiHandlers.search); ipcMain.handle('api:search', apiHandlers.search);
ipcMain.handle('api:getChapters', apiHandlers.getChapters);
ipcMain.handle('api:getPages', apiHandlers.getPages);
ipcMain.handle('api:getMetadata', apiHandlers.getMetadata);
ipcMain.handle('db:getFavorites', dbHandlers.getFavorites); ipcMain.handle('db:getFavorites', dbHandlers.getFavorites);
ipcMain.handle('db:addFavorite', dbHandlers.addFavorite); ipcMain.handle('db:addFavorite', dbHandlers.addFavorite);
ipcMain.handle('db:removeFavorite', dbHandlers.removeFavorite); ipcMain.handle('db:removeFavorite', dbHandlers.removeFavorite);
ipcMain.handle('api:getInstalledExtensions', apiHandlers.getInstalledExtensions);
ipcMain.handle('api:installExtension', apiHandlers.installExtension);
ipcMain.handle('api:uninstallExtension', apiHandlers.uninstallExtension);
ipcMain.on('app:restart', () => {
app.relaunch();
app.exit();
});
ipcMain.on('toggle-dev-tools', (event) => {
event.sender.toggleDevTools();
});

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "waifu-board", "name": "waifu-board",
"version": "v1.2.0", "version": "v1.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "waifu-board", "name": "waifu-board",
"version": "v1.2.0", "version": "v1.6.2",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@ryuziii/discord-rpc": "^1.0.1-rc.1", "@ryuziii/discord-rpc": "^1.0.1-rc.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "waifu-board", "name": "waifu-board",
"version": "v1.3.1", "version": "v1.6.4",
"description": "An image board app to store and browse your favorite waifus!", "description": "An image board app to store and browse your favorite waifus!",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

BIN
public/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

View File

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

View File

@@ -55,6 +55,10 @@ function initDiscordRPC() {
}, 1000); }, 1000);
}); });
rpcClient.on('error', (err) => {
console.log('[Discord RPC] Background warning:', err.message);
});
rpcClient.on('disconnected', () => { rpcClient.on('disconnected', () => {
if (!app.isPackaged) { if (!app.isPackaged) {
console.log('Discord RPC: Disconnected. Attempting to reconnect in 10s...'); console.log('Discord RPC: Disconnected. Attempting to reconnect in 10s...');
@@ -69,6 +73,7 @@ function initDiscordRPC() {
try { try {
rpcClient.connect().catch(err => { rpcClient.connect().catch(err => {
console.error('Discord RPC: Connection failed', err.message); console.error('Discord RPC: Connection failed', err.message);
return;
}); });
} catch (err) { } catch (err) {
console.error('Discord RPC: Error initializing', err); console.error('Discord RPC: Error initializing', err);

306
src/emulator/emulator.js Normal file
View File

@@ -0,0 +1,306 @@
const CheerioShim = {
load: (html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const $ = (selector, context) => {
let elements;
if (selector && selector.nodeType) {
elements = [selector];
} else if (Array.isArray(selector)) {
elements = selector;
} else {
const root = (context && context[0]) ? context[0] : (context || doc);
elements = Array.from(root.querySelectorAll ? root.querySelectorAll(selector) : []);
}
return createWrapper(elements);
};
function createWrapper(elements) {
elements.forEach((el, i) => elements[i] = el);
elements.attr = (name) => {
if (elements.length === 0) return undefined;
return elements[0].getAttribute ? elements[0].getAttribute(name) : null;
};
elements.text = () => {
return elements.map(el => el.textContent).join('');
};
elements.find = (selector) => {
let found = [];
elements.forEach(el => {
if (el.querySelectorAll) {
found = found.concat(Array.from(el.querySelectorAll(selector)));
}
});
return createWrapper(found);
};
elements.each = (callback) => {
elements.forEach((el, i) => {
callback(i, el);
});
return elements;
};
elements.map = (callback) => {
const results = elements.map((el, i) => callback(i, el));
return createWrapper(results.filter(r => r !== null && r !== undefined));
};
elements.get = () => Array.from(elements);
elements.filter = (selectorOrFn) => {
if (typeof selectorOrFn === 'function') {
return createWrapper(elements.filter(selectorOrFn));
}
return createWrapper(elements.filter(el => el.matches && el.matches(selectorOrFn)));
};
elements.first = () => createWrapper(elements.length ? [elements[0]] : []);
elements.last = () => createWrapper(elements.length ? [elements[elements.length - 1]] : []);
elements.eq = (i) => createWrapper(elements.length > i ? [elements[i]] : []);
return elements;
}
return $;
}
};
window.require = (moduleName) => {
if (moduleName === 'cheerio') return CheerioShim;
if (moduleName === 'node-fetch' || moduleName === 'fetch') return window.fetch.bind(window);
return {};
};
window.module = { exports: {} };
class MockBrowser {
constructor() {
}
async scrape(url, pageFn, options = {}) {
emulatorLog(`[Browser] Scraping: ${url}`, 'info');
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
return await this.executePageFn(pageFn, doc);
} catch (error) {
emulatorLog(`[Browser] Error: ${error.message}`, 'error');
if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
emulatorLog('Tip: This looks like a CORS error. Ensure you have a "Allow CORS" extension enabled in your browser for testing.', 'warn');
}
throw error;
}
}
async executePageFn(pageFn, virtualDoc) {
const fnString = pageFn.toString();
const wrapper = new Function("document", `return (${fnString})();`);
return wrapper(virtualDoc);
}
}
const codeInput = document.getElementById('code-input');
const runBtn = document.getElementById('run-btn');
const outputVisual = document.getElementById('visual-container');
const outputJson = document.getElementById('json-content');
const outputConsole = document.getElementById('console-content');
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
function captureLogs() {
console.log = (...args) => {
originalConsoleLog(...args);
emulatorLog(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '), 'info');
};
console.error = (...args) => {
originalConsoleError(...args);
emulatorLog(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '), 'error');
};
console.warn = (...args) => {
originalConsoleWarn(...args);
emulatorLog(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '), 'warn');
};
}
function emulatorLog(msg, type = 'info') {
const div = document.createElement('div');
div.className = `log-entry log-${type}`;
div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
outputConsole.appendChild(div);
outputConsole.scrollTop = outputConsole.scrollHeight;
}
window.switchTab = (tabName) => {
['visual', 'json', 'console'].forEach(t => {
document.getElementById(`output-${t}`).classList.add('hidden');
});
document.getElementById(`output-${tabName}`).classList.remove('hidden');
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
if (event && event.target) event.target.classList.add('active');
};
runBtn.addEventListener('click', async () => {
outputVisual.innerHTML = '';
outputJson.textContent = '';
outputConsole.innerHTML = '';
window.module.exports = {};
captureLogs();
const code = codeInput.value;
const functionName = document.getElementById('func-select').value;
const argInput = document.getElementById('arg-input').value;
const pageInput = parseInt(document.getElementById('page-input').value) || 1;
if (!code.trim()) {
alert("Please paste extension code first.");
return;
}
emulatorLog("--- Starting Execution ---");
try {
try {
new Function(code)();
} catch (e) {
throw new Error(`Syntax/Runtime Error in Extension Code: ${e.message}`);
}
const exports = window.module.exports;
const keys = Object.keys(exports);
if (keys.length === 0) throw new Error("No class found in module.exports. Did you add 'module.exports = { ClassName };'?");
const ExtensionClass = exports[keys[0]];
emulatorLog(`Loaded Class: ${keys[0]}`, 'info');
const browserInstance = new MockBrowser();
const extension = new ExtensionClass('node-fetch', 'cheerio', browserInstance);
if (typeof extension[functionName] !== 'function') {
throw new Error(`Function '${functionName}' not found in class '${keys[0]}'.`);
}
emulatorLog(`Calling ${functionName}('${argInput}', ${pageInput})...`);
document.querySelector('.loading-state').classList.remove('hidden');
let result;
if (functionName === 'fetchSearchResult') {
result = await extension.fetchSearchResult(argInput, pageInput);
} else if (functionName === 'fetchInfo') {
result = await extension.fetchInfo(argInput);
} else if (functionName === 'findChapters') {
result = await extension.findChapters(argInput);
} else if (functionName === 'findChapterPages') {
result = await extension.findChapterPages(argInput);
}
document.querySelector('.loading-state').classList.add('hidden');
emulatorLog("Data received. Rendering...", 'info');
renderJson(result);
renderVisuals(result, functionName);
if (result) switchTab('visual');
} catch (err) {
document.querySelector('.loading-state').classList.add('hidden');
emulatorLog(err.message, 'error');
console.error(err);
}
});
function renderJson(data) {
outputJson.textContent = JSON.stringify(data, null, 2);
}
function renderVisuals(data, type) {
outputVisual.innerHTML = '';
if (!data) {
outputVisual.innerHTML = '<div style="padding:1rem; color: #a1a1aa;">No data returned (null/undefined). Check Console for errors.</div>';
return;
}
if (type === 'fetchSearchResult') {
if (data.results && Array.isArray(data.results) && data.results.length > 0) {
data.results.forEach(item => {
const card = document.createElement('div');
card.className = 'visual-card';
const imgSrc = item.image || item.cover || item.sampleImageUrl || '';
card.innerHTML = `
<img src="${imgSrc}" onerror="this.onerror=null;this.src='https://via.placeholder.com/150?text=Error';">
<div class="title" title="${item.title || item.id}">
${item.title || ('ID: ' + item.id)}
</div>
`;
outputVisual.appendChild(card);
});
} else {
outputVisual.innerHTML = '<div style="padding:1rem">No results found in "results" array.</div>';
}
}
else if (type === 'findChapters') {
if (data.chapters && Array.isArray(data.chapters)) {
const list = document.createElement('div');
list.style.cssText = 'display: flex; flex-direction: column; width: 100%; gap: 5px;';
data.chapters.forEach(chap => {
const row = document.createElement('div');
row.className = 'visual-chapter';
row.style.cssText = 'padding: 10px; background: var(--bg-surface); border-radius: 4px;';
row.innerHTML = `<span style="color: var(--accent);">Ch. ${chap.chapter}</span> - ${chap.title} <br><small style="color:#666">${chap.id}</small>`;
list.appendChild(row);
});
outputVisual.appendChild(list);
} else {
outputVisual.innerHTML = '<div>No chapters found. Check JSON.</div>';
}
}
else if (type === 'findChapterPages') {
if (Array.isArray(data)) {
data.forEach(page => {
if(page.type === 'text') {
const div = document.createElement('div');
div.innerHTML = page.content;
div.style.cssText = 'background: #fff; color: #000; padding: 20px; border-radius: 8px; margin-bottom: 20px;';
outputVisual.appendChild(div);
} else {
const img = document.createElement('img');
img.src = page.url;
img.style.cssText = 'max-width: 100%; margin-bottom: 10px; border-radius: 4px;';
outputVisual.appendChild(img);
}
});
} else {
outputVisual.innerHTML = '<div>No pages array returned.</div>';
}
}
else if (type === 'fetchInfo') {
const container = document.createElement('div');
container.style.padding = '1rem';
if (data.fullImage) {
container.innerHTML += `<img src="${data.fullImage}" style="max-height: 300px; display: block; margin-bottom: 1rem;">`;
}
container.innerHTML += `<pre>${JSON.stringify(data, null, 2)}</pre>`;
outputVisual.appendChild(container);
}
}

View File

@@ -1,56 +1,130 @@
export async function populateSources(sourceList) { export async function populateSources(sourceList, targetType = 'image-board') {
console.log('Requesting sources from main process...'); console.log(`Requesting sources for type: ${targetType}...`);
const sources = await window.api.getSources(); const sources = await window.api.getSources();
sourceList.innerHTML = ''; sourceList.innerHTML = '';
let initialSource = ''; let initialSource = '';
console.log("Raw sources received:", sources);
if (sources && sources.length > 0) { if (sources && sources.length > 0) {
sources.forEach((source) => { sources.forEach((source) => {
if (targetType !== 'all' && source.type !== targetType) {
return;
}
const button = document.createElement('button'); const button = document.createElement('button');
button.className =
'source-button w-12 h-12 flex items-center justify-center rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white transition-all duration-200'; button.className = 'source-button hover:bg-gray-700 hover:text-white transition-all duration-200';
button.dataset.source = source.name; button.dataset.source = source.name;
button.title = source.name; button.title = source.name;
const favicon = document.createElement('img');
favicon.className = 'w-8 h-8 rounded';
let mainDomain = source.url; let mainDomain = source.url;
try { try {
const hostname = new URL(source.url).hostname; const urlToParse = source.url || source.baseUrl || "";
const parts = hostname.split('.'); if (urlToParse) {
if (parts.length > 2 && ['api', 'www'].includes(parts[0])) { const hostname = new URL(urlToParse).hostname;
mainDomain = parts.slice(1).join('.'); const parts = hostname.split('.');
if (parts.length > 2 && ['api', 'www'].includes(parts[0])) {
mainDomain = parts.slice(1).join('.');
} else {
mainDomain = hostname;
}
} else { } else {
mainDomain = hostname; mainDomain = source.name;
} }
} catch (e) { } catch (e) {
console.warn(`Could not parse domain from ${source.url}:`, e);
mainDomain = source.name; mainDomain = source.name;
} }
const favicon = document.createElement('img');
favicon.className = 'source-icon rounded';
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;
const textWrapper = document.createElement('div');
textWrapper.className = 'source-text-wrapper';
const nameSpan = document.createElement('span');
nameSpan.className = 'source-name';
nameSpan.textContent = source.name;
const urlSpan = document.createElement('span');
urlSpan.className = 'source-url';
urlSpan.textContent = mainDomain;
textWrapper.appendChild(nameSpan);
textWrapper.appendChild(urlSpan);
favicon.onerror = () => { favicon.onerror = () => {
button.innerHTML = `<span class="font-bold text-sm">${source.name.substring(
0,
2
)}</span>`;
favicon.remove(); favicon.remove();
const fallbackIcon = document.createElement('div');
fallbackIcon.className = 'source-icon fallback';
fallbackIcon.textContent = source.name.substring(0, 1).toUpperCase();
button.insertBefore(fallbackIcon, textWrapper);
}; };
button.appendChild(favicon); button.appendChild(favicon);
button.appendChild(textWrapper);
sourceList.appendChild(button); sourceList.appendChild(button);
}); });
console.log('Sources populated:', sources);
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');
initialSource = firstButton.dataset.source; initialSource = firstButton.dataset.source;
} else {
console.warn(`No sources found for type: ${targetType}`);
} }
setupCarousel(sourceList);
} else { } else {
console.warn('No sources were loaded from the main process.'); console.warn('No sources loaded from API.');
} }
return initialSource; return initialSource;
}
function setupCarousel(element) {
element.addEventListener('wheel', (evt) => {
if (evt.deltaY !== 0) {
if (element.scrollWidth > element.clientWidth) {
evt.preventDefault();
element.scrollLeft += evt.deltaY;
}
}
});
let isDown = false;
let startX;
let scrollLeft;
element.addEventListener('mousedown', (e) => {
isDown = true;
element.style.cursor = 'grabbing';
startX = e.pageX - element.offsetLeft;
scrollLeft = element.scrollLeft;
});
element.addEventListener('mouseleave', () => {
isDown = false;
element.style.cursor = 'grab';
});
element.addEventListener('mouseup', () => {
isDown = false;
element.style.cursor = 'grab';
});
element.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - element.offsetLeft;
const walk = (x - startX) * 2;
element.scrollLeft = scrollLeft - walk;
});
element.style.cursor = 'grab';
} }

19
src/hamburger.js Normal file
View File

@@ -0,0 +1,19 @@
const btn = document.getElementById("hamburger-btn");
const sidebar = document.querySelector(".sidebar");
btn.addEventListener("click", (e) => {
e.stopPropagation();
sidebar.classList.toggle("active");
btn.textContent = sidebar.classList.contains("active") ? "✕" : "☰";
});
document.addEventListener("click", (e) => {
if (window.innerWidth <= 767) {
const outsideSidebar = !sidebar.contains(e.target);
const outsideBtn = !btn.contains(e.target);
if (outsideSidebar && outsideBtn) {
sidebar.classList.remove("active");
btn.textContent = "☰";
}
}
});

View File

@@ -1,11 +1,15 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const fetchPath = require.resolve('node-fetch'); const fetchPath = require.resolve('node-fetch');
const cheerioPath = require.resolve('cheerio'); const cheerioPath = require.resolve('cheerio');
const fetch = require(fetchPath);
const { app } = require('electron');
function peekBaseUrl(filePath) { function peekProperty(filePath, propertyName) {
try { try {
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/baseUrl\s*=\s*["']([^"']+)["']/); const regex = new RegExp(`(?:this\\.|^|\\s)${propertyName}\\s*=\\s*["']([^"']+)["']`);
const match = content.match(regex);
return match ? match[1] : null; return match ? match[1] : null;
} catch (e) { } catch (e) {
return null; return null;
@@ -17,64 +21,168 @@ module.exports = function (availableScrapers, headlessBrowser) {
Object.keys(availableScrapers).forEach(name => { Object.keys(availableScrapers).forEach(name => {
const scraper = availableScrapers[name]; const scraper = availableScrapers[name];
if (!scraper.url) { if (!scraper.url) {
const url = peekBaseUrl(scraper.path); if (scraper.instance && scraper.instance.baseUrl) {
if (url) { scraper.url = scraper.instance.baseUrl;
scraper.url = url; } else {
scraper.url = peekProperty(scraper.path, 'baseUrl');
}
}
if (!scraper.type) {
if (scraper.instance && scraper.instance.type) {
scraper.type = scraper.instance.type;
} else {
const typeFromFile = peekProperty(scraper.path, 'type');
if (typeFromFile) scraper.type = typeFromFile;
} }
} }
}); });
const getScraperInstance = (source) => {
const scraperData = availableScrapers[source];
if (!scraperData) throw new Error(`Source ${source} not found.`);
if (!scraperData.instance) {
console.log(`[LazyLoad] Initializing scraper: ${source}...`);
const scraperModule = require(scraperData.path);
const className = Object.keys(scraperModule)[0];
const ScraperClass = scraperModule[className];
const instance = new ScraperClass(fetchPath, cheerioPath, headlessBrowser);
scraperData.instance = instance;
if (instance.type) scraperData.type = instance.type;
if (instance.baseUrl) scraperData.url = instance.baseUrl;
}
return scraperData.instance;
};
return { return {
getSources: () => { getSources: () => {
return Object.keys(availableScrapers).map((name) => { return Object.keys(availableScrapers).map((name) => {
const scraper = availableScrapers[name]; const scraper = availableScrapers[name];
return { return {
name: name, name: name,
url: scraper.url || name url: scraper.url || name,
type: scraper.type || (scraper.instance ? scraper.instance.type : null)
}; };
}); });
}, },
search: async (event, source, query, page) => { search: async (event, source, query, page) => {
const scraperData = availableScrapers[source];
if (!scraperData) {
return { success: false, error: `Source ${source} not found.` };
}
if (!scraperData.instance) {
console.log(`[LazyLoad] Initializing scraper: ${source}...`);
try {
const scraperModule = require(scraperData.path);
const className = Object.keys(scraperModule)[0];
const ScraperClass = scraperModule[className];
if (!ScraperClass || typeof ScraperClass !== 'function') {
throw new Error(`File ${scraperData.path} does not export a valid class.`);
}
const instance = new ScraperClass(fetchPath, cheerioPath, headlessBrowser);
scraperData.instance = instance;
if (instance.baseUrl) {
scraperData.url = instance.baseUrl;
}
} catch (err) {
console.error(`Failed to lazy load ${source}:`, err);
return { success: false, error: `Failed to load extension: ${err.message}` };
}
}
try { try {
const results = await scraperData.instance.fetchSearchResult(query, page); const instance = getScraperInstance(source);
const results = await instance.fetchSearchResult(query, page);
return { success: true, data: results }; return { success: true, data: results };
} catch (err) { } catch (err) {
console.error(`Error during search in ${source}:`, err); console.error(`Error during search in ${source}:`, err);
return { success: false, error: err.message }; return { success: false, error: err.message };
} }
},
getChapters: async (event, source, mangaId) => {
try {
const instance = getScraperInstance(source);
if (!instance.findChapters) throw new Error("Extension does not support chapters.");
const chapters = await instance.findChapters(mangaId);
return { success: true, data: chapters };
} catch (err) {
console.error(`Error fetching chapters from ${source}:`, err);
return { success: false, error: err.message };
}
},
getPages: async (event, source, chapterId) => {
try {
const instance = getScraperInstance(source);
if (!instance.findChapterPages) throw new Error("Extension does not support reading pages.");
const pages = await instance.findChapterPages(chapterId);
return { success: true, data: pages };
} catch (err) {
console.error(`Error fetching pages from ${source}:`, err);
return { success: false, error: err.message };
}
},
getMetadata: async (event, title) => {
let cleanTitle = title.replace(/(\[.*?\]|\(.*?\))/g, '').trim().replace(/\s+/g, ' ');
console.log(`[AniList] Searching for: "${cleanTitle}"`);
const query = `
query ($search: String, $type: MediaType) {
Media (search: $search, type: $type, sort: SEARCH_MATCH) {
id
title { romaji english native }
description(asHtml: false)
averageScore
genres
coverImage { extraLarge large }
characters(page: 1, perPage: 10, sort: ROLE) {
edges {
role
node { id name { full } image { medium } }
}
}
}
}
`;
try {
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ query, variables: { search: cleanTitle, type: 'MANGA' } })
});
const json = await response.json();
if (json.errors || !json.data || !json.data.Media) {
return { success: false, error: "No media found" };
}
return { success: true, data: json.data.Media };
} catch (err) {
return { success: false, error: err.message };
}
},
getInstalledExtensions: async () => {
const pPath = path.join(app.getPath('home'), 'WaifuBoards', 'extensions');
try {
return fs.readdirSync(pPath).filter(f => f.endsWith('.js'));
} catch (e) {
console.error("Error reading extensions dir:", e);
return [];
}
},
installExtension: async (event, filename, url) => {
const https = require('https'); // Require https here or at top
const pPath = path.join(app.getPath('home'), 'WaifuBoards', 'extensions', filename);
return new Promise((resolve) => {
const file = fs.createWriteStream(pPath);
https.get(url, function(response) {
response.pipe(file);
file.on('finish', function() {
file.close(() => resolve(true));
});
}).on('error', function(err) {
fs.unlink(pPath, () => {});
resolve(false);
});
});
},
uninstallExtension: async (event, filename) => {
const pPath = path.join(app.getPath('home'), 'WaifuBoards', 'extensions', filename);
try {
if (fs.existsSync(pPath)) {
fs.unlinkSync(pPath);
return true;
}
return false;
} catch (e) {
console.error("Uninstall error:", e);
return false;
}
} }
}; };
}; };

View File

@@ -19,7 +19,7 @@ module.exports = function (db) {
'INSERT INTO favorites (id, title, image_url, thumbnail_url, tags) VALUES (?, ?, ?, ?, ?)'; 'INSERT INTO favorites (id, title, image_url, thumbnail_url, tags) VALUES (?, ?, ?, ?, ?)';
db.run( db.run(
stmt, stmt,
[fav.id, fav.title, fav.imageUrl, fav.thumbnailUrl, fav.tags], [fav.id, fav.title, fav.image_url, fav.thumbnail_url, fav.tags],
function (err) { function (err) {
if (err) { if (err) {
if (err.code.includes('SQLITE_CONSTRAINT')) { if (err.code.includes('SQLITE_CONSTRAINT')) {

282
src/marketplace.js Normal file
View File

@@ -0,0 +1,282 @@
const GITEA_INSTANCE = 'https://git.waifuboard.app';
const REPO_OWNER = 'ItsSkaiya';
const REPO_NAME = 'WaifuBoard-Extensions';
let DETECTED_BRANCH = 'main';
const API_URL_BASE = `${GITEA_INSTANCE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents`;
document.addEventListener('DOMContentLoaded', async () => {
const browseGrid = document.getElementById('marketplace-grid');
const installedGrid = document.getElementById('installed-grid');
const statTotal = document.getElementById('stat-total');
const statInstalled = document.getElementById('stat-installed');
const tabBrowse = document.getElementById('tab-browse');
const tabInstalled = document.getElementById('tab-installed');
const viewBrowse = document.getElementById('view-browse');
const viewInstalled = document.getElementById('view-installed');
const messageBar = document.getElementById('message-bar');
let allRemoteExtensions = [];
let installedExtensionsList = [];
function showMessage(msg, type = 'success') {
if (!messageBar) return;
messageBar.textContent = msg;
messageBar.className = `toast ${type === 'error' ? 'error' : ''}`;
messageBar.classList.remove('hidden');
setTimeout(() => messageBar.classList.add('hidden'), 3000);
}
function updateStats() {
if(statTotal) statTotal.textContent = allRemoteExtensions.length;
if(statInstalled) statInstalled.textContent = installedExtensionsList.length;
}
function showRestartToast() {
if (document.getElementById('restart-toast')) return;
const toast = document.createElement('div');
toast.id = 'restart-toast';
toast.style.cssText = `position: fixed; bottom: 20px; right: 20px; background: #1f2937; border: 1px solid var(--accent); border-radius: 12px; padding: 1rem 1.5rem; display: flex; align-items: center; gap: 1rem; box-shadow: 0 10px 30px rgba(0,0,0,0.5); z-index: 2000; animation: slideUp 0.3s ease-out;`;
toast.innerHTML = `<div style="display:flex; flex-direction:column;"><span style="color:#fff; font-weight:600;">Restart Required</span><span style="color:#9ca3af; font-size:0.8rem;">Changes will apply after restart.</span></div><button id="btn-restart-now" style="background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer; font-weight: 700; font-size: 0.85rem; transition: 0.2s;">Restart Now</button>`;
document.body.appendChild(toast);
const btn = document.getElementById('btn-restart-now');
btn.onmouseover = () => btn.style.opacity = '0.9';
btn.onmouseout = () => btn.style.opacity = '1';
btn.onclick = () => {
if (window.api && typeof window.api.restartApp === 'function') window.api.restartApp();
else alert("Please close and reopen the application manually.");
};
}
function switchTab(tab) {
if (tab === 'browse') {
tabBrowse.classList.add('active');
tabInstalled.classList.remove('active');
viewBrowse.classList.remove('hidden');
viewInstalled.classList.add('hidden');
} else {
tabBrowse.classList.remove('active');
tabInstalled.classList.add('active');
viewBrowse.classList.add('hidden');
viewInstalled.classList.remove('hidden');
renderInstalledGrid();
}
}
if(tabBrowse) tabBrowse.onclick = () => switchTab('browse');
if(tabInstalled) tabInstalled.onclick = () => switchTab('installed');
async function fetchInstalledExtensions() {
try {
const extensions = await window.api.getInstalledExtensions();
installedExtensionsList = extensions || [];
} catch (e) {
console.error("Failed to fetch installed extensions:", e);
installedExtensionsList = [];
}
}
async function fetchRemoteExtensions() {
try {
let res = await fetch(API_URL_BASE);
if (res.status === 404) {
console.warn("Default branch failed, trying 'main'...");
DETECTED_BRANCH = 'main';
res = await fetch(`${API_URL_BASE}?ref=main`);
}
if (res.status === 404) {
console.warn("Main branch failed, trying 'master'...");
DETECTED_BRANCH = 'master';
res = await fetch(`${API_URL_BASE}?ref=master`);
}
if (!res.ok) throw new Error(`Gitea API Error: ${res.status}`);
const data = await res.json();
if (Array.isArray(data)) {
allRemoteExtensions = data.filter(item => item.name.endsWith('.js'));
renderBrowseGrid(allRemoteExtensions);
updateStats();
} else {
throw new Error("Invalid API response format");
}
} catch (e) {
console.error(e);
if(browseGrid) browseGrid.innerHTML = `<div class="loading-state"><p style="color:#ef4444">Failed to load marketplace.<br>${e.message}</p></div>`;
}
}
function getRawUrl(filename) {
return `${GITEA_INSTANCE}/${REPO_OWNER}/${REPO_NAME}/raw/branch/main/${filename}`;
}
async function getExtensionDetails(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const text = await res.text();
const regex = /(?:this\.|const\s+|let\s+|var\s+)?baseUrl\s*=\s*(["'`])(.*?)\1/i;
const match = text.match(regex);
let finalHostname = null;
if (match && match[2]) {
let rawUrl = match[2].trim();
if (!rawUrl.startsWith('http')) rawUrl = 'https://' + rawUrl;
try {
const urlObj = new URL(rawUrl);
finalHostname = urlObj.hostname;
} catch(e) {}
}
const classMatch = text.match(/class\s+(\w+)/);
const name = classMatch ? classMatch[1] : null;
let type = 'Image';
if (text.includes('type = "book-board"') || text.includes("type = 'book-board'")) type = 'Book';
return { baseUrl: finalHostname, name, type };
} catch (e) {
return { baseUrl: null, name: null, type: 'Unknown' };
}
}
async function createCard(item, isLocalOnly = false) {
const isInstalled = installedExtensionsList.includes(item.name);
const downloadUrl = getRawUrl(item.name);
let sizeKB = item.size ? (item.size / 1024).toFixed(1) + ' KB' : 'Local';
let iconUrl = '';
let displayName = item.name.replace('.js', '');
let typeLabel = 'Extension';
let typeClass = 'type-image';
const fallbackIcon = `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22gray%22 stroke-width=%222%22><rect x=%223%22 y=%223%22 width=%2218%22 height=%2218%22 rx=%222%22/></svg>`;
if (!isLocalOnly) {
const details = await getExtensionDetails(downloadUrl);
displayName = details.name || displayName;
if (details.baseUrl) {
iconUrl = `https://www.google.com/s2/favicons?domain=${details.baseUrl}&sz=128`;
} else {
iconUrl = `https://ui-avatars.com/api/?name=${displayName}&background=1f2937&color=fff&length=1`;
}
if (details.type === 'Book') {
typeLabel = 'Book Board';
typeClass = 'type-book';
}
} else {
iconUrl = `https://ui-avatars.com/api/?name=${displayName}&background=1f2937&color=fff&length=1`;
}
const card = document.createElement('div');
card.className = 'extension-card';
card.dataset.name = item.name;
const downloadIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
const trashIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
const checkIcon = '<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
const renderInner = (installed) => `
<div class="card-header-row">
<div class="ext-icon-box">
<img src="${iconUrl}" class="ext-icon" onerror="this.onerror=null; this.src='${fallbackIcon}'">
</div>
<span class="type-badge ${typeClass}">${typeLabel}</span>
</div>
<div class="ext-info">
<h3 class="ext-name">${displayName}</h3>
<div class="ext-filename">${item.name}</div>
</div>
<div class="card-footer">
<div class="ext-status-group">${installed ? `<span class="status-badge status-installed">${checkIcon} Installed</span>` : ''}
<span class="ext-size-badge">${sizeKB}</span>
</div>
<button class="install-btn ${installed ? 'installed' : ''}" title="${installed ? 'Uninstall' : 'Install'}">
${installed ? `${trashIcon} Uninstall` : `${downloadIcon} Install`}
</button>
</div>
`;
card.innerHTML = renderInner(isInstalled);
card.addEventListener('click', async (e) => {
const btn = e.target.closest('.install-btn');
if (!btn) return;
const currentlyInstalled = installedExtensionsList.includes(item.name);
if (currentlyInstalled) {
const success = await window.api.uninstallExtension(item.name);
if (success) {
installedExtensionsList = installedExtensionsList.filter(n => n !== item.name);
card.innerHTML = renderInner(false);
updateStats();
if (tabInstalled.classList.contains('active')) {
card.remove();
if (installedGrid.children.length === 0) installedGrid.innerHTML = '<div class="loading-state"><p>No extensions installed.</p></div>';
}
showRestartToast();
} else {
showMessage('Failed to uninstall', 'error');
}
} else {
if (!downloadUrl) return;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="spinner" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="5"></circle></svg>';
const success = await window.api.installExtension(item.name, downloadUrl);
if (success) {
installedExtensionsList.push(item.name);
card.innerHTML = renderInner(true);
updateStats();
showMessage(`Installed ${displayName}`);
showRestartToast();
} else {
showMessage('Failed to install', 'error');
console.error("Install failed for URL:", downloadUrl);
btn.innerHTML = originalHTML;
}
}
});
return card;
}
async function renderBrowseGrid(list) {
browseGrid.innerHTML = '';
if (list.length === 0) {
browseGrid.innerHTML = '<p class="loading-state">No extensions found.</p>';
return;
}
for (const item of list) {
const card = await createCard(item);
browseGrid.appendChild(card);
}
}
async function renderInstalledGrid() {
installedGrid.innerHTML = '';
if (installedExtensionsList.length === 0) {
installedGrid.innerHTML = '<div class="loading-state"><p>No extensions installed.</p></div>';
return;
}
for (const filename of installedExtensionsList) {
const remoteData = allRemoteExtensions.find(r => r.name === filename);
const item = remoteData || { name: filename, download_url: null, size: 0 };
const card = await createCard(item, !remoteData);
installedGrid.appendChild(card);
}
}
await fetchInstalledExtensions();
await fetchRemoteExtensions();
});

View File

@@ -18,8 +18,7 @@ export function showPage(pageId, domRefs, callbacks, state) {
}); });
document.querySelectorAll('.nav-button').forEach((tab) => { document.querySelectorAll('.nav-button').forEach((tab) => {
tab.classList.remove('bg-indigo-600', 'text-white'); tab.classList.remove('active');
tab.classList.add('text-gray-400', 'hover:bg-gray-700');
}); });
const activePage = document.getElementById(pageId); const activePage = document.getElementById(pageId);
@@ -46,7 +45,6 @@ export function showPage(pageId, domRefs, callbacks, state) {
} }
if (activeTab) { if (activeTab) {
activeTab.classList.add('bg-indigo-600', 'text-white'); activeTab.classList.add('active');
activeTab.classList.remove('text-gray-400', 'hover:bg-gray-700');
} }
} }

View File

@@ -1,4 +1,3 @@
let currentPage = 1;
let hasNextPage = true; let hasNextPage = true;
let isLoading = false; let isLoading = false;
let currentQuery = ''; let currentQuery = '';
@@ -18,10 +17,9 @@ export async function performSearch(
return; return;
} }
currentPage = 1;
hasNextPage = true; hasNextPage = true;
isLoading = false; isLoading = false;
currentQuery = searchInput.value.trim().replace(/[, ]+/g, ' '); currentQuery = searchInput.value ? searchInput.value.trim().replace(/[, ]+/g, ' ') : '';
if (galleryPlaceholder) galleryPlaceholder.classList.add('hidden'); if (galleryPlaceholder) galleryPlaceholder.classList.add('hidden');
@@ -29,13 +27,16 @@ export async function performSearch(
contentGallery.innerHTML = ''; contentGallery.innerHTML = '';
updateHeader(); updateHeader();
searchModal.classList.add('hidden'); if (searchModal) {
searchModal.classList.add('hidden');
}
await loadMoreResults(currentSource, currentLayout, domRefs, callbacks); await loadMoreResults(currentSource, 1, currentLayout, domRefs, callbacks);
} }
export async function loadMoreResults( export async function loadMoreResults(
currentSource, currentSource,
page,
currentLayout, currentLayout,
domRefs, domRefs,
callbacks callbacks
@@ -43,73 +44,76 @@ export async function loadMoreResults(
const { loadingSpinner, infiniteLoadingSpinner, contentGallery } = domRefs; const { loadingSpinner, infiniteLoadingSpinner, contentGallery } = domRefs;
const { applyLayoutToGallery, createImageCard } = callbacks; const { applyLayoutToGallery, createImageCard } = callbacks;
if (isLoading || !hasNextPage) { if (isLoading || !hasNextPage) return;
return;
}
isLoading = true; isLoading = true;
if (currentPage === 1) { if (page === 1) {
loadingSpinner.classList.remove('hidden'); if(loadingSpinner) loadingSpinner.classList.remove('hidden');
} else { } else {
infiniteLoadingSpinner.classList.remove('hidden'); if(infiniteLoadingSpinner) infiniteLoadingSpinner.classList.remove('hidden');
} }
const result = await window.api.search( try {
currentSource, const result = await window.api.search(
currentQuery, currentSource,
currentPage currentQuery,
); page
);
loadingSpinner.classList.add('hidden'); if (loadingSpinner) loadingSpinner.classList.add('hidden');
infiniteLoadingSpinner.classList.add('hidden'); if (infiniteLoadingSpinner) infiniteLoadingSpinner.classList.add('hidden');
if ( if (!result.success || !result.data.results || result.data.results.length === 0) {
!result.success || hasNextPage = false;
!result.data.results || if (page === 1) {
result.data.results.length === 0 applyLayoutToGallery(contentGallery, currentLayout);
) { contentGallery.innerHTML = '<p class="text-gray-400 text-center text-lg">No results found.</p>';
hasNextPage = false; }
if (currentPage === 1) { isLoading = false;
return;
}
const validResults = result.data.results.filter((item) => item.image);
if (validResults.length === 0) {
if (page === 1) {
hasNextPage = false;
applyLayoutToGallery(contentGallery, currentLayout);
contentGallery.innerHTML = '<p class="text-gray-400 text-center text-lg">No valid images found.</p>';
}
isLoading = false;
return;
}
const fragment = document.createDocumentFragment();
validResults.forEach((item) => {
const thumbnailUrl = item.image;
const displayUrl = item.sampleImageUrl || item.fullImageUrl || thumbnailUrl;
const title = item.title || '';
const card = createImageCard(
item.id.toString(),
item.tags || [],
displayUrl,
thumbnailUrl,
item.type || 'browse'
);
if (title) card.dataset.title = title;
fragment.appendChild(card);
});
contentGallery.appendChild(fragment);
applyLayoutToGallery(contentGallery, currentLayout); applyLayoutToGallery(contentGallery, currentLayout);
contentGallery.innerHTML = hasNextPage = result.data.hasNextPage;
'<p class="text-gray-400 text-center text-lg">No results found. Please try another search term.</p>';
} } catch (error) {
isLoading = false; console.error("Search/Load Error:", error);
return; if (loadingSpinner) loadingSpinner.classList.add('hidden');
if (infiniteLoadingSpinner) infiniteLoadingSpinner.classList.add('hidden');
} finally {
isLoading = false;
} }
const validResults = result.data.results.filter((item) => item.image);
if (validResults.length === 0) {
hasNextPage = false;
if (currentPage === 1) {
applyLayoutToGallery(contentGallery, currentLayout);
contentGallery.innerHTML =
'<p class="text-gray-400 text-center text-lg">Found results, but none had valid images.</p>';
}
isLoading = false;
return;
}
const fragment = document.createDocumentFragment();
validResults.forEach((item) => {
const thumbnailUrl = item.image;
const displayUrl = item.sampleImageUrl || item.fullImageUrl || thumbnailUrl;
const card = createImageCard(
item.id.toString(),
item.tags,
displayUrl,
thumbnailUrl,
'browse'
);
fragment.appendChild(card);
});
contentGallery.appendChild(fragment);
hasNextPage = result.data.hasNextPage;
currentPage++;
isLoading = false;
} }

View File

@@ -6,7 +6,17 @@ contextBridge.exposeInMainWorld('api', {
addFavorite: (fav) => ipcRenderer.invoke('db:addFavorite', fav), addFavorite: (fav) => ipcRenderer.invoke('db:addFavorite', fav),
removeFavorite: (id) => ipcRenderer.invoke('db:removeFavorite', id), removeFavorite: (id) => ipcRenderer.invoke('db:removeFavorite', id),
search: (source, query) => ipcRenderer.invoke('api:search', source, query), getChapters: (source, mangaId) => ipcRenderer.invoke('api:getChapters', source, mangaId),
getPages: (source, chapterId) => ipcRenderer.invoke('api:getPages', source, chapterId),
search: (source, query, page) => ipcRenderer.invoke('api:search', source, query, page),
toggleDevTools: () => ipcRenderer.send('toggle-dev-tools'),
getSources: () => ipcRenderer.invoke('api:getSources'), getSources: () => ipcRenderer.invoke('api:getSources'),
getInstalledExtensions: () => ipcRenderer.invoke('api:getInstalledExtensions'),
installExtension: (name, url) => ipcRenderer.invoke('api:installExtension', name, url),
uninstallExtension: (name) => ipcRenderer.invoke('api:uninstallExtension', name),
restartApp: () => ipcRenderer.send('app:restart')
}); });

View File

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

View File

@@ -1,6 +1,6 @@
const GITHUB_OWNER = 'ItsSkaiya'; const Gitea_OWNER = 'ItsSkaiya';
const GITHUB_REPO = 'WaifuBoard'; const Gitea_REPO = 'WaifuBoard';
const CURRENT_VERSION = 'v1.3.1'; const CURRENT_VERSION = 'v1.6.4';
const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000; const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000;
let currentVersionDisplay; let currentVersionDisplay;
@@ -58,20 +58,36 @@ function isVersionOutdated(versionA, versionB) {
} }
async function checkForUpdates() { async function checkForUpdates() {
console.log(`Checking for updates for ${GITHUB_OWNER}/${GITHUB_REPO}...`); console.log(`Checking for updates for ${Gitea_OWNER}/${Gitea_REPO}...`);
const apiUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`;
const apiUrl = `https://git.waifuboard.app/api/v1/repos/${Gitea_OWNER}/${Gitea_REPO}/releases/latest`;
try { try {
const response = await fetch(apiUrl); const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) { if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`); if (response.status === 404) {
console.info('No releases found for this repository.');
return;
}
throw new Error(`Gitea API error: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
const latestVersion = data.tag_name; const latestVersion = data.tag_name;
console.log(`Latest GitHub Release: ${latestVersion}`);
if (!latestVersion) {
console.warn("Release found but no tag_name present");
return;
}
console.log(`Latest Gitea Release: ${latestVersion}`);
if (isVersionOutdated(CURRENT_VERSION, latestVersion)) { if (isVersionOutdated(CURRENT_VERSION, latestVersion)) {
console.warn('Update available!'); console.warn('Update available!');
@@ -82,6 +98,6 @@ async function checkForUpdates() {
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch GitHub release:', error); console.error('Failed to fetch Gitea release:', error);
} }
} }

View File

@@ -1,60 +1,150 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow, session } = require('electron');
class HeadlessBrowser { class HeadlessBrowser {
async scrape(url, evalFunc, options = {}) { constructor() {
const { waitSelector = null, timeout = 15000 } = options; this.win = null;
this.currentConfig = null;
}
const win = new BrowserWindow({ /**
* Pre-loads the browser window on app startup.
*/
async init() {
console.log('[Headless] Pre-warming browser instance...');
await this.getWindow(true); // Default to loading images
console.log('[Headless] Browser ready.');
}
/**
* Gets an existing window or creates a new one if config changes/window missing.
*/
async getWindow(loadImages) {
// If window exists and config matches, reuse it (FAST PATH)
if (this.win && !this.win.isDestroyed() && this.currentConfig === loadImages) {
return this.win;
}
// Otherwise, destroy old window and create new one (SLOW PATH)
if (this.win && !this.win.isDestroyed()) {
this.win.destroy();
}
this.currentConfig = loadImages;
this.win = new BrowserWindow({
show: false, show: false,
width: 800, width: 1920,
height: 600, height: 1080,
webPreferences: { webPreferences: {
offscreen: true, offscreen: true,
contextIsolation: false, contextIsolation: false,
nodeIntegration: false, nodeIntegration: false,
images: true, images: loadImages,
webgl: false, webgl: false,
backgroundThrottling: false,
autoplayPolicy: 'no-user-gesture-required',
disableHtmlFullscreenWindowResize: true
}, },
}); });
try { const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36';
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; this.win.webContents.setUserAgent(userAgent);
win.webContents.setUserAgent(userAgent);
await win.loadURL(url, { userAgent }); const ses = this.win.webContents.session;
ses.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => {
const url = details.url.toLowerCase();
const type = details.resourceType;
if (waitSelector) { if (
await this.waitForSelector(win, waitSelector, timeout); type === 'font' ||
type === 'stylesheet' ||
type === 'media' ||
type === 'websocket' ||
type === 'manifest'
) {
return callback({ cancel: true });
} }
const result = await win.webContents.executeJavaScript(`(${evalFunc.toString()})()`); const blockList = [
'google-analytics', 'doubleclick', 'facebook', 'twitter', 'adsystem',
'analytics', 'tracker', 'pixel', 'quantserve', 'newrelic'
];
if (blockList.some(keyword => url.includes(keyword))) return callback({ cancel: true });
if (!loadImages && (type === 'image' || url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/))) {
return callback({ cancel: true });
}
return callback({ cancel: false });
});
// Load a blank page to keep the process alive and ready
await this.win.loadURL('about:blank');
return this.win;
}
async scrape(url, evalFunc, options = {}) {
const {
waitSelector = null,
timeout = 10000,
args = [],
scrollToBottom = false,
renderWaitTime = 0,
loadImages = true
} = options;
try {
const win = await this.getWindow(loadImages);
await win.loadURL(url);
if (waitSelector) {
try {
await this.waitForSelector(win, waitSelector, timeout);
} catch (e) {
console.warn(`[Headless] Timeout waiting for ${waitSelector}, proceeding...`);
}
}
if (scrollToBottom) {
await this.turboScroll(win);
}
if (renderWaitTime > 0) {
await new Promise(resolve => setTimeout(resolve, renderWaitTime));
}
const result = await win.webContents.executeJavaScript(
`(${evalFunc.toString()}).apply(null, ${JSON.stringify(args)})`
);
return result; return result;
} catch (error) { } catch (error) {
console.error('Headless Scrape Error:', error.message); console.error('Headless Scrape Error:', error.message);
throw error; // Force recreation next time if something crashed
} finally { if (this.win) {
if (!win.isDestroyed()) { try { this.win.destroy(); } catch(e){}
win.destroy(); this.win = null;
} }
throw error;
} }
} }
async waitForSelector(win, selector, timeout) { async waitForSelector(win, selector, timeout) {
const script = ` const script = `
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const timer = setTimeout(() => { const start = Date.now();
reject(new Error('Timeout waiting for selector: ${selector}'));
}, ${timeout});
const check = () => { const check = () => {
if (document.querySelector('${selector}')) { if (document.querySelector('${selector}')) {
clearTimeout(timer);
resolve(true); resolve(true);
} else if (Date.now() - start > ${timeout}) {
reject(new Error('Timeout'));
} else { } else {
// FIX: Use setTimeout because requestAnimationFrame stops in hidden windows requestAnimationFrame(check);
setTimeout(check, 100);
} }
}; };
check(); check();
@@ -62,6 +152,30 @@ class HeadlessBrowser {
`; `;
await win.webContents.executeJavaScript(script); await win.webContents.executeJavaScript(script);
} }
async turboScroll(win) {
const script = `
new Promise((resolve) => {
let lastHeight = 0;
let sameHeightCount = 0;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollTo(0, scrollHeight);
if (scrollHeight === lastHeight) {
sameHeightCount++;
if (sameHeightCount >= 5) {
clearInterval(timer);
resolve();
}
} else {
sameHeightCount = 0;
lastHeight = scrollHeight;
}
}, 20);
});
`;
await win.webContents.executeJavaScript(script);
}
} }
module.exports = new HeadlessBrowser(); module.exports = new HeadlessBrowser();

View File

@@ -1,7 +1,13 @@
export function setupGlobalKeybinds(searchModal) { export function setupGlobalKeybinds() {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.altKey && (e.key === 'i' || e.key === 'I')) {
searchModal.classList.add('hidden'); e.preventDefault();
if (window.api && window.api.toggleDevTools) {
window.api.toggleDevTools();
} else {
console.warn('window.api.toggleDevTools is not defined in preload.js');
}
} }
}); });
} }

136
views/books.html Normal file
View File

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

104
views/emulator.html Normal file
View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WaifuBoard Extension Emulator</title>
<link rel="stylesheet" href="styles/home.css">
<link rel="stylesheet" href="styles/emulator.css">
</head>
<body>
<aside class="sidebar">
<a href="index.html" class="nav-button" title="Image Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<span>Back To Home</span>
</a>
<button class="nav-button active" title="Emulator">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-xml-icon lucide-code-xml"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>
<span>Emulator</span>
</button>
<button class="nav-button" title="Reset" onclick="window.location.reload()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<span>Reset</span>
</button>
</aside>
<div class="main-wrapper">
<header class="top-header">
<button id="hamburger-btn" class="hamburger"></button>
<h3>Extension Emulator</h3>
</header>
<div class="emulator-container">
<div class="editor-pane">
<div class="control-bar" style="background: transparent; border: none; padding: 0;">
<h4 style="margin:0">Extension Code</h4>
<span style="font-size: 0.8rem; color: var(--text-tertiary); margin-left: auto;">Paste your .js file
here</span>
</div>
<textarea id="code-input" class="code-editor" spellcheck="false"
placeholder="// Paste your extension class here..."></textarea>
</div>
<div class="preview-pane">
<div class="control-bar">
<div class="control-group">
<label>Function</label>
<select id="func-select" class="control-input">
<option value="fetchSearchResult">fetchSearchResult (Search)</option>
<option value="fetchInfo">fetchInfo (Image Details)</option>
<option value="findChapters">findChapters (Manga/Novel)</option>
<option value="findChapterPages">findChapterPages (Read)</option>
</select>
</div>
<div class="control-group">
<label>Query / ID / URL</label>
<input type="text" id="arg-input" class="control-input" value=""
placeholder="Search query or ID">
</div>
<div class="control-group" style="max-width: 80px;">
<label>Page</label>
<input type="number" id="page-input" class="control-input" value="1" min="1">
</div>
<button class="btn-run" id="run-btn">Run</button>
</div>
<div style="display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative;">
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('visual')">Visual</button>
<button class="tab-btn" onclick="switchTab('json')">JSON</button>
<button class="tab-btn" onclick="switchTab('console')">Console</button>
</div>
<div id="output-visual" class="output-area">
<div class="loading-state hidden">Loading...</div>
<div id="visual-container"></div>
</div>
<div id="output-json" class="output-area hidden">
<pre id="json-content"></pre>
</div>
<div id="output-console" class="output-area hidden">
<div id="console-content"></div>
</div>
</div>
</div>
</div>
</div>
<script src="../src/emulator/emulator.js"></script>
<script src="../src/hamburger.js"></script>
</body>
</html>

112
views/favorites.html Normal file
View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https:;" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Waifu Board - Favorites</title>
<link rel="stylesheet" href="styles/home.css">
</head>
<body>
<aside class="sidebar">
<br>
<nav style="display: flex; flex-direction: column; gap: 0.5rem;">
<a href="index.html" class="nav-button" title="Image Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<span>Image Boards</span>
</a>
<a href="books.html" class="nav-button" title="Book Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>Book Boards</span>
</a>
<a href="favorites.html" class="nav-button active" title="Favorites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span>Favorites</span>
</a>
</nav>
<nav style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: auto;">
<a href="emulator.html" class="nav-button" title="Emulator">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-xml-icon lucide-code-xml"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>
<span>Emulator</span>
</a>
<a href="marketplace.html" class="nav-button" title="Marketplace">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-puzzle-icon lucide-puzzle"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>
<span>Marketplace</span>
</a>
<a href="settings.html" class="nav-button" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Settings</span>
</a>
</nav>
</aside>
<div class="main-wrapper">
<header class="top-header">
<button id="hamburger-btn" class="hamburger"></button>
<div style="position: relative;margin-left: 50px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position: absolute; left: 15px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--text-tertiary);">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="search-input" placeholder="Search across sources..." style="padding-left: 2.5rem;" />
</div>
<button id="search-button" class="hidden"></button>
<button id="search-icon-button" class="hidden"></button>
<button id="search-close-button" class="hidden"></button>
<div id="header-context" class="hidden"></div>
<h1 id="page-title" class="hidden">Favorites</h1>
</header>
<div class="content-view">
<div id="favorites-page" class="page">
<div style="margin-bottom: 2rem;">
<h2 style="margin-bottom: 0.5rem;">Your Favorites</h2>
<p style="color: var(--text-secondary);">Your personally curated collection.</p>
</div>
<main id="favorites-gallery" class="gallery-masonry"></main>
</div>
</div>
</div>
<div id="tag-info-modal" class="hidden">
<div>
<button id="tag-info-close-button">&times;</button>
<h3 style="margin-top:0; margin-bottom: 1rem;">Tags</h3>
<div id="tag-info-content" class="tag-cloud"></div>
</div>
</div>
<div id="message-bar" class="toast hidden">Message</div>
<script type="module" src="../src/renderer.js"></script>
<script type="module" src="../scripts/main.js"></script>
<script src="../src/updateNotification.js"></script>
<script>
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-button');
if(searchInput && searchBtn) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchBtn.click();
});
}
</script>
<script src="../src/hamburger.js"></script>
</body>
</html>

View File

@@ -2,323 +2,149 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content=" <meta http-equiv="Content-Security-Policy"
default-src 'self'; content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https:;" />
script-src 'self' https://cdn.tailwindcss.com; <meta name="viewport" content="width=device-width, initial-scale=1.0" />
style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; <title>Waifu Board</title>
img-src 'self' https: data:; <link rel="stylesheet" href="styles/home.css">
connect-src 'self' https:;
" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Waifu Board</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #111827;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #1f2937;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 5px;
border: 2px solid #1f2937;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
.hidden {
display: none;
}
.dark-focus-ring:focus {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
.source-button.active {
background-color: #4f46e5;
color: white;
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
.image-entry:hover .image-buttons {
opacity: 1;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.gallery-masonry {
column-count: 2;
column-gap: 1rem;
}
@media (min-width: 768px) {
.gallery-masonry {
column-count: 3;
}
}
@media (min-width: 1024px) {
.gallery-masonry {
column-count: 4;
}
}
.gallery-masonry .image-entry {
display: inline-block;
width: 100%;
margin-bottom: 1rem;
break-inside: avoid;
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px;
border-radius: 5px;
color: white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
display: flex;
align-items: flex-start;
min-width: 350px;
z-index: 1000;
transition: opacity 0.3s ease-in-out;
}
.toast-content {
flex-grow: 1;
}
.toast p {
margin: 0 0 5px 0;
}
.toast.update-available {
background-color: #e53935;
}
.toast.hidden {
opacity: 0;
pointer-events: none;
}
</style>
</head> </head>
<body class="text-gray-200 flex h-screen overflow-hidden"> <body>
<nav class="w-20 bg-gray-900 flex flex-col items-center flex-shrink-0 p-4 space-y-6"> <aside class="sidebar">
<button id="browse-button" class="nav-button p-3 rounded-xl bg-indigo-600 text-white" title="Browse"> <br>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <nav style="display: flex; flex-direction: column; gap: 0.5rem;">
class="w-6 h-6"> <a href="index.html" class="nav-button active" title="Image Boards">
<path stroke-linecap="round" stroke-linejoin="round" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
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" /> <rect x="3" y="3" width="7" height="7"></rect>
</svg> <rect x="14" y="3" width="7" height="7"></rect>
</button> <rect x="14" y="14" width="7" height="7"></rect>
<button id="favorites-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white" <rect x="3" y="14" width="7" height="7"></rect>
title="Favorites"> </svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <span>Image Boards</span>
class="w-6 h-6"> </a>
<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>
</button>
<button id="search-icon-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white"
title="Search">
<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">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
</button>
<button id="settings-button" class="nav-button p-3 rounded-xl text-gray-400 hover:bg-gray-700 hover:text-white" <a href="books.html" class="nav-button" title="Book Boards">
title="Settings"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
class="w-6 h-6"> <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
<path stroke-linecap="round" stroke-linejoin="round" </svg>
d="M9.594 3.94c.09-.542.56-1.003 1.114-1.114.554-.111 1.085-.111 1.64 0 .554.111 1.023.571 1.114 1.114.09.542.09 1.12 0 1.662-.09.542-.56 1.003-1.114 1.114a3.49 3.49 0 01-1.64 0c-.554-.111-1.023-.571-1.114-1.114-.09-.542-.09-1.12 0-1.662zM21 12a9 9 0 11-18 0 9 9 0 0118 0zM7.16 14.969c.09-.542.56-1.003 1.114-1.114.554-.111 1.085-.111 1.64 0 .554.111 1.023.571 1.114 1.114.09.542.09 1.12 0 1.662-.09.542-.56 1.003-1.114 1.114a3.49 3.49 0 01-1.64 0c-.554-.111-1.023-.571-1.114-1.114-.09-.542-.09-1.12 0-1.662zM14.969 7.16c.09-.542.56-1.003 1.114-1.114.554-.111 1.085-.111 1.64 0 .554.111 1.023.571 1.114 1.114.09.542.09 1.12 0 1.662-.09.542-.56 1.003-1.114 1.114a3.49 3.49 0 01-1.64 0c-.554-.111-1.023-.571-1.114-1.114-.09-.542-.09-1.12 0-1.662z" /> <span>Book Boards</span>
</svg> </a>
</button>
<div class="h-px w-10 bg-gray-700"></div> <a href="favorites.html" class="nav-button" title="Favorites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z">
</path>
</svg>
<span>Favorites</span>
</a>
</nav>
<div id="source-list" class="flex flex-col items-center space-y-4" aria-label="Sources"> <nav style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: auto;">
</div> <a href="emulator.html" class="nav-button" title="Emulator">
</nav> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-xml-icon lucide-code-xml"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>
<span>Emulator</span>
</a>
<a href="marketplace.html" class="nav-button" title="Marketplace">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-puzzle-icon lucide-puzzle"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>
<span>Marketplace</span>
</a>
<a href="settings.html" class="nav-button" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z">
</path>
</svg>
<span>Settings</span>
</a>
</nav>
</aside>
<div class="flex-1 flex flex-col overflow-hidden"> <div class="main-wrapper">
<header class="bg-gray-800 flex-shrink-0 flex items-center justify-between p-4 border-b border-gray-700 h-[69px]"> <header class="top-header">
<h1 id="page-title" class="text-xl font-bold text-gray-100">Browse</h1> <button id="hamburger-btn" class="hamburger"></button>
<div id="header-context" class="text-sm text-gray-400"></div> <div style="position: relative;margin-left: 50px">
</header> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="position: absolute; left: 15px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--text-tertiary);">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="search-input" placeholder="Search across sources..."
style="padding-left: 2.5rem;" />
</div>
<button id="search-button" class="hidden"></button>
<button id="search-icon-button" class="hidden"></button>
<button id="search-close-button" class="hidden"></button>
<div id="header-context" class="hidden"></div>
<h1 id="page-title" class="hidden">Dashboard</h1>
</header>
<div id="browse-page" class="page flex-1 overflow-y-auto"> <div class="content-view">
<main id="content-gallery" class="p-4 w-full" aria-live="polite"> <div id="browse-page" class="page">
<div id="loading-spinner" class="hidden text-center p-10 text-gray-400"> <h3
<svg class="animate-spin h-8 w-8 text-indigo-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" style="color: var(--text-tertiary); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 1rem;">
viewBox="0 0 24 24"> Sources</h3>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <div id="source-list"></div>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"> <h3
</path> style="color: var(--text-tertiary); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; margin: 2rem 0 1rem 0;">
</svg> Library</h3>
<p class="mt-2">Loading...</p> <main id="content-gallery" class="gallery-masonry">
<div id="gallery-placeholder" class="loading-state" style="width: 100%; grid-column: 1 / -1;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#52525b" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
<p>Select a source above to load content</p>
</div>
<div id="loading-spinner" class="hidden loading-state" style="width: 100%; grid-column: 1 / -1;">
<svg style="width:32px; height:32px; color: var(--accent); animation: spin 1s linear infinite;"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83">
</path>
</svg>
<p>Fetching images...</p>
</div>
<div id="infinite-loading-spinner" class="hidden loading-state"
style="width: 100%; grid-column: 1 / -1;">
<p>Loading more...</p>
</div>
</main>
</div>
</div> </div>
<p id="gallery-placeholder" class="text-gray-400 text-center text-lg"> </div>
Select a source and click the search icon to browse.
</p>
<div id="infinite-loading-spinner" class="hidden text-center p-10 text-gray-400"> <div id="tag-info-modal" class="hidden">
<svg class="animate-spin h-8 w-8 text-indigo-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" <div>
viewBox="0 0 24"> <button id="tag-info-close-button">&times;</button>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <h3 style="margin-top:0; margin-bottom: 1rem;">Tags</h3>
<path class="opacity-75" fill="currentColor" <div id="tag-info-content" class="tag-cloud"></div>
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<p class="mt-2">Loading more...</p>
</div> </div>
</main>
</div> </div>
<div id="favorites-page" class="page hidden flex-1 overflow-y-auto"> <div id="search-modal" class="hidden"></div>
<main id="favorites-gallery" class="p-4 w-full"> <div id="message-bar" class="toast hidden">Message</div>
</main> <div id="updateToast" class="toast hidden" style="border-left-color: #eab308;">
<p>Update Available: <span id="latestVersionDisplay">v1.x</span></p>
</div> </div>
<div id="settings-page" class="page hidden flex-1 overflow-y-auto p-8"> <script type="module" src="../src/renderer.js"></script>
<div class="max-w-2xl mx-auto space-y-8"> <script src="../src/updateNotification.js"></script>
<h2 class="text-2xl font-bold text-white">Settings</h2> <script>
const searchInput = document.getElementById('search-input');
<div class="bg-gray-800 rounded-lg p-6"> const searchBtn = document.getElementById('search-button');
<h3 class="text-lg font-semibold text-gray-100 mb-4"> if (searchInput && searchBtn) {
Gallery Layout searchInput.addEventListener('keydown', (e) => {
</h3> if (e.key === 'Enter') searchBtn.click();
<fieldset class="space-y-4"> });
<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" </script>
class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" /> <script src="../src/hamburger.js"></script>
<span class="ml-3 flex flex-col">
<span class="font-medium text-gray-200">Scroll</span>
<span class="text-sm text-gray-400">A single, vertical column of large images.</span>
</span>
</label>
<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"
class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" />
<span class="ml-3 flex flex-col">
<span class="font-medium text-gray-200">Grid</span>
<span class="text-sm text-gray-400">A "Masonry" layout that adapts to image height.</span>
</span>
</label>
<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"
class="h-5 w-5 text-indigo-600 border-gray-500 focus:ring-indigo-500" />
<span class="ml-3 flex flex-col">
<span class="font-medium text-gray-200">Compact</span>
<span class="text-sm text-gray-400">A responsive grid of static-sized cards.</span>
</span>
</label>
</fieldset>
</div>
</div>
</div>
</div>
<div id="search-modal"
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">
<button id="search-close-button" class="absolute top-3 right-3 p-2 text-gray-400 hover:text-white">
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 class="text-xl font-semibold mb-4">Search</h2>
<div class="flex space-x-2">
<input type="search" id="search-input" placeholder="Search tags..."
class="flex-1 p-3 rounded-lg bg-gray-700 border border-gray-600 text-white placeholder-gray-400 dark-focus-ring" />
<button id="search-button"
class="px-5 py-3 rounded-lg bg-indigo-600 font-semibold text-white hover:bg-indigo-700 dark-focus-ring">
Search
</button>
</div>
</div>
</div>
<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"
aria-modal="true">
<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">
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<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>
</div>
</div>
<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">
Message
</div>
<div id="updateToast" class="toast hidden">
<p>An update is required for Waifu Board! newest version - <span id="latestVersionDisplay"></span></p>
</div>
<script type="module" src="../src/renderer.js"></script>
<script src="../src/updateNotification.js"></script>
</body> </body>
</html> </html>

124
views/marketplace.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; connect-src 'self' https:;" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Waifu Board - Marketplace</title>
<link rel="stylesheet" href="styles/home.css">
<link rel="stylesheet" href="styles/marketplace.css">
</head>
<body>
<aside class="sidebar">
<br>
<nav style="display: flex; flex-direction: column; gap: 0.5rem;">
<a href="index.html" class="nav-button" title="Image Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<span>Image Boards</span>
</a>
<a href="books.html" class="nav-button" title="Book Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>Book Boards</span>
</a>
<a href="favorites.html" class="nav-button" title="Favorites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span>Favorites</span>
</a>
</nav>
<nav style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: auto;">
<a href="emulator.html" class="nav-button" title="Emulator">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-xml-icon lucide-code-xml"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>
<span>Emulator</span>
</a>
<a href="marketplace.html" class="nav-button active" title="Marketplace">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-puzzle-icon lucide-puzzle"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>
<span>Marketplace</span>
</a>
<a href="settings.html" class="nav-button" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z">
</path>
</svg>
<span>Settings</span>
</a>
</nav>
</aside>
<div class="main-wrapper">
<header class="top-header">
<button id="hamburger-btn" class="hamburger"></button>
<h3>Marketplace</h3>
</header>
<div class="content-view">
<div id="marketplace-page">
<div class="marketplace-hero">
<div class="hero-content">
<h1 class="hero-title">WaifuBoard Store</h1>
<p class="hero-subtitle">Discover extensions for manga reading, image browsing, and more. Customize your experience.</p>
<div class="hero-stats">
<div class="stat-box">
<span class="stat-value" id="stat-total">0</span>
<span class="stat-label">Extensions</span>
</div>
<div class="stat-box">
<span class="stat-value" id="stat-installed">0</span>
<span class="stat-label">Installed</span>
</div>
</div>
</div>
</div>
<div class="marketplace-tabs" style="margin-top: 2rem;">
<button id="tab-browse" class="tab-btn active">Explore</button>
<button id="tab-installed" class="tab-btn">Library</button>
</div>
<div id="view-browse" class="tab-view">
<div class="section-header">
<svg class="section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 7h-9"></path><path d="M14 17H5"></path><circle cx="17" cy="17" r="3"></circle><circle cx="7" cy="7" r="3"></circle></svg>
<span class="section-title">Trending Extensions</span>
</div>
<div id="marketplace-grid" class="marketplace-grid">
<div class="loading-state">
<svg style="width:32px; height:32px; color: var(--accent); animation: spin 1s linear infinite;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"></path></svg>
<p>Connecting to repository...</p>
</div>
</div>
</div>
<div id="view-installed" class="tab-view hidden">
<div class="section-header">
<svg class="section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
<span class="section-title">Your Library</span>
</div>
<div id="installed-grid" class="marketplace-grid"></div>
</div>
</div>
</div>
</div>
<div id="message-bar" class="toast hidden">Message</div>
<script type="module" src="../src/marketplace.js"></script>
<script src="../src/hamburger.js"></script>
</body>
</html>

109
views/settings.html Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https:;" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Waifu Board - Settings</title>
<link rel="stylesheet" href="styles/home.css">
</head>
<body>
<aside class="sidebar">
<br>
<nav style="display: flex; flex-direction: column; gap: 0.5rem;">
<a href="index.html" class="nav-button" title="Image Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<span>Image Boards</span>
</a>
<a href="books.html" class="nav-button" title="Book Boards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>Book Boards</span>
</a>
<a href="favorites.html" class="nav-button" title="Favorites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span>Favorites</span>
</a>
</nav>
<nav style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: auto;">
<a href="emulator.html" class="nav-button" title="Emulator">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-xml-icon lucide-code-xml"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>
<span>Emulator</span>
</a>
<a href="marketplace.html" class="nav-button" title="Marketplace">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-puzzle-icon lucide-puzzle"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>
<span>Marketplace</span>
</a>
<a href="settings.html" class="nav-button active" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Settings</span>
</a>
</nav>
</aside>
<div class="main-wrapper">
<header class="top-header">
<button id="hamburger-btn" class="hamburger"></button>
<div style="position: relative;margin-left: 50px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position: absolute; left: 15px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--text-tertiary);">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="search-input" placeholder="Search across sources..." style="padding-left: 2.5rem;" />
</div>
<button id="search-button" class="hidden"></button>
<button id="search-icon-button" class="hidden"></button>
<button id="search-close-button" class="hidden"></button>
<div id="header-context" class="hidden"></div>
<h1 id="page-title" class="hidden">Settings</h1>
</header>
<div class="content-view">
<div id="settings-page" class="page">
<div style="margin-bottom: 2rem;">
<h2>Settings</h2>
<p style="color: var(--text-secondary);">App configuration.</p>
</div>
<div class="settings-grid">
<div class="settings-card">
<p style="color: var(--text-tertiary); font-style: italic;">Settings is currently under going a revamp features will be placed here during v2.0.0.</p>
</div>
</div>
</div>
</div>
</div>
<div id="message-bar" class="toast hidden">Message</div>
<script type="module" src="../src/renderer.js"></script>
<script type="module" src="../scripts/main.js"></script>
<script src="../src/updateNotification.js"></script>
<script>
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-button');
if(searchInput && searchBtn) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchBtn.click();
});
}
</script>
<script src="../src/hamburger.js"></script>
</body>
</html>

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

@@ -0,0 +1,404 @@
.image-entry[data-type="book"]::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: inherit;
pointer-events: none;
}
.gallery-masonry .image-entry[data-type="book"] {
grid-row-end: span 1 !important;
aspect-ratio: 2 / 3 !important;
position: relative !important;
contain: layout style !important;
}
.gallery-masonry .image-entry[data-type="book"]::before {
content: "";
display: block;
padding-top: 150% !important;
}
.gallery-masonry .image-entry[data-type="book"] img {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
}
.gallery-masonry:has(.image-entry[data-type="book"]) {
grid-auto-rows: 1fr !important;
}
.gallery-masonry.books-only .image-entry[data-type="book"],
.gallery-masonry .image-entry[data-type="book"] {
aspect-ratio: 2 / 3;
height: auto;
grid-row-end: span 1 !important;
contain: layout style;
background: var(--bg-surface);
border-radius: var(--radius-md);
overflow: hidden;
position: relative;
cursor: pointer;
transition: transform 0.2s;
}
.gallery-masonry .image-entry[data-type="book"]::before {
content: "";
display: block;
padding-top: 150%;
}
.gallery-masonry .image-entry[data-type="book"] img {
position: absolute;
top: 0; left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
transition: all 0.3s ease;
}
.gallery-masonry .image-entry[data-type="book"] img:not(.loaded) {
background: linear-gradient(90deg, #18181b 0%, #27272a 50%, #18181b 100%);
background-size: 200% 100%;
animation: shimmer 1.8s infinite;
opacity: 1;
}
.gallery-masonry .image-entry[data-type="book"]:hover img {
filter: brightness(0.7) blur(2px);
transform: scale(1.08);
}
.gallery-masonry .image-entry[data-type="book"]:hover .book-read-overlay {
opacity: 1;
}
.book-top-nav {
margin-bottom: 2rem;
}
.back-btn-large {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
transition: 0.2s;
font-weight: 500;
}
.back-btn-large:hover {
background: var(--bg-surface-hover);
border-color: var(--accent);
}
.back-btn-large svg {
flex-shrink: 0;
}
.book-layout-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
align-items: start;
}
.book-left-col {
position: sticky;
top: 2rem;
}
.book-poster-large {
width: 100%;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
margin-bottom: 1.5rem;
}
.book-title-sidebar {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
}
.book-chapters-column {
display: flex;
flex-direction: column;
gap: 1rem;
}
.chapter-table-container {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.chapter-row {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: 0.2s;
display: flex;
align-items: center;
justify-content: space-between;
}
.chapter-row:last-child {
border-bottom: none;
}
.chapter-row:hover {
background: var(--bg-surface-hover);
}
.chapter-main-text {
font-weight: 500;
color: var(--text-primary);
font-size: 0.95rem;
}
.pagination-bar {
display: flex;
justify-content: center;
}
.page-btn {
padding: 0.5rem 1rem;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
transition: 0.2s;
font-weight: 500;
}
.page-btn:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#reader-view {
position: fixed;
inset: 0;
background: #000;
z-index: 100;
overflow-y: auto;
padding: 4rem 0 2rem 0;
}
.reader-close-btn {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
padding: 0.75rem 1.25rem;
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
z-index: 101;
transition: 0.2s;
}
.reader-close-btn:hover {
background: rgba(139, 92, 246, 0.9);
border-color: var(--accent);
}
#reader-content {
max-width: 900px;
margin: 0 auto;
padding: 0 1rem;
}
.reader-page-img {
width: 100%;
height: auto;
display: block;
margin-bottom: 0;
border-radius: 4px;
}
.reader-text-content {
background: #1a1a1a;
color: #e5e5e5;
padding: 3rem;
border-radius: var(--radius-lg);
line-height: 1.8;
font-size: 1.05rem;
max-width: 800px;
margin: 0 auto;
}
.reader-text-content p {
margin-bottom: 1.5rem;
}
.reader-text-content h1,
.reader-text-content h2,
.reader-text-content h3 {
color: var(--accent);
margin-top: 2rem;
margin-bottom: 1rem;
}
@media (max-width: 767px) {
/* Book Cards */
.gallery-masonry.books-only {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
}
.image-entry[data-type="book"] img {
height: 200px;
}
.image-entry[data-type="book"]:hover {
transform: none;
}
.image-entry[data-type="book"] .image-buttons {
padding: 0.5rem;
gap: 0.4rem;
opacity: 0.9;
}
.image-entry[data-type="book"] .image-buttons button {
width: 32px;
height: 32px;
}
/* Book Details */
.book-layout-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.book-left-col {
position: relative;
top: 0;
display: grid;
grid-template-columns: 140px 1fr;
gap: 1rem;
align-items: start;
}
.book-poster-large {
width: 100%;
margin-bottom: 0;
}
.book-title-sidebar {
font-size: 1.2rem;
align-self: center;
}
.back-btn-large {
padding: 0.6rem 1rem;
font-size: 0.9rem;
margin-bottom: 1rem;
}
/* Chapter List */
.chapter-row {
padding: 0.85rem 1rem;
}
.chapter-main-text {
font-size: 0.9rem;
}
.pagination-bar {
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem;
}
.page-btn {
padding: 0.5rem 0.85rem;
font-size: 0.85rem;
}
#reader-view {
padding: 3.5rem 0 1rem 0;
}
.reader-close-btn {
top: 0.75rem;
right: 0.75rem;
padding: 0.6rem 1rem;
font-size: 0.9rem;
}
#reader-content {
padding: 0 0.75rem;
}
.reader-text-content {
padding: 1.5rem;
font-size: 1rem;
line-height: 1.7;
}
.reader-page-img {
border-radius: 2px;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.gallery-masonry.books-only {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.book-layout-grid {
grid-template-columns: 240px 1fr;
gap: 1.5rem;
}
.image-entry[data-type="book"] img {
height: 280px;
}
.book-left-col {
position: sticky;
top: 1rem;
}
}
@media (min-width: 1400px) {
.gallery-masonry.books-only {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.image-entry[data-type="book"] img {
height: 340px;
}
.book-layout-grid {
grid-template-columns: 320px 1fr;
gap: 2.5rem;
}
}

370
views/styles/emulator.css Normal file
View File

@@ -0,0 +1,370 @@
.emulator-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
height: calc(100vh - var(--header-height));
padding: 1rem 2rem 2rem 2rem;
overflow: auto;
}
.editor-pane,
.preview-pane {
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
overflow: hidden;
}
.code-editor {
flex: 1;
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: #d4d4d8;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
padding: 1rem;
resize: none;
line-height: 1.5;
tab-size: 4;
}
.code-editor:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-glow);
}
.control-bar {
display: flex;
gap: 0.5rem;
background: var(--bg-surface);
padding: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
align-items: center;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 120px;
}
.control-group label {
font-size: 0.75rem;
color: var(--text-secondary);
border: none;
padding: 0;
margin: 0;
}
.control-input {
background: var(--bg-base);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.5rem;
border-radius: var(--radius-md);
font-size: 0.9rem;
width: 100%;
}
.btn-run {
background: var(--accent);
color: white;
border: none;
padding: 0.6rem 1.5rem;
border-radius: var(--radius-md);
font-weight: 600;
cursor: pointer;
height: 40px;
align-self: flex-end;
transition: 0.2s;
}
.btn-run:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.5rem;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab-btn {
background: transparent;
border: none;
color: var(--text-secondary);
padding: 0.5rem 1rem;
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: 500;
white-space: nowrap;
transition: 0.2s;
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.output-area {
flex: 1;
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: auto;
padding: 1rem;
position: relative;
}
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
color: #a5b4fc;
}
.visual-card {
display: inline-flex;
flex-direction: column;
width: 150px;
margin: 0.5rem;
background: var(--bg-surface);
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border);
vertical-align: top;
}
.visual-card img {
width: 100%;
height: 200px;
object-fit: cover;
background: #27272a;
}
.visual-card .title {
padding: 0.5rem;
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.visual-chapter {
padding: 0.5rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.visual-chapter:hover {
background: var(--bg-surface-hover);
}
.log-entry {
font-family: monospace;
border-bottom: 1px solid var(--border);
padding: 2px 0;
}
.log-info {
color: #60a5fa;
}
.log-warn {
color: #facc15;
}
.log-error {
color: #f87171;
}
.loading-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-secondary);
}
.loading-state.hidden {
display: none;
}
@media (max-width: 767px) {
.emulator-container {
grid-template-columns: 1fr;
grid-template-rows: minmax(250px, 40vh) minmax(400px, 1fr);
gap: 1rem;
padding: 1rem;
height: auto;
overflow-y: auto;
}
.editor-pane,
.preview-pane {
gap: 0.75rem;
height: auto;
min-height: 0;
overflow: visible;
}
.editor-pane {
display: flex;
flex-direction: column;
}
.preview-pane {
display: flex;
flex-direction: column;
overflow: visible;
}
.code-editor {
font-size: 13px;
padding: 0.75rem;
min-height: 200px;
max-height: 35vh;
}
.control-bar {
padding: 0.75rem;
gap: 0.5rem;
flex-direction: column;
align-items: stretch;
}
.control-bar[style*="background: transparent"] {
flex-direction: row !important;
padding: 0 !important;
}
.control-group {
flex: unset;
width: 100%;
min-width: 0;
}
.control-group:has(label:contains("Page")) {
max-width: none;
}
.control-input {
font-size: 0.85rem;
padding: 0.6rem;
}
.btn-run {
width: 100%;
height: auto;
padding: 0.75rem;
align-self: stretch;
margin-top: 0.25rem;
}
.tabs {
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.tab-btn {
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
flex: 1;
text-align: center;
}
.output-area {
padding: 0.75rem;
min-height: 300px;
overflow: auto;
}
.visual-card {
width: 130px;
margin: 0.35rem;
}
.visual-card img {
height: 170px;
}
.visual-card .title {
font-size: 0.75rem;
padding: 0.4rem;
}
.visual-chapter {
padding: 0.6rem 0.5rem;
font-size: 0.9rem;
}
pre {
font-size: 0.8rem;
}
.log-entry {
font-size: 0.8rem;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.emulator-container {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
gap: 1.25rem;
padding: 1rem 1.5rem;
}
.editor-pane {
max-height: 35vh;
}
.preview-pane {
min-height: 45vh;
}
.code-editor {
font-size: 13px;
}
.control-bar {
flex-wrap: wrap;
}
.control-group {
min-width: 140px;
}
.btn-run {
width: auto;
min-width: 100px;
}
}
@media (min-width: 1024px) and (max-width: 1279px) {
.emulator-container {
gap: 1.25rem;
}
.visual-card {
width: 140px;
}
.visual-card img {
height: 180px;
}
}

820
views/styles/home.css Normal file
View File

@@ -0,0 +1,820 @@
:root {
--bg-base: #09090b;
--bg-sidebar: #101012;
--bg-surface: #18181b;
--bg-surface-hover: #27272a;
--accent: #8b5cf6;
--accent-glow: rgba(139, 92, 246, 0.3);
--accent-gradient: linear-gradient(135deg, #8b5cf6, #6366f1);
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-tertiary: #52525b;
--border: #27272a;
--border-hover: #3f3f46;
--radius-md: 8px;
--radius-lg: 16px;
--radius-full: 9999px;
--sidebar-width-collapsed: 72px;
--sidebar-width-expanded: 240px;
--header-height: 70px;
}
* {
box-sizing: border-box;
outline: none;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
body {
margin: 0;
padding: 0;
background-color: var(--bg-base);
color: var(--text-primary);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
height: 100vh;
display: flex;
overflow: hidden;
}
.sidebar {
width: var(--sidebar-width-collapsed);
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 1.5rem 0.75rem;
gap: 0.5rem;
transition: width 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 50;
flex-shrink: 0;
}
.sidebar:hover {
width: var(--sidebar-width-expanded);
}
.main-wrapper {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
min-width: 0;
}
.brand-logo {
height: 48px;
display: flex;
align-items: center;
padding: 0 0.5rem;
margin-bottom: 2rem;
overflow: hidden;
color: var(--text-primary);
font-weight: 800;
font-size: 1.25rem;
letter-spacing: -0.5px;
cursor: default;
}
.brand-icon {
min-width: 32px;
height: 32px;
background: var(--accent-gradient);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
margin-right: 1rem;
box-shadow: 0 0 15px var(--accent-glow);
color: white;
}
.nav-button {
display: flex;
align-items: center;
padding: 0.85rem;
border-radius: var(--radius-md);
color: var(--text-secondary);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
overflow: hidden;
position: relative;
}
.nav-button:hover {
background: var(--bg-surface);
color: var(--text-primary);
}
.nav-button.active {
background: linear-gradient(90deg, rgba(139, 92, 246, 0.1), transparent);
border-left: 3px solid var(--accent);
color: var(--accent);
}
.nav-button svg {
min-width: 24px;
width: 24px;
height: 24px;
margin-right: 1rem;
}
a,
a:visited,
a:hover,
a:active {
text-decoration: none;
}
.nav-button span {
opacity: 0;
transform: translateX(-10px);
transition: 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sidebar:hover .nav-button span {
opacity: 1;
transform: translateX(0);
}
.top-header {
height: var(--header-height);
display: flex;
align-items: center;
justify-content: center;
padding: 0 2rem;
position: sticky;
top: 0;
z-index: 40;
}
.search-box {
display: contents;
}
#search-input {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 0.5rem 1rem;
color: var(--text-primary);
width: 350px;
transition: 0.2s;
font-size: 0.9rem;
}
#search-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.content-view {
flex: 1;
overflow-y: auto;
padding: 0 2rem 2rem 2rem;
}
.page {
max-width: 1600px;
margin: 0 auto;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#source-list {
display: flex;
gap: 1rem;
padding-bottom: 1rem;
scrollbar-width: none;
align-items: center;
flex-direction: row;
scroll-behavior: smooth;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
#source-list::-webkit-scrollbar {
height: 6px;
}
#source-list::-webkit-scrollbar-track {
background: transparent;
}
#source-list::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
#source-list::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
.source-button {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
min-width: 200px;
width: auto;
height: auto;
cursor: pointer;
transition: all 0.2s;
position: relative;
overflow: hidden;
text-align: left;
flex-shrink: 0;
}
.source-button:hover {
background: var(--bg-surface-hover);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
color: var(--text-primary);
}
.source-button.active {
border-color: var(--accent);
background: linear-gradient(to right, rgba(139, 92, 246, 0.05), transparent);
color: var(--text-primary);
}
.source-button img,
.source-button .brand-icon {
width: 32px;
height: 32px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.source-text-wrapper {
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
line-height: 1.3;
}
.source-name {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.source-url {
font-size: 0.75rem;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-masonry {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.gallery-masonry:not(.books-only) {
grid-auto-rows: 8px;
}
.image-entry:not([data-type="book"]) {
margin-bottom: 0;
border-radius: var(--radius-md);
overflow: hidden;
position: relative;
background: var(--bg-surface);
break-inside: avoid;
transition: transform 0.2s;
cursor: zoom-in;
display: block;
width: 100%;
}
.image-entry:not([data-type="book"]):hover {
transform: scale(1.02);
z-index: 2;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.image-entry img {
width: 100%;
height: auto;
display: block;
aspect-ratio: auto;
}
.image-entry img.loaded {
opacity: 1;
transition: opacity 0.3s ease-in;
min-height: unset;
}
.image-buttons {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 2rem 1rem 1rem 1rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.9), transparent);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
opacity: 0;
transition: 0.2s;
top: auto;
right: auto;
flex-direction: row;
}
.image-entry:hover .image-buttons {
opacity: 1;
}
.image-buttons button {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
cursor: pointer;
transition: 0.2s;
padding: 0;
}
.image-buttons button:hover {
background: var(--accent);
border-color: var(--accent);
}
.image-buttons button svg {
width: 16px;
height: 16px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.settings-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.settings-card h3 {
margin-top: 0;
font-size: 1.1rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
fieldset {
border: none;
padding: 0;
margin: 0;
}
label {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: 0.5rem;
transition: 0.2s;
}
label:hover {
background: var(--bg-surface-hover);
}
input[type="radio"] {
accent-color: var(--accent);
margin-top: 4px;
width: 1.2em;
height: 1.2em;
}
.hidden {
display: none !important;
}
.loading-state {
text-align: center;
padding: 4rem;
color: var(--text-tertiary);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#tag-info-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
#tag-info-modal.hidden {
display: none;
}
#tag-info-modal>div {
background: var(--bg-surface);
border: 1px solid var(--border);
padding: 2rem;
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
position: relative;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
max-height: 80vh;
overflow-y: auto;
}
#tag-info-close-button {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 1.5rem;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 1rem;
}
#tag-info-content span {
background: var(--bg-surface-hover);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.8rem;
}
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
padding: 1rem 1.5rem;
border-radius: var(--radius-md);
color: var(--text-primary);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
z-index: 1000;
transform: translateY(20px);
opacity: 0;
pointer-events: none;
transition: 0.3s;
max-width: calc(100vw - 4rem);
}
.toast:not(.hidden) {
transform: translateY(0);
opacity: 1;
pointer-events: all;
}
#message-bar:not(.hidden) {
transform: translateY(0);
opacity: 1;
pointer-events: all;
}
#gallery-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 4rem 1rem;
break-inside: avoid;
column-span: all;
grid-column: 1 / -1;
}
#gallery-placeholder p {
max-width: 300px;
text-align: center;
white-space: normal;
margin: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.image-entry.newly-added {
animation: fadeInUp 0.4s ease-out;
}
.hamburger {
display: none;
width: 42px;
height: 42px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font-size: 1.8rem;
font-weight: bold;
cursor: pointer;
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
transition: background 0.2s, border-color 0.2s;
align-items: center;
justify-content: center;
z-index: 999;
}
.hamburger:hover {
background: var(--bg-surface-hover);
border-color: var(--accent);
}
@media (min-width: 1400px) {
.gallery-masonry {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
}
@media (min-width: 1024px) and (max-width: 1399px) {
.gallery-masonry {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.content-view {
padding: 0 1.5rem 2rem 1.5rem;
}
.top-header {
padding: 0 1.5rem;
}
.gallery-masonry {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
#search-input {
width: 280px;
}
.source-button {
min-width: 180px;
}
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
html {
font-size: 16px;
}
body {
zoom: 1;
}
.content-view {
overflow-x: hidden !important;
padding: 0 1rem 2rem 1rem;
}
.top-header {
height: 70px;
padding: 0 1rem;
justify-content: flex-start;
}
.hamburger {
display: flex;
position: static;
transform: none;
margin-right: auto;
}
#search-input {
width: 100%;
max-width: none;
font-size: 1rem;
padding: 0.6rem 1rem;
}
.sidebar {
position: fixed;
left: -100%;
top: 0;
width: 280px;
height: 100%;
background: var(--bg-sidebar);
transition: left 0.3s ease;
z-index: 300;
padding: 1.5rem 1rem;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
}
.sidebar.active {
left: 0;
}
.sidebar:hover {
width: 280px;
}
.main-wrapper {
margin-left: 0 !important;
width: 100%;
}
.sidebar .nav-button span {
opacity: 1 !important;
transform: translateX(0) !important;
}
.sidebar nav {
gap: 0.75rem !important;
}
.nav-button {
padding: 1rem 1.25rem !important;
font-size: 1rem !important;
}
.nav-button span {
font-size: 1rem !important;
}
.nav-button svg {
width: 24px !important;
height: 24px !important;
stroke-width: 2 !important;
margin-right: 0.75rem !important;
}
.gallery-masonry {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.image-entry:not([data-type="book"]):hover {
transform: none;
}
.image-buttons {
opacity: 0.8;
padding: 1.5rem 0.75rem 0.75rem 0.75rem;
}
.image-buttons button {
width: 36px;
height: 36px;
}
.image-buttons button svg {
width: 18px;
height: 18px;
}
#source-list {
gap: 0.75rem;
padding-bottom: 0.75rem;
}
.source-button {
min-width: 160px;
padding: 0.6rem 0.85rem;
gap: 0.75rem;
}
.source-button img,
.source-button .brand-icon {
width: 28px;
height: 28px;
}
.source-name {
font-size: 0.9rem;
}
.source-url {
font-size: 0.7rem;
}
.source-button:hover {
transform: none;
}
.toast {
bottom: 1rem;
right: 1rem;
left: 1rem;
max-width: none;
padding: 0.85rem 1rem;
font-size: 0.9rem;
}
.loading-state {
padding: 2rem 1rem;
}
#tag-info-modal>div {
padding: 1.5rem;
width: 95%;
}
.settings-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.settings-card {
padding: 1.25rem;
}
h1, h2, h3 {
font-size: 1.25rem;
}
}
@media (max-width: 480px) {
.gallery-masonry {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.6rem;
}
.source-button {
min-width: 140px;
padding: 0.5rem 0.75rem;
}
.source-name {
font-size: 0.85rem;
}
}

View File

@@ -0,0 +1,406 @@
#marketplace-page {
display: flex;
flex-direction: column;
gap: 2.5rem;
padding-bottom: 4rem;
}
.marketplace-hero {
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
border-radius: var(--radius-lg);
padding: 2.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 20px 50px -10px rgba(49, 46, 129, 0.5);
}
.marketplace-hero::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, transparent 70%);
filter: blur(60px);
z-index: 0;
}
.hero-content {
z-index: 1;
max-width: 100%;
}
.hero-title {
font-size: 2.5rem;
font-weight: 800;
margin-bottom: 0.5rem;
background: linear-gradient(to right, #fff, #a5b4fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
color: #c7d2fe;
font-size: 1rem;
line-height: 1.5;
margin-bottom: 2rem;
}
.hero-stats {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.stat-box {
background: rgba(0,0,0,0.3);
padding: 0.75rem 1.25rem;
border-radius: 12px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.1);
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
display: block;
}
.stat-label {
font-size: 0.75rem;
color: #a5b4fc;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.marketplace-tabs {
display: flex;
gap: 1rem;
background: var(--bg-surface);
padding: 0.5rem;
border-radius: 12px;
width: fit-content;
border: 1px solid var(--border);
}
.tab-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
font-size: 0.9rem;
font-weight: 600;
padding: 0.6rem 1.5rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn:hover {
color: var(--text-primary);
background: rgba(255,255,255,0.05);
}
.tab-btn.active {
background: var(--accent);
color: white;
box-shadow: 0 4px 15px var(--accent-glow);
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.section-icon {
color: var(--accent);
flex-shrink: 0;
}
.section-title {
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
}
.marketplace-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.extension-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
gap: 1rem;
}
.extension-card:hover {
transform: translateY(-5px);
border-color: var(--accent);
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
}
.ext-icon-box {
width: 54px;
height: 54px;
background: var(--bg-base);
border-radius: 12px;
padding: 8px;
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ext-icon {
width: 100%;
height: 100%;
object-fit: contain;
}
.type-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
padding: 4px 8px;
border-radius: 6px;
letter-spacing: 0.5px;
}
.type-image { color: #38bdf8; background: rgba(56, 189, 248, 0.1); border: 1px solid rgba(56, 189, 248, 0.2); }
.type-book { color: #f472b6; background: rgba(244, 114, 182, 0.1); border: 1px solid rgba(244, 114, 182, 0.2); }
.ext-name {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
margin: 0.5rem 0 0.25rem 0;
}
.ext-meta {
font-size: 0.8rem;
color: var(--text-tertiary);
font-family: monospace;
}
.card-footer {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.ext-size {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.install-btn {
background: var(--accent);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.install-btn:hover {
background: #7c3aed;
box-shadow: 0 0 15px var(--accent-glow);
}
.install-btn.installed {
background: var(--bg-base);
color: #ef4444;
border: 1px solid var(--border);
}
.install-btn.installed:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
}
.spinner {
width: 14px;
height: 14px;
animation: rotate 1s linear infinite;
stroke: currentColor;
}
@keyframes rotate { 100% { transform: rotate(360deg); } }
.hidden { display: none !important; }
.loading-state { text-align: center; padding: 4rem; color: var(--text-tertiary); }
.main-wrapper .top-header {
display: none;
}
@media (max-width: 767px) {
.main-wrapper .top-header {
display: flex;
}
#marketplace-page {
gap: 1.5rem;
padding-bottom: 2rem;
}
.marketplace-hero {
padding: 1.5rem;
border-radius: 12px;
}
.marketplace-hero::before {
width: 250px;
height: 250px;
right: -20%;
top: -30%;
}
.hero-title {
font-size: 1.8rem;
margin-bottom: 0.5rem;
}
.hero-subtitle {
font-size: 0.9rem;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.hero-stats {
gap: 1rem;
}
.stat-box {
padding: 0.6rem 1rem;
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 1.3rem;
}
.stat-label {
font-size: 0.7rem;
}
.marketplace-tabs {
width: 100%;
justify-content: stretch;
gap: 0.5rem;
padding: 0.4rem;
}
.tab-btn {
flex: 1;
padding: 0.7rem 1rem;
font-size: 0.9rem;
}
.section-header {
margin-bottom: 1rem;
}
.section-title {
font-size: 1rem;
}
.section-icon {
width: 18px;
height: 18px;
}
.marketplace-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.extension-card {
padding: 1.25rem;
gap: 0.75rem;
}
.extension-card:hover {
transform: none;
}
.ext-icon-box {
width: 48px;
height: 48px;
}
.ext-name {
font-size: 1rem;
}
.ext-meta {
font-size: 0.75rem;
}
.type-badge {
font-size: 0.6rem;
padding: 3px 7px;
}
.card-footer {
padding-top: 0.75rem;
}
.ext-size {
font-size: 0.7rem;
}
.install-btn {
padding: 0.5rem 0.85rem;
font-size: 0.8rem;
}
.loading-state {
padding: 2rem 1rem;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.marketplace-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.hero-title {
font-size: 2rem;
}
.marketplace-tabs {
width: auto;
}
}