8 Commits

47 changed files with 1781 additions and 1865 deletions

View File

@@ -19,6 +19,7 @@
"fastify": "^5.6.2", "fastify": "^5.6.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-addon-api": "^8.5.0", "node-addon-api": "^8.5.0",
"node-cron": "^4.2.1",
"playwright-chromium": "^1.57.0", "playwright-chromium": "^1.57.0",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
@@ -5446,6 +5447,15 @@
"semver": "^7.3.5" "semver": "^7.3.5"
} }
}, },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-gyp": { "node_modules/node-gyp": {
"version": "12.1.0", "version": "12.1.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.1.0.tgz", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.1.0.tgz",

View File

@@ -22,6 +22,7 @@
"fastify": "^5.6.2", "fastify": "^5.6.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-addon-api": "^8.5.0", "node-addon-api": "^8.5.0",
"node-cron": "^4.2.1",
"playwright-chromium": "^1.57.0", "playwright-chromium": "^1.57.0",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
@@ -46,7 +47,8 @@
"package.json", "package.json",
"views/**/*", "views/**/*",
"src/scripts/**/*", "src/scripts/**/*",
"public/assets/*" "public/assets/*",
"loading.html"
], ],
"extraResources": [ "extraResources": [
{ {

View File

@@ -4,14 +4,16 @@ const fastify = require("fastify")({
const path = require("path"); const path = require("path");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const cron = require("node-cron");
const { initHeadless } = require("./electron/shared/headless"); const { initHeadless } = require("./electron/shared/headless");
const { initDatabase } = require("./electron/shared/database"); const { initDatabase } = require("./electron/shared/database");
const { loadExtensions } = require("./electron/shared/extensions"); const { loadExtensions } = require("./electron/shared/extensions");
const { init } = require("./electron/api/rpc/rpc.controller"); const { init } = require("./electron/api/rpc/rpc.controller");
const {refreshTrendingAnime, refreshTopAiringAnime} = require("./electron/api/anime/anime.service");
const {refreshPopularBooks, refreshTrendingBooks} = require("./electron/api/books/books.service");
const dotenv = require("dotenv"); const dotenv = require("dotenv");
const isPackaged = process.env.IS_PACKAGED === "true"; const isPackaged = process.env.IS_PACKAGED === "true";
const envPath = isPackaged const envPath = isPackaged
? path.join(process.resourcesPath, ".env") ? path.join(process.resourcesPath, ".env")
: path.join(__dirname, ".env"); : path.join(__dirname, ".env");
@@ -28,39 +30,6 @@ const userRoutes = require("./electron/api/user/user.routes");
const listRoutes = require("./electron/api/list/list.routes"); const listRoutes = require("./electron/api/list/list.routes");
const anilistRoute = require("./electron/api/anilist/anilist"); const anilistRoute = require("./electron/api/anilist/anilist");
const fs = require("fs");
try {
console.log("--- DEBUGGING PATHS ---");
// 1. Check where we are currently running
console.log("Current Directory:", __dirname);
// 2. Check if 'electron' exists
const electronPath = path.join(__dirname, "electron");
if (fs.existsSync(electronPath)) {
console.log("✅ electron folder found.");
} else {
console.log("❌ electron folder missing!");
}
// 3. Check 'electron/api/rpc' specifically
const rpcPath = path.join(__dirname, "electron", "api", "rpc");
if (fs.existsSync(rpcPath)) {
console.log("✅ electron/api/rpc folder found. Contents:");
// LIST EVERYTHING INSIDE THE RPC FOLDER
console.log(fs.readdirSync(rpcPath));
} else {
console.log(`❌ electron/api/rpc folder NOT found at: ${rpcPath}`);
// Check parent folder to see what IS there
const parent = path.join(__dirname, "electron", "api");
console.log("Contents of electron/api:", fs.readdirSync(parent));
}
console.log("-----------------------");
} catch (e) {
console.log("Debug Error:", e);
}
fastify.addHook("preHandler", async (request) => { fastify.addHook("preHandler", async (request) => {
const auth = request.headers.authorization; const auth = request.headers.authorization;
if (!auth) return; if (!auth) return;
@@ -102,6 +71,8 @@ fastify.register(userRoutes, { prefix: "/api" });
fastify.register(anilistRoute, { prefix: "/api" }); fastify.register(anilistRoute, { prefix: "/api" });
fastify.register(listRoutes, { prefix: "/api" }); fastify.register(listRoutes, { prefix: "/api" });
const sleep = ms => new Promise(r => setTimeout(r, ms));
const start = async () => { const start = async () => {
try { try {
initDatabase("anilist"); initDatabase("anilist");
@@ -110,12 +81,31 @@ const start = async () => {
initDatabase("userdata"); initDatabase("userdata");
init(); init();
const refreshAll = async () => {
await refreshTrendingAnime();
await sleep(300);
await refreshTopAiringAnime();
await sleep(300);
await refreshTrendingBooks();
await sleep(300);
await refreshPopularBooks();
};
cron.schedule("*/30 * * * *", async () => {
try {
await refreshAll();
console.log("cache refreshed");
} catch (e) {
console.error("refresh failed", e);
}
});
await loadExtensions(); await loadExtensions();
await initHeadless();
await refreshAll();
await fastify.listen({ port: 54322, host: "0.0.0.0" }); await fastify.listen({ port: 54322, host: "0.0.0.0" });
console.log(`Server running at http://localhost:54322`); console.log(`Server running at http://localhost:54322`);
await initHeadless();
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
process.exit(1); process.exit(1);

View File

@@ -3,7 +3,6 @@ import { queryAll, queryOne } from '../../shared/database';
import {Anime, Episode, Extension, StreamData} from '../types'; import {Anime, Episode, Extension, StreamData} from '../types';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TTL = 60 * 60 * 6;
const ANILIST_URL = "https://graphql.anilist.co"; const ANILIST_URL = "https://graphql.anilist.co";
@@ -79,6 +78,54 @@ const MEDIA_FIELDS = `
} }
`; `;
export async function refreshTrendingAnime(): Promise<void> {
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM trending");
let rank = 1;
for (const anime of list) {
await queryOne(
"INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, anime.id, JSON.stringify(anime), now]
);
}
}
export async function refreshTopAiringAnime(): Promise<void> {
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM top_airing");
let rank = 1;
for (const anime of list) {
await queryOne(
"INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, anime.id, JSON.stringify(anime), now]
);
}
}
async function fetchAniList(query: string, variables: any) { async function fetchAniList(query: string, variables: any) {
const res = await fetch(ANILIST_URL, { const res = await fetch(ANILIST_URL, {
method: "POST", method: "POST",
@@ -119,76 +166,16 @@ export async function getAnimeById(id: string | number): Promise<Anime | { error
export async function getTrendingAnime(): Promise<Anime[]> { export async function getTrendingAnime(): Promise<Anime[]> {
const rows = await queryAll( const rows = await queryAll(
"SELECT full_data, updated_at FROM trending ORDER BY rank ASC LIMIT 10" "SELECT full_data FROM trending ORDER BY rank ASC LIMIT 10"
); );
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM trending");
let rank = 1;
for (const anime of list) {
await queryOne(
"INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, anime.id, JSON.stringify(anime), now]
);
}
return list;
} }
export async function getTopAiringAnime(): Promise<Anime[]> { export async function getTopAiringAnime(): Promise<Anime[]> {
const rows = await queryAll( const rows = await queryAll(
"SELECT full_data, updated_at FROM top_airing ORDER BY rank ASC LIMIT 10" "SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10"
); );
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM top_airing");
let rank = 1;
for (const anime of list) {
await queryOne(
"INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, anime.id, JSON.stringify(anime), now]
);
}
return list;
} }
export async function searchAnimeLocal(query: string): Promise<Anime[]> { export async function searchAnimeLocal(query: string): Promise<Anime[]> {

View File

@@ -4,7 +4,6 @@ import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions'
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types'; import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TTL = 60 * 60 * 6;
const ANILIST_URL = "https://graphql.anilist.co"; const ANILIST_URL = "https://graphql.anilist.co";
async function fetchAniList(query: string, variables: any) { async function fetchAniList(query: string, variables: any) {
@@ -134,18 +133,7 @@ export async function getBookById(id: string | number): Promise<Book | { error:
return { error: "Book not found" }; return { error: "Book not found" };
} }
export async function getTrendingBooks(): Promise<Book[]> { export async function refreshTrendingBooks(): Promise<void> {
const rows = await queryAll(
"SELECT full_data, updated_at FROM trending_books ORDER BY rank ASC LIMIT 10"
);
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
}
}
const query = ` const query = `
query { query {
Page(page: 1, perPage: 10) { Page(page: 1, perPage: 10) {
@@ -167,23 +155,9 @@ export async function getTrendingBooks(): Promise<Book[]> {
[rank++, book.id, JSON.stringify(book), now] [rank++, book.id, JSON.stringify(book), now]
); );
} }
return list;
}
export async function getPopularBooks(): Promise<Book[]> {
const rows = await queryAll(
"SELECT full_data, updated_at FROM popular_books ORDER BY rank ASC LIMIT 10"
);
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
}
} }
export async function refreshPopularBooks(): Promise<void> {
const query = ` const query = `
query { query {
Page(page: 1, perPage: 10) { Page(page: 1, perPage: 10) {
@@ -205,10 +179,21 @@ export async function getPopularBooks(): Promise<Book[]> {
[rank++, book.id, JSON.stringify(book), now] [rank++, book.id, JSON.stringify(book), now]
); );
} }
return list;
} }
export async function getTrendingBooks(): Promise<Book[]> {
const rows = await queryAll(
"SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10"
);
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
}
export async function getPopularBooks(): Promise<Book[]> {
const rows = await queryAll(
"SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10"
);
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
}
export async function searchBooksLocal(query: string): Promise<Book[]> { export async function searchBooksLocal(query: string): Promise<Book[]> {
if (!query || query.length < 2) { if (!query || query.length < 2) {

View File

@@ -1,6 +1,59 @@
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap, saveExtensionFile, deleteExtensionFile } from '../../shared/extensions'; import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap, saveExtensionFile, deleteExtensionFile } from '../../shared/extensions';
import { ExtensionNameRequest } from '../types'; import { ExtensionNameRequest } from '../types';
import path from 'path';
import fs from 'fs/promises';
const TYPE_MAP: Record<string, string> = {
'anime-board': 'anime',
'image-board': 'image',
'book-board': 'book',
};
function extractProp(source: string, prop: string): string | null {
const m = source.match(new RegExp(`this\\.${prop}\\s*=\\s*["']([^"']+)["']`));
return m ? m[1] : null;
}
function isNewer(remote: string, local?: string | null) {
if (!local) return true;
return remote !== local;
}
export async function updateExtensions(req: any, reply: FastifyReply) {
const updated: string[] = [];
for (const name of getExtensionsList()) {
const ext = getExtension(name);
if (!ext) continue;
const type = ext.type;
if (!TYPE_MAP[type]) continue;
const fileName = ext.__fileName;
const remoteUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${TYPE_MAP[type]}/${fileName}`;
let remoteSrc: string;
try {
const res = await fetch(remoteUrl);
if (!res.ok) continue;
remoteSrc = await res.text();
} catch {
continue;
}
const remoteVersion = extractProp(remoteSrc, 'version');
const localVersion = ext.version ?? null;
if (!remoteVersion) continue;
if (isNewer(remoteVersion, localVersion)) {
await saveExtensionFile(fileName, remoteUrl);
updated.push(name);
}
}
return { updated };
}
export async function getExtensions(req: FastifyRequest, reply: FastifyReply) { export async function getExtensions(req: FastifyRequest, reply: FastifyReply) {
return { extensions: getExtensionsList() }; return { extensions: getExtensionsList() };
@@ -37,24 +90,33 @@ export async function getExtensionSettings(req: ExtensionNameRequest, reply: Fas
} }
export async function installExtension(req: any, reply: FastifyReply) { export async function installExtension(req: any, reply: FastifyReply) {
const { fileName } = req.body; const { url } = req.body;
if (!fileName || !fileName.endsWith('.js')) { if (!url || typeof url !== 'string' || !url.endsWith('.js')) {
return reply.code(400).send({ error: "Invalid extension fileName provided" }); return reply.code(400).send({ error: "Invalid extension URL provided" });
} }
try { try {
const fileName = url.split('/').pop();
const downloadUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${fileName}` if (!fileName) {
return reply.code(400).send({ error: "Could not determine file name from URL" });
}
await saveExtensionFile(fileName, downloadUrl); await saveExtensionFile(fileName, url);
req.server.log.info(`Extension installed: ${fileName}`); req.server.log.info(`Extension installed: ${fileName}`);
return reply.code(200).send({ success: true, message: `Extension ${fileName} installed successfully.` }); return reply.code(200).send({
success: true,
message: `Extension ${fileName} installed successfully.`,
});
} catch (error) { } catch (error) {
req.server.log.error(`Failed to install extension ${fileName}:`, error); req.server.log.error(`Failed to install extension from ${url}:`, error);
return reply.code(500).send({ success: false, error: `Failed to install extension ${fileName}.` }); return reply.code(500).send({
success: false,
error: "Failed to install extension.",
});
} }
} }

View File

@@ -4,6 +4,7 @@ import * as controller from './extensions.controller';
async function extensionsRoutes(fastify: FastifyInstance) { async function extensionsRoutes(fastify: FastifyInstance) {
fastify.get('/extensions', controller.getExtensions); fastify.get('/extensions', controller.getExtensions);
fastify.get('/extensions/anime', controller.getAnimeExtensions); fastify.get('/extensions/anime', controller.getAnimeExtensions);
fastify.post('/extensions/update', controller.updateExtensions);
fastify.get('/extensions/book', controller.getBookExtensions); fastify.get('/extensions/book', controller.getBookExtensions);
fastify.get('/extensions/gallery', controller.getGalleryExtensions); fastify.get('/extensions/gallery', controller.getGalleryExtensions);
fastify.get('/extensions/:name/settings', controller.getExtensionSettings); fastify.get('/extensions/:name/settings', controller.getExtensionSettings);

View File

@@ -8,7 +8,10 @@ let plyrInstance;
let hlsInstance; let hlsInstance;
let totalEpisodes = 0; let totalEpisodes = 0;
let animeTitle = ""; let animeTitle = "";
let aniSkipData = null;
let isAnilist = false;
let malId = null;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const firstKey = params.keys().next().value; const firstKey = params.keys().next().value;
@@ -21,6 +24,17 @@ const href = extName
document.getElementById('back-link').href = href; document.getElementById('back-link').href = href;
document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`; document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`;
async function loadAniSkip(malId, episode, duration) {
try {
const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`);
if (!res.ok) return null;
const data = await res.json();
return data.results || [];
} catch (error) {
console.error('Error loading AniSkip data:', error);
return null;
}
}
async function loadMetadata() { async function loadMetadata() {
try { try {
@@ -61,6 +75,14 @@ async function loadMetadata() {
seasonYear = data.year || ''; seasonYear = data.year || '';
} }
if (isAnilistFormat && data.idMal) {
isAnilist = true;
malId = data.idMal;
} else {
isAnilist = false;
malId = null;
}
document.getElementById('anime-title-details').innerText = title; document.getElementById('anime-title-details').innerText = title;
document.getElementById('anime-title-details2').innerText = title; document.getElementById('anime-title-details2').innerText = title;
animeTitle = title; animeTitle = title;
@@ -106,6 +128,102 @@ async function loadMetadata() {
} }
} }
async function applyAniSkip(video) {
if (!isAnilist || !malId) {
console.log('AniSkip disabled: isAnilist=' + isAnilist + ', malId=' + malId);
return;
}
console.log('Loading AniSkip for MAL ID:', malId, 'Episode:', currentEpisode);
aniSkipData = await loadAniSkip(
malId,
currentEpisode,
Math.floor(video.duration)
);
console.log('AniSkip data received:', aniSkipData);
if (!aniSkipData || aniSkipData.length === 0) {
console.log('No AniSkip data available');
return;
}
let op, ed;
const markers = [];
aniSkipData.forEach(item => {
const { startTime, endTime } = item.interval;
if (item.skipType === 'op') {
op = { start: startTime, end: endTime };
markers.push({
start: startTime,
end: endTime,
label: 'Opening'
});
console.log('Opening found:', startTime, '-', endTime);
}
if (item.skipType === 'ed') {
ed = { start: startTime, end: endTime };
markers.push({
start: startTime,
end: endTime,
label: 'Ending'
});
console.log('Ending found:', startTime, '-', endTime);
}
});
// Crear markers visuales en el DOM
if (plyrInstance && markers.length > 0) {
console.log('Creating visual markers:', markers);
// Esperar a que el player esté completamente cargado
setTimeout(() => {
const progressContainer = document.querySelector('.plyr__progress');
if (!progressContainer) {
console.error('Progress container not found');
return;
}
// Eliminar markers anteriores si existen
const oldMarkers = progressContainer.querySelector('.plyr__markers');
if (oldMarkers) oldMarkers.remove();
// Crear contenedor de markers
const markersContainer = document.createElement('div');
markersContainer.className = 'plyr__markers';
markers.forEach(marker => {
const markerElement = document.createElement('div');
markerElement.className = 'plyr__marker';
markerElement.dataset.label = marker.label;
const startPercent = (marker.start / video.duration) * 100;
const widthPercent = ((marker.end - marker.start) / video.duration) * 100;
markerElement.style.left = `${startPercent}%`;
markerElement.style.width = `${widthPercent}%`;
markerElement.addEventListener('click', (e) => {
e.stopPropagation();
video.currentTime = marker.start;
});
markersContainer.appendChild(markerElement);
});
progressContainer.appendChild(markersContainer);
console.log('Visual markers created successfully');
}, 500);
}
}
async function loadExtensionEpisodes() { async function loadExtensionEpisodes() {
try { try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist"; const extQuery = extName ? `?source=${extName}` : "?source=anilist";
@@ -337,7 +455,15 @@ function playVideo(url, subtitles = []) {
plyrInstance = new Plyr(video, { plyrInstance = new Plyr(video, {
captions: { active: true, update: true, language: 'en' }, captions: { active: true, update: true, language: 'en' },
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'], controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
settings: ['captions', 'quality', 'speed'] settings: ['captions', 'quality', 'speed'],
markers: {
enabled: true,
points: []
}
});
video.addEventListener('loadedmetadata', () => {
applyAniSkip(video);
}); });
let rpcActive = false; let rpcActive = false;

View File

@@ -29,20 +29,32 @@ async function populateSourceFilter() {
`; `;
try { try {
const response = await fetch(`${API_BASE}/extensions`); const [animeRes, bookRes] = await Promise.all([
if (response.ok) { fetch(`${API_BASE}/extensions/anime`),
const data = await response.json(); fetch(`${API_BASE}/extensions/book`)
const extensions = data.extensions || []; ]);
const extensions = new Set();
if (animeRes.ok) {
const data = await animeRes.json();
(data.extensions || []).forEach(ext => extensions.add(ext));
}
if (bookRes.ok) {
const data = await bookRes.json();
(data.extensions || []).forEach(ext => extensions.add(ext));
}
extensions.forEach(extName => { extensions.forEach(extName => {
if (extName.toLowerCase() !== 'anilist' && extName.toLowerCase() !== 'local') { const lower = extName.toLowerCase();
if (lower !== 'anilist' && lower !== 'local') {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = extName; option.value = extName;
option.textContent = extName.charAt(0).toUpperCase() + extName.slice(1); option.textContent = extName.charAt(0).toUpperCase() + extName.slice(1);
select.appendChild(option); select.appendChild(option);
} }
}); });
}
} catch (error) { } catch (error) {
console.error('Error loading extensions:', error); console.error('Error loading extensions:', error);
} }

View File

@@ -1,422 +1,262 @@
const GITEA_INSTANCE = 'https://git.waifuboard.app'; const ORIGINAL_MARKETPLACE_URL = 'https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/marketplace.json';
const REPO_OWNER = 'ItsSkaiya'; const MARKETPLACE_JSON_URL = `/api/proxy?url=${encodeURIComponent(ORIGINAL_MARKETPLACE_URL)}`;
const REPO_NAME = 'WaifuBoard-Extensions';
let DETECTED_BRANCH = 'main';
const API_URL_BASE = `${GITEA_INSTANCE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents`;
const INSTALLED_EXTENSIONS_API = '/api/extensions'; const INSTALLED_EXTENSIONS_API = '/api/extensions';
const UPDATE_EXTENSIONS_API = '/api/extensions/update';
const extensionsGrid = document.getElementById('extensions-grid'); const marketplaceContent = document.getElementById('marketplace-content');
const filterSelect = document.getElementById('extension-filter'); const filterSelect = document.getElementById('extension-filter');
const updateAllBtn = document.getElementById('btn-update-all');
let allExtensionsData = []; const modal = document.getElementById('customModal');
const customModal = document.getElementById('customModal');
const modalTitle = document.getElementById('modalTitle'); const modalTitle = document.getElementById('modalTitle');
const modalMessage = document.getElementById('modalMessage'); const modalMessage = document.getElementById('modalMessage');
const modalConfirmBtn = document.getElementById('modalConfirmButton');
const modalCloseBtn = document.getElementById('modalCloseButton');
function getRawUrl(filename) { let marketplaceMetadata = {};
let installedExtensions = [];
let currentTab = 'marketplace';
const targetUrl = `${GITEA_INSTANCE}/${REPO_OWNER}/${REPO_NAME}/raw/branch/main/${filename}`; async function loadMarketplace() {
showSkeletons();
const encodedUrl = encodeURIComponent(targetUrl);
return `/api/proxy?url=${encodedUrl}`;
}
function updateExtensionState(fileName, installed) {
const ext = allExtensionsData.find(e => e.fileName === fileName);
if (!ext) return;
ext.isInstalled = installed;
ext.isLocal = installed && ext.isLocal;
filterAndRenderExtensions(filterSelect?.value || 'All');
}
function formatExtensionName(fileName) {
return fileName.replace('.js', '')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^[a-z]/, (char) => char.toUpperCase());
}
function getIconUrl(extensionDetails) {
return extensionDetails;
}
async function getExtensionDetails(url) {
try { try {
const res = await fetch(url); const [metaRes, installedRes] = await Promise.all([
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); fetch(MARKETPLACE_JSON_URL).then(res => res.json()),
const text = await res.text(); fetch(INSTALLED_EXTENSIONS_API).then(res => res.json())
]);
const regex = /(?:this\.|const\s+|let\s+|var\s+)?baseUrl\s*=\s*(["'`])(.*?)\1/i; marketplaceMetadata = metaRes.extensions;
const match = text.match(regex); installedExtensions = (installedRes.extensions || []).map(e => e.toLowerCase());
let finalHostname = null;
if (match && match[2]) { initTabs();
let rawUrl = match[2].trim(); renderGroupedView();
if (!rawUrl.startsWith('http')) rawUrl = 'https://' + rawUrl;
try { if (filterSelect) {
const urlObj = new URL(rawUrl); filterSelect.addEventListener('change', () => renderGroupedView());
finalHostname = urlObj.hostname; }
} catch(e) {
console.warn(`Could not parse baseUrl: ${rawUrl}`); if (updateAllBtn) {
updateAllBtn.onclick = handleUpdateAll;
}
} catch (error) {
console.error('Error loading marketplace:', error);
marketplaceContent.innerHTML = `<div class="error-msg">Error al cargar el marketplace.</div>`;
} }
} }
const classMatch = text.match(/class\s+(\w+)/); function initTabs() {
const name = classMatch ? classMatch[1] : null; const tabs = document.querySelectorAll('.tab-button');
tabs.forEach(tab => {
tab.onclick = () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentTab = tab.dataset.tab;
let type = 'Image'; if (updateAllBtn) {
if (text.includes('type = "book-board"') || text.includes("type = 'book-board'")) type = 'Book'; if (currentTab === 'installed') {
else if (text.includes('type = "anime-board"') || text.includes("type = 'anime-board'")) type = 'Anime'; updateAllBtn.classList.remove('hidden');
return { baseUrl: finalHostname, name, type };
} catch (e) {
return { baseUrl: null, name: null, type: 'Unknown' };
}
}
function showCustomModal(title, message, isConfirm = false) {
return new Promise(resolve => {
modalTitle.textContent = title;
modalMessage.textContent = message;
const currentConfirmButton = document.getElementById('modalConfirmButton');
const currentCloseButton = document.getElementById('modalCloseButton');
const newConfirmButton = currentConfirmButton.cloneNode(true);
currentConfirmButton.parentNode.replaceChild(newConfirmButton, currentConfirmButton);
const newCloseButton = currentCloseButton.cloneNode(true);
currentCloseButton.parentNode.replaceChild(newCloseButton, currentCloseButton);
if (isConfirm) {
newConfirmButton.classList.remove('hidden');
newConfirmButton.textContent = 'Confirm';
newCloseButton.textContent = 'Cancel';
} else { } else {
updateAllBtn.classList.add('hidden');
newConfirmButton.classList.add('hidden'); }
newCloseButton.textContent = 'Close';
} }
const closeModal = (confirmed) => { renderGroupedView();
customModal.classList.add('hidden');
resolve(confirmed);
}; };
newConfirmButton.onclick = () => closeModal(true);
newCloseButton.onclick = () => closeModal(false);
customModal.classList.remove('hidden');
}); });
} }
function renderExtensionCard(extension, isInstalled, isLocalOnly = false) { async function handleUpdateAll() {
const originalText = updateAllBtn.innerText;
try {
updateAllBtn.disabled = true;
updateAllBtn.innerText = 'Updating...';
const extensionName = formatExtensionName(extension.fileName || extension.name); const res = await fetch(UPDATE_EXTENSIONS_API, { method: 'POST' });
const extensionType = extension.type || 'Unknown'; if (!res.ok) throw new Error('Update failed');
let iconUrl; const data = await res.json();
if (extension.baseUrl && extension.baseUrl !== 'Local Install') { if (data.updated && data.updated.length > 0) {
iconUrl = `https://www.google.com/s2/favicons?domain=${extension.baseUrl}&sz=128`; const list = data.updated.join(', ');
window.NotificationUtils.success(`Updated: ${list}`);
await loadMarketplace();
} else { } else {
window.NotificationUtils.info('Everything is up to date.');
const displayName = extensionName.replace(/\s/g, '+'); }
iconUrl = `https://ui-avatars.com/api/?name=${displayName}&background=1f2937&color=fff&length=1`; } catch (error) {
console.error('Update All Error:', error);
window.NotificationUtils.error('Failed to perform bulk update.');
} finally {
updateAllBtn.disabled = false;
updateAllBtn.innerText = originalText;
}
} }
function renderGroupedView() {
marketplaceContent.innerHTML = '';
const activeFilter = filterSelect.value;
const groups = {};
let listToRender = [];
if (currentTab === 'marketplace') {
for (const [id, data] of Object.entries(marketplaceMetadata)) {
listToRender.push({
id,
...data,
isInstalled: installedExtensions.includes(id.toLowerCase())
});
}
} else {
for (const [id, data] of Object.entries(marketplaceMetadata)) {
if (installedExtensions.includes(id.toLowerCase())) {
listToRender.push({ id, ...data, isInstalled: true });
}
}
installedExtensions.forEach(id => {
const existsInMeta = Object.keys(marketplaceMetadata).some(k => k.toLowerCase() === id);
if (!existsInMeta) {
listToRender.push({
id: id,
name: id.charAt(0).toUpperCase() + id.slice(1),
type: 'Local',
author: 'Unknown',
isInstalled: true
});
}
});
}
listToRender.forEach(ext => {
const type = ext.type || 'Other';
if (activeFilter !== 'All' && type !== activeFilter) return;
if (!groups[type]) groups[type] = [];
groups[type].push(ext);
});
const sortedTypes = Object.keys(groups).sort();
if (sortedTypes.length === 0) {
marketplaceContent.innerHTML = `<p class="empty-msg">No extensions found for this criteria.</p>`;
return;
}
sortedTypes.forEach(type => {
const section = document.createElement('div');
section.className = 'category-group';
const title = document.createElement('h2');
title.className = 'marketplace-section-title';
title.innerText = type.replace('-', ' ');
const grid = document.createElement('div');
grid.className = 'marketplace-grid';
groups[type].forEach(ext => grid.appendChild(createCard(ext)));
section.appendChild(title);
section.appendChild(grid);
marketplaceContent.appendChild(section);
});
}
function createCard(ext) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = `extension-card grid-item extension-type-${extensionType.toLowerCase()}`; card.className = `extension-card ${ext.nsfw ? 'nsfw-ext' : ''} ${ext.broken ? 'broken-ext' : ''}`;
card.dataset.path = extension.fileName || extension.name;
card.dataset.type = extensionType;
let buttonHtml; const iconUrl = `https://www.google.com/s2/favicons?domain=${ext.domain}&sz=128`;
let badgeHtml = '';
if (isInstalled) { let buttonHtml = '';
if (ext.isInstalled) {
if (isLocalOnly) { buttonHtml = `<button class="extension-action-button btn-uninstall">Uninstall</button>`;
badgeHtml = '<span class="extension-status-badge badge-local">Local</span>'; } else if (ext.broken) {
buttonHtml = `<button class="extension-action-button" style="background: #4b5563; cursor: not-allowed;" disabled>Broken</button>`;
} else { } else {
badgeHtml = '<span class="extension-status-badge badge-installed">Installed</span>'; buttonHtml = `<button class="extension-action-button btn-install">Install</button>`;
}
buttonHtml = `
<button class="extension-action-button btn-uninstall" data-action="uninstall">Uninstall</button>
`;
} else {
buttonHtml = `
<button class="extension-action-button btn-install" data-action="install">Install</button>
`;
} }
card.innerHTML = ` card.innerHTML = `
<img class="extension-icon" src="${iconUrl}" alt="${extensionName} Icon" onerror="this.onerror=null; this.src='https://ui-avatars.com/api/?name=E&background=1f2937&color=fff&length=1'"> <img class="extension-icon" src="${iconUrl}" onerror="this.src='/public/assets/waifuboards.ico'">
<div class="card-content-wrapper"> <div class="card-content-wrapper">
<h3 class="extension-name" title="${extensionName}">${extensionName}</h3> <h3 class="extension-name">${ext.name}</h3>
${badgeHtml} <span class="extension-author">by ${ext.author || 'Unknown'}</span>
<p class="extension-description">${ext.description || 'No description available.'}</p>
<div class="extension-tags">
<span class="extension-status-badge badge-${ext.isInstalled ? 'installed' : (ext.broken ? 'local' : 'available')}">
${ext.isInstalled ? 'Installed' : (ext.broken ? 'Broken' : 'Available')}
</span>
${ext.nsfw ? '<span class="extension-status-badge badge-local">NSFW</span>' : ''}
</div>
</div> </div>
${buttonHtml} ${buttonHtml}
`; `;
const installButton = card.querySelector('[data-action="install"]'); const btn = card.querySelector('.extension-action-button');
const uninstallButton = card.querySelector('[data-action="uninstall"]'); if (!ext.broken || ext.isInstalled) {
btn.onclick = () => ext.isInstalled ? promptUninstall(ext) : handleInstall(ext);
}
if (installButton) { return card;
installButton.addEventListener('click', async () => { }
function showModal(title, message, showConfirm = false, onConfirm = null) {
modalTitle.innerText = title;
modalMessage.innerText = message;
if (showConfirm) {
modalConfirmBtn.classList.remove('hidden');
modalConfirmBtn.onclick = () => { hideModal(); if (onConfirm) onConfirm(); };
} else {
modalConfirmBtn.classList.add('hidden');
}
modalCloseBtn.onclick = hideModal;
modal.classList.remove('hidden');
}
function hideModal() { modal.classList.add('hidden'); }
async function handleInstall(ext) {
try { try {
const response = await fetch('/api/extensions/install', { const res = await fetch('/api/extensions/install', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: extension.fileName }), body: JSON.stringify({ url: ext.entry })
}); });
if (res.ok) {
const result = await response.json(); installedExtensions.push(ext.id.toLowerCase());
renderGroupedView();
if (response.ok) { window.NotificationUtils.success(`${ext.name} installed!`);
updateExtensionState(extension.fileName, true);
await showCustomModal(
'Installation Successful',
`${extensionName} has been successfully installed.`,
false
);
} else {
await showCustomModal(
'Installation Failed',
`Installation failed: ${result.error || 'Unknown error.'}`,
false
);
} }
} catch (error) { } catch (e) { window.NotificationUtils.error('Install failed.'); }
await showCustomModal(
'Installation Failed',
`Network error during installation.`,
false
);
}
});
} }
if (uninstallButton) { function promptUninstall(ext) {
uninstallButton.addEventListener('click', async () => { showModal('Confirm', `Uninstall ${ext.name}?`, true, () => handleUninstall(ext));
}
const confirmed = await showCustomModal(
'Confirm Uninstallation',
`Are you sure you want to uninstall ${extensionName}?`,
true
);
if (!confirmed) return;
async function handleUninstall(ext) {
try { try {
const response = await fetch('/api/extensions/uninstall', { const res = await fetch('/api/extensions/uninstall', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: extension.fileName }), body: JSON.stringify({ fileName: ext.id + '.js' })
}); });
if (res.ok) {
const result = await response.json(); installedExtensions = installedExtensions.filter(id => id !== ext.id.toLowerCase());
renderGroupedView();
if (response.ok) { window.NotificationUtils.info(`${ext.name} uninstalled.`);
updateExtensionState(extension.fileName, false);
await showCustomModal(
'Uninstallation Successful',
`${extensionName} has been successfully uninstalled.`,
false
);
} else {
await showCustomModal(
'Uninstallation Failed',
`Uninstallation failed: ${result.error || 'Unknown error.'}`,
false
);
} }
} catch (error) { } catch (e) { window.NotificationUtils.error('Uninstall failed.'); }
await showCustomModal(
'Uninstallation Failed',
`Network error during uninstallation.`,
false
);
}
});
} }
extensionsGrid.appendChild(card); function showSkeletons() {
} marketplaceContent.innerHTML = `
<div class="marketplace-grid">
async function getInstalledExtensions() { ${Array(3).fill('<div class="extension-card skeleton"></div>').join('')}
console.log(`Fetching installed extensions from: ${INSTALLED_EXTENSIONS_API}`);
try {
const response = await fetch(INSTALLED_EXTENSIONS_API);
if (!response.ok) {
console.error(`Error fetching installed extensions. Status: ${response.status}`);
return new Set();
}
const data = await response.json();
if (!data.extensions || !Array.isArray(data.extensions)) {
console.error("Invalid response format from /api/extensions: 'extensions' array missing or incorrect.");
return new Set();
}
const installedFileNames = data.extensions
.map(name => `${name.toLowerCase()}.js`);
return new Set(installedFileNames);
} catch (error) {
console.error('Network or JSON parsing error during fetch of installed extensions:', error);
return new Set();
}
}
function filterAndRenderExtensions(filterType) {
extensionsGrid.innerHTML = '';
if (!allExtensionsData || allExtensionsData.length === 0) {
console.log('No extension data to filter.');
return;
}
const filteredExtensions = allExtensionsData.filter(ext =>
filterType === 'All' || ext.type === filterType || (ext.isLocal && filterType === 'Local')
);
filteredExtensions.forEach(ext => {
renderExtensionCard(ext, ext.isInstalled, ext.isLocal);
});
if (filteredExtensions.length === 0) {
extensionsGrid.innerHTML = `<p style="grid-column: 1 / -1; text-align: center; color: var(--text-secondary);">No extensions found for the selected filter (${filterType}).</p>`;
}
}
async function loadMarketplace() {
extensionsGrid.innerHTML = '';
for (let i = 0; i < 6; i++) {
extensionsGrid.innerHTML += `
<div class="extension-card skeleton grid-item">
<div class="skeleton-icon skeleton" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button skeleton" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>`;
}
try {
const [availableExtensionsRaw, installedExtensionsSet] = await Promise.all([
fetch(API_URL_BASE).then(res => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json();
}),
getInstalledExtensions()
]);
const availableExtensionsJs = availableExtensionsRaw.filter(ext => ext.type === 'file' && ext.name.endsWith('.js'));
const detailPromises = [];
const marketplaceFileNames = new Set(availableExtensionsJs.map(ext => ext.name.toLowerCase()));
for (const ext of availableExtensionsJs) {
const downloadUrl = getRawUrl(ext.name);
const detailsPromise = getExtensionDetails(downloadUrl).then(details => ({
...ext,
...details,
fileName: ext.name,
isInstalled: installedExtensionsSet.has(ext.name.toLowerCase()),
isLocal: false,
}));
detailPromises.push(detailsPromise);
}
const extensionsWithDetails = await Promise.all(detailPromises);
installedExtensionsSet.forEach(installedName => {
if (!marketplaceFileNames.has(installedName)) {
const localExt = {
name: formatExtensionName(installedName),
fileName: installedName,
type: 'Local',
isInstalled: true,
isLocal: true,
baseUrl: 'Local Install',
};
extensionsWithDetails.push(localExt);
}
});
extensionsWithDetails.sort((a, b) => {
if (a.isInstalled !== b.isInstalled) {
return b.isInstalled - a.isInstalled;
}
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
});
allExtensionsData = extensionsWithDetails;
if (filterSelect) {
filterSelect.addEventListener('change', (event) => {
filterAndRenderExtensions(event.target.value);
});
}
filterAndRenderExtensions('All');
} catch (error) {
console.error('Error loading the marketplace:', error);
extensionsGrid.innerHTML = `
<div style="grid-column: 1 / -1; color: #dc2626; text-align: center; padding: 2rem; background: rgba(220,38,38,0.1); border-radius: 12px; margin-top: 1rem;">
🚨 Error loading extensions.
<p>Could not connect to the extension repository or local endpoint. Detail: ${error.message}</p>
</div> </div>
`; `;
allExtensionsData = [];
} }
}
customModal.addEventListener('click', (e) => {
if (e.target === customModal || e.target.tagName === 'BUTTON') {
customModal.classList.add('hidden');
}
});
document.addEventListener('DOMContentLoaded', loadMarketplace); document.addEventListener('DOMContentLoaded', loadMarketplace);
window.addEventListener('scroll', () => {
const navbar = document.getElementById('navbar');
if (window.scrollY > 0) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
});

View File

@@ -46,7 +46,6 @@ async function loadExtensions() {
} }
} }
async function loadExtension(fileName) { async function loadExtension(fileName) {
const homeDir = os.homedir(); const homeDir = os.homedir();
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions'); const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
@@ -77,6 +76,7 @@ async function loadExtension(fileName) {
} }
const name = instance.constructor.name; const name = instance.constructor.name;
instance.__fileName = fileName;
instance.scrape = scrape; instance.scrape = scrape;
instance.cheerio = cheerio; instance.cheerio = cheerio;
extensions.set(name, instance); extensions.set(name, instance);
@@ -114,6 +114,14 @@ async function saveExtensionFile(fileName, downloadUrl) {
file.on('finish', async () => { file.on('finish', async () => {
file.close(async () => { file.close(async () => {
try { try {
const extName = fileName.replace('.js', '');
for (const key of extensions.keys()) {
if (key.toLowerCase() === extName.toLowerCase()) {
extensions.delete(key);
break;
}
}
await loadExtension(fileName); await loadExtension(fileName);
resolve(); resolve();
} catch (err) { } catch (err) {

View File

@@ -4,7 +4,11 @@
left: 0; left: 0;
right: 0; right: 0;
padding: var(--spacing-lg) var(--spacing-xl); padding: var(--spacing-lg) var(--spacing-xl);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%); background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.8) 0%,
transparent 100%
);
z-index: 1000; z-index: 1000;
pointer-events: none; pointer-events: none;
} }
@@ -64,7 +68,11 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.control-group { display: flex; align-items: center; gap: var(--spacing-md); } .control-group {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.sd-toggle { .sd-toggle {
display: flex; display: flex;
@@ -87,11 +95,15 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.sd-option.active { color: var(--color-text-primary); } .sd-option.active {
color: var(--color-text-primary);
}
.sd-bg { .sd-bg {
position: absolute; position: absolute;
top: 4px; left: 4px; bottom: 4px; top: 4px;
left: 4px;
bottom: 4px;
width: calc(50% - 4px); width: calc(50% - 4px);
background: var(--color-primary); background: var(--color-primary);
border-radius: var(--radius-full); border-radius: var(--radius-full);
@@ -100,7 +112,9 @@
z-index: 1; z-index: 1;
} }
.sd-toggle[data-state="dub"] .sd-bg { transform: translateX(100%); } .sd-toggle[data-state="dub"] .sd-bg {
transform: translateX(100%);
}
.source-select { .source-select {
appearance: none; appearance: none;
@@ -119,8 +133,15 @@
transition: all var(--transition-base); transition: all var(--transition-base);
} }
.source-select:hover { border-color: var(--color-primary); background-color: var(--color-bg-card); } .source-select:hover {
.source-select:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px var(--color-primary-glow); } border-color: var(--color-primary);
background-color: var(--color-bg-card);
}
.source-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-glow);
}
.video-container { .video-container {
aspect-ratio: 16/9; aspect-ratio: 16/9;
@@ -128,14 +149,25 @@
background: var(--color-bg-base); background: var(--color-bg-base);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-lg), 0 0 0 1px var(--glass-border); box-shadow:
var(--shadow-lg),
0 0 0 1px var(--glass-border);
position: relative; position: relative;
transition: box-shadow var(--transition-smooth); transition: box-shadow var(--transition-smooth);
} }
.video-container:hover { box-shadow: var(--shadow-lg), 0 0 0 1px var(--color-primary), var(--shadow-glow); } .video-container:hover {
box-shadow:
var(--shadow-lg),
0 0 0 1px var(--color-primary),
var(--shadow-glow);
}
#player { width: 100%; height: 100%; object-fit: contain; } #player {
width: 100%;
height: 100%;
object-fit: contain;
}
.loading-overlay { .loading-overlay {
position: absolute; position: absolute;
@@ -150,16 +182,25 @@
} }
.spinner { .spinner {
width: 48px; height: 48px; width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.1); border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--color-primary); border-top-color: var(--color-primary);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-overlay p { color: var(--color-text-secondary); font-size: 0.95rem; font-weight: 500; } .loading-overlay p {
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 500;
}
.episode-controls { .episode-controls {
display: flex; display: flex;
@@ -174,21 +215,49 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.episode-info h1 { font-size: 1.75rem; font-weight: 800; margin: 0 0 var(--spacing-xs); } .episode-info h1 {
.episode-info p { color: var(--color-primary); font-weight: 600; font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; } font-size: 1.75rem;
font-weight: 800;
.navigation-buttons { display: flex; gap: var(--spacing-md); } margin: 0 0 var(--spacing-xs);
}
.nav-btn { .episode-info p {
display: flex; align-items: center; gap: var(--spacing-sm); color: var(--color-primary);
background: var(--color-bg-elevated); border: var(--border-subtle); font-weight: 600;
color: var(--color-text-primary); padding: 0.75rem 1.5rem; font-size: 1rem;
border-radius: var(--radius-full); font-weight: 600; font-size: 0.9rem; text-transform: uppercase;
cursor: pointer; transition: all var(--transition-base); letter-spacing: 0.05em;
} }
.nav-btn:hover:not(:disabled) { background: var(--color-primary); border-color: var(--color-primary); transform: translateY(-2px); box-shadow: var(--shadow-glow); } .navigation-buttons {
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; } display: flex;
gap: var(--spacing-md);
}
.nav-btn {
display: flex;
align-items: center;
gap: var(--spacing-sm);
background: var(--color-bg-elevated);
border: var(--border-subtle);
color: var(--color-text-primary);
padding: 0.75rem 1.5rem;
border-radius: var(--radius-full);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all var(--transition-base);
}
.nav-btn:hover:not(:disabled) {
background: var(--color-primary);
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.episode-carousel-compact { .episode-carousel-compact {
width: 100%; width: 100%;
@@ -258,10 +327,18 @@
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
mask-image: linear-gradient(to right, transparent, black var(--spacing-md), black calc(100% - var(--spacing-md)), transparent); mask-image: linear-gradient(
to right,
transparent,
black var(--spacing-md),
black calc(100% - var(--spacing-md)),
transparent
);
} }
.episode-carousel-compact-list::-webkit-scrollbar { display: none; } .episode-carousel-compact-list::-webkit-scrollbar {
display: none;
}
.carousel-item { .carousel-item {
flex: 0 0 200px; flex: 0 0 200px;
@@ -290,12 +367,14 @@
.carousel-item.active-ep-carousel { .carousel-item.active-ep-carousel {
border-color: var(--color-primary); border-color: var(--color-primary);
background: rgba(139, 92, 246, 0.15); background: rgba(139, 92, 246, 0.15);
box-shadow: 0 0 0 2px var(--color-primary), var(--shadow-md); box-shadow:
0 0 0 2px var(--color-primary),
var(--shadow-md);
transform: scale(1.02); transform: scale(1.02);
} }
.carousel-item.active-ep-carousel::after { .carousel-item.active-ep-carousel::after {
content: 'WATCHING'; content: "WATCHING";
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@@ -391,7 +470,8 @@
color: var(--color-primary); color: var(--color-primary);
} }
.anime-details, .anime-extra-content { .anime-details,
.anime-extra-content {
max-width: 1600px; max-width: 1600px;
margin: var(--spacing-2xl) auto; margin: var(--spacing-2xl) auto;
} }
@@ -425,9 +505,17 @@
text-align: left; text-align: left;
} }
.cover-image { width: 220px; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); } .cover-image {
width: 220px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.details-content h1 { font-size: 1.5rem; font-weight: 800; margin-bottom: var(--spacing-md); } .details-content h1 {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: var(--spacing-md);
}
.meta-badge { .meta-badge {
background: rgba(139, 92, 246, 0.12); background: rgba(139, 92, 246, 0.12);
@@ -439,8 +527,15 @@
border: 1px solid rgba(139, 92, 246, 0.2); border: 1px solid rgba(139, 92, 246, 0.2);
} }
.meta-badge.meta-score { background: var(--color-primary); color: white; } .meta-badge.meta-score {
.details-description { font-size: 1rem; line-height: 1.7; color: var(--color-text-secondary); } background: var(--color-primary);
color: white;
}
.details-description {
font-size: 1rem;
line-height: 1.7;
color: var(--color-text-secondary);
}
.characters-header { .characters-header {
display: flex; display: flex;
@@ -471,9 +566,15 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.expand-btn:hover { background: rgba(139, 92, 246, 0.1); } .expand-btn:hover {
.expand-btn svg { transition: transform var(--transition-smooth); } background: rgba(139, 92, 246, 0.1);
.expand-btn[data-expanded="true"] svg { transform: rotate(180deg); } }
.expand-btn svg {
transition: transform var(--transition-smooth);
}
.expand-btn[data-expanded="true"] svg {
transform: rotate(180deg);
}
.characters-carousel { .characters-carousel {
display: flex; display: flex;
@@ -495,7 +596,6 @@
} }
.characters-carousel.expanded { .characters-carousel.expanded {
height: auto; height: auto;
max-height: 3200px; max-height: 3200px;
overflow-y: auto; overflow-y: auto;
@@ -519,15 +619,38 @@
background: transparent; background: transparent;
} }
.plyr--video { border-radius: var(--radius-xl); } .plyr--video {
.plyr__controls { background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 50%, transparent 100%) !important; padding: 1rem 1.5rem 1.5rem !important; } border-radius: var(--radius-xl);
.plyr--full-ui input[type=range] { color: var(--color-primary); } }
.plyr__control:hover { background: rgba(255,255,255,0.12) !important; } .plyr__controls {
.plyr__menu__container { background: var(--glass-bg) !important; backdrop-filter: blur(16px); border: 1px solid var(--glass-border); box-shadow: var(--shadow-lg) !important; } background: linear-gradient(
to top,
rgba(0, 0, 0, 0.9) 0%,
rgba(0, 0, 0, 0.5) 50%,
transparent 100%
) !important;
padding: 1rem 1.5rem 1.5rem !important;
}
.plyr--full-ui input[type="range"] {
color: var(--color-primary);
}
.plyr__control:hover {
background: rgba(255, 255, 255, 0.12) !important;
}
.plyr__menu__container {
background: var(--glass-bg) !important;
backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-lg) !important;
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.carousel-nav { display: flex; } .carousel-nav {
.watch-container { padding-top: 5rem; } display: flex;
}
.watch-container {
padding-top: 5rem;
}
.details-cover { .details-cover {
align-items: center; align-items: center;
@@ -540,64 +663,168 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.watch-container { padding: 4.5rem 1rem; } .watch-container {
padding: 5rem 1rem 2rem 1rem;
.episode-carousel-compact-list { margin: 0;
padding: var(--spacing-sm) var(--spacing-md); width: 100%;
} overflow-x: hidden;
.carousel-header {
padding: 0 var(--spacing-md);
}
.carousel-item {
flex: 0 0 180px;
height: 100px;
}
.carousel-item-img-container { height: 60px; }
.carousel-item-info p { font-size: 0.95rem; }
.carousel-item.no-thumbnail {
flex: 0 0 140px;
height: 80px;
} }
.details-container { flex-direction: column; text-align: center; } .player-toolbar {
flex-direction: column;
align-items: stretch;
gap: 1rem;
padding: 1rem;
}
.details-cover { .control-group {
align-items: center; justify-content: space-between;
width: 100%; width: 100%;
} }
.details-cover h1 {
font-size: 2rem;
text-align: center;
}
.cover-image { width: 180px; margin: 0 auto; }
.episode-controls { flex-direction: column; gap: var(--spacing-md); } .source-select {
.navigation-buttons { width: 100%; justify-content: center; } width: 100%;
.nav-btn { flex: 1; justify-content: center; } background-position: right 1.5rem center;
}
.episode-controls {
flex-direction: column;
align-items: flex-start;
gap: 1.5rem;
}
.episode-info {
width: 100%;
text-align: left;
}
.episode-info h1 {
font-size: 1.4rem;
line-height: 1.3;
}
.navigation-buttons {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem;
}
.nav-btn {
justify-content: center;
padding: 0.8rem;
width: 100%;
}
.details-container {
flex-direction: column;
padding: 1.5rem;
gap: 2rem;
}
.details-cover {
flex-direction: row;
align-items: flex-start;
width: 100%;
gap: 1.5rem;
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.episode-info h1, .details-content h1 { font-size: 1.5rem; } .details-cover {
flex-direction: column;
.carousel-item { align-items: center;
flex: 0 0 150px; text-align: center;
height: 90px;
}
.carousel-item-img-container { height: 50px; }
.carousel-item-info p { font-size: 0.9rem; }
.carousel-item.no-thumbnail {
flex: 0 0 120px;
height: 70px;
} }
.details-cover h1 { .details-cover h1 {
font-size: 1.5rem; text-align: center;
}
} }
.nav-btn span { display: none; } .cover-image {
width: 140px;
flex-shrink: 0;
margin: 0 auto;
}
.details-content h1 {
font-size: 1.3rem;
}
.characters-carousel {
justify-content: center;
padding-bottom: 1rem;
}
.character-card { .character-card {
width: calc(50% - 0.75rem);
flex: 0 0 calc(50% - 0.75rem);
}
}
flex: 1 1 100%; .plyr__progress {
position: relative;
} }
.plyr__markers {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 100%;
pointer-events: none;
z-index: 2;
}
.plyr__marker {
position: absolute;
bottom: 0;
width: 3px;
height: 100%;
background: rgba(255, 215, 0, 0.8); /* Color dorado para Opening */
pointer-events: all;
cursor: pointer;
transition: all 0.2s ease;
}
.plyr__marker[data-label*="Ending"] {
background: rgba(255, 100, 100, 0.8); /* Color rojo para Ending */
}
.plyr__marker:hover {
height: 120%;
width: 4px;
background: rgba(255, 215, 0, 1);
}
.plyr__marker[data-label*="Ending"]:hover {
background: rgba(255, 100, 100, 1);
}
/* Tooltip para mostrar el label */
.plyr__marker::before {
content: attr(data-label);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-8px);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.plyr__marker:hover::before {
opacity: 1;
}
.plyr__marker {
position: absolute;
height: 100%;
background: rgba(255, 255, 255, 0.35);
cursor: pointer;
} }

View File

@@ -293,3 +293,98 @@
background: #ef4444; background: #ef4444;
transform: scale(1.02); transform: scale(1.02);
} }
.extension-author {
font-size: 0.8rem;
color: var(--color-text-secondary);
display: block;
margin-bottom: 0.5rem;
}
.extension-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge-available {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.nsfw-ext {
border-color: rgba(220, 38, 38, 0.3);
}
.broken-ext {
filter: grayscale(0.8);
opacity: 0.7;
border: 1px dashed #ef4444; /* Borde rojo discontinuo */
}
.broken-ext:hover {
transform: none; /* Evitamos que se mueva al pasar el ratón si está rota */
}
/* Estilos para los Tabs */
.tabs-container {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 0.5rem;
}
.tab-button {
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 1.1rem;
font-weight: 700;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.tab-button.active {
color: var(--color-primary);
}
.tab-button.active::after {
content: '';
position: absolute;
bottom: -0.6rem;
left: 0;
width: 100%;
height: 3px;
background: var(--color-primary);
border-radius: 999px;
box-shadow: 0 0 10px var(--color-primary-glow);
}
/* Títulos de Secciones en Marketplace */
.marketplace-section-title {
font-size: 1.4rem;
font-weight: 800;
margin: 2rem 0 1rem 0;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
text-transform: capitalize;
}
.marketplace-section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 20px;
background: var(--color-primary);
border-radius: 2px;
}
.category-group {
margin-bottom: 3rem;
}

View File

@@ -10,10 +10,10 @@
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<link rel="stylesheet" href="/views/css/components/titlebar.css"> <link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left"> <div id="titlebar">
<div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/> <img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span> <span class="app-title">WaifuBoard</span>
</div> </div>
@@ -36,9 +36,9 @@
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button> <button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
<button class="nav-button" onclick="window.location.href='/books'">Books</button> <button class="nav-button" onclick="window.location.href='/books'">Books</button>
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button> <button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button> <button class="nav-button" onclick="window.location.href='/schedule.html'">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button> <button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button active">Marketplace</button> <button class="nav-button">Marketplace</button>
</div> </div>
<div class="nav-right"> <div class="nav-right">
@@ -90,47 +90,31 @@
<main> <main>
<section class="section"> <section class="section">
<header class="section-header"> <header class="section-header">
<p class="marketplace-subtitle">Explore, install, and manage all available data source extensions for WaifuBoard.</p> <div class="tabs-container">
<button class="tab-button active" data-tab="marketplace">Marketplace</button>
<button class="tab-button" data-tab="installed">My Extensions</button>
</div>
<div class="filter-controls"> <div class="filter-controls">
<button id="btn-update-all" class="btn-blur hidden" style="margin-right: 10px; width: auto; padding: 0 15px;">
Update All
</button>
<label for="extension-filter" class="filter-label">Filter by Type:</label> <label for="extension-filter" class="filter-label">Filter by Type:</label>
<select id="extension-filter" class="filter-select"> <select id="extension-filter" class="filter-select">
<option value="All">All Extensions</option> <option value="All">All Categories</option>
<option value="Image">Image Boards</option> <option value="image-board">Image Boards</option>
<option value="Anime">Anime Boards</option> <option value="anime-board">Anime Boards</option>
<option value="Book">Book Boards</option> <option value="book-board">Book Boards</option>
<option value="Local">Local Only</option> <option value="Local">Local/Manual</option>
</select> </select>
</div> </div>
</header> </header>
<div class="marketplace-grid" id="extensions-grid">
<div class="extension-card skeleton grid-item"> <div id="marketplace-content">
<div class="skeleton-icon" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>
<div class="extension-card skeleton grid-item">
<div class="skeleton-icon" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>
<div class="extension-card skeleton grid-item">
<div class="skeleton-icon" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>
</div> </div>
</section> </section>
<div id="customModal" class="modal-overlay hidden"> <div id="customModal" class="modal-overlay hidden">
<div class="modal-content"> <div class="modal-content">
<h3 id="modalTitle"></h3> <h3 id="modalTitle"></h3>
@@ -143,22 +127,11 @@
</div> </div>
</main> </main>
<div id="updateToast" class="hidden"> <script src="/src/scripts/utils/notification-utils.js"></script>
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a
id="downloadButton"
href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases"
target="_blank"
>
Click To Download
</a>
</div>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/marketplace.js"></script> <script src="/src/scripts/marketplace.js"></script>
<script src="/src/scripts/titlebar.js"></script> <script src="/src/scripts/titlebar.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,41 +0,0 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const { fork } = require('child_process');
const path = require('path');
let win;
let backend;
function startBackend() {
backend = fork(path.join(__dirname, 'server.js'));
}
function createWindow() {
win = new BrowserWindow({
width: 1200,
height: 800,
frame: false,
titleBarStyle: "hidden",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
nodeIntegration: false,
contextIsolation: true
}
});
win.setMenu(null);
win.loadURL('http://localhost:54322');
}
ipcMain.on("win:minimize", () => win.minimize());
ipcMain.on("win:maximize", () => win.maximize());
ipcMain.on("win:close", () => win.close());
app.whenReady().then(() => {
startBackend();
createWindow();
});
app.on('window-all-closed', () => {
if (backend) backend.kill();
app.quit();
});

View File

@@ -17,6 +17,7 @@
"fastify": "^5.6.2", "fastify": "^5.6.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-addon-api": "^8.5.0", "node-addon-api": "^8.5.0",
"node-cron": "^4.2.1",
"playwright-chromium": "^1.57.0", "playwright-chromium": "^1.57.0",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
@@ -2230,6 +2231,15 @@
"node": "^18 || ^20 || >= 21" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-gyp": { "node_modules/node-gyp": {
"version": "12.1.0", "version": "12.1.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.1.0.tgz", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.1.0.tgz",

View File

@@ -20,6 +20,7 @@
"fastify": "^5.6.2", "fastify": "^5.6.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-addon-api": "^8.5.0", "node-addon-api": "^8.5.0",
"node-cron": "^4.2.1",
"playwright-chromium": "^1.57.0", "playwright-chromium": "^1.57.0",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },

View File

@@ -1,10 +0,0 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
isElectron: true,
win: {
minimize: () => ipcRenderer.send("win:minimize"),
maximize: () => ipcRenderer.send("win:maximize"),
close: () => ipcRenderer.send("win:close")
}
});

View File

@@ -4,26 +4,25 @@ const fastify = require("fastify")({
const path = require("path"); const path = require("path");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const { initHeadless } = require("./electron/shared/headless"); const cron = require("node-cron");
const { initDatabase } = require("./electron/shared/database"); const { initHeadless } = require("./dist/shared/headless");
const { loadExtensions } = require("./electron/shared/extensions"); const { initDatabase } = require("./dist/shared/database");
const { loadExtensions } = require("./dist/shared/extensions");
const {refreshTrendingAnime, refreshTopAiringAnime} = require("./dist/api/anime/anime.service");
const {refreshPopularBooks, refreshTrendingBooks} = require("./dist/api/books/books.service");
const dotenv = require("dotenv"); const dotenv = require("dotenv");
const envPath = process.resourcesPath
? path.join(process.resourcesPath, ".env")
: path.join(__dirname, ".env");
// Attempt to load it and log the result to be sure dotenv.config();
dotenv.config({ path: envPath });
const viewsRoutes = require("./electron/views/views.routes"); const viewsRoutes = require("./dist/views/views.routes");
const animeRoutes = require("./electron/api/anime/anime.routes"); const animeRoutes = require("./dist/api/anime/anime.routes");
const booksRoutes = require("./electron/api/books/books.routes"); const booksRoutes = require("./dist/api/books/books.routes");
const proxyRoutes = require("./electron/api/proxy/proxy.routes"); const proxyRoutes = require("./dist/api/proxy/proxy.routes");
const extensionsRoutes = require("./electron/api/extensions/extensions.routes"); const extensionsRoutes = require("./dist/api/extensions/extensions.routes");
const galleryRoutes = require("./electron/api/gallery/gallery.routes"); const galleryRoutes = require("./dist/api/gallery/gallery.routes");
const userRoutes = require("./electron/api/user/user.routes"); const userRoutes = require("./dist/api/user/user.routes");
const listRoutes = require("./electron/api/list/list.routes"); const listRoutes = require("./dist/api/list/list.routes");
const anilistRoute = require("./electron/api/anilist/anilist"); const anilistRoute = require("./dist/api/anilist/anilist");
fastify.addHook("preHandler", async (request) => { fastify.addHook("preHandler", async (request) => {
const auth = request.headers.authorization; const auth = request.headers.authorization;
@@ -65,6 +64,8 @@ fastify.register(userRoutes, { prefix: "/api" });
fastify.register(anilistRoute, { prefix: "/api" }); fastify.register(anilistRoute, { prefix: "/api" });
fastify.register(listRoutes, { prefix: "/api" }); fastify.register(listRoutes, { prefix: "/api" });
const sleep = ms => new Promise(r => setTimeout(r, ms));
const start = async () => { const start = async () => {
try { try {
initDatabase("anilist"); initDatabase("anilist");
@@ -72,12 +73,31 @@ const start = async () => {
initDatabase("cache"); initDatabase("cache");
initDatabase("userdata"); initDatabase("userdata");
const refreshAll = async () => {
await refreshTrendingAnime();
await sleep(300);
await refreshTopAiringAnime();
await sleep(300);
await refreshTrendingBooks();
await sleep(300);
await refreshPopularBooks();
};
cron.schedule("*/30 * * * *", async () => {
try {
await refreshAll();
console.log("cache refreshed");
} catch (e) {
console.error("refresh failed", e);
}
});
await loadExtensions(); await loadExtensions();
await initHeadless();
await refreshAll();
await fastify.listen({ port: 54322, host: "0.0.0.0" }); await fastify.listen({ port: 54322, host: "0.0.0.0" });
console.log(`Server is now running!`); console.log(`Server running at http://localhost:54322`);
await initHeadless();
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
process.exit(1); process.exit(1);

View File

@@ -3,7 +3,6 @@ import { queryAll, queryOne } from '../../shared/database';
import {Anime, Episode, Extension, StreamData} from '../types'; import {Anime, Episode, Extension, StreamData} from '../types';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TTL = 60 * 60 * 6;
const ANILIST_URL = "https://graphql.anilist.co"; const ANILIST_URL = "https://graphql.anilist.co";
@@ -79,6 +78,54 @@ const MEDIA_FIELDS = `
} }
`; `;
export async function refreshTrendingAnime(): Promise<void> {
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM trending");
let rank = 1;
for (const anime of list) {
await queryOne(
"INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, anime.id, JSON.stringify(anime), now]
);
}
}
export async function refreshTopAiringAnime(): Promise<void> {
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM top_airing");
let rank = 1;
for (const anime of list) {
await queryOne(
"INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, anime.id, JSON.stringify(anime), now]
);
}
}
async function fetchAniList(query: string, variables: any) { async function fetchAniList(query: string, variables: any) {
const res = await fetch(ANILIST_URL, { const res = await fetch(ANILIST_URL, {
method: "POST", method: "POST",
@@ -119,76 +166,16 @@ export async function getAnimeById(id: string | number): Promise<Anime | { error
export async function getTrendingAnime(): Promise<Anime[]> { export async function getTrendingAnime(): Promise<Anime[]> {
const rows = await queryAll( const rows = await queryAll(
"SELECT full_data, updated_at FROM trending ORDER BY rank ASC LIMIT 10" "SELECT full_data FROM trending ORDER BY rank ASC LIMIT 10"
); );
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM trending");
let rank = 1;
for (const anime of list) {
await queryOne(
"INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, anime.id, JSON.stringify(anime), now]
);
}
return list;
} }
export async function getTopAiringAnime(): Promise<Anime[]> { export async function getTopAiringAnime(): Promise<Anime[]> {
const rows = await queryAll( const rows = await queryAll(
"SELECT full_data, updated_at FROM top_airing ORDER BY rank ASC LIMIT 10" "SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10"
); );
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM top_airing");
let rank = 1;
for (const anime of list) {
await queryOne(
"INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, anime.id, JSON.stringify(anime), now]
);
}
return list;
} }
export async function searchAnimeLocal(query: string): Promise<Anime[]> { export async function searchAnimeLocal(query: string): Promise<Anime[]> {

View File

@@ -4,7 +4,6 @@ import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions'
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types'; import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TTL = 60 * 60 * 6;
const ANILIST_URL = "https://graphql.anilist.co"; const ANILIST_URL = "https://graphql.anilist.co";
async function fetchAniList(query: string, variables: any) { async function fetchAniList(query: string, variables: any) {
@@ -134,18 +133,7 @@ export async function getBookById(id: string | number): Promise<Book | { error:
return { error: "Book not found" }; return { error: "Book not found" };
} }
export async function getTrendingBooks(): Promise<Book[]> { export async function refreshTrendingBooks(): Promise<void> {
const rows = await queryAll(
"SELECT full_data, updated_at FROM trending_books ORDER BY rank ASC LIMIT 10"
);
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
}
}
const query = ` const query = `
query { query {
Page(page: 1, perPage: 10) { Page(page: 1, perPage: 10) {
@@ -167,23 +155,9 @@ export async function getTrendingBooks(): Promise<Book[]> {
[rank++, book.id, JSON.stringify(book), now] [rank++, book.id, JSON.stringify(book), now]
); );
} }
return list;
}
export async function getPopularBooks(): Promise<Book[]> {
const rows = await queryAll(
"SELECT full_data, updated_at FROM popular_books ORDER BY rank ASC LIMIT 10"
);
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
}
} }
export async function refreshPopularBooks(): Promise<void> {
const query = ` const query = `
query { query {
Page(page: 1, perPage: 10) { Page(page: 1, perPage: 10) {
@@ -205,10 +179,21 @@ export async function getPopularBooks(): Promise<Book[]> {
[rank++, book.id, JSON.stringify(book), now] [rank++, book.id, JSON.stringify(book), now]
); );
} }
return list;
} }
export async function getTrendingBooks(): Promise<Book[]> {
const rows = await queryAll(
"SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10"
);
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
}
export async function getPopularBooks(): Promise<Book[]> {
const rows = await queryAll(
"SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10"
);
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
}
export async function searchBooksLocal(query: string): Promise<Book[]> { export async function searchBooksLocal(query: string): Promise<Book[]> {
if (!query || query.length < 2) { if (!query || query.length < 2) {

View File

@@ -1,6 +1,59 @@
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap, saveExtensionFile, deleteExtensionFile } from '../../shared/extensions'; import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap, saveExtensionFile, deleteExtensionFile } from '../../shared/extensions';
import { ExtensionNameRequest } from '../types'; import { ExtensionNameRequest } from '../types';
import path from 'path';
import fs from 'fs/promises';
const TYPE_MAP: Record<string, string> = {
'anime-board': 'anime',
'image-board': 'image',
'book-board': 'book',
};
function extractProp(source: string, prop: string): string | null {
const m = source.match(new RegExp(`this\\.${prop}\\s*=\\s*["']([^"']+)["']`));
return m ? m[1] : null;
}
function isNewer(remote: string, local?: string | null) {
if (!local) return true;
return remote !== local;
}
export async function updateExtensions(req: any, reply: FastifyReply) {
const updated: string[] = [];
for (const name of getExtensionsList()) {
const ext = getExtension(name);
if (!ext) continue;
const type = ext.type;
if (!TYPE_MAP[type]) continue;
const fileName = ext.__fileName;
const remoteUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${TYPE_MAP[type]}/${fileName}`;
let remoteSrc: string;
try {
const res = await fetch(remoteUrl);
if (!res.ok) continue;
remoteSrc = await res.text();
} catch {
continue;
}
const remoteVersion = extractProp(remoteSrc, 'version');
const localVersion = ext.version ?? null;
if (!remoteVersion) continue;
if (isNewer(remoteVersion, localVersion)) {
await saveExtensionFile(fileName, remoteUrl);
updated.push(name);
}
}
return { updated };
}
export async function getExtensions(req: FastifyRequest, reply: FastifyReply) { export async function getExtensions(req: FastifyRequest, reply: FastifyReply) {
return { extensions: getExtensionsList() }; return { extensions: getExtensionsList() };
@@ -37,24 +90,33 @@ export async function getExtensionSettings(req: ExtensionNameRequest, reply: Fas
} }
export async function installExtension(req: any, reply: FastifyReply) { export async function installExtension(req: any, reply: FastifyReply) {
const { fileName } = req.body; const { url } = req.body;
if (!fileName || !fileName.endsWith('.js')) { if (!url || typeof url !== 'string' || !url.endsWith('.js')) {
return reply.code(400).send({ error: "Invalid extension fileName provided" }); return reply.code(400).send({ error: "Invalid extension URL provided" });
} }
try { try {
const fileName = url.split('/').pop();
const downloadUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${fileName}` if (!fileName) {
return reply.code(400).send({ error: "Could not determine file name from URL" });
}
await saveExtensionFile(fileName, downloadUrl); await saveExtensionFile(fileName, url);
req.server.log.info(`Extension installed: ${fileName}`); req.server.log.info(`Extension installed: ${fileName}`);
return reply.code(200).send({ success: true, message: `Extension ${fileName} installed successfully.` }); return reply.code(200).send({
success: true,
message: `Extension ${fileName} installed successfully.`,
});
} catch (error) { } catch (error) {
req.server.log.error(`Failed to install extension ${fileName}:`, error); req.server.log.error(`Failed to install extension from ${url}:`, error);
return reply.code(500).send({ success: false, error: `Failed to install extension ${fileName}.` }); return reply.code(500).send({
success: false,
error: "Failed to install extension.",
});
} }
} }

View File

@@ -4,6 +4,7 @@ import * as controller from './extensions.controller';
async function extensionsRoutes(fastify: FastifyInstance) { async function extensionsRoutes(fastify: FastifyInstance) {
fastify.get('/extensions', controller.getExtensions); fastify.get('/extensions', controller.getExtensions);
fastify.get('/extensions/anime', controller.getAnimeExtensions); fastify.get('/extensions/anime', controller.getAnimeExtensions);
fastify.post('/extensions/update', controller.updateExtensions);
fastify.get('/extensions/book', controller.getBookExtensions); fastify.get('/extensions/book', controller.getBookExtensions);
fastify.get('/extensions/gallery', controller.getGalleryExtensions); fastify.get('/extensions/gallery', controller.getGalleryExtensions);
fastify.get('/extensions/:name/settings', controller.getExtensionSettings); fastify.get('/extensions/:name/settings', controller.getExtensionSettings);

View File

@@ -7,6 +7,10 @@ let currentExtension = '';
let plyrInstance; let plyrInstance;
let hlsInstance; let hlsInstance;
let totalEpisodes = 0; let totalEpisodes = 0;
let aniSkipData = null;
let isAnilist = false;
let malId = null;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const firstKey = params.keys().next().value; const firstKey = params.keys().next().value;
@@ -20,6 +24,18 @@ const href = extName
document.getElementById('back-link').href = href; document.getElementById('back-link').href = href;
document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`; document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`;
async function loadAniSkip(malId, episode, duration) {
try {
const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`);
if (!res.ok) return null;
const data = await res.json();
return data.results || [];
} catch (error) {
console.error('Error loading AniSkip data:', error);
return null;
}
}
async function loadMetadata() { async function loadMetadata() {
try { try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist"; const extQuery = extName ? `?source=${extName}` : "?source=anilist";
@@ -40,11 +56,8 @@ async function loadMetadata() {
let format = ''; let format = '';
let seasonYear = ''; let seasonYear = '';
let season = ''; let season = '';
let episodesCount = 0;
let characters = [];
if (isAnilistFormat) { if (isAnilistFormat) {
title = data.title.romaji || data.title.english || data.title.native || 'Anime Title'; title = data.title.romaji || data.title.english || data.title.native || 'Anime Title';
description = data.description || 'No description available.'; description = data.description || 'No description available.';
coverImage = data.coverImage?.large || data.coverImage?.medium || ''; coverImage = data.coverImage?.large || data.coverImage?.medium || '';
@@ -62,20 +75,18 @@ async function loadMetadata() {
seasonYear = data.year || ''; seasonYear = data.year || '';
} }
if (isAnilistFormat && data.idMal) {
isAnilist = true;
malId = data.idMal;
} else {
isAnilist = false;
malId = null;
}
document.getElementById('anime-title-details').innerText = title; document.getElementById('anime-title-details').innerText = title;
document.getElementById('anime-title-details2').innerText = title; document.getElementById('anime-title-details2').innerText = title;
document.title = `Watching ${title} - Ep ${currentEpisode}`; document.title = `Watching ${title} - Ep ${currentEpisode}`;
fetch("/api/rpc", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
details: title,
state: `Episode ${currentEpisode}`,
mode: "watching"
})
});
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = description; tempDiv.innerHTML = description;
document.getElementById('detail-description').innerText = tempDiv.textContent || tempDiv.innerText || 'No description available.'; document.getElementById('detail-description').innerText = tempDiv.textContent || tempDiv.innerText || 'No description available.';
@@ -116,6 +127,102 @@ async function loadMetadata() {
} }
} }
async function applyAniSkip(video) {
if (!isAnilist || !malId) {
console.log('AniSkip disabled: isAnilist=' + isAnilist + ', malId=' + malId);
return;
}
console.log('Loading AniSkip for MAL ID:', malId, 'Episode:', currentEpisode);
aniSkipData = await loadAniSkip(
malId,
currentEpisode,
Math.floor(video.duration)
);
console.log('AniSkip data received:', aniSkipData);
if (!aniSkipData || aniSkipData.length === 0) {
console.log('No AniSkip data available');
return;
}
let op, ed;
const markers = [];
aniSkipData.forEach(item => {
const { startTime, endTime } = item.interval;
if (item.skipType === 'op') {
op = { start: startTime, end: endTime };
markers.push({
start: startTime,
end: endTime,
label: 'Opening'
});
console.log('Opening found:', startTime, '-', endTime);
}
if (item.skipType === 'ed') {
ed = { start: startTime, end: endTime };
markers.push({
start: startTime,
end: endTime,
label: 'Ending'
});
console.log('Ending found:', startTime, '-', endTime);
}
});
// Crear markers visuales en el DOM
if (plyrInstance && markers.length > 0) {
console.log('Creating visual markers:', markers);
// Esperar a que el player esté completamente cargado
setTimeout(() => {
const progressContainer = document.querySelector('.plyr__progress');
if (!progressContainer) {
console.error('Progress container not found');
return;
}
// Eliminar markers anteriores si existen
const oldMarkers = progressContainer.querySelector('.plyr__markers');
if (oldMarkers) oldMarkers.remove();
// Crear contenedor de markers
const markersContainer = document.createElement('div');
markersContainer.className = 'plyr__markers';
markers.forEach(marker => {
const markerElement = document.createElement('div');
markerElement.className = 'plyr__marker';
markerElement.dataset.label = marker.label;
const startPercent = (marker.start / video.duration) * 100;
const widthPercent = ((marker.end - marker.start) / video.duration) * 100;
markerElement.style.left = `${startPercent}%`;
markerElement.style.width = `${widthPercent}%`;
markerElement.addEventListener('click', (e) => {
e.stopPropagation();
video.currentTime = marker.start;
});
markersContainer.appendChild(markerElement);
});
progressContainer.appendChild(markersContainer);
console.log('Visual markers created successfully');
}, 500);
}
}
async function loadExtensionEpisodes() { async function loadExtensionEpisodes() {
try { try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist"; const extQuery = extName ? `?source=${extName}` : "?source=anilist";
@@ -127,7 +234,6 @@ async function loadExtensionEpisodes() {
if (Array.isArray(data) && data.length > 0) { if (Array.isArray(data) && data.length > 0) {
populateEpisodeCarousel(data); populateEpisodeCarousel(data);
} else { } else {
const fallback = []; const fallback = [];
for (let i = 1; i <= totalEpisodes; i++) { for (let i = 1; i <= totalEpisodes; i++) {
fallback.push({ number: i, title: null, thumbnail: null }); fallback.push({ number: i, title: null, thumbnail: null });
@@ -142,6 +248,8 @@ async function loadExtensionEpisodes() {
function populateEpisodeCarousel(episodesData) { function populateEpisodeCarousel(episodesData) {
const carousel = document.getElementById('episode-carousel'); const carousel = document.getElementById('episode-carousel');
if (!carousel) return;
carousel.innerHTML = ''; carousel.innerHTML = '';
episodesData.forEach((ep, index) => { episodesData.forEach((ep, index) => {
@@ -329,9 +437,8 @@ function playVideo(url, subtitles = []) {
if (plyrInstance) plyrInstance.destroy(); if (plyrInstance) plyrInstance.destroy();
while (video.textTracks.length > 0) { const existingTracks = video.querySelectorAll('track');
video.removeChild(video.textTracks[0]); existingTracks.forEach(track => track.remove());
}
subtitles.forEach(sub => { subtitles.forEach(sub => {
if (!sub.url) return; if (!sub.url) return;
@@ -347,7 +454,15 @@ function playVideo(url, subtitles = []) {
plyrInstance = new Plyr(video, { plyrInstance = new Plyr(video, {
captions: { active: true, update: true, language: 'en' }, captions: { active: true, update: true, language: 'en' },
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'], controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
settings: ['captions', 'quality', 'speed'] settings: ['captions', 'quality', 'speed'],
markers: {
enabled: true,
points: []
}
});
video.addEventListener('loadedmetadata', () => {
applyAniSkip(video);
}); });
let alreadyTriggered = false; let alreadyTriggered = false;
@@ -363,7 +478,6 @@ function playVideo(url, subtitles = []) {
} }
}); });
video.play().catch(() => console.log("Autoplay blocked")); video.play().catch(() => console.log("Autoplay blocked"));
} }
@@ -392,7 +506,6 @@ if (currentEpisode <= 1) {
document.getElementById('prev-btn').disabled = true; document.getElementById('prev-btn').disabled = true;
} }
async function sendProgress() { async function sendProgress() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
@@ -425,7 +538,5 @@ async function sendProgress() {
} }
} }
loadMetadata(); loadMetadata();
loadExtensions(); loadExtensions();

View File

@@ -145,18 +145,6 @@ async function loadChapter() {
setupProgressTracking(data, source); setupProgressTracking(data, source);
const res2 = await fetch(`/api/book/${bookId}?source=${source}`);
const data2 = await res2.json();
fetch("/api/rpc", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
details: data2.title.romaji ?? data2.title,
state: `Chapter ${data.title}`,
mode: "reading"
})
});
if (data.error) { if (data.error) {
reader.innerHTML = ` reader.innerHTML = `
<div class="loading-container"> <div class="loading-container">

View File

@@ -29,20 +29,32 @@ async function populateSourceFilter() {
`; `;
try { try {
const response = await fetch(`${API_BASE}/extensions`); const [animeRes, bookRes] = await Promise.all([
if (response.ok) { fetch(`${API_BASE}/extensions/anime`),
const data = await response.json(); fetch(`${API_BASE}/extensions/book`)
const extensions = data.extensions || []; ]);
const extensions = new Set();
if (animeRes.ok) {
const data = await animeRes.json();
(data.extensions || []).forEach(ext => extensions.add(ext));
}
if (bookRes.ok) {
const data = await bookRes.json();
(data.extensions || []).forEach(ext => extensions.add(ext));
}
extensions.forEach(extName => { extensions.forEach(extName => {
if (extName.toLowerCase() !== 'anilist' && extName.toLowerCase() !== 'local') { const lower = extName.toLowerCase();
if (lower !== 'anilist' && lower !== 'local') {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = extName; option.value = extName;
option.textContent = extName.charAt(0).toUpperCase() + extName.slice(1); option.textContent = extName.charAt(0).toUpperCase() + extName.slice(1);
select.appendChild(option); select.appendChild(option);
} }
}); });
}
} catch (error) { } catch (error) {
console.error('Error loading extensions:', error); console.error('Error loading extensions:', error);
} }

View File

@@ -1,422 +1,262 @@
const GITEA_INSTANCE = 'https://git.waifuboard.app'; const ORIGINAL_MARKETPLACE_URL = 'https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/marketplace.json';
const REPO_OWNER = 'ItsSkaiya'; const MARKETPLACE_JSON_URL = `/api/proxy?url=${encodeURIComponent(ORIGINAL_MARKETPLACE_URL)}`;
const REPO_NAME = 'WaifuBoard-Extensions';
let DETECTED_BRANCH = 'main';
const API_URL_BASE = `${GITEA_INSTANCE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents`;
const INSTALLED_EXTENSIONS_API = '/api/extensions'; const INSTALLED_EXTENSIONS_API = '/api/extensions';
const UPDATE_EXTENSIONS_API = '/api/extensions/update';
const extensionsGrid = document.getElementById('extensions-grid'); const marketplaceContent = document.getElementById('marketplace-content');
const filterSelect = document.getElementById('extension-filter'); const filterSelect = document.getElementById('extension-filter');
const updateAllBtn = document.getElementById('btn-update-all');
let allExtensionsData = []; const modal = document.getElementById('customModal');
const customModal = document.getElementById('customModal');
const modalTitle = document.getElementById('modalTitle'); const modalTitle = document.getElementById('modalTitle');
const modalMessage = document.getElementById('modalMessage'); const modalMessage = document.getElementById('modalMessage');
const modalConfirmBtn = document.getElementById('modalConfirmButton');
const modalCloseBtn = document.getElementById('modalCloseButton');
function getRawUrl(filename) { let marketplaceMetadata = {};
let installedExtensions = [];
let currentTab = 'marketplace';
const targetUrl = `${GITEA_INSTANCE}/${REPO_OWNER}/${REPO_NAME}/raw/branch/main/${filename}`; async function loadMarketplace() {
showSkeletons();
const encodedUrl = encodeURIComponent(targetUrl);
return `/api/proxy?url=${encodedUrl}`;
}
function updateExtensionState(fileName, installed) {
const ext = allExtensionsData.find(e => e.fileName === fileName);
if (!ext) return;
ext.isInstalled = installed;
ext.isLocal = installed && ext.isLocal;
filterAndRenderExtensions(filterSelect?.value || 'All');
}
function formatExtensionName(fileName) {
return fileName.replace('.js', '')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^[a-z]/, (char) => char.toUpperCase());
}
function getIconUrl(extensionDetails) {
return extensionDetails;
}
async function getExtensionDetails(url) {
try { try {
const res = await fetch(url); const [metaRes, installedRes] = await Promise.all([
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); fetch(MARKETPLACE_JSON_URL).then(res => res.json()),
const text = await res.text(); fetch(INSTALLED_EXTENSIONS_API).then(res => res.json())
]);
const regex = /(?:this\.|const\s+|let\s+|var\s+)?baseUrl\s*=\s*(["'`])(.*?)\1/i; marketplaceMetadata = metaRes.extensions;
const match = text.match(regex); installedExtensions = (installedRes.extensions || []).map(e => e.toLowerCase());
let finalHostname = null;
if (match && match[2]) { initTabs();
let rawUrl = match[2].trim(); renderGroupedView();
if (!rawUrl.startsWith('http')) rawUrl = 'https://' + rawUrl;
try { if (filterSelect) {
const urlObj = new URL(rawUrl); filterSelect.addEventListener('change', () => renderGroupedView());
finalHostname = urlObj.hostname; }
} catch(e) {
console.warn(`Could not parse baseUrl: ${rawUrl}`); if (updateAllBtn) {
updateAllBtn.onclick = handleUpdateAll;
}
} catch (error) {
console.error('Error loading marketplace:', error);
marketplaceContent.innerHTML = `<div class="error-msg">Error al cargar el marketplace.</div>`;
} }
} }
const classMatch = text.match(/class\s+(\w+)/); function initTabs() {
const name = classMatch ? classMatch[1] : null; const tabs = document.querySelectorAll('.tab-button');
tabs.forEach(tab => {
tab.onclick = () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentTab = tab.dataset.tab;
let type = 'Image'; if (updateAllBtn) {
if (text.includes('type = "book-board"') || text.includes("type = 'book-board'")) type = 'Book'; if (currentTab === 'installed') {
else if (text.includes('type = "anime-board"') || text.includes("type = 'anime-board'")) type = 'Anime'; updateAllBtn.classList.remove('hidden');
return { baseUrl: finalHostname, name, type };
} catch (e) {
return { baseUrl: null, name: null, type: 'Unknown' };
}
}
function showCustomModal(title, message, isConfirm = false) {
return new Promise(resolve => {
modalTitle.textContent = title;
modalMessage.textContent = message;
const currentConfirmButton = document.getElementById('modalConfirmButton');
const currentCloseButton = document.getElementById('modalCloseButton');
const newConfirmButton = currentConfirmButton.cloneNode(true);
currentConfirmButton.parentNode.replaceChild(newConfirmButton, currentConfirmButton);
const newCloseButton = currentCloseButton.cloneNode(true);
currentCloseButton.parentNode.replaceChild(newCloseButton, currentCloseButton);
if (isConfirm) {
newConfirmButton.classList.remove('hidden');
newConfirmButton.textContent = 'Confirm';
newCloseButton.textContent = 'Cancel';
} else { } else {
updateAllBtn.classList.add('hidden');
newConfirmButton.classList.add('hidden'); }
newCloseButton.textContent = 'Close';
} }
const closeModal = (confirmed) => { renderGroupedView();
customModal.classList.add('hidden');
resolve(confirmed);
}; };
newConfirmButton.onclick = () => closeModal(true);
newCloseButton.onclick = () => closeModal(false);
customModal.classList.remove('hidden');
}); });
} }
function renderExtensionCard(extension, isInstalled, isLocalOnly = false) { async function handleUpdateAll() {
const originalText = updateAllBtn.innerText;
try {
updateAllBtn.disabled = true;
updateAllBtn.innerText = 'Updating...';
const extensionName = formatExtensionName(extension.fileName || extension.name); const res = await fetch(UPDATE_EXTENSIONS_API, { method: 'POST' });
const extensionType = extension.type || 'Unknown'; if (!res.ok) throw new Error('Update failed');
let iconUrl; const data = await res.json();
if (extension.baseUrl && extension.baseUrl !== 'Local Install') { if (data.updated && data.updated.length > 0) {
iconUrl = `https://www.google.com/s2/favicons?domain=${extension.baseUrl}&sz=128`; const list = data.updated.join(', ');
window.NotificationUtils.success(`Updated: ${list}`);
await loadMarketplace();
} else { } else {
window.NotificationUtils.info('Everything is up to date.');
const displayName = extensionName.replace(/\s/g, '+'); }
iconUrl = `https://ui-avatars.com/api/?name=${displayName}&background=1f2937&color=fff&length=1`; } catch (error) {
console.error('Update All Error:', error);
window.NotificationUtils.error('Failed to perform bulk update.');
} finally {
updateAllBtn.disabled = false;
updateAllBtn.innerText = originalText;
}
} }
function renderGroupedView() {
marketplaceContent.innerHTML = '';
const activeFilter = filterSelect.value;
const groups = {};
let listToRender = [];
if (currentTab === 'marketplace') {
for (const [id, data] of Object.entries(marketplaceMetadata)) {
listToRender.push({
id,
...data,
isInstalled: installedExtensions.includes(id.toLowerCase())
});
}
} else {
for (const [id, data] of Object.entries(marketplaceMetadata)) {
if (installedExtensions.includes(id.toLowerCase())) {
listToRender.push({ id, ...data, isInstalled: true });
}
}
installedExtensions.forEach(id => {
const existsInMeta = Object.keys(marketplaceMetadata).some(k => k.toLowerCase() === id);
if (!existsInMeta) {
listToRender.push({
id: id,
name: id.charAt(0).toUpperCase() + id.slice(1),
type: 'Local',
author: 'Unknown',
isInstalled: true
});
}
});
}
listToRender.forEach(ext => {
const type = ext.type || 'Other';
if (activeFilter !== 'All' && type !== activeFilter) return;
if (!groups[type]) groups[type] = [];
groups[type].push(ext);
});
const sortedTypes = Object.keys(groups).sort();
if (sortedTypes.length === 0) {
marketplaceContent.innerHTML = `<p class="empty-msg">No extensions found for this criteria.</p>`;
return;
}
sortedTypes.forEach(type => {
const section = document.createElement('div');
section.className = 'category-group';
const title = document.createElement('h2');
title.className = 'marketplace-section-title';
title.innerText = type.replace('-', ' ');
const grid = document.createElement('div');
grid.className = 'marketplace-grid';
groups[type].forEach(ext => grid.appendChild(createCard(ext)));
section.appendChild(title);
section.appendChild(grid);
marketplaceContent.appendChild(section);
});
}
function createCard(ext) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = `extension-card grid-item extension-type-${extensionType.toLowerCase()}`; card.className = `extension-card ${ext.nsfw ? 'nsfw-ext' : ''} ${ext.broken ? 'broken-ext' : ''}`;
card.dataset.path = extension.fileName || extension.name;
card.dataset.type = extensionType;
let buttonHtml; const iconUrl = `https://www.google.com/s2/favicons?domain=${ext.domain}&sz=128`;
let badgeHtml = '';
if (isInstalled) { let buttonHtml = '';
if (ext.isInstalled) {
if (isLocalOnly) { buttonHtml = `<button class="extension-action-button btn-uninstall">Uninstall</button>`;
badgeHtml = '<span class="extension-status-badge badge-local">Local</span>'; } else if (ext.broken) {
buttonHtml = `<button class="extension-action-button" style="background: #4b5563; cursor: not-allowed;" disabled>Broken</button>`;
} else { } else {
badgeHtml = '<span class="extension-status-badge badge-installed">Installed</span>'; buttonHtml = `<button class="extension-action-button btn-install">Install</button>`;
}
buttonHtml = `
<button class="extension-action-button btn-uninstall" data-action="uninstall">Uninstall</button>
`;
} else {
buttonHtml = `
<button class="extension-action-button btn-install" data-action="install">Install</button>
`;
} }
card.innerHTML = ` card.innerHTML = `
<img class="extension-icon" src="${iconUrl}" alt="${extensionName} Icon" onerror="this.onerror=null; this.src='https://ui-avatars.com/api/?name=E&background=1f2937&color=fff&length=1'"> <img class="extension-icon" src="${iconUrl}" onerror="this.src='/public/assets/waifuboards.ico'">
<div class="card-content-wrapper"> <div class="card-content-wrapper">
<h3 class="extension-name" title="${extensionName}">${extensionName}</h3> <h3 class="extension-name">${ext.name}</h3>
${badgeHtml} <span class="extension-author">by ${ext.author || 'Unknown'}</span>
<p class="extension-description">${ext.description || 'No description available.'}</p>
<div class="extension-tags">
<span class="extension-status-badge badge-${ext.isInstalled ? 'installed' : (ext.broken ? 'local' : 'available')}">
${ext.isInstalled ? 'Installed' : (ext.broken ? 'Broken' : 'Available')}
</span>
${ext.nsfw ? '<span class="extension-status-badge badge-local">NSFW</span>' : ''}
</div>
</div> </div>
${buttonHtml} ${buttonHtml}
`; `;
const installButton = card.querySelector('[data-action="install"]'); const btn = card.querySelector('.extension-action-button');
const uninstallButton = card.querySelector('[data-action="uninstall"]'); if (!ext.broken || ext.isInstalled) {
btn.onclick = () => ext.isInstalled ? promptUninstall(ext) : handleInstall(ext);
}
if (installButton) { return card;
installButton.addEventListener('click', async () => { }
function showModal(title, message, showConfirm = false, onConfirm = null) {
modalTitle.innerText = title;
modalMessage.innerText = message;
if (showConfirm) {
modalConfirmBtn.classList.remove('hidden');
modalConfirmBtn.onclick = () => { hideModal(); if (onConfirm) onConfirm(); };
} else {
modalConfirmBtn.classList.add('hidden');
}
modalCloseBtn.onclick = hideModal;
modal.classList.remove('hidden');
}
function hideModal() { modal.classList.add('hidden'); }
async function handleInstall(ext) {
try { try {
const response = await fetch('/api/extensions/install', { const res = await fetch('/api/extensions/install', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: extension.fileName }), body: JSON.stringify({ url: ext.entry })
}); });
if (res.ok) {
const result = await response.json(); installedExtensions.push(ext.id.toLowerCase());
renderGroupedView();
if (response.ok) { window.NotificationUtils.success(`${ext.name} installed!`);
updateExtensionState(extension.fileName, true);
await showCustomModal(
'Installation Successful',
`${extensionName} has been successfully installed.`,
false
);
} else {
await showCustomModal(
'Installation Failed',
`Installation failed: ${result.error || 'Unknown error.'}`,
false
);
} }
} catch (error) { } catch (e) { window.NotificationUtils.error('Install failed.'); }
await showCustomModal(
'Installation Failed',
`Network error during installation.`,
false
);
}
});
} }
if (uninstallButton) { function promptUninstall(ext) {
uninstallButton.addEventListener('click', async () => { showModal('Confirm', `Uninstall ${ext.name}?`, true, () => handleUninstall(ext));
}
const confirmed = await showCustomModal(
'Confirm Uninstallation',
`Are you sure you want to uninstall ${extensionName}?`,
true
);
if (!confirmed) return;
async function handleUninstall(ext) {
try { try {
const response = await fetch('/api/extensions/uninstall', { const res = await fetch('/api/extensions/uninstall', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: extension.fileName }), body: JSON.stringify({ fileName: ext.id + '.js' })
}); });
if (res.ok) {
const result = await response.json(); installedExtensions = installedExtensions.filter(id => id !== ext.id.toLowerCase());
renderGroupedView();
if (response.ok) { window.NotificationUtils.info(`${ext.name} uninstalled.`);
updateExtensionState(extension.fileName, false);
await showCustomModal(
'Uninstallation Successful',
`${extensionName} has been successfully uninstalled.`,
false
);
} else {
await showCustomModal(
'Uninstallation Failed',
`Uninstallation failed: ${result.error || 'Unknown error.'}`,
false
);
} }
} catch (error) { } catch (e) { window.NotificationUtils.error('Uninstall failed.'); }
await showCustomModal(
'Uninstallation Failed',
`Network error during uninstallation.`,
false
);
}
});
} }
extensionsGrid.appendChild(card); function showSkeletons() {
} marketplaceContent.innerHTML = `
<div class="marketplace-grid">
async function getInstalledExtensions() { ${Array(3).fill('<div class="extension-card skeleton"></div>').join('')}
console.log(`Fetching installed extensions from: ${INSTALLED_EXTENSIONS_API}`);
try {
const response = await fetch(INSTALLED_EXTENSIONS_API);
if (!response.ok) {
console.error(`Error fetching installed extensions. Status: ${response.status}`);
return new Set();
}
const data = await response.json();
if (!data.extensions || !Array.isArray(data.extensions)) {
console.error("Invalid response format from /api/extensions: 'extensions' array missing or incorrect.");
return new Set();
}
const installedFileNames = data.extensions
.map(name => `${name.toLowerCase()}.js`);
return new Set(installedFileNames);
} catch (error) {
console.error('Network or JSON parsing error during fetch of installed extensions:', error);
return new Set();
}
}
function filterAndRenderExtensions(filterType) {
extensionsGrid.innerHTML = '';
if (!allExtensionsData || allExtensionsData.length === 0) {
console.log('No extension data to filter.');
return;
}
const filteredExtensions = allExtensionsData.filter(ext =>
filterType === 'All' || ext.type === filterType || (ext.isLocal && filterType === 'Local')
);
filteredExtensions.forEach(ext => {
renderExtensionCard(ext, ext.isInstalled, ext.isLocal);
});
if (filteredExtensions.length === 0) {
extensionsGrid.innerHTML = `<p style="grid-column: 1 / -1; text-align: center; color: var(--text-secondary);">No extensions found for the selected filter (${filterType}).</p>`;
}
}
async function loadMarketplace() {
extensionsGrid.innerHTML = '';
for (let i = 0; i < 6; i++) {
extensionsGrid.innerHTML += `
<div class="extension-card skeleton grid-item">
<div class="skeleton-icon skeleton" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button skeleton" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>`;
}
try {
const [availableExtensionsRaw, installedExtensionsSet] = await Promise.all([
fetch(API_URL_BASE).then(res => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json();
}),
getInstalledExtensions()
]);
const availableExtensionsJs = availableExtensionsRaw.filter(ext => ext.type === 'file' && ext.name.endsWith('.js'));
const detailPromises = [];
const marketplaceFileNames = new Set(availableExtensionsJs.map(ext => ext.name.toLowerCase()));
for (const ext of availableExtensionsJs) {
const downloadUrl = getRawUrl(ext.name);
const detailsPromise = getExtensionDetails(downloadUrl).then(details => ({
...ext,
...details,
fileName: ext.name,
isInstalled: installedExtensionsSet.has(ext.name.toLowerCase()),
isLocal: false,
}));
detailPromises.push(detailsPromise);
}
const extensionsWithDetails = await Promise.all(detailPromises);
installedExtensionsSet.forEach(installedName => {
if (!marketplaceFileNames.has(installedName)) {
const localExt = {
name: formatExtensionName(installedName),
fileName: installedName,
type: 'Local',
isInstalled: true,
isLocal: true,
baseUrl: 'Local Install',
};
extensionsWithDetails.push(localExt);
}
});
extensionsWithDetails.sort((a, b) => {
if (a.isInstalled !== b.isInstalled) {
return b.isInstalled - a.isInstalled;
}
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
});
allExtensionsData = extensionsWithDetails;
if (filterSelect) {
filterSelect.addEventListener('change', (event) => {
filterAndRenderExtensions(event.target.value);
});
}
filterAndRenderExtensions('All');
} catch (error) {
console.error('Error loading the marketplace:', error);
extensionsGrid.innerHTML = `
<div style="grid-column: 1 / -1; color: #dc2626; text-align: center; padding: 2rem; background: rgba(220,38,38,0.1); border-radius: 12px; margin-top: 1rem;">
🚨 Error loading extensions.
<p>Could not connect to the extension repository or local endpoint. Detail: ${error.message}</p>
</div> </div>
`; `;
allExtensionsData = [];
} }
}
customModal.addEventListener('click', (e) => {
if (e.target === customModal || e.target.tagName === 'BUTTON') {
customModal.classList.add('hidden');
}
});
document.addEventListener('DOMContentLoaded', loadMarketplace); document.addEventListener('DOMContentLoaded', loadMarketplace);
window.addEventListener('scroll', () => {
const navbar = document.getElementById('navbar');
if (window.scrollY > 0) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
});

View File

@@ -1,9 +0,0 @@
fetch("/api/rpc", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
details: "Browsing",
state: `In App`,
mode: "idle"
})
});

View File

@@ -1,18 +0,0 @@
if (window.electronAPI?.isElectron) {
document.documentElement.classList.add("electron");
}
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.style.visibility = "visible";
if (!window.electronAPI?.isElectron) return;
document.body.classList.add("electron");
const titlebar = document.getElementById("titlebar");
if (!titlebar) return;
titlebar.style.display = "flex";
titlebar.querySelector(".min").onclick = () => window.electronAPI.win.minimize();
titlebar.querySelector(".max").onclick = () => window.electronAPI.win.maximize();
titlebar.querySelector(".close").onclick = () => window.electronAPI.win.close();
});

View File

@@ -46,7 +46,6 @@ async function loadExtensions() {
} }
} }
async function loadExtension(fileName) { async function loadExtension(fileName) {
const homeDir = os.homedir(); const homeDir = os.homedir();
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions'); const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
@@ -77,6 +76,7 @@ async function loadExtension(fileName) {
} }
const name = instance.constructor.name; const name = instance.constructor.name;
instance.__fileName = fileName;
instance.scrape = scrape; instance.scrape = scrape;
instance.cheerio = cheerio; instance.cheerio = cheerio;
extensions.set(name, instance); extensions.set(name, instance);
@@ -114,6 +114,14 @@ async function saveExtensionFile(fileName, downloadUrl) {
file.on('finish', async () => { file.on('finish', async () => {
file.close(async () => { file.close(async () => {
try { try {
const extName = fileName.replace('.js', '');
for (const key of extensions.keys()) {
if (key.toLowerCase() === extName.toLowerCase()) {
extensions.delete(key);
break;
}
}
await loadExtension(fileName); await loadExtension(fileName);
resolve(); resolve();
} catch (err) { } catch (err) {

View File

@@ -5,7 +5,7 @@
"allowJs": true, "allowJs": true,
"checkJs": false, "checkJs": false,
"strict": true, "strict": true,
"outDir": "electron", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true "skipLibCheck": true

View File

@@ -17,26 +17,8 @@
rel="stylesheet" rel="stylesheet"
href="/views/css/components/updateNotifier.css" href="/views/css/components/updateNotifier.css"
/> />
<script src="/src/scripts/titlebar.js"></script>
<link rel="stylesheet" href="/views/css/components/titlebar.css" />
</head> </head>
<body> <body>
<div id="titlebar">
<div class="title-left">
<img
class="app-icon"
src="/public/assets/waifuboards.ico"
alt=""
/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<div class="modal-overlay" id="desc-modal"> <div class="modal-overlay" id="desc-modal">
<div class="modal-content"> <div class="modal-content">
<button class="modal-close" onclick="closeModal()"></button> <button class="modal-close" onclick="closeModal()"></button>
@@ -350,7 +332,6 @@
</div> </div>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
<script src="/src/scripts/utils/auth-utils.js"></script> <script src="/src/scripts/utils/auth-utils.js"></script>

View File

@@ -9,22 +9,9 @@
<link rel="stylesheet" href="/views/css/components/hero.css"> <link rel="stylesheet" href="/views/css/components/hero.css">
<link rel="stylesheet" href="/views/css/components/anilist-modal.css"> <link rel="stylesheet" href="/views/css/components/anilist-modal.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body > <body >
<div id="titlebar"><div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<nav class="navbar" id="navbar"> <nav class="navbar" id="navbar">
<a href="#" class="nav-brand"> <a href="#" class="nav-brand">
<div class="brand-icon"> <div class="brand-icon">
@@ -259,7 +246,6 @@
<script src="/src/scripts/utils/youtube-player-utils.js"></script> <script src="/src/scripts/utils/youtube-player-utils.js"></script>
<script src="/src/scripts/anime/animes.js"></script> <script src="/src/scripts/anime/animes.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>
</html> </html>

View File

@@ -11,21 +11,8 @@
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script> <script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
<link rel="icon" href="/public/assets/waifuboards.ico"> <link rel="icon" href="/public/assets/waifuboards.ico">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<header class="top-bar"> <header class="top-bar">
<a href="#" id="back-link" class="back-btn"> <a href="#" id="back-link" class="back-btn">

View File

@@ -10,20 +10,8 @@
<link rel="stylesheet" href="/views/css/components/anilist-modal.css"> <link rel="stylesheet" href="/views/css/components/anilist-modal.css">
<link rel="stylesheet" href="/views/css/books/book.css"> <link rel="stylesheet" href="/views/css/books/book.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<div class="modal-overlay" id="add-list-modal"> <div class="modal-overlay" id="add-list-modal">
<div class="modal-content"> <div class="modal-content">
@@ -204,7 +192,6 @@
</div> </div>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/utils/auth-utils.js"></script> <script src="/src/scripts/utils/auth-utils.js"></script>
<script src="/src/scripts/utils/notification-utils.js"></script> <script src="/src/scripts/utils/notification-utils.js"></script>

View File

@@ -7,23 +7,11 @@
<link rel="stylesheet" href="/views/css/globals.css"> <link rel="stylesheet" href="/views/css/globals.css">
<link rel="stylesheet" href="/views/css/components/navbar.css"> <link rel="stylesheet" href="/views/css/components/navbar.css">
<link rel="stylesheet" href="/views/css/components/hero.css"> <link rel="stylesheet" href="/views/css/components/hero.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<link rel="stylesheet" href="/views/css/components/anilist-modal.css"> <link rel="stylesheet" href="/views/css/components/anilist-modal.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<nav class="navbar" id="navbar"> <nav class="navbar" id="navbar">
<a href="#" class="nav-brand"> <a href="#" class="nav-brand">
@@ -228,7 +216,6 @@
<script src="/src/scripts/books/books.js"></script> <script src="/src/scripts/books/books.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>
</html> </html>

View File

@@ -8,20 +8,8 @@
<link rel="stylesheet" href="/views/css/books/reader.css"> <link rel="stylesheet" href="/views/css/books/reader.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<header class="top-bar"> <header class="top-bar">
<button id="back-btn" class="glass-btn"> <button id="back-btn" class="glass-btn">

View File

@@ -761,3 +761,70 @@
flex: 0 0 calc(50% - 0.75rem); flex: 0 0 calc(50% - 0.75rem);
} }
} }
.plyr__progress {
position: relative;
}
.plyr__markers {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 100%;
pointer-events: none;
z-index: 2;
}
.plyr__marker {
position: absolute;
bottom: 0;
width: 3px;
height: 100%;
background: rgba(255, 215, 0, 0.8); /* Color dorado para Opening */
pointer-events: all;
cursor: pointer;
transition: all 0.2s ease;
}
.plyr__marker[data-label*="Ending"] {
background: rgba(255, 100, 100, 0.8); /* Color rojo para Ending */
}
.plyr__marker:hover {
height: 120%;
width: 4px;
background: rgba(255, 215, 0, 1);
}
.plyr__marker[data-label*="Ending"]:hover {
background: rgba(255, 100, 100, 1);
}
/* Tooltip para mostrar el label */
.plyr__marker::before {
content: attr(data-label);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-8px);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.plyr__marker:hover::before {
opacity: 1;
}
.plyr__marker {
position: absolute;
height: 100%;
background: rgba(255, 255, 255, 0.35);
cursor: pointer;
}

View File

@@ -1,195 +0,0 @@
:root {
--titlebar-height: 40px;
}
* {
box-sizing: border-box;
}
html {
background: #09090b;
visibility: hidden;
scrollbar-gutter: stable;
}
html.electron {
margin: 0;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
}
html.electron .navbar,
html.electron .top-bar,
html.electron .panel-header {
top: var(--titlebar-height) !important;
}
html.electron .panel-content {
margin-top: 2rem;
}
html.electron .calendar-wrapper{
margin-top: 4rem;
}
html.electron .back-btn {
top: 55px !important;
}
#back-link {
margin-top: 55px !important;
}
#titlebar {
display: none;
height: var(--titlebar-height);
background: rgba(9, 9, 11, 0.95);
color: white;
align-items: center;
justify-content: space-between;
padding: 0 12px;
-webkit-app-region: drag;
user-select: none;
font-family: "Inter", system-ui, sans-serif;
border-bottom: 1px solid rgba(139, 92, 246, 0.2);
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: 999999;
backdrop-filter: blur(12px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.title-left {
display: flex;
align-items: center !important;
gap: 10px;
}
#titlebar .app-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(139, 92, 246, 0.15);
border: 1px solid rgba(139, 92, 246, 0.3);
padding: 3px;
}
#titlebar .app-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.app-title {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
letter-spacing: -0.2px;
}
.title-right {
display: flex;
height: 100%;
gap: 1px;
}
.title-right button {
-webkit-app-region: no-drag;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.7);
width: 46px;
height: 100%;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.title-right button svg {
width: 16px;
height: 16px;
transition: transform 0.2s;
}
.title-right button:hover {
color: white;
}
.title-right button:active {
transform: scale(0.95);
}
.title-right .min:hover {
background: rgba(139, 92, 246, 0.2);
}
.title-right .max:hover {
background: rgba(34, 197, 94, 0.2);
}
.title-right .close:hover {
background: #e81123;
color: white;
}
.title-right button:hover svg {
transform: scale(1.1);
}
html.electron::-webkit-scrollbar {
width: 12px;
position: absolute;
}
html.electron::-webkit-scrollbar-track {
background: #09090b;
margin-top: var(--titlebar-height);
}
html.electron::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 6px;
border: 2px solid #09090b;
}
html.electron::-webkit-scrollbar-thumb:hover {
background: rgba(139, 92, 246, 0.5);
}
body {
margin: 0;
padding: 0;
overflow-x: hidden;
}
.user-box {
display: flex;
align-items: center;
gap: 8px;
margin-right: 12px;
}
.user-box img {
width: 26px;
height: 26px;
border-radius: 50%;
object-fit: cover;
}
.user-box span {
font-size: 13px;
opacity: 0.9;
}
.hidden {
display: none;
}

View File

@@ -300,21 +300,6 @@ body {
transform: scale(1.05); transform: scale(1.05);
} }
Here is the CSS you need to add to the very bottom of your file.
I have targeted the specific classes you provided (.card, .section, .back-btn) to make them touch-friendly, and I added a specific section to fix the "Who's exploring?" screen from your screenshot.
1. First: The HTML Tag (Crucial)
Ensure this is in your HTML <head> or the CSS below will not work:
HTML
<meta name="viewport" content="width=device-width, initial-scale=1.0">
2. The CSS Update
Copy and paste this entire block to the bottom of your CSS file:
CSS
@media (max-width: 768px) { @media (max-width: 768px) {
:root { :root {
--nav-height: 60px; --nav-height: 60px;

View File

@@ -128,10 +128,7 @@
font-size: 0.9rem; font-size: 0.9rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
background 0.2s,
transform 0.2s,
box-shadow 0.2s;
margin-top: auto; margin-top: auto;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -231,14 +228,10 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
width: 90%; width: 90%;
max-width: 450px; max-width: 450px;
box-shadow: box-shadow: 0 15px 50px rgba(0, 0, 0, 0.8), 0 0 20px var(--color-primary-glow);
0 15px 50px rgba(0, 0, 0, 0.8),
0 0 20px var(--color-primary-glow);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
transform: translateY(-50px); transform: translateY(-50px);
transition: transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.3s ease-in-out;
transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.3s ease-in-out;
opacity: 0; opacity: 0;
} }
@@ -271,15 +264,14 @@
} }
.modal-button { .modal-button {
padding: 0.6rem 1.5rem; padding: 0.6rem 1.5rem;
border-radius: 999px; border-radius: 999px;
font-weight: 700; font-weight: 700;
font-size: 0.9rem; font-size: 0.9rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transition: background 0.2s, transform 0.2s;
background 0.2s,
transform 0.2s;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -302,102 +294,100 @@
transform: scale(1.02); transform: scale(1.02);
} }
@media (max-width: 768px) { .extension-author {
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.filter-controls {
width: 100%;
overflow-x: auto;
white-space: nowrap;
padding-bottom: 0.5rem;
-ms-overflow-style: none;
scrollbar-width: none;
}
.filter-controls::-webkit-scrollbar {
display: none;
}
.filter-select {
flex-shrink: 0;
padding: 0.6rem 2.2rem 0.6rem 1rem;
font-size: 0.9rem;
}
.marketplace-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.extension-card {
padding: 0.75rem;
min-height: auto;
}
.extension-icon {
width: 40px;
height: 40px;
}
.extension-name {
font-size: 1rem;
}
.extension-status-badge {
font-size: 0.65rem;
}
.extension-card:hover {
transform: none;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.extension-action-button {
padding: 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-secondary);
display: block;
margin-bottom: 0.5rem;
} }
.modal-content { .extension-tags {
width: 95%;
max-width: none;
padding: 1.5rem;
position: absolute;
bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
transform: translateY(100%);
margin-bottom: 0;
}
.modal-overlay:not(.hidden) .modal-content {
transform: translateY(0);
}
#modalTitle {
font-size: 1.3rem;
margin-bottom: 1rem;
}
#modalMessage {
font-size: 0.95rem;
margin-bottom: 1.5rem;
}
.modal-actions {
flex-direction: column;
gap: 0.75rem;
}
.modal-button {
width: 100%;
padding: 0.8rem;
justify-content: center;
display: flex; display: flex;
gap: 0.5rem;
flex-wrap: wrap;
} }
.badge-available {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.nsfw-ext {
border-color: rgba(220, 38, 38, 0.3);
}
.broken-ext {
filter: grayscale(0.8);
opacity: 0.7;
border: 1px dashed #ef4444; /* Borde rojo discontinuo */
}
.broken-ext:hover {
transform: none; /* Evitamos que se mueva al pasar el ratón si está rota */
}
/* Estilos para los Tabs */
.tabs-container {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 0.5rem;
}
.tab-button {
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 1.1rem;
font-weight: 700;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.tab-button.active {
color: var(--color-primary);
}
.tab-button.active::after {
content: '';
position: absolute;
bottom: -0.6rem;
left: 0;
width: 100%;
height: 3px;
background: var(--color-primary);
border-radius: 999px;
box-shadow: 0 0 10px var(--color-primary-glow);
}
.marketplace-section-title {
font-size: 1.4rem;
font-weight: 800;
margin: 2rem 0 1rem 0;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
text-transform: capitalize;
}
.marketplace-section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 20px;
background: var(--color-primary);
border-radius: 2px;
}
.category-group {
margin-bottom: 3rem;
}
.hidden {
display: none !important;
} }

View File

@@ -12,20 +12,8 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<script src="https://unpkg.com/masonry-layout@4/dist/masonry.pkgd.min.js" async></script> <script src="https://unpkg.com/masonry-layout@4/dist/masonry.pkgd.min.js" async></script>
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<nav class="navbar" id="navbar"> <nav class="navbar" id="navbar">
<a href="#" class="nav-brand"> <a href="#" class="nav-brand">
@@ -131,7 +119,6 @@
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/gallery/gallery.js"></script> <script src="/src/scripts/gallery/gallery.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>
</html> </html>

View File

@@ -7,24 +7,12 @@
<link rel="stylesheet" href="/views/css/components/navbar.css"> <link rel="stylesheet" href="/views/css/components/navbar.css">
<link rel="stylesheet" href="/views/css/globals.css"> <link rel="stylesheet" href="/views/css/globals.css">
<link rel="stylesheet" href="/views/css/gallery/gallery.css"> <link rel="stylesheet" href="/views/css/gallery/gallery.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<link rel="stylesheet" href="/views/css/gallery/image.css"> <link rel="stylesheet" href="/views/css/gallery/image.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<nav class="navbar" id="navbar"> <nav class="navbar" id="navbar">
<a href="#" class="nav-brand"> <a href="#" class="nav-brand">
@@ -116,7 +104,6 @@
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/gallery/image.js"></script> <script src="/src/scripts/gallery/image.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>
</html> </html>

View File

@@ -10,20 +10,8 @@
<link rel="stylesheet" href="/views/css/components/anilist-modal.css"> <link rel="stylesheet" href="/views/css/components/anilist-modal.css">
<link rel="stylesheet" href="/views/css/list.css"> <link rel="stylesheet" href="/views/css/list.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<nav class="navbar" id="navbar"> <nav class="navbar" id="navbar">
<a href="#" class="nav-brand"> <a href="#" class="nav-brand">
@@ -268,7 +256,6 @@
</div> </div>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
<script src="/src/scripts/utils/auth-utils.js"></script> <script src="/src/scripts/utils/auth-utils.js"></script>
<script src="/src/scripts/utils/notification-utils.js"></script> <script src="/src/scripts/utils/notification-utils.js"></script>

View File

@@ -9,20 +9,8 @@
<link rel="stylesheet" href="/views/css/marketplace.css"> <link rel="stylesheet" href="/views/css/marketplace.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<nav class="navbar" id="navbar"> <nav class="navbar" id="navbar">
<a href="#" class="nav-brand"> <a href="#" class="nav-brand">
@@ -36,9 +24,9 @@
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button> <button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
<button class="nav-button" onclick="window.location.href='/books'">Books</button> <button class="nav-button" onclick="window.location.href='/books'">Books</button>
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button> <button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button> <button class="nav-button" onclick="window.location.href='/schedule.html'">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button> <button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button active">Marketplace</button> <button class="nav-button">Marketplace</button>
</div> </div>
<div class="nav-right"> <div class="nav-right">
@@ -90,47 +78,31 @@
<main> <main>
<section class="section"> <section class="section">
<header class="section-header"> <header class="section-header">
<p class="marketplace-subtitle">Explore, install, and manage all available data source extensions for WaifuBoard.</p> <div class="tabs-container">
<button class="tab-button active" data-tab="marketplace">Marketplace</button>
<button class="tab-button" data-tab="installed">My Extensions</button>
</div>
<div class="filter-controls"> <div class="filter-controls">
<button id="btn-update-all" class="btn-blur hidden" style="margin-right: 10px; width: auto; padding: 0 15px;">
Update All
</button>
<label for="extension-filter" class="filter-label">Filter by Type:</label> <label for="extension-filter" class="filter-label">Filter by Type:</label>
<select id="extension-filter" class="filter-select"> <select id="extension-filter" class="filter-select">
<option value="All">All Extensions</option> <option value="All">All Categories</option>
<option value="Image">Image Boards</option> <option value="image-board">Image Boards</option>
<option value="Anime">Anime Boards</option> <option value="anime-board">Anime Boards</option>
<option value="Book">Book Boards</option> <option value="book-board">Book Boards</option>
<option value="Local">Local Only</option> <option value="Local">Local/Manual</option>
</select> </select>
</div> </div>
</header> </header>
<div class="marketplace-grid" id="extensions-grid">
<div class="extension-card skeleton grid-item"> <div id="marketplace-content">
<div class="skeleton-icon" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>
<div class="extension-card skeleton grid-item">
<div class="skeleton-icon" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>
<div class="extension-card skeleton grid-item">
<div class="skeleton-icon" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>
</div> </div>
</section> </section>
<div id="customModal" class="modal-overlay hidden"> <div id="customModal" class="modal-overlay hidden">
<div class="modal-content"> <div class="modal-content">
<h3 id="modalTitle"></h3> <h3 id="modalTitle"></h3>
@@ -143,22 +115,9 @@
</div> </div>
</main> </main>
<div id="updateToast" class="hidden"> <script src="/src/scripts/utils/notification-utils.js"></script>
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a
id="downloadButton"
href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases"
target="_blank"
>
Click To Download
</a>
</div>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/marketplace.js"></script> <script src="/src/scripts/marketplace.js"></script>
<script src="/src/scripts/titlebar.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>
</html> </html>

View File

@@ -12,21 +12,8 @@
<link rel="stylesheet" href="/views/css/schedule/schedule.css"> <link rel="stylesheet" href="/views/css/schedule/schedule.css">
<link rel="stylesheet" href="/views/css/components/navbar.css"> <link rel="stylesheet" href="/views/css/components/navbar.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<div class="ambient-bg" id="ambientBg"></div> <div class="ambient-bg" id="ambientBg"></div>
<nav class="navbar" id="navbar"> <nav class="navbar" id="navbar">
@@ -152,7 +139,6 @@
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/schedule/schedule.js"></script> <script src="/src/scripts/schedule/schedule.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>
</html> </html>

View File

@@ -8,7 +8,6 @@
rel="stylesheet" rel="stylesheet"
href="/views/css/components/updateNotifier.css" href="/views/css/components/updateNotifier.css"
/> />
<link rel="stylesheet" href="/views/css/components/titlebar.css" />
<link rel="stylesheet" href="/views/css/users.css" /> <link rel="stylesheet" href="/views/css/users.css" />
<link <link
rel="icon" rel="icon"
@@ -16,25 +15,8 @@
type="image/x-icon" type="image/x-icon"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/src/scripts/titlebar.js"></script>
</head> </head>
<body> <body>
<div id="titlebar">
<div class="title-left">
<img
class="app-icon"
src="/public/assets/waifuboards.ico"
alt=""
/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
</div>
<div class="page-wrapper"> <div class="page-wrapper">
<div class="background-gradient"></div> <div class="background-gradient"></div>