Organized all the code
Updated the update notification to check periodically every 5 minutes Added in headless browser support for extensions (check the extensions repo to see an example) Added in DiscordRPC support
This commit is contained in:
115
src/content/image-handler.js
Normal file
115
src/content/image-handler.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createAddFavoriteButton, createRemoveFavoriteButton } from '../favorites/favorites-handler.js';
|
||||
|
||||
export function populateTagModal(container, tags) {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
container.innerHTML =
|
||||
'<p class="text-gray-400">No tags available for this image.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
tags.forEach((tag) => {
|
||||
if (tag) {
|
||||
const tagPill = document.createElement('span');
|
||||
tagPill.className =
|
||||
'px-2.5 py-1 bg-gray-700 text-gray-300 text-xs font-medium rounded-full';
|
||||
tagPill.textContent = tag.replace(/_/g, ' ');
|
||||
fragment.appendChild(tagPill);
|
||||
}
|
||||
});
|
||||
container.appendChild(fragment);
|
||||
}
|
||||
|
||||
function createInfoButton(safeTags, showTagModalCallback) {
|
||||
const button = document.createElement('button');
|
||||
button.title = 'Show Info';
|
||||
button.className =
|
||||
'p-2 rounded-full bg-black/50 text-white hover:bg-blue-600 backdrop-blur-sm transition-colors';
|
||||
button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>`;
|
||||
button.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
showTagModalCallback(safeTags);
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createImageCard(id, tags, imageUrl, thumbnailUrl, type, context) {
|
||||
const {
|
||||
currentLayout,
|
||||
showMessage,
|
||||
showTagModal,
|
||||
applyLayoutToGallery,
|
||||
favoritesGallery
|
||||
} = context;
|
||||
|
||||
const safeTags = Array.isArray(tags) ? tags : [];
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.dataset.id = id;
|
||||
entry.className = `image-entry group relative bg-gray-800 rounded-lg shadow-lg overflow-hidden transition-all duration-300`;
|
||||
|
||||
if (currentLayout === 'compact') {
|
||||
entry.classList.add('aspect-square');
|
||||
}
|
||||
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'w-full bg-gray-700 animate-pulse relative';
|
||||
|
||||
if (currentLayout === 'compact') {
|
||||
imageContainer.classList.add('h-full');
|
||||
} else {
|
||||
imageContainer.classList.add('min-h-[200px]');
|
||||
}
|
||||
|
||||
entry.appendChild(imageContainer);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = imageUrl;
|
||||
img.alt = safeTags.join(', ');
|
||||
img.className = 'w-full h-auto object-contain bg-gray-900 opacity-0';
|
||||
img.loading = 'lazy';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
|
||||
if (currentLayout === 'compact') {
|
||||
img.className = 'w-full h-full object-cover bg-gray-900 opacity-0';
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
imageContainer.classList.remove('animate-pulse', 'bg-gray-700');
|
||||
img.classList.remove('opacity-0');
|
||||
img.classList.add('transition-opacity', 'duration-500');
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.warn(`Failed to load full image: ${imageUrl}. Falling back to thumbnail.`);
|
||||
img.src = thumbnailUrl;
|
||||
imageContainer.classList.remove('animate-pulse', 'bg-gray-700');
|
||||
img.classList.remove('opacity-0');
|
||||
img.classList.add('transition-opacity', 'duration-500');
|
||||
img.onerror = null;
|
||||
};
|
||||
imageContainer.appendChild(img);
|
||||
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.className =
|
||||
'image-buttons absolute top-3 right-3 flex flex-col space-y-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200';
|
||||
|
||||
buttonContainer.appendChild(createInfoButton(safeTags, showTagModal));
|
||||
|
||||
if (type === 'browse') {
|
||||
buttonContainer.appendChild(
|
||||
createAddFavoriteButton(id, safeTags, imageUrl, thumbnailUrl, showMessage)
|
||||
);
|
||||
} else {
|
||||
buttonContainer.appendChild(
|
||||
createRemoveFavoriteButton(id, favoritesGallery, showMessage, applyLayoutToGallery, currentLayout)
|
||||
);
|
||||
}
|
||||
imageContainer.appendChild(buttonContainer);
|
||||
|
||||
return entry;
|
||||
}
|
||||
74
src/database/db-init.js
Normal file
74
src/database/db-init.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
function runDatabaseMigrations(db) {
|
||||
db.serialize(() => {
|
||||
db.run(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL,
|
||||
thumbnail_url TEXT NOT NULL DEFAULT "",
|
||||
tags TEXT NOT NULL DEFAULT ""
|
||||
)
|
||||
`,
|
||||
(err) => {
|
||||
if (err) console.error('Error creating table:', err.message);
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Checking database schema for "thumbnail_url"...');
|
||||
db.all('PRAGMA table_info(favorites)', (err, columns) => {
|
||||
if (err) return console.error('Failed to get table info:', err.message);
|
||||
|
||||
const hasThumbnailColumn = columns.some((col) => col.name === 'thumbnail_url');
|
||||
|
||||
if (!hasThumbnailColumn) {
|
||||
console.log('MIGRATION: Adding "thumbnail_url" column...');
|
||||
db.run(
|
||||
'ALTER TABLE favorites ADD COLUMN thumbnail_url TEXT NOT NULL DEFAULT ""',
|
||||
(err) => {
|
||||
if (err) console.error('Migration error (thumbnail_url):', err.message);
|
||||
else console.log('MIGRATION: "thumbnail_url" added successfully.');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('"thumbnail_url" column is up-to-date.');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Checking database schema for "tags" column...');
|
||||
db.all('PRAGMA table_info(favorites)', (err, columns) => {
|
||||
if (err) return console.error('Failed to get table info:', err.message);
|
||||
|
||||
const hasTagsColumn = columns.some((col) => col.name === 'tags');
|
||||
|
||||
if (!hasTagsColumn) {
|
||||
console.log('MIGRATION: Adding "tags" column...');
|
||||
db.run(
|
||||
'ALTER TABLE favorites ADD COLUMN tags TEXT NOT NULL DEFAULT ""',
|
||||
(err) => {
|
||||
if (err) console.error('Migration error (tags):', err.message);
|
||||
else console.log('MIGRATION: "tags" column added successfully.');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('"tags" column is up-to-date.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initDatabase(dbPath) {
|
||||
const db = new sqlite3.Database(dbPath, (err) => {
|
||||
if (err) {
|
||||
console.error('Error opening database:', err.message);
|
||||
} else {
|
||||
console.log('Connected to the favorites database.');
|
||||
runDatabaseMigrations(db);
|
||||
}
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
module.exports = initDatabase;
|
||||
82
src/discord-rpc.js
Normal file
82
src/discord-rpc.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const { DiscordRPCClient } = require('@ryuziii/discord-rpc');
|
||||
require('dotenv').config();
|
||||
|
||||
let rpcClient;
|
||||
let reconnectTimer;
|
||||
|
||||
function initDiscordRPC() {
|
||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||
|
||||
if (!clientId) {
|
||||
console.warn('Discord RPC: Invalid or missing Client ID. Check your .env file.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Discord RPC: Initializing with Client ID ending in ...${clientId.slice(-4)}`);
|
||||
|
||||
if (rpcClient) {
|
||||
try { rpcClient.destroy(); } catch (e) {}
|
||||
rpcClient = null;
|
||||
}
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
rpcClient = new DiscordRPCClient({
|
||||
clientId: clientId,
|
||||
transport: 'ipc'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Discord RPC: Failed to instantiate Client.', err);
|
||||
return;
|
||||
}
|
||||
|
||||
rpcClient.on('ready', () => {
|
||||
const user = rpcClient.user ? rpcClient.user.username : 'User';
|
||||
console.log(`Discord RPC: Authed for user ${user}`);
|
||||
|
||||
setTimeout(() => {
|
||||
setActivity();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
rpcClient.on('disconnected', () => {
|
||||
console.log('Discord RPC: Disconnected. Attempting to reconnect in 10s...');
|
||||
if (!reconnectTimer) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
initDiscordRPC();
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
rpcClient.connect().catch(err => {
|
||||
console.error('Discord RPC: Connection failed', err.message);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Discord RPC: Error initializing', err);
|
||||
}
|
||||
}
|
||||
|
||||
function setActivity() {
|
||||
if (!rpcClient) return;
|
||||
|
||||
try {
|
||||
const activity = {
|
||||
details: 'Browsing',
|
||||
state: 'In App',
|
||||
startTimestamp: new Date(),
|
||||
largeImageKey: 'bigpicture',
|
||||
instance: false,
|
||||
};
|
||||
rpcClient.setActivity(activity);
|
||||
console.log('Discord RPC: Activity set successfully');
|
||||
} catch (error) {
|
||||
console.error("Discord RPC: Failed to set activity", error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initDiscordRPC };
|
||||
56
src/extensions/load-extensions.js
Normal file
56
src/extensions/load-extensions.js
Normal file
@@ -0,0 +1,56 @@
|
||||
export async function populateSources(sourceList) {
|
||||
console.log('Requesting sources from main process...');
|
||||
const sources = await window.api.getSources();
|
||||
sourceList.innerHTML = '';
|
||||
let initialSource = '';
|
||||
|
||||
if (sources && sources.length > 0) {
|
||||
sources.forEach((source) => {
|
||||
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.dataset.source = source.name;
|
||||
button.title = source.name;
|
||||
|
||||
const favicon = document.createElement('img');
|
||||
favicon.className = 'w-8 h-8 rounded';
|
||||
|
||||
let mainDomain = source.url;
|
||||
try {
|
||||
const hostname = new URL(source.url).hostname;
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length > 2 && ['api', 'www'].includes(parts[0])) {
|
||||
mainDomain = parts.slice(1).join('.');
|
||||
} else {
|
||||
mainDomain = hostname;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse domain from ${source.url}:`, e);
|
||||
mainDomain = source.name;
|
||||
}
|
||||
|
||||
favicon.src = `https://www.google.com/s2/favicons?domain=${mainDomain}&sz=32`;
|
||||
favicon.alt = source.name;
|
||||
favicon.onerror = () => {
|
||||
button.innerHTML = `<span class="font-bold text-sm">${source.name.substring(
|
||||
0,
|
||||
2
|
||||
)}</span>`;
|
||||
favicon.remove();
|
||||
};
|
||||
|
||||
button.appendChild(favicon);
|
||||
sourceList.appendChild(button);
|
||||
});
|
||||
console.log('Sources populated:', sources);
|
||||
|
||||
if (sourceList.children.length > 0) {
|
||||
const firstButton = sourceList.children[0];
|
||||
firstButton.classList.add('active');
|
||||
initialSource = firstButton.dataset.source;
|
||||
}
|
||||
} else {
|
||||
console.warn('No sources were loaded from the main process.');
|
||||
}
|
||||
return initialSource;
|
||||
}
|
||||
76
src/favorites/favorites-handler.js
Normal file
76
src/favorites/favorites-handler.js
Normal file
@@ -0,0 +1,76 @@
|
||||
export async function handleAddFavorite(id, tags, imageUrl, thumbnailUrl, showMessageCallback) {
|
||||
const safeTags = Array.isArray(tags) ? tags : [];
|
||||
const title = safeTags.length > 0 ? safeTags[0] : 'Favorite';
|
||||
const allTags = safeTags.join(',');
|
||||
|
||||
const result = await window.api.addFavorite({
|
||||
id,
|
||||
title,
|
||||
imageUrl,
|
||||
thumbnailUrl,
|
||||
tags: allTags,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showMessageCallback('Added to favorites!', 'success');
|
||||
} else {
|
||||
showMessageCallback(result.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleRemoveFavorite(id, favoritesGallery, showMessageCallback, applyLayoutCallback, currentLayout) {
|
||||
const result = await window.api.removeFavorite(id);
|
||||
|
||||
if (result.success) {
|
||||
showMessageCallback('Removed from favorites.', 'success');
|
||||
|
||||
const cardToRemove = document.querySelector(`#favorites-gallery [data-id='${id}']`);
|
||||
|
||||
if (cardToRemove) {
|
||||
cardToRemove.classList.add('opacity-0', 'scale-90');
|
||||
|
||||
setTimeout(() => {
|
||||
cardToRemove.remove();
|
||||
if (favoritesGallery.children.length === 0) {
|
||||
applyLayoutCallback(favoritesGallery, currentLayout);
|
||||
favoritesGallery.innerHTML =
|
||||
'<p class="text-gray-400 text-center text-lg">You haven\'t saved any favorites yet.</p>';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
showMessageCallback(result.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function createAddFavoriteButton(id, safeTags, imageUrl, thumbnailUrl, showMessageCallback) {
|
||||
const button = document.createElement('button');
|
||||
button.title = 'Add to Favorites';
|
||||
button.className =
|
||||
'p-2 rounded-full bg-black/50 text-white hover:bg-indigo-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.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.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
handleAddFavorite(id, safeTags, imageUrl, thumbnailUrl, showMessageCallback);
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createRemoveFavoriteButton(id, favoritesGallery, showMessageCallback, applyLayoutCallback, currentLayout) {
|
||||
const button = document.createElement('button');
|
||||
button.title = 'Remove from Favorites';
|
||||
button.className =
|
||||
'p-2 rounded-full bg-black/50 text-white hover:bg-red-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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12.578 0a48.108 48.108 0 01-3.478-.397m15.408 0l-2.147-2.147A1.125 1.125 0 0016.34 3H7.66a1.125 1.125 0 00-.795.325L4.772 5.79m14.456 0l-2.29-2.29a1.125 1.125 0 00-.795-.324H8.455a1.125 1.125 0 00-.795.324L5.37 5.79m13.84 0L20.25 7.5" />
|
||||
</svg>`;
|
||||
|
||||
button.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFavorite(id, favoritesGallery, showMessageCallback, applyLayoutCallback, currentLayout);
|
||||
};
|
||||
return button;
|
||||
}
|
||||
33
src/favorites/favorites-loader.js
Normal file
33
src/favorites/favorites-loader.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export async function loadFavorites(favoritesGallery, currentLayout, applyLayoutCallback, createImageCardCallback) {
|
||||
|
||||
applyLayoutCallback(favoritesGallery, currentLayout);
|
||||
favoritesGallery.innerHTML =
|
||||
'<div class="text-center p-10"><p class="text-gray-400">Loading favorites...</p></div>';
|
||||
|
||||
const currentFavorites = await window.api.getFavorites();
|
||||
|
||||
if (currentFavorites.length === 0) {
|
||||
applyLayoutCallback(favoritesGallery, currentLayout);
|
||||
favoritesGallery.innerHTML =
|
||||
'<p class="text-gray-400 text-center text-lg">You haven\'t saved any favorites yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
applyLayoutCallback(favoritesGallery, currentLayout);
|
||||
favoritesGallery.innerHTML = '';
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
currentFavorites.forEach((fav) => {
|
||||
const card = createImageCardCallback(
|
||||
fav.id,
|
||||
fav.tags ? fav.tags.split(',') : [],
|
||||
fav.image_url,
|
||||
fav.thumbnail_url,
|
||||
'fav'
|
||||
);
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
|
||||
favoritesGallery.appendChild(fragment);
|
||||
}
|
||||
29
src/ipc/api-handlers.js
Normal file
29
src/ipc/api-handlers.js
Normal file
@@ -0,0 +1,29 @@
|
||||
module.exports = function (loadedScrapers) {
|
||||
return {
|
||||
getSources: () => {
|
||||
return Object.keys(loadedScrapers).map((name) => {
|
||||
return {
|
||||
name: name,
|
||||
url: loadedScrapers[name].baseUrl,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
search: async (event, source, query, page) => {
|
||||
try {
|
||||
if (loadedScrapers[source] && loadedScrapers[source].instance) {
|
||||
const results = await loadedScrapers[source].instance.fetchSearchResult(
|
||||
query,
|
||||
page
|
||||
);
|
||||
return { success: true, data: results };
|
||||
} else {
|
||||
throw new Error(`Unknown source or source failed to load: ${source}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error searching ${source}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
53
src/ipc/db-handlers.js
Normal file
53
src/ipc/db-handlers.js
Normal file
@@ -0,0 +1,53 @@
|
||||
module.exports = function (db) {
|
||||
return {
|
||||
getFavorites: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT * FROM favorites', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error getting favorites:', err.message);
|
||||
resolve([]);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
addFavorite: (event, fav) => {
|
||||
return new Promise((resolve) => {
|
||||
const stmt =
|
||||
'INSERT INTO favorites (id, title, image_url, thumbnail_url, tags) VALUES (?, ?, ?, ?, ?)';
|
||||
db.run(
|
||||
stmt,
|
||||
[fav.id, fav.title, fav.imageUrl, fav.thumbnailUrl, fav.tags],
|
||||
function (err) {
|
||||
if (err) {
|
||||
if (err.code.includes('SQLITE_CONSTRAINT')) {
|
||||
resolve({ success: false, error: 'Item is already a favorite.' });
|
||||
} else {
|
||||
console.error('Error adding favorite:', err.message);
|
||||
resolve({ success: false, error: err.message });
|
||||
}
|
||||
} else {
|
||||
resolve({ success: true, id: fav.id });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
removeFavorite: (event, id) => {
|
||||
return new Promise((resolve) => {
|
||||
const stmt = 'DELETE FROM favorites WHERE id = ?';
|
||||
db.run(stmt, id, function (err) {
|
||||
if (err) {
|
||||
console.error('Error removing favorite:', err.message);
|
||||
resolve({ success: false, error: err.message });
|
||||
} else {
|
||||
resolve({ success: this.changes > 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
32
src/modules/layout-manager.js
Normal file
32
src/modules/layout-manager.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export function applyLayoutToGallery(galleryElement, layout) {
|
||||
if (!galleryElement) return;
|
||||
|
||||
galleryElement.className = 'p-4 w-full';
|
||||
|
||||
if (layout === 'scroll') {
|
||||
galleryElement.classList.add('max-w-3xl', 'mx-auto', 'space-y-8');
|
||||
} else if (layout === 'grid') {
|
||||
galleryElement.classList.add('gallery-masonry');
|
||||
} else if (layout === 'compact') {
|
||||
galleryElement.classList.add('gallery-grid');
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSavedLayout() {
|
||||
const savedLayout = localStorage.getItem('waifuBoardLayout') || 'scroll';
|
||||
|
||||
const savedRadio = document.querySelector(`input[name="layout"][value="${savedLayout}"]`);
|
||||
if (savedRadio) {
|
||||
savedRadio.checked = true;
|
||||
} else {
|
||||
const defaultRadio = document.getElementById('layout-scroll');
|
||||
if(defaultRadio) defaultRadio.checked = true;
|
||||
}
|
||||
|
||||
return savedLayout;
|
||||
}
|
||||
|
||||
export function saveLayout(newLayout) {
|
||||
localStorage.setItem('waifuBoardLayout', newLayout);
|
||||
console.log('Layout changed to:', newLayout);
|
||||
}
|
||||
52
src/modules/navigation-handler.js
Normal file
52
src/modules/navigation-handler.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { loadFavorites } from '../favorites/favorites-loader.js';
|
||||
|
||||
export function showPage(pageId, domRefs, callbacks, state) {
|
||||
const {
|
||||
browseButton,
|
||||
favoritesButton,
|
||||
settingsButton,
|
||||
pageTitle,
|
||||
headerContext,
|
||||
favoritesGallery
|
||||
} = domRefs;
|
||||
|
||||
const { updateHeader, applyLayoutToGallery, createImageCard } = callbacks;
|
||||
const { currentLayout } = state;
|
||||
|
||||
document.querySelectorAll('.page').forEach((page) => {
|
||||
page.classList.add('hidden');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.nav-button').forEach((tab) => {
|
||||
tab.classList.remove('bg-indigo-600', 'text-white');
|
||||
tab.classList.add('text-gray-400', 'hover:bg-gray-700');
|
||||
});
|
||||
|
||||
const activePage = document.getElementById(pageId);
|
||||
if (activePage) {
|
||||
activePage.classList.remove('hidden');
|
||||
}
|
||||
|
||||
let activeTab;
|
||||
if (pageId === 'browse-page') {
|
||||
activeTab = browseButton;
|
||||
pageTitle.textContent = 'Browse';
|
||||
updateHeader();
|
||||
} else if (pageId === 'favorites-page') {
|
||||
activeTab = favoritesButton;
|
||||
pageTitle.textContent = 'Favorites';
|
||||
headerContext.textContent = '';
|
||||
|
||||
loadFavorites(favoritesGallery, currentLayout, applyLayoutToGallery, createImageCard);
|
||||
|
||||
} else if (pageId === 'settings-page') {
|
||||
activeTab = settingsButton;
|
||||
pageTitle.textContent = 'Settings';
|
||||
headerContext.textContent = '';
|
||||
}
|
||||
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('bg-indigo-600', 'text-white');
|
||||
activeTab.classList.remove('text-gray-400', 'hover:bg-gray-700');
|
||||
}
|
||||
}
|
||||
115
src/modules/search-handler.js
Normal file
115
src/modules/search-handler.js
Normal file
@@ -0,0 +1,115 @@
|
||||
let currentPage = 1;
|
||||
let hasNextPage = true;
|
||||
let isLoading = false;
|
||||
let currentQuery = '';
|
||||
|
||||
export async function performSearch(
|
||||
currentSource,
|
||||
searchInput,
|
||||
currentLayout,
|
||||
domRefs,
|
||||
callbacks
|
||||
) {
|
||||
const { showMessage, applyLayoutToGallery, updateHeader } = callbacks;
|
||||
const { galleryPlaceholder, contentGallery, searchModal } = domRefs;
|
||||
|
||||
if (!currentSource) {
|
||||
showMessage('Please select a source from the sidebar.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentPage = 1;
|
||||
hasNextPage = true;
|
||||
isLoading = false;
|
||||
currentQuery = searchInput.value.trim().replace(/[, ]+/g, ' ');
|
||||
|
||||
if (galleryPlaceholder) galleryPlaceholder.classList.add('hidden');
|
||||
|
||||
applyLayoutToGallery(contentGallery, currentLayout);
|
||||
contentGallery.innerHTML = '';
|
||||
updateHeader();
|
||||
|
||||
searchModal.classList.add('hidden');
|
||||
|
||||
await loadMoreResults(currentSource, currentLayout, domRefs, callbacks);
|
||||
}
|
||||
|
||||
export async function loadMoreResults(
|
||||
currentSource,
|
||||
currentLayout,
|
||||
domRefs,
|
||||
callbacks
|
||||
) {
|
||||
const { loadingSpinner, infiniteLoadingSpinner, contentGallery } = domRefs;
|
||||
const { applyLayoutToGallery, createImageCard } = callbacks;
|
||||
|
||||
if (isLoading || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
if (currentPage === 1) {
|
||||
loadingSpinner.classList.remove('hidden');
|
||||
} else {
|
||||
infiniteLoadingSpinner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
const result = await window.api.search(
|
||||
currentSource,
|
||||
currentQuery,
|
||||
currentPage
|
||||
);
|
||||
|
||||
loadingSpinner.classList.add('hidden');
|
||||
infiniteLoadingSpinner.classList.add('hidden');
|
||||
|
||||
if (
|
||||
!result.success ||
|
||||
!result.data.results ||
|
||||
result.data.results.length === 0
|
||||
) {
|
||||
hasNextPage = false;
|
||||
if (currentPage === 1) {
|
||||
applyLayoutToGallery(contentGallery, currentLayout);
|
||||
contentGallery.innerHTML =
|
||||
'<p class="text-gray-400 text-center text-lg">No results found. Please try another search term.</p>';
|
||||
}
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
18
src/modules/ui-utils.js
Normal file
18
src/modules/ui-utils.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export function showMessage(messageBar, message, type = 'success') {
|
||||
if (!messageBar) return;
|
||||
messageBar.textContent = message;
|
||||
|
||||
if (type === 'error') {
|
||||
messageBar.classList.remove('bg-green-600');
|
||||
messageBar.classList.add('bg-red-600');
|
||||
} else {
|
||||
messageBar.classList.remove('bg-red-600');
|
||||
messageBar.classList.add('bg-green-600');
|
||||
}
|
||||
|
||||
messageBar.classList.remove('hidden', 'translate-y-16');
|
||||
|
||||
setTimeout(() => {
|
||||
messageBar.classList.add('hidden', 'translate-y-16');
|
||||
}, 3000);
|
||||
}
|
||||
12
src/preload.js
Normal file
12
src/preload.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
|
||||
getFavorites: () => ipcRenderer.invoke('db:getFavorites'),
|
||||
addFavorite: (fav) => ipcRenderer.invoke('db:addFavorite', fav),
|
||||
removeFavorite: (id) => ipcRenderer.invoke('db:removeFavorite', id),
|
||||
|
||||
search: (source, query) => ipcRenderer.invoke('api:search', source, query),
|
||||
|
||||
getSources: () => ipcRenderer.invoke('api:getSources'),
|
||||
});
|
||||
132
src/renderer.js
Normal file
132
src/renderer.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import { populateSources } from './extensions/load-extensions.js';
|
||||
import { setupGlobalKeybinds } from './utils/keybinds.js';
|
||||
import { getDomElements } from './utils/dom-loader.js';
|
||||
import { performSearch, loadMoreResults } from './modules/search-handler.js';
|
||||
import { createImageCard, populateTagModal } from './content/image-handler.js';
|
||||
import { showMessage as uiShowMessage } from './modules/ui-utils.js';
|
||||
import { showPage as navShowPage } from './modules/navigation-handler.js';
|
||||
import { applyLayoutToGallery, loadSavedLayout, saveLayout } from './modules/layout-manager.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const domRefs = getDomElements();
|
||||
|
||||
let currentSource = '';
|
||||
let currentLayout = loadSavedLayout();
|
||||
|
||||
setupGlobalKeybinds(domRefs.searchModal);
|
||||
|
||||
function showMessage(message, type = 'success') {
|
||||
uiShowMessage(domRefs.messageBar, message, type);
|
||||
}
|
||||
|
||||
function showTagModal(tags) {
|
||||
populateTagModal(domRefs.tagInfoContent, tags);
|
||||
domRefs.tagInfoModal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function localCreateImageCard(id, tags, imageUrl, thumbnailUrl, type) {
|
||||
return createImageCard(id, tags, imageUrl, thumbnailUrl, type, {
|
||||
currentLayout,
|
||||
showMessage,
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const initialSource = await populateSources(domRefs.sourceList);
|
||||
currentSource = initialSource;
|
||||
|
||||
updateHeader();
|
||||
handleNavigation('browse-page');
|
||||
});
|
||||
87
src/updateNotification.js
Normal file
87
src/updateNotification.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const GITHUB_OWNER = 'ItsSkaiya';
|
||||
const GITHUB_REPO = 'WaifuBoard';
|
||||
const CURRENT_VERSION = 'v1.3.0';
|
||||
const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
let currentVersionDisplay;
|
||||
let latestVersionDisplay;
|
||||
let updateToast;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
currentVersionDisplay = document.getElementById('currentVersionDisplay');
|
||||
latestVersionDisplay = document.getElementById('latestVersionDisplay');
|
||||
updateToast = document.getElementById('updateToast');
|
||||
|
||||
if (currentVersionDisplay) {
|
||||
currentVersionDisplay.textContent = CURRENT_VERSION;
|
||||
}
|
||||
|
||||
checkForUpdates();
|
||||
|
||||
setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL);
|
||||
});
|
||||
|
||||
function showToast(latestVersion) {
|
||||
|
||||
if (latestVersionDisplay && updateToast) {
|
||||
latestVersionDisplay.textContent = latestVersion;
|
||||
updateToast.classList.add('update-available');
|
||||
updateToast.classList.remove('hidden');
|
||||
|
||||
} else {
|
||||
console.error("Error: Cannot display toast because one or more DOM elements were not found.");
|
||||
}
|
||||
}
|
||||
|
||||
function hideToast() {
|
||||
if (updateToast) {
|
||||
updateToast.classList.add('hidden');
|
||||
updateToast.classList.remove('update-available');
|
||||
}
|
||||
}
|
||||
|
||||
function isVersionOutdated(versionA, versionB) {
|
||||
|
||||
const vA = versionA.replace(/^v/, '').split('.').map(Number);
|
||||
const vB = versionB.replace(/^v/, '').split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(vA.length, vB.length); i++) {
|
||||
const numA = vA[i] || 0;
|
||||
const numB = vB[i] || 0;
|
||||
|
||||
if (numA < numB) return true;
|
||||
if (numA > numB) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
console.log(`Checking for updates for ${GITHUB_OWNER}/${GITHUB_REPO}...`);
|
||||
const apiUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const latestVersion = data.tag_name;
|
||||
console.log(`Latest GitHub Release: ${latestVersion}`);
|
||||
|
||||
if (isVersionOutdated(CURRENT_VERSION, latestVersion)) {
|
||||
console.warn('Update available!');
|
||||
showToast(latestVersion);
|
||||
} else {
|
||||
console.info('Package is up to date.');
|
||||
hideToast();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub release:', error);
|
||||
}
|
||||
}
|
||||
26
src/utils/dom-loader.js
Normal file
26
src/utils/dom-loader.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export function getDomElements() {
|
||||
return {
|
||||
browseButton: document.getElementById('browse-button'),
|
||||
favoritesButton: document.getElementById('favorites-button'),
|
||||
settingsButton: document.getElementById('settings-button'),
|
||||
browsePage: document.getElementById('browse-page'),
|
||||
pageTitle: document.getElementById('page-title'),
|
||||
headerContext: document.getElementById('header-context'),
|
||||
searchIconButton: document.getElementById('search-icon-button'),
|
||||
searchModal: document.getElementById('search-modal'),
|
||||
searchCloseButton: document.getElementById('search-close-button'),
|
||||
searchInput: document.getElementById('search-input'),
|
||||
searchButton: document.getElementById('search-button'),
|
||||
sourceList: document.getElementById('source-list'),
|
||||
contentGallery: document.getElementById('content-gallery'),
|
||||
favoritesGallery: document.getElementById('favorites-gallery'),
|
||||
loadingSpinner: document.getElementById('loading-spinner'),
|
||||
infiniteLoadingSpinner: document.getElementById('infinite-loading-spinner'),
|
||||
messageBar: document.getElementById('message-bar'),
|
||||
galleryPlaceholder: document.getElementById('gallery-placeholder'),
|
||||
layoutRadios: document.querySelectorAll('input[name="layout"]'),
|
||||
tagInfoModal: document.getElementById('tag-info-modal'),
|
||||
tagInfoCloseButton: document.getElementById('tag-info-close-button'),
|
||||
tagInfoContent: document.getElementById('tag-info-content'),
|
||||
};
|
||||
}
|
||||
67
src/utils/headless-browser.js
Normal file
67
src/utils/headless-browser.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { BrowserWindow } = require('electron');
|
||||
|
||||
class HeadlessBrowser {
|
||||
async scrape(url, evalFunc, options = {}) {
|
||||
const { waitSelector = null, timeout = 15000 } = options;
|
||||
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
offscreen: true,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: false,
|
||||
images: true,
|
||||
webgl: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
win.webContents.setUserAgent(userAgent);
|
||||
|
||||
await win.loadURL(url, { userAgent });
|
||||
|
||||
if (waitSelector) {
|
||||
await this.waitForSelector(win, waitSelector, timeout);
|
||||
}
|
||||
|
||||
const result = await win.webContents.executeJavaScript(`(${evalFunc.toString()})()`);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Headless Scrape Error:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!win.isDestroyed()) {
|
||||
win.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async waitForSelector(win, selector, timeout) {
|
||||
const script = `
|
||||
new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Timeout waiting for selector: ${selector}'));
|
||||
}, ${timeout});
|
||||
|
||||
const check = () => {
|
||||
if (document.querySelector('${selector}')) {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
} else {
|
||||
// FIX: Use setTimeout because requestAnimationFrame stops in hidden windows
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
`;
|
||||
await win.webContents.executeJavaScript(script);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new HeadlessBrowser();
|
||||
7
src/utils/keybinds.js
Normal file
7
src/utils/keybinds.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export function setupGlobalKeybinds(searchModal) {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
searchModal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user