11 Commits

Author SHA1 Message Date
6222e7736f added missing package (again xd) 2025-12-27 22:07:26 +01:00
03d8337d89 added missing package 2025-12-27 22:07:26 +01:00
d49f739565 added local manga, todo: novels 2025-12-27 22:07:26 +01:00
295cab93f3 enhanced list modal and fixes 2025-12-27 22:07:26 +01:00
4bca41f6a2 made navbar ssr 2025-12-27 22:07:26 +01:00
bc74aa8116 fixed a bug & replicated all changes to docker version 2025-12-27 22:07:26 +01:00
cc0b0a891e wip settings section 2025-12-27 22:07:26 +01:00
76391f74d2 created config api 2025-12-27 22:07:26 +01:00
487e24a20a added page 404 2025-12-27 22:07:26 +01:00
25ea30f086 wip implementation of local library on anime 2025-12-27 22:07:26 +01:00
6075dcf149 implemented api for local library & global config 2025-12-27 22:07:26 +01:00
77 changed files with 4711 additions and 2460 deletions

View File

@@ -11,12 +11,14 @@
"dependencies": { "dependencies": {
"@fastify/static": "^8.3.0", "@fastify/static": "^8.3.0",
"@xhayper/discord-rpc": "^1.3.0", "@xhayper/discord-rpc": "^1.3.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"bindings": "^1.5.0", "bindings": "^1.5.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"js-yaml": "^4.1.1",
"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", "node-cron": "^4.2.1",
@@ -24,6 +26,7 @@
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -1508,6 +1511,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/adm-zip": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
"integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/bcrypt": { "node_modules/@types/bcrypt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -1721,6 +1734,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/adm-zip": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1955,7 +1977,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assert-plus": { "node_modules/assert-plus": {
@@ -4825,7 +4846,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"

View File

@@ -14,12 +14,14 @@
"dependencies": { "dependencies": {
"@fastify/static": "^8.3.0", "@fastify/static": "^8.3.0",
"@xhayper/discord-rpc": "^1.3.0", "@xhayper/discord-rpc": "^1.3.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"bindings": "^1.5.0", "bindings": "^1.5.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"js-yaml": "^4.1.1",
"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", "node-cron": "^4.2.1",
@@ -27,6 +29,7 @@
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

View File

@@ -8,6 +8,7 @@ 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 { ensureConfigFile } = require("./electron/shared/config");
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 {refreshTrendingAnime, refreshTopAiringAnime} = require("./electron/api/anime/anime.service");
const {refreshPopularBooks, refreshTrendingBooks} = require("./electron/api/books/books.service"); const {refreshPopularBooks, refreshTrendingBooks} = require("./electron/api/books/books.service");
@@ -29,6 +30,8 @@ const rpcRoutes = require("./electron/api/rpc/rpc.routes");
const userRoutes = require("./electron/api/user/user.routes"); 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 localRoutes = require("./electron/api/local/local.routes");
const configRoutes = require("./electron/api/config/config.routes");
fastify.addHook("preHandler", async (request) => { fastify.addHook("preHandler", async (request) => {
const auth = request.headers.authorization; const auth = request.headers.authorization;
@@ -70,15 +73,19 @@ fastify.register(rpcRoutes, { prefix: "/api" });
fastify.register(userRoutes, { prefix: "/api" }); 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" });
fastify.register(localRoutes, { prefix: "/api" });
fastify.register(configRoutes, { prefix: "/api" });
const sleep = ms => new Promise(r => setTimeout(r, ms)); const sleep = ms => new Promise(r => setTimeout(r, ms));
const start = async () => { const start = async () => {
try { try {
ensureConfigFile()
initDatabase("anilist"); initDatabase("anilist");
initDatabase("favorites"); initDatabase("favorites");
initDatabase("cache"); initDatabase("cache");
initDatabase("userdata"); initDatabase("userdata");
initDatabase("local_library");
init(); init();
const refreshAll = async () => { const refreshAll = async () => {

View File

@@ -0,0 +1,43 @@
import {FastifyReply, FastifyRequest} from 'fastify';
import {getConfig, setConfig} from '../../shared/config';
export async function getFullConfig(req: FastifyRequest, reply: FastifyReply) {
try {
return getConfig();
} catch (err) {
return { error: "Error loading config" };
}
}
export async function getConfigSection(req: FastifyRequest<{ Params: { section: string } }>, reply: FastifyReply) {
try {
const { section } = req.params;
const config = getConfig();
if (config[section] === undefined) {
return { error: "Section not found" };
}
return { [section]: config[section] };
} catch (err) {
return { error: "Error loading config section" };
}
}
export async function updateConfig(req: FastifyRequest<{ Body: any }>, reply: FastifyReply) {
try {
return setConfig(req.body);
} catch (err) {
return { error: "Error updating config" };
}
}
export async function updateConfigSection(req: FastifyRequest<{ Params: { section: string }, Body: any }>, reply: FastifyReply) {
try {
const { section } = req.params;
const updatedConfig = setConfig({ [section]: req.body });
return { [section]: updatedConfig[section] };
} catch (err) {
return { error: "Error updating config section" };
}
}

View File

@@ -0,0 +1,11 @@
import { FastifyInstance } from 'fastify';
import * as controller from './config.controller';
async function configRoutes(fastify: FastifyInstance) {
fastify.get('/config', controller.getFullConfig);
fastify.get('/config/:section', controller.getConfigSection);
fastify.post('/config', controller.updateConfig);
fastify.post('/config/:section', controller.updateConfigSection);
}
export default configRoutes;

View File

@@ -0,0 +1,357 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { getConfig as loadConfig, setConfig as saveConfig } from '../../shared/config.js';
import { queryOne, queryAll, run } from '../../shared/database.js';
import crypto from 'crypto';
import fs from "fs";
import { PathLike } from "node:fs";
import path from "path";
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service";
import AdmZip from 'adm-zip';
type SetConfigBody = {
library?: {
anime?: string | null;
manga?: string | null;
novels?: string | null;
};
};
type ScanQuery = {
mode?: 'full' | 'incremental';
};
type Params = {
type: 'anime' | 'manga' | 'novels';
id?: string;
};
async function resolveEntryMetadata(entry: any, type: string) {
let metadata = null;
let matchedId = entry.matched_id;
if (!matchedId) {
const query = entry.folder_name;
const results = type === 'anime'
? await searchAnimeLocal(query)
: await searchBooksAniList(query);
const first = results?.[0];
if (first?.id) {
matchedId = first.id;
await run(
`UPDATE local_entries
SET matched_id = ?, matched_source = 'anilist'
WHERE id = ?`,
[matchedId, entry.id],
'local_library'
);
}
}
if (matchedId) {
metadata = type === 'anime'
? await getAnimeById(matchedId)
: await getBookById(matchedId);
}
return {
id: entry.id,
type: entry.type,
matched: !!matchedId,
metadata
};
}
export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) {
try {
const mode = request.query.mode || 'incremental';
const config = loadConfig();
if (!config.library) {
return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' });
}
if (mode === 'full') {
await run(`DELETE FROM local_files`, [], 'local_library');
await run(`DELETE FROM local_entries`, [], 'local_library');
}
for (const [type, basePath] of Object.entries(config.library)) {
if (!basePath || !fs.existsSync(<PathLike>basePath)) continue;
const dirs = fs.readdirSync(<string>basePath, { withFileTypes: true }).filter(d => d.isDirectory());
for (const dir of dirs) {
const fullPath = path.join(<string>basePath, dir.name);
const id = crypto.createHash('sha1').update(fullPath).digest('hex');
const now = Date.now();
const existing = await queryOne(`SELECT id FROM local_entries WHERE id = ?`, [id], 'local_library');
if (existing) {
await run(`UPDATE local_entries SET last_scan = ? WHERE id = ?`, [now, id], 'local_library');
await run(`DELETE FROM local_files WHERE entry_id = ?`, [id], 'local_library');
} else {
await run(
`INSERT INTO local_entries (id, type, path, folder_name, last_scan) VALUES (?, ?, ?, ?, ?)`,
[id, type, fullPath, dir.name, now],
'local_library'
);
}
const files = fs.readdirSync(fullPath, { withFileTypes: true })
.filter(f => f.isFile())
.sort((a, b) => a.name.localeCompare(b.name));
let unit = 1;
for (const file of files) {
await run(
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
VALUES (?, ?, ?, ?)`,
[crypto.randomUUID(), id, path.join(fullPath, file.name), unit],
'local_library'
);
unit++;
}
}
}
return { status: 'OK' };
} catch (err) {
return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' });
}
}
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try {
const { type } = request.params;
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
} catch {
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
}
}
export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try {
const { type, id } = request.params as { type: string, id: string };
const entry = await queryOne(
`SELECT * FROM local_entries WHERE matched_id = ? AND type = ?`,
[Number(id), type],
'local_library'
);
if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
}
const [details, files] = await Promise.all([
resolveEntryMetadata(entry, type),
queryAll(
`SELECT id, file_path, unit_number FROM local_files WHERE entry_id = ? ORDER BY unit_number ASC`,
[id],
'local_library'
)
]);
return { ...details, files };
} catch {
return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' });
}
}
export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
const { id, unit } = request.params as any;
const file = await queryOne(
`SELECT file_path FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[id, unit],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) {
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
}
const stat = fs.statSync(file.file_path);
const range = request.headers.range;
if (!range) {
reply
.header('Content-Length', stat.size)
.header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path);
}
const parts = range.replace(/bytes=/, '').split('-');
const start = Number(parts[0]);
let end = parts[1] ? Number(parts[1]) : stat.size - 1;
if (
Number.isNaN(start) ||
Number.isNaN(end) ||
start < 0 ||
end < start ||
end >= stat.size
) {
end = stat.size - 1;
}
reply
.status(206)
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
.header('Accept-Ranges', 'bytes')
.header('Content-Length', end - start + 1)
.header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path, { start, end });
}
type MatchBody = {
source: 'anilist';
matched_id: number | null;
};
export async function matchEntry(
request: FastifyRequest<{ Body: MatchBody }>,
reply: FastifyReply
) {
const { id, type } = request.params as any;
const { source, matched_id } = request.body;
const entry = await queryOne(
`SELECT id FROM local_entries WHERE id = ? AND type = ?`,
[id, type],
'local_library'
);
if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
}
await run(
`UPDATE local_entries
SET matched_source = ?, matched_id = ?
WHERE id = ?`,
[source, matched_id, id],
'local_library'
);
return { status: 'OK', matched: !!matched_id };
}
export async function getUnits(
request: FastifyRequest<{ Params: Params }>,
reply: FastifyReply
) {
try {
const { type, id } = request.params as { type: string, id: string };
// Buscar la entrada por matched_id
const entry = await queryOne(
`SELECT id, type, matched_id FROM local_entries WHERE matched_id = ? AND type = ?`,
[Number(id), type],
'local_library'
);
if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
}
// Obtener todos los archivos/unidades ordenados
const files = await queryAll(
`SELECT id, file_path, unit_number FROM local_files
WHERE entry_id = ?
ORDER BY unit_number ASC`,
[entry.id],
'local_library'
);
// Formatear la respuesta según el tipo
const units = files.map((file: any) => {
const fileName = path.basename(file.file_path);
const fileExt = path.extname(file.file_path).toLowerCase();
// Detectar si es un archivo comprimido (capítulo único) o carpeta
const isDirectory = fs.existsSync(file.file_path) &&
fs.statSync(file.file_path).isDirectory();
return {
id: file.id,
number: file.unit_number,
name: fileName,
type: type === 'anime' ? 'episode' : 'chapter',
format: fileExt === '.cbz' ? 'cbz' : 'file',
path: file.file_path
};
});
return {
entry_id: entry.id,
matched_id: entry.matched_id,
type: entry.type,
total: units.length,
units
};
} catch (err) {
console.error('Error getting units:', err);
return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' });
}
}
export async function getCbzPages(request: FastifyRequest, reply: FastifyReply) {
const { unitId } = request.params as any;
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) {
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
}
const zip = new AdmZip(file.file_path);
const pages = zip.getEntries()
.filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }))
.map((_, i) =>
`/api/library/manga/cbz/${unitId}/page/${i}`
);
return { pages };
}
export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) {
const { unitId, page } = request.params as any;
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file) return reply.status(404).send();
const zip = new AdmZip(file.file_path);
const images = zip.getEntries()
.filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }));
const entry = images[page];
if (!entry) return reply.status(404).send();
reply
.header('Content-Type', 'image/jpeg')
.send(entry.getData());
}

View File

@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import * as controller from './local.controller';
async function localRoutes(fastify: FastifyInstance) {
fastify.post('/library/scan', controller.scanLibrary);
fastify.get('/library/:type', controller.listEntries);
fastify.get('/library/:type/:id', controller.getEntry);
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
fastify.post('/library/:type/:id/match', controller.matchEntry);
fastify.get('/library/:type/:id/units', controller.getUnits);
fastify.get('/library/:type/cbz/:unitId/pages', controller.getCbzPages);
fastify.get('/library/:type/cbz/:unitId/page/:page', controller.getCbzPage);
}
export default localRoutes;

View File

@@ -1,6 +1,7 @@
let animeData = null; let animeData = null;
let extensionName = null; let extensionName = null;
let animeId = null; let animeId = null;
let isLocal = false;
const episodePagination = Object.create(PaginationManager); const episodePagination = Object.create(PaginationManager);
episodePagination.init(12, renderEpisodes); episodePagination.init(12, renderEpisodes);
@@ -13,6 +14,30 @@ document.addEventListener('DOMContentLoaded', () => {
setupEpisodeSearch(); setupEpisodeSearch();
}); });
function markAsLocal() {
isLocal = true;
const pill = document.getElementById('local-pill');
if (!pill) return;
pill.textContent = 'Local';
pill.style.display = 'inline-flex';
pill.style.background = 'rgba(34,197,94,.2)';
pill.style.color = '#22c55e';
pill.style.borderColor = 'rgba(34,197,94,.3)';
}
async function checkLocalLibraryEntry() {
try {
const res = await fetch(`/api/library/anime/${animeId}`);
if (!res.ok) return;
markAsLocal();
} catch (e) {
}
}
async function loadAnime() { async function loadAnime() {
try { try {
@@ -24,6 +49,7 @@ async function loadAnime() {
extensionName = urlData.extensionName; extensionName = urlData.extensionName;
animeId = urlData.entityId; animeId = urlData.entityId;
await checkLocalLibraryEntry();
const fetchUrl = extensionName const fetchUrl = extensionName
? `/api/anime/${animeId}?source=${extensionName}` ? `/api/anime/${animeId}?source=${extensionName}`
@@ -38,7 +64,7 @@ async function loadAnime() {
} }
animeData = data; animeData = data;
animeData.entry_type = 'ANIME';
const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName); const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName);
updatePageTitle(metadata.title); updatePageTitle(metadata.title);
@@ -142,8 +168,8 @@ function setupWatchButton() {
const watchBtn = document.getElementById('watch-btn'); const watchBtn = document.getElementById('watch-btn');
if (watchBtn) { if (watchBtn) {
watchBtn.onclick = () => { watchBtn.onclick = () => {
const url = URLUtils.buildWatchUrl(animeId, 1, extensionName); const source = isLocal ? 'local' : (extensionName || 'anilist');
window.location.href = url; window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
}; };
} }
} }
@@ -226,8 +252,8 @@ function createEpisodeButton(num, container) {
btn.className = 'episode-btn'; btn.className = 'episode-btn';
btn.innerText = `Ep ${num}`; btn.innerText = `Ep ${num}`;
btn.onclick = () => { btn.onclick = () => {
const url = URLUtils.buildWatchUrl(animeId, num, extensionName); const source = isLocal ? 'local' : (extensionName || 'anilist');
window.location.href = url; window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
}; };
container.appendChild(btn); container.appendChild(btn);
} }

View File

@@ -92,6 +92,7 @@ function startHeroCycle() {
async function updateHeroUI(anime) { async function updateHeroUI(anime) {
if(!anime) return; if(!anime) return;
anime.entry_type = 'ANIME';
const title = anime.title.english || anime.title.romaji || "Unknown Title"; const title = anime.title.english || anime.title.romaji || "Unknown Title";
const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A'; const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A';

View File

@@ -18,12 +18,28 @@ const firstKey = params.keys().next().value;
let extName; let extName;
if (firstKey) extName = firstKey; if (firstKey) extName = firstKey;
const href = extName // URL de retroceso: Si es local, volvemos a la vista de Anilist normal
const href = (extName && extName !== 'local')
? `/anime/${extName}/${animeId}` ? `/anime/${extName}/${animeId}`
: `/anime/${animeId}`; : `/anime/${animeId}`;
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}`;
let localEntryId = null;
async function checkLocal() {
try {
const res = await fetch(`/api/library/anime/${animeId}`);
if (!res.ok) return;
const data = await res.json();
localEntryId = data.id; // ← ID interna
} catch {}
}
async function loadAniSkip(malId, episode, duration) { async function loadAniSkip(malId, episode, duration) {
try { try {
const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`); const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`);
@@ -37,9 +53,10 @@ async function loadAniSkip(malId, episode, duration) {
} }
async function loadMetadata() { async function loadMetadata() {
checkLocal();
try { try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist"; const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`;
const res = await fetch(`/api/anime/${animeId}${extQuery}`); const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`);
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error) {
@@ -49,13 +66,7 @@ async function loadMetadata() {
const isAnilistFormat = data.title && (data.title.romaji || data.title.english); const isAnilistFormat = data.title && (data.title.romaji || data.title.english);
let title = ''; let title = '', description = '', coverImage = '', averageScore = '', format = '', seasonYear = '', season = '';
let description = '';
let coverImage = '';
let averageScore = '';
let format = '';
let seasonYear = '';
let season = '';
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';
@@ -97,7 +108,8 @@ async function loadMetadata() {
document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--'); document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--');
document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg'; document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg';
if (extName) { // Solo cargamos episodios de extensión si hay extensión real y no es local
if (extName && extName !== 'local') {
await loadExtensionEpisodes(); await loadExtensionEpisodes();
} else { } else {
if (data.nextAiringEpisode?.episode) { if (data.nextAiringEpisode?.episode) {
@@ -109,12 +121,7 @@ async function loadMetadata() {
} }
const simpleEpisodes = []; const simpleEpisodes = [];
for (let i = 1; i <= totalEpisodes; i++) { for (let i = 1; i <= totalEpisodes; i++) {
simpleEpisodes.push({ simpleEpisodes.push({ number: i, title: null, thumbnail: null, isDub: false });
number: i,
title: null,
thumbnail: null,
isDub: false
});
} }
populateEpisodeCarousel(simpleEpisodes); populateEpisodeCarousel(simpleEpisodes);
} }
@@ -129,72 +136,30 @@ async function loadMetadata() {
} }
async function applyAniSkip(video) { async function applyAniSkip(video) {
if (!isAnilist || !malId) { if (!isAnilist || !malId) return;
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));
aniSkipData = await loadAniSkip( if (!aniSkipData || aniSkipData.length === 0) return;
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 = []; const markers = [];
aniSkipData.forEach(item => { aniSkipData.forEach(item => {
const { startTime, endTime } = item.interval; const { startTime, endTime } = item.interval;
if (item.skipType === 'op') {
op = { start: startTime, end: endTime };
markers.push({ markers.push({
start: startTime, start: startTime,
end: endTime, end: endTime,
label: 'Opening' label: item.skipType === 'op' ? 'Opening' : 'Ending'
});
}); });
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) { if (plyrInstance && markers.length > 0) {
console.log('Creating visual markers:', markers);
// Esperar a que el player esté completamente cargado
setTimeout(() => { setTimeout(() => {
const progressContainer = document.querySelector('.plyr__progress'); const progressContainer = document.querySelector('.plyr__progress');
if (!progressContainer) { if (!progressContainer) return;
console.error('Progress container not found');
return;
}
// Eliminar markers anteriores si existen
const oldMarkers = progressContainer.querySelector('.plyr__markers'); const oldMarkers = progressContainer.querySelector('.plyr__markers');
if (oldMarkers) oldMarkers.remove(); if (oldMarkers) oldMarkers.remove();
// Crear contenedor de markers
const markersContainer = document.createElement('div'); const markersContainer = document.createElement('div');
markersContainer.className = 'plyr__markers'; markersContainer.className = 'plyr__markers';
@@ -216,35 +181,19 @@ async function applyAniSkip(video) {
markersContainer.appendChild(markerElement); markersContainer.appendChild(markerElement);
}); });
progressContainer.appendChild(markersContainer); progressContainer.appendChild(markersContainer);
console.log('Visual markers created successfully');
}, 500); }, 500);
} }
} }
async function loadExtensionEpisodes() { async function loadExtensionEpisodes() {
try { try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist"; const res = await fetch(`/api/anime/${animeId}/episodes?source=${extName}`);
const res = await fetch(`/api/anime/${animeId}/episodes${extQuery}`);
const data = await res.json(); const data = await res.json();
totalEpisodes = Array.isArray(data) ? data.length : 0; totalEpisodes = Array.isArray(data) ? data.length : 0;
populateEpisodeCarousel(Array.isArray(data) ? data : []);
if (Array.isArray(data) && data.length > 0) {
populateEpisodeCarousel(data);
} else {
const fallback = [];
for (let i = 1; i <= totalEpisodes; i++) {
fallback.push({ number: i, title: null, thumbnail: null });
}
populateEpisodeCarousel(fallback);
}
} catch (e) { } catch (e) {
console.error("Error cargando episodios por extensión:", e); console.error("Error cargando episodios:", e);
totalEpisodes = 0;
} }
} }
@@ -256,15 +205,12 @@ function populateEpisodeCarousel(episodesData) {
const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1); const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1);
if (!epNumber) return; if (!epNumber) return;
const extParam = extName ? `?${extName}` : ""; const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== ''; const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== '';
const link = document.createElement('a'); const link = document.createElement('a');
link.href = `/watch/${animeId}/${epNumber}${extParam}`; link.href = `/watch/${animeId}/${epNumber}${extParam}`;
link.classList.add('carousel-item'); link.classList.add('carousel-item');
link.dataset.episode = epNumber;
if (!hasThumbnail) link.classList.add('no-thumbnail');
if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel'); if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel');
const imgContainer = document.createElement('div'); const imgContainer = document.createElement('div');
@@ -272,21 +218,15 @@ function populateEpisodeCarousel(episodesData) {
if (hasThumbnail) { if (hasThumbnail) {
const img = document.createElement('img'); const img = document.createElement('img');
img.classList.add('carousel-item-img');
img.src = ep.thumbnail; img.src = ep.thumbnail;
img.alt = `Episode ${epNumber} Thumbnail`; img.classList.add('carousel-item-img');
imgContainer.appendChild(img); imgContainer.appendChild(img);
} }
link.appendChild(imgContainer); link.appendChild(imgContainer);
const info = document.createElement('div'); const info = document.createElement('div');
info.classList.add('carousel-item-info'); info.classList.add('carousel-item-info');
info.innerHTML = `<p>Ep ${epNumber}: ${ep.title || 'Untitled'}</p>`;
const title = document.createElement('p');
title.innerText = `Ep ${epNumber}: ${ep.title || 'Untitled'}`;
info.appendChild(title);
link.appendChild(info); link.appendChild(info);
carousel.appendChild(link); carousel.appendChild(link);
}); });
@@ -297,28 +237,27 @@ async function loadExtensions() {
const res = await fetch('/api/extensions/anime'); const res = await fetch('/api/extensions/anime');
const data = await res.json(); const data = await res.json();
const select = document.getElementById('extension-select'); const select = document.getElementById('extension-select');
let extensions = data.extensions || [];
if (extName === 'local' && !extensions.includes('local')) {
extensions.push('local');
}
if (data.extensions && data.extensions.length > 0) {
select.innerHTML = ''; select.innerHTML = '';
data.extensions.forEach(ext => { extensions.forEach(ext => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = opt.innerText = ext; opt.value = opt.innerText = ext;
select.appendChild(opt); select.appendChild(opt);
}); });
if (typeof extName === 'string' && data.extensions.includes(extName)) { if (extName && extensions.includes(extName)) {
select.value = extName; select.value = extName;
} else { } else if (extensions.length > 0) {
select.selectedIndex = 0; select.value = extensions[0];
} }
currentExtension = select.value; currentExtension = select.value;
onExtensionChange(); onExtensionChange();
} else {
select.innerHTML = '<option>No Extensions</option>';
select.disabled = true;
setLoading("No anime extensions found.");
}
} catch (error) { } catch (error) {
console.error("Extension Error:", error); console.error("Extension Error:", error);
} }
@@ -327,83 +266,70 @@ async function loadExtensions() {
async function onExtensionChange() { async function onExtensionChange() {
const select = document.getElementById('extension-select'); const select = document.getElementById('extension-select');
currentExtension = select.value; currentExtension = select.value;
setLoading("Fetching extension settings...");
if (currentExtension === 'local') {
document.getElementById('sd-toggle').style.display = 'none';
document.getElementById('server-select').style.display = 'none';
loadStream();
return;
}
setLoading("Fetching extension settings...");
try { try {
const res = await fetch(`/api/extensions/${currentExtension}/settings`); const res = await fetch(`/api/extensions/${currentExtension}/settings`);
const settings = await res.json(); const settings = await res.json();
const toggle = document.getElementById('sd-toggle'); const toggle = document.getElementById('sd-toggle');
if (settings.supportsDub) { toggle.style.display = settings.supportsDub ? 'flex' : 'none';
toggle.style.display = 'flex';
setAudioMode('sub'); setAudioMode('sub');
} else {
toggle.style.display = 'none';
setAudioMode('sub');
}
const serverSelect = document.getElementById('server-select'); const serverSelect = document.getElementById('server-select');
serverSelect.innerHTML = ''; serverSelect.innerHTML = '';
if (settings.episodeServers && settings.episodeServers.length > 0) { if (settings.episodeServers?.length > 0) {
settings.episodeServers.forEach(srv => { settings.episodeServers.forEach(srv => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = srv; opt.value = opt.innerText = srv;
opt.innerText = srv;
serverSelect.appendChild(opt); serverSelect.appendChild(opt);
}); });
serverSelect.style.display = 'block'; serverSelect.style.display = 'block';
} else { } else {
serverSelect.style.display = 'none'; serverSelect.style.display = 'none';
} }
loadStream(); loadStream();
} catch (error) { } catch (error) {
console.error(error); setLoading("Failed to load settings.");
setLoading("Failed to load extension settings.");
} }
} }
function toggleAudioMode() {
const newMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(newMode);
loadStream();
}
function setAudioMode(mode) {
audioMode = mode;
const toggle = document.getElementById('sd-toggle');
const subOpt = document.getElementById('opt-sub');
const dubOpt = document.getElementById('opt-dub');
toggle.setAttribute('data-state', mode);
subOpt.classList.toggle('active', mode === 'sub');
dubOpt.classList.toggle('active', mode === 'dub');
}
async function loadStream() { async function loadStream() {
if (!currentExtension) return; if (!currentExtension) return;
if (currentExtension === 'local') {
console.log(localEntryId);
if (!localEntryId) {
setLoading("No existe en local");
return;
}
const localUrl = `/api/library/stream/anime/${localEntryId}/${currentEpisode}`;
playVideo(localUrl, []);
document.getElementById('loading-overlay').style.display = 'none';
return;
}
const serverSelect = document.getElementById('server-select'); const serverSelect = document.getElementById('server-select');
const server = serverSelect.value || "default"; const server = serverSelect.value || "default";
setLoading(`Loading stream (${audioMode})...`); setLoading(`Loading stream (${audioMode})...`);
try { try {
let sourc = "&source=anilist"; const sourc = (extName && extName !== 'local') ? `&source=${extName}` : "&source=anilist";
if (extName){
sourc = `&source=${extName}`;
}
const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`; const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`;
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error || !data.videoSources?.length) {
setLoading(`Error: ${data.error}`); setLoading(data.error || "No video sources.");
return;
}
if (!data.videoSources || data.videoSources.length === 0) {
setLoading("No video sources found.");
return; return;
} }
@@ -415,34 +341,31 @@ async function loadStream() {
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`; if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`; if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
playVideo(proxyUrl, data.videoSources[0].subtitles || data.subtitles); playVideo(proxyUrl, source.subtitles || data.subtitles || []);
document.getElementById('loading-overlay').style.display = 'none'; document.getElementById('loading-overlay').style.display = 'none';
} catch (error) { } catch (error) {
setLoading("Stream error. Check console."); setLoading("Stream error.");
console.error(error);
} }
} }
function playVideo(url, subtitles = []) { function playVideo(url, subtitles = []) {
const video = document.getElementById('player'); const video = document.getElementById('player');
const isLocal = url.includes('/api/library/stream/');
if (Hls.isSupported()) { if (!isLocal && Hls.isSupported()) {
if (hlsInstance) hlsInstance.destroy(); if (hlsInstance) hlsInstance.destroy();
hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false }); hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false });
hlsInstance.loadSource(url); hlsInstance.loadSource(url);
hlsInstance.attachMedia(video); hlsInstance.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else {
if (hlsInstance) hlsInstance.destroy();
video.src = url; video.src = url;
} }
if (plyrInstance) plyrInstance.destroy(); if (plyrInstance) plyrInstance.destroy();
while (video.textTracks.length > 0) video.removeChild(video.textTracks[0]);
while (video.textTracks.length > 0) {
video.removeChild(video.textTracks[0]);
}
subtitles.forEach(sub => { subtitles.forEach(sub => {
if (!sub.url) return;
const track = document.createElement('track'); const track = document.createElement('track');
track.kind = 'captions'; track.kind = 'captions';
track.label = sub.language || 'Unknown'; track.label = sub.language || 'Unknown';
@@ -455,59 +378,34 @@ 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', () => { video.addEventListener('loadedmetadata', () => applyAniSkip(video));
applyAniSkip(video);
});
// LÓGICA DE RPC (Discord)
let rpcActive = false; let rpcActive = false;
let lastSeek = 0;
video.addEventListener("play", () => { video.addEventListener("play", () => {
if (!video.duration) return; if (!video.duration) return;
const elapsed = Math.floor(video.currentTime); const elapsed = Math.floor(video.currentTime);
const start = Math.floor(Date.now() / 1000) - elapsed; const start = Math.floor(Date.now() / 1000) - elapsed;
const end = start + Math.floor(video.duration); const end = start + Math.floor(video.duration);
sendRPC({ startTimestamp: start, endTimestamp: end });
sendRPC({
startTimestamp: start,
endTimestamp: end
});
rpcActive = true; rpcActive = true;
}); });
video.addEventListener("pause", () => { video.addEventListener("pause", () => {
if (!rpcActive) return; if (rpcActive) sendRPC({ paused: true });
sendRPC({
paused: true
});
});
video.addEventListener("seeking", () => {
lastSeek = video.currentTime;
}); });
video.addEventListener("seeked", () => { video.addEventListener("seeked", () => {
if (video.paused || !rpcActive) return; if (video.paused || !rpcActive) return;
const elapsed = Math.floor(video.currentTime); const elapsed = Math.floor(video.currentTime);
const start = Math.floor(Date.now() / 1000) - elapsed; const start = Math.floor(Date.now() / 1000) - elapsed;
const end = start + Math.floor(video.duration); const end = start + Math.floor(video.duration);
sendRPC({ startTimestamp: start, endTimestamp: end });
sendRPC({
startTimestamp: start,
endTimestamp: end
});
}); });
}
function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) { function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) {
fetch("/api/rpc", { fetch("/api/rpc", {
@@ -523,50 +421,18 @@ function playVideo(url, subtitles = []) {
}) })
}); });
} }
}
function setLoading(message) {
const overlay = document.getElementById('loading-overlay');
const text = document.getElementById('loading-text');
overlay.style.display = 'flex';
text.innerText = message;
}
const extParam = extName ? `?${extName}` : "";
document.getElementById('prev-btn').onclick = () => {
if (currentEpisode > 1) {
window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
}
};
document.getElementById('next-btn').onclick = () => {
if (currentEpisode < totalEpisodes || totalEpisodes === 0) {
window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
}
};
if (currentEpisode <= 1) {
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;
const source = (extName && extName !== 'local') ? extName : "anilist";
const source = extName
? extName
: "anilist";
const body = { const body = {
entry_id: animeId, entry_id: animeId,
source: source, source: source,
entry_type: "ANIME", entry_type: "ANIME",
status: 'CURRENT', status: 'CURRENT',
progress: source === 'anilist' progress: currentEpisode
? Math.floor(currentEpisode)
: currentEpisode
}; };
try { try {
@@ -583,7 +449,39 @@ async function sendProgress() {
} }
} }
// Botones y Toggle
document.getElementById('sd-toggle').onclick = () => {
audioMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(audioMode);
loadStream();
};
function setAudioMode(mode) {
const toggle = document.getElementById('sd-toggle');
toggle.setAttribute('data-state', mode);
document.getElementById('opt-sub').classList.toggle('active', mode === 'sub');
document.getElementById('opt-dub').classList.toggle('active', mode === 'dub');
}
function setLoading(message) {
document.getElementById('loading-text').innerText = message;
document.getElementById('loading-overlay').style.display = 'flex';
}
const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
document.getElementById('prev-btn').onclick = () => {
if (currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
};
document.getElementById('next-btn').onclick = () => {
if (currentEpisode < totalEpisodes || totalEpisodes === 0) window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
};
if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
// Actualizar progreso cada 1 minuto si el video está reproduciéndose
setInterval(() => {
if (plyrInstance && !plyrInstance.paused) sendProgress();
}, 60000);
loadMetadata(); loadMetadata();
loadExtensions(); loadExtensions();

View File

@@ -43,6 +43,43 @@ async function loadMeUI() {
} }
} }
// Variable para saber si el modal ya fue cargado
let settingsModalLoaded = false;
document.getElementById('nav-settings').addEventListener('click', openSettings)
async function openSettings() {
if (!settingsModalLoaded) {
try {
const res = await fetch('/views/components/settings-modal.html')
const html = await res.text()
document.body.insertAdjacentHTML('beforeend', html)
settingsModalLoaded = true;
// Esperar un momento para que el DOM se actualice
await new Promise(resolve => setTimeout(resolve, 50));
// Ahora cargar los settings
if (window.toggleSettingsModal) {
await window.toggleSettingsModal(false);
}
} catch (err) {
console.error('Error loading settings modal:', err);
}
} else {
if (window.toggleSettingsModal) {
await window.toggleSettingsModal(false);
}
}
}
function closeSettings() {
const modal = document.getElementById('settings-modal');
if (modal) {
modal.classList.add('hidden');
}
}
function setupDropdown() { function setupDropdown() {
const userAvatarBtn = document.querySelector(".user-avatar-btn") const userAvatarBtn = document.querySelector(".user-avatar-btn")
const navDropdown = document.getElementById("nav-dropdown") const navDropdown = document.getElementById("nav-dropdown")

View File

@@ -7,7 +7,7 @@ let allChapters = [];
let filteredChapters = []; let filteredChapters = [];
let availableExtensions = []; let availableExtensions = [];
let isLocal = false;
const chapterPagination = Object.create(PaginationManager); const chapterPagination = Object.create(PaginationManager);
chapterPagination.init(12, () => renderChapterTable()); chapterPagination.init(12, () => renderChapterTable());
@@ -16,6 +16,40 @@ document.addEventListener('DOMContentLoaded', () => {
setupModalClickOutside(); setupModalClickOutside();
}); });
async function checkLocalLibraryEntry() {
try {
const res = await fetch(`/api/library/manga/${bookId}`);
if (!res.ok) return;
const data = await res.json();
if (data.matched) {
isLocal = true;
const pill = document.getElementById('local-pill');
if (pill) {
pill.textContent = 'Local';
pill.style.display = 'inline-flex';
pill.style.background = 'rgba(34, 197, 94, 0.2)';
pill.style.color = '#22c55e';
pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
}
}
} catch (e) {
console.error("Error checking local status:", e);
}
}
function markAsLocal() {
isLocal = true;
const pill = document.getElementById('local-pill');
if (pill) {
pill.textContent = 'Local';
pill.style.display = 'inline-flex';
pill.style.background = 'rgba(34, 197, 94, 0.2)';
pill.style.color = '#22c55e';
pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
}
}
async function init() { async function init() {
try { try {
const urlData = URLUtils.parseEntityPath('book'); const urlData = URLUtils.parseEntityPath('book');
@@ -27,7 +61,7 @@ async function init() {
extensionName = urlData.extensionName; extensionName = urlData.extensionName;
bookId = urlData.entityId; bookId = urlData.entityId;
bookSlug = urlData.slug; bookSlug = urlData.slug;
await checkLocalLibraryEntry();
await loadBookMetadata(); await loadBookMetadata();
await loadAvailableExtensions(); await loadAvailableExtensions();
@@ -69,7 +103,8 @@ async function loadBookMetadata() {
bookData = raw; bookData = raw;
const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName); const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName);
bookData.entry_type =
metadata.format === 'MANGA' ? 'MANGA' : 'NOVEL';
updatePageTitle(metadata.title); updatePageTitle(metadata.title);
updateMetadata(metadata); updateMetadata(metadata);
updateExtensionPill(); updateExtensionPill();
@@ -172,32 +207,46 @@ async function loadChapters(targetProvider = null) {
const tbody = document.getElementById('chapters-body'); const tbody = document.getElementById('chapters-body');
if (!tbody) return; if (!tbody) return;
// Si no se pasa provider, intentamos pillar el del select o el primero disponible
if (!targetProvider) { if (!targetProvider) {
const select = document.getElementById('provider-filter'); const select = document.getElementById('provider-filter');
targetProvider = select ? select.value : (availableExtensions[0] || 'all'); targetProvider = select ? select.value : (availableExtensions[0] || 'all');
} }
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extension for chapters...</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Loading chapters...</td></tr>';
try { try {
let fetchUrl;
let isLocalRequest = targetProvider === 'local';
if (isLocalRequest) {
// Nuevo endpoint para archivos locales
fetchUrl = `/api/library/manga/${bookId}/units`;
} else {
const source = extensionName || 'anilist'; const source = extensionName || 'anilist';
// Añadimos el query param 'provider' para que el backend filtre fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
let fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
if (targetProvider !== 'all') {
fetchUrl += `&provider=${targetProvider}`;
} }
const res = await fetch(fetchUrl); const res = await fetch(fetchUrl);
const data = await res.json(); const data = await res.json();
// Mapeo de datos: Si es local usamos 'units', si no, usamos 'chapters'
if (isLocalRequest) {
allChapters = (data.units || []).map((unit, idx) => ({
number: unit.number,
title: unit.name,
provider: 'local',
index: idx, // ✅ índice (0,1,2…)
format: unit.format
}));
} else {
allChapters = data.chapters || []; allChapters = data.chapters || [];
filteredChapters = [...allChapters]; }
filteredChapters = [...allChapters];
applyChapterFilter(); applyChapterFilter();
const totalEl = document.getElementById('total-chapters'); const totalEl = document.getElementById('total-chapters');
if (allChapters.length === 0) { if (allChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
if (totalEl) totalEl.innerText = "0 Found"; if (totalEl) totalEl.innerText = "0 Found";
@@ -207,7 +256,6 @@ async function loadChapters(targetProvider = null) {
if (totalEl) totalEl.innerText = `${allChapters.length} Found`; if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
setupReadButton(); setupReadButton();
chapterPagination.setTotalItems(filteredChapters.length); chapterPagination.setTotalItems(filteredChapters.length);
chapterPagination.reset(); chapterPagination.reset();
renderChapterTable(); renderChapterTable();
@@ -234,16 +282,26 @@ function applyChapterFilter() {
function setupProviderFilter() { function setupProviderFilter() {
const select = document.getElementById('provider-filter'); const select = document.getElementById('provider-filter');
if (!select || availableExtensions.length === 0) return; if (!select) return;
select.style.display = 'inline-block'; select.style.display = 'inline-block';
select.innerHTML = ''; select.innerHTML = '';
// Opción para cargar todo
const allOpt = document.createElement('option'); const allOpt = document.createElement('option');
allOpt.value = 'all'; allOpt.value = 'all';
allOpt.innerText = 'Load All (Slower)'; allOpt.innerText = 'Load All (Slower)';
select.appendChild(allOpt); select.appendChild(allOpt);
// NUEVO: Si es local, añadimos la opción 'local' al principio
if (isLocal) {
const localOpt = document.createElement('option');
localOpt.value = 'local';
localOpt.innerText = 'Local';
select.appendChild(localOpt);
}
// Añadir extensiones normales
availableExtensions.forEach(ext => { availableExtensions.forEach(ext => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = ext; opt.value = ext;
@@ -251,7 +309,10 @@ function setupProviderFilter() {
select.appendChild(opt); select.appendChild(opt);
}); });
if (extensionName && availableExtensions.includes(extensionName)) { // Lógica de selección automática
if (isLocal) {
select.value = 'local'; // Prioridad si es local
} else if (extensionName && availableExtensions.includes(extensionName)) {
select.value = extensionName; select.value = extensionName;
} else if (availableExtensions.length > 0) { } else if (availableExtensions.length > 0) {
select.value = availableExtensions[0]; select.value = availableExtensions[0];
@@ -313,7 +374,14 @@ function renderChapterTable() {
} }
function openReader(chapterId, provider) { function openReader(chapterId, provider) {
window.location.href = URLUtils.buildReadUrl(bookId, chapterId, provider, extensionName); const effectiveExtension = extensionName || 'anilist';
window.location.href = URLUtils.buildReadUrl(
bookId, // SIEMPRE anilist
chapterId, // número normal
provider, // 'local' o extensión
extensionName || 'anilist'
);
} }
function setupModalClickOutside() { function setupModalClickOutside() {

View File

@@ -55,7 +55,8 @@ function startHeroCycle() {
async function updateHeroUI(book) { async function updateHeroUI(book) {
if(!book) return; if(!book) return;
book.entry_type =
book.format === 'MANGA' ? 'MANGA' : 'NOVEL';
const title = book.title.english || book.title.romaji; const title = book.title.english || book.title.romaji;
const desc = book.description || "No description available."; const desc = book.description || "No description available.";
const poster = (book.coverImage && (book.coverImage.extraLarge || book.coverImage.large)) || ''; const poster = (book.coverImage && (book.coverImage.extraLarge || book.coverImage.large)) || '';

View File

@@ -129,11 +129,44 @@ async function loadChapter() {
if (!source) { if (!source) {
source = 'anilist'; source = 'anilist';
} }
const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`; let newEndpoint;
if (provider === 'local') {
newEndpoint = `/api/library/manga/${bookId}/units`;
} else {
newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
}
try { try {
const res = await fetch(newEndpoint); const res = await fetch(newEndpoint);
const data = await res.json(); const data = await res.json();
if (provider === 'local') {
const unit = data.units[Number(chapter)];
if (!unit) {
reader.innerHTML = '<div class="loading-container"><span>Chapter not found</span></div>';
return;
}
if (unit.format === 'cbz') {
chapterLabel.textContent = unit.name; // ✅
document.title = unit.name;
const pagesRes = await fetch(
`/api/library/manga/cbz/${unit.id}/pages`
);
const pagesData = await pagesRes.json();
currentType = 'manga';
updateSettingsVisibility();
applyStyles();
currentPages = pagesData.pages.map(url => ({ url }));
reader.innerHTML = '';
loadManga(currentPages);
return;
}
}
if (data.title) { if (data.title) {
chapterLabel.textContent = data.title; chapterLabel.textContent = data.title;
@@ -172,8 +205,13 @@ async function loadChapter() {
reader.innerHTML = ''; reader.innerHTML = '';
if (data.type === 'manga') { if (data.type === 'manga') {
if (provider === 'local' && data.format === 'cbz') {
currentPages = data.pages.map(url => ({ url }));
loadManga(currentPages);
} else {
currentPages = data.pages || []; currentPages = data.pages || [];
loadManga(currentPages); loadManga(currentPages);
}
} else if (data.type === 'ln') { } else if (data.type === 'ln') {
loadLN(data.content); loadLN(data.content);
} }
@@ -293,7 +331,9 @@ function createImageElement(page, index) {
img.className = 'page-img'; img.className = 'page-img';
img.dataset.index = index; img.dataset.index = index;
const url = buildProxyUrl(page.url, page.headers); const url = provider === 'local'
? page.url
: buildProxyUrl(page.url, page.headers);
const placeholder = "/public/assets/placeholder.svg"; const placeholder = "/public/assets/placeholder.svg";
img.onerror = () => { img.onerror = () => {

View File

@@ -1,5 +1,5 @@
const providerSelector = document.getElementById('provider-selector'); const providerSelector = document.getElementById('provider-selector');
const searchInput = document.getElementById('main-search-input'); const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('gallery-results'); const resultsContainer = document.getElementById('gallery-results');
let currentPage = 1; let currentPage = 1;
@@ -299,7 +299,7 @@ async function searchGallery(isLoadMore = false) {
const msg = favoritesMode const msg = favoritesMode
? (query ? 'No favorites found matching your search' : 'You don\'t have any favorite images yet') ? (query ? 'No favorites found matching your search' : 'You don\'t have any favorite images yet')
: 'No results found'; : 'No results found';
resultsContainer.innerHTML = `<p style="text-align:center;color:var(--text-secondary);padding:4rem;font-size:1.1rem;">${msg}</p>`; resultsContainer.innerHTML = `<p style="text-align:center;color:var(--color-text-secondary);padding:4rem;font-size:1.1rem;">${msg}</p>`;
} }
if (msnry) msnry.layout(); if (msnry) msnry.layout();

View File

@@ -284,7 +284,7 @@ function createListItem(item) {
const itemLink = getEntryLink(item); const itemLink = getEntryLink(item);
const posterUrl = item.poster || '/public/assets/placeholder.png'; const posterUrl = item.poster || '/public/assets/placeholder.svg';
const progress = item.progress || 0; const progress = item.progress || 0;
const totalUnits = item.entry_type === 'ANIME' ? const totalUnits = item.entry_type === 'ANIME' ?

View File

@@ -0,0 +1,89 @@
let activeFilter = 'all';
let activeSort = 'az';
let isLocalMode = false;
let localEntries = [];
function toggleLibraryMode() {
isLocalMode = !isLocalMode;
const btn = document.getElementById('library-mode-btn');
const onlineContent = document.getElementById('online-content');
const localContent = document.getElementById('local-content');
if (isLocalMode) {
btn.classList.add('active');
onlineContent.classList.add('hidden');
localContent.classList.remove('hidden');
loadLocalEntries();
} else {
btn.classList.remove('active');
onlineContent.classList.remove('hidden');
localContent.classList.add('hidden');
}
}
async function loadLocalEntries() {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(6);
try {
// Cambiado a endpoint de libros
const response = await fetch('/api/library/manga');
const entries = await response.json();
localEntries = entries;
if (entries.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No books found in your local library.</p>';
return;
}
renderLocalEntries(entries);
} catch (err) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local books.</p>';
}
}
function renderLocalEntries(entries) {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = entries.map(entry => {
const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id;
const cover = entry.metadata?.coverImage?.extraLarge || '/public/assets/placeholder.jpg';
const chapters = entry.metadata?.chapters || '??';
return `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${chapters} Chapters
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
}
async function scanLocalLibrary() {
const btnText = document.getElementById('scan-text');
btnText.innerText = "Scanning...";
try {
// Asumiendo que el scan de libros usa este query param
const response = await fetch('/api/library/scan?mode=incremental', { method: 'POST' });
if (response.ok) {
await loadLocalEntries();
if (window.NotificationUtils) NotificationUtils.show('Library scanned!', 'success');
}
} catch (err) {
if (window.NotificationUtils) NotificationUtils.show('Scan failed', 'error');
} finally {
btnText.innerText = "Scan Library";
}
}
function viewLocalEntry(id) {
if (id) window.location.href = `/book/${id}`;
}

View File

@@ -0,0 +1,209 @@
let activeFilter = 'all';
let activeSort = 'az';
let isLocalMode = false;
let localEntries = [];
function toggleLibraryMode() {
isLocalMode = !isLocalMode;
const btn = document.getElementById('library-mode-btn');
const onlineContent = document.getElementById('online-content');
const localContent = document.getElementById('local-content');
const svg = btn.querySelector('svg');
const label = btn.querySelector('span');
if (isLocalMode) {
// LOCAL MODE
btn.classList.add('active');
onlineContent.classList.add('hidden');
localContent.classList.remove('hidden');
loadLocalEntries();
svg.innerHTML = `
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
`;
} else {
// ONLINE MODE
btn.classList.remove('active');
onlineContent.classList.remove('hidden');
localContent.classList.add('hidden');
svg.innerHTML = `
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
`;
}
}
async function loadLocalEntries() {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(8);
try {
const response = await fetch('/api/library/anime');
const entries = await response.json();
localEntries = entries;
if (entries.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No anime found in your local library. Click "Scan Library" to scan your folders.</p>';
return;
}
// Renderizar grid
grid.innerHTML = entries.map(entry => {
const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id;
const cover = entry.metadata?.coverImage?.extraLarge || entry.metadata?.coverImage?.large || '/public/assets/placeholder.jpg';
const score = entry.metadata?.averageScore || '--';
const episodes = entry.metadata?.episodes || '??';
return `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${score}% • ${episodes} Eps
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
} catch (err) {
console.error('Error loading local entries:', err);
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local library. Make sure the backend is running.</p>';
}
}
async function scanLocalLibrary() {
const btnText = document.getElementById('scan-text');
const originalText = btnText.innerText;
btnText.innerText = "Scanning...";
try {
const response = await fetch('/api/library/scan?mode=incremental', {
method: 'POST'
});
if (response.ok) {
await loadLocalEntries();
// Mostrar notificación de éxito si tienes sistema de notificaciones
if (window.NotificationUtils) {
NotificationUtils.show('Library scanned successfully!', 'success');
}
} else {
throw new Error('Scan failed');
}
} catch (err) {
console.error("Scan failed", err);
alert("Failed to scan library. Check console for details.");
// Mostrar notificación de error si tienes sistema de notificaciones
if (window.NotificationUtils) {
NotificationUtils.show('Failed to scan library', 'error');
}
} finally {
btnText.innerText = originalText;
}
}
function viewLocalEntry(anilistId) {
if (!anilistId) {
console.warn('Anime not linked');
return;
}
window.location.href = `/anime/${anilistId}`;
}
function renderLocalEntries(entries) {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = entries.map(entry => {
const title = entry.metadata?.title?.romaji
|| entry.metadata?.title?.english
|| entry.id;
const cover =
entry.metadata?.coverImage?.extraLarge
|| entry.metadata?.coverImage?.large
|| '/public/assets/placeholder.jpg';
const score = entry.metadata?.averageScore || '--';
const episodes = entry.metadata?.episodes || '??';
return `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${score}% • ${episodes} Eps
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
}
function applyLocalFilters() {
let filtered = [...localEntries];
if (activeFilter === 'linked') {
filtered = filtered.filter(e => e.matched);
}
if (activeFilter === 'unlinked') {
filtered = filtered.filter(e => !e.matched);
}
if (activeSort === 'az') {
filtered.sort((a, b) =>
(a.metadata?.title?.romaji || a.id)
.localeCompare(b.metadata?.title?.romaji || b.id)
);
}
if (activeSort === 'za') {
filtered.sort((a, b) =>
(b.metadata?.title?.romaji || b.id)
.localeCompare(a.metadata?.title?.romaji || a.id)
);
}
renderLocalEntries(filtered);
}
document.addEventListener('click', e => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
if (btn.dataset.filter) {
activeFilter = btn.dataset.filter;
}
if (btn.dataset.sort) {
activeSort = btn.dataset.sort;
}
btn
.closest('.local-filters')
.querySelectorAll('.filter-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyLocalFilters();
});

View File

@@ -0,0 +1,218 @@
const API_BASE = '/api/config';
let currentConfig = {};
let activeSection = '';
let modal, navContainer, formContent, form;
window.toggleSettingsModal = async (forceClose = false) => {
modal = document.getElementById('settings-modal');
navContainer = document.getElementById('config-nav');
formContent = document.getElementById('config-section-content');
form = document.getElementById('config-form');
if (!modal) {
console.error('Modal not found');
return;
}
if (forceClose) {
modal.classList.add('hidden');
} else {
const isHidden = modal.classList.contains('hidden');
if (isHidden) {
// Abrir modal
modal.classList.remove('hidden');
await loadSettings();
} else {
// Cerrar modal
modal.classList.add('hidden');
}
}
};
async function loadSettings() {
if (!formContent) {
console.error('Form content not found');
return;
}
// Mostrar loading
formContent.innerHTML = `
<div class="skeleton-loader">
<div class="skeleton title-skeleton"></div>
<div class="skeleton text-skeleton"></div>
<div class="skeleton text-skeleton"></div>
</div>
`;
try {
const res = await fetch(API_BASE);
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
if (data.error) throw new Error(data.error);
currentConfig = data;
renderNav();
// Seleccionar la primera sección si no hay ninguna activa
if (!activeSection || !currentConfig[activeSection]) {
activeSection = Object.keys(currentConfig)[0];
}
switchSection(activeSection);
} catch (err) {
console.error('Error loading settings:', err);
formContent.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<p style="color: var(--color-danger); margin-bottom: 1rem;">Failed to load settings</p>
<p style="color: var(--color-text-muted); font-size: 0.9rem;">${err.message}</p>
</div>
`;
}
}
function renderNav() {
if (!navContainer) return;
navContainer.innerHTML = '';
Object.keys(currentConfig).forEach(section => {
const btn = document.createElement('div');
btn.className = `nav-item ${section === activeSection ? 'active' : ''}`;
btn.textContent = section;
btn.onclick = () => switchSection(section);
navContainer.appendChild(btn);
});
}
function switchSection(section) {
if (!currentConfig[section]) return;
activeSection = section;
renderNav();
const sectionData = currentConfig[section];
formContent.innerHTML = `
<h2 class="section-title" style="margin-bottom: 2rem; text-transform: capitalize;">
${section.replace(/_/g, ' ')}
</h2>
`;
Object.entries(sectionData).forEach(([key, value]) => {
const group = document.createElement('div');
group.className = 'config-group';
const isBool = typeof value === 'boolean';
const inputId = `input-${section}-${key}`;
const label = key.replace(/_/g, ' ');
if (isBool) {
group.innerHTML = `
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="${inputId}" name="${key}" ${value ? 'checked' : ''}>
<label for="${inputId}" style="margin: 0; cursor: pointer;">${label}</label>
</div>
`;
} else {
group.innerHTML = `
<label for="${inputId}">${label}</label>
<input class="config-input" id="${inputId}" name="${key}"
type="${typeof value === 'number' ? 'number' : 'text'}"
value="${value}">
`;
}
formContent.appendChild(group);
});
}
// Setup form submit handler
document.addEventListener('DOMContentLoaded', () => {
// Usar delegación de eventos ya que el form se carga dinámicamente
document.addEventListener('submit', async (e) => {
if (e.target.id === 'config-form') {
e.preventDefault();
await saveSettings();
}
});
});
async function saveSettings() {
if (!form || !activeSection) return;
const updatedData = {};
Object.keys(currentConfig[activeSection]).forEach(key => {
const input = form.elements[key];
if (!input) return;
if (input.type === 'checkbox') {
updatedData[key] = input.checked;
} else if (input.type === 'number') {
updatedData[key] = Number(input.value);
} else {
updatedData[key] = input.value;
}
});
try {
const res = await fetch(`${API_BASE}/${activeSection}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedData)
});
if (res.ok) {
currentConfig[activeSection] = updatedData;
// Mostrar notificación de éxito
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: var(--color-success, #10b981);
color: white;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10000;
animation: slideIn 0.3s ease-out;
`;
notification.textContent = 'Settings saved successfully!';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => notification.remove(), 300);
}, 2000);
} else {
throw new Error('Failed to save settings');
}
} catch (err) {
console.error('Error saving settings:', err);
alert('Error saving settings: ' + err.message);
}
}
// Añadir estilos para las animaciones (solo si no existen)
if (!document.getElementById('settings-animations')) {
const animationStyles = document.createElement('style');
animationStyles.id = 'settings-animations';
animationStyles.textContent = `
@keyframes slideIn {
from { transform: translateX(400px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(400px); opacity: 0; }
}
`;
document.head.appendChild(animationStyles);
}

View File

@@ -83,7 +83,7 @@ const ListModalManager = {
document.getElementById('progress-label'); document.getElementById('progress-label');
if (this.isInList && this.currentEntry) { if (this.isInList && this.currentEntry) {
document.getElementById('entry-status').value = this.currentEntry.status || 'PLANNING'; document.getElementById('entry-status').value = this.normalizeStatus(this.currentEntry.status);
document.getElementById('entry-progress').value = this.currentEntry.progress || 0; document.getElementById('entry-progress').value = this.currentEntry.progress || 0;
document.getElementById('entry-score').value = this.currentEntry.score || ''; document.getElementById('entry-score').value = this.currentEntry.score || '';
document.getElementById('entry-start-date').value = this.currentEntry.start_date?.split('T')[0] || ''; document.getElementById('entry-start-date').value = this.currentEntry.start_date?.split('T')[0] || '';
@@ -131,6 +131,12 @@ const ListModalManager = {
document.getElementById('add-list-modal').classList.add('active'); document.getElementById('add-list-modal').classList.add('active');
}, },
normalizeStatus(status) {
if (!status) return 'PLANNING';
if (status === 'WATCHING' || status === 'READING') return 'CURRENT';
return status;
},
close() { close() {
document.getElementById('add-list-modal').classList.remove('active'); document.getElementById('add-list-modal').classList.remove('active');
}, },
@@ -212,15 +218,21 @@ const ListModalManager = {
} }
}; };
document.addEventListener('DOMContentLoaded', () => { async function loadListModal() {
if (document.getElementById('add-list-modal')) return;
const res = await fetch('/views/components/list-modal.html');
const html = await res.text();
document.body.insertAdjacentHTML('beforeend', html);
const modal = document.getElementById('add-list-modal'); const modal = document.getElementById('add-list-modal');
if (modal) {
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target.id === 'add-list-modal') { if (e.target.id === 'add-list-modal') {
ListModalManager.close(); ListModalManager.close();
} }
}); });
} }
});
document.addEventListener('DOMContentLoaded', loadListModal);
window.ListModalManager = ListModalManager; window.ListModalManager = ListModalManager;

View File

@@ -0,0 +1,71 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'js-yaml';
const BASE_DIR = path.join(os.homedir(), 'WaifuBoards');
const CONFIG_PATH = path.join(BASE_DIR, 'config.yaml');
const DEFAULT_CONFIG = {
library: {
anime: null,
manga: null,
novels: null
}
};
function ensureConfigFile() {
if (!fs.existsSync(BASE_DIR)) {
fs.mkdirSync(BASE_DIR, { recursive: true });
}
if (!fs.existsSync(CONFIG_PATH)) {
fs.writeFileSync(
CONFIG_PATH,
yaml.dump(DEFAULT_CONFIG),
'utf8'
);
}
}
export function getConfig() {
ensureConfigFile();
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
return yaml.load(raw) || DEFAULT_CONFIG;
}
export function setConfig(partialConfig) {
ensureConfigFile();
const current = getConfig();
const next = deepMerge(current, partialConfig);
fs.writeFileSync(
CONFIG_PATH,
yaml.dump(next),
'utf8'
);
return next;
}
function deepMerge(target, source) {
for (const key in source) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
module.exports = {
ensureConfigFile,
getConfig,
setConfig,
};

View File

@@ -2,7 +2,7 @@ const sqlite3 = require('sqlite3').verbose();
const os = require("os"); const os = require("os");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB} = require('./schemas'); const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB, ensureLocalLibrarySchema } = require('./schemas');
const databases = new Map(); const databases = new Map();
@@ -10,7 +10,8 @@ const DEFAULT_PATHS = {
anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'), anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'),
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"), favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
cache: path.join(os.homedir(), "WaifuBoards", "cache.db"), cache: path.join(os.homedir(), "WaifuBoards", "cache.db"),
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db") userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db"),
local_library: path.join(os.homedir(), "WaifuBoards", "local_library.db")
}; };
function initDatabase(name = 'anilist', dbPath = null, readOnly = false) { function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
@@ -49,6 +50,11 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
databases.set(name, db); databases.set(name, db);
if (name === "local_library") {
ensureLocalLibrarySchema(db)
.catch(err => console.error("Error creating local library schema:", err));
}
if (name === "anilist") { if (name === "anilist") {
ensureAnilistSchema(db) ensureAnilistSchema(db)
.then(() => ensureExtensionsTable(db)) .then(() => ensureExtensionsTable(db))

View File

@@ -2,6 +2,54 @@ const sqlite3 = require('sqlite3').verbose();
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
async function ensureLocalLibrarySchema(db) {
await run(db, `
CREATE TABLE IF NOT EXISTS local_entries (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
path TEXT NOT NULL,
folder_name TEXT NOT NULL,
matched_id INTEGER,
matched_source TEXT,
last_scan INTEGER NOT NULL
)
`);
await run(db, `
CREATE TABLE IF NOT EXISTS local_files (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
file_path TEXT NOT NULL,
unit_number INTEGER,
FOREIGN KEY (entry_id) REFERENCES local_entries(id)
)
`);
await run(db, `
CREATE INDEX IF NOT EXISTS idx_local_entries_type
ON local_entries(type)
`);
await run(db, `
CREATE INDEX IF NOT EXISTS idx_local_entries_matched
ON local_entries(matched_id)
`);
await run(db, `
CREATE INDEX IF NOT EXISTS idx_local_files_entry
ON local_files(entry_id)
`);
}
function run(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, err => {
if (err) reject(err);
else resolve();
});
});
}
async function ensureUserDataDB(dbPath) { async function ensureUserDataDB(dbPath) {
const dir = path.dirname(dbPath); const dir = path.dirname(dbPath);
@@ -230,5 +278,6 @@ module.exports = {
ensureAnilistSchema, ensureAnilistSchema,
ensureExtensionsTable, ensureExtensionsTable,
ensureCacheTable, ensureCacheTable,
ensureFavoritesDB ensureFavoritesDB,
ensureLocalLibrarySchema
}; };

View File

@@ -2,80 +2,144 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
let cachedNavbar: string | null = null;
function getNavbarHTML(activePage: string, showSearch: boolean = true): string {
if (!cachedNavbar) {
const navbarPath = path.join(__dirname, '..', '..', 'views', 'components', 'navbar.html');
cachedNavbar = fs.readFileSync(navbarPath, 'utf-8');
}
let navbar = cachedNavbar;
const pages = ['anime', 'books', 'gallery', 'schedule', 'my-list', 'marketplace'];
pages.forEach(page => {
const regex = new RegExp(`(<button class="nav-button[^"]*)"\\s+data-page="${page}"`, 'g');
if (page === activePage) {
navbar = navbar.replace(regex, `$1 active" data-page="${page}"`);
}
});
if (!showSearch) {
navbar = navbar.replace(
'<div class="search-wrapper">',
'<div class="search-wrapper" style="visibility: hidden;">'
);
}
return navbar;
}
function injectNavbar(htmlContent: string, activePage: string, showSearch: boolean = true): string {
const navbar = getNavbarHTML(activePage, showSearch);
return htmlContent.replace(/<body[^>]*>/, `$&\n${navbar}`);
}
async function viewsRoutes(fastify: FastifyInstance) { async function viewsRoutes(fastify: FastifyInstance) {
fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'users.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'users.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'anime', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/my-list', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/my-list', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'list.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'list.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'my-list', false);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/books', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/books', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'books.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'books.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'books', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/schedule', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/schedule', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'schedule.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'schedule.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'schedule', false);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/gallery', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/gallery', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'gallery.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'gallery.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'gallery', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/marketplace', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/marketplace', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'marketplace.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'marketplace.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'marketplace', false);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/gallery/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/gallery/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'gallery', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/gallery/favorites/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/gallery/favorites/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'gallery', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/anime/:id', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/anime/:id', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/anime/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/anime/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'book.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/book/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/book/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'book.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/read/:provider/:chapter/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/read/:provider/:chapter/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'read.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'read.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
});
fastify.setNotFoundHandler((req, reply) => {
const htmlPath = path.join(__dirname, '..', '..', 'views', '404.html');
const html = fs.readFileSync(htmlPath, 'utf-8');
reply.code(404).type('text/html').send(html);
}); });
} }

139
desktop/views/404.html Normal file
View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - WaifuBoard</title>
<link rel="stylesheet" href="/views/css/globals.css">
<link rel="stylesheet" href="/views/css/components/navbar.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<script src="/src/scripts/titlebar.js"></script>
<style>
.error-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--spacing-2xl);
background: var(--color-bg-base);
}
.error-code {
font-size: 6rem;
font-weight: 900;
margin: 0;
color: var(--color-primary);
}
.error-message {
font-size: 1.1rem;
color: var(--color-text-secondary);
margin: var(--spacing-md) 0 var(--spacing-xl);
max-width: 420px;
}
.error-actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
}
</style>
</head>
<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">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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='/gallery'">Gallery</button>
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<main class="error-container">
<div>
<h1 class="error-code">404</h1>
<p class="error-message">
This page doesnt exist.
</p>
<div class="error-actions">
<button class="btn-primary" onclick="location.href='/'">Home</button>
<button class="btn-blur" onclick="history.back()">Back</button>
</div>
</div>
</main>
<script src="/src/scripts/utils/auth-utils.js"></script>
</body>
</html>

View File

@@ -34,75 +34,6 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content modal-list">
<button class="modal-close" onclick="closeAddToListModal()"></button>
<h2 class="modal-title" id="modal-title">Add to List</h2>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="WATCHING">Watching</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rewatching</option>
</select>
</div>
<div class="form-group">
<label>Episodes Watched</label>
<input type="number" id="entry-progress" class="form-input" min="0" placeholder="0">
</div>
<div class="form-group">
<label>Your Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Rewatch Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>
<a href="/anime" class="back-btn"> <a href="/anime" class="back-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
Back to Home Back to Home
@@ -158,6 +89,7 @@
<div class="meta-row"> <div class="meta-row">
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div> <div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill score" id="score">--% Score</div> <div class="pill score" id="score">--% Score</div>
<div class="pill" id="year">----</div> <div class="pill" id="year">----</div>
<div class="pill" id="genres">Action</div> <div class="pill" id="genres">Action</div>

View File

@@ -9,84 +9,24 @@
<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/local-library.css">
<link rel="stylesheet" href="/views/css/components/titlebar.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> <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>
<div class="title-right"> <div class="title-right">
<button class="min"></button> <button class="min"></button>
<button class="max">🗖</button> <button class="max">🗖</button>
<button class="close"></button> <button class="close"></button>
</div> </div>
</div> </div>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<button class="nav-button active">Anime</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='/schedule'">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="hero-wrapper"> <div class="hero-wrapper">
<div class="hero-background"> <div class="hero-background">
<img id="hero-bg-media" alt=""> <img id="hero-bg-media" alt="">
@@ -121,77 +61,17 @@
</div> </div>
</div> </div>
</div> </div>
</div> <button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<div class="modal-overlay" id="add-list-modal"> <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<div class="modal-content modal-list"> <polyline points="9 22 9 12 15 12 15 22"/>
<button class="modal-close" onclick="closeAddToListModal()"></button> </svg>
<h2 class="modal-title" id="modal-title">Add to List</h2> </button>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="WATCHING">Watching/Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rewatching</option>
</select>
</div>
<div class="form-group">
<label>Episodes Watched</label>
<input type="number" id="entry-progress" class="form-input" min="0" placeholder="0">
</div>
<div class="form-group">
<label>Your Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Rewatch Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div> </div>
</div>
<div class="modal-actions"> <!-- Online Mode Content -->
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button> <main id="online-content">
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>
<main>
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<div class="section-title">Continue watching</div> <div class="section-title">Continue watching</div>
@@ -208,13 +88,11 @@
</div> </div>
</section> </section>
<section class="section"> <section class="section">
<div class="section-header"><div class="section-title">Trending This Season</div></div> <div class="section-header"><div class="section-title">Trending This Season</div></div>
<div class="carousel-wrapper"> <div class="carousel-wrapper">
<button class="scroll-btn left" onclick="scrollCarousel('trending', -1)"></button> <button class="scroll-btn left" onclick="scrollCarousel('trending', -1)"></button>
<div class="carousel" id="trending"> <div class="carousel" id="trending">
<div class="card"><div class="card-img-wrap skeleton"></div></div> <div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div> <div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div> <div class="card"><div class="card-img-wrap skeleton"></div></div>
@@ -224,7 +102,6 @@
</div> </div>
</section> </section>
<section class="section"> <section class="section">
<div class="section-header"><div class="section-title">Top Airing Now</div></div> <div class="section-header"><div class="section-title">Top Airing Now</div></div>
<div class="carousel-wrapper"> <div class="carousel-wrapper">
@@ -240,14 +117,49 @@
</section> </section>
</main> </main>
<!-- Local Library Mode Content -->
<main id="local-content" class="hidden">
<section class="section">
<div class="section-header">
<div class="section-title">Local Anime Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/>
<path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="watching">Watching</button>
<button class="filter-btn" data-filter="completed">Completed</button>
<button class="filter-btn" data-filter="unwatched">Unwatched</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
<button class="filter-btn" data-sort="recent">Recent</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<div id="updateToast" class="hidden"> <div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p> <p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">
<a
id="downloadButton"
href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases"
target="_blank"
>
Click To Download Click To Download
</a> </a>
</div> </div>
@@ -258,8 +170,10 @@
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<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/local-library.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/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
<script src="/src/scripts/settings.js"></script>
</body> </body>
</html> </html>

View File

@@ -25,72 +25,6 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content">
<button class="modal-close" onclick="closeAddToListModal()"></button>
<h2 class="modal-title" id="modal-title">Add to Library</h2>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label for="entry-status">Status</label>
<select id="entry-status" class="form-input">
<option value="CURRENT">Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Plan to Read</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rereading</option>
</select>
</div>
<div class="form-group">
<label for="entry-progress" id="progress-label">Chapters Read</label>
<input type="number" id="entry-progress" class="form-input" min="0" max="0" placeholder="0">
</div>
<div class="form-group">
<label for="entry-score">Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Re-read Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>
<a href="/books" class="back-btn"> <a href="/books" class="back-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
Back to Books Back to Books
@@ -140,6 +74,7 @@
<div class="meta-row"> <div class="meta-row">
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div> <div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill score" id="score">--% Score</div> <div class="pill score" id="score">--% Score</div>
<div class="pill" id="genres">Action</div> <div class="pill" id="genres">Action</div>
</div> </div>

View File

@@ -12,6 +12,7 @@
<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> <script src="/src/scripts/titlebar.js"></script>
<link rel="stylesheet" href="/views/css/components/local-library.css">
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left"> <div id="titlebar"> <div class="title-left">
@@ -25,66 +26,6 @@
</div> </div>
</div> </div>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
<button class="nav-button active">Books</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='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" class="search-input" id="search-input" placeholder="Search books..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="hero-wrapper"> <div class="hero-wrapper">
<div class="hero-background"> <div class="hero-background">
<img id="hero-bg-media" src="" alt=""> <img id="hero-bg-media" src="" alt="">
@@ -109,75 +50,40 @@
</div> </div>
</div> </div>
</div> </div>
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
</div> </div>
<main id="local-content" class="hidden">
<div class="modal-overlay" id="add-list-modal"> <section class="section">
<div class="modal-content modal-list"> <div class="section-header">
<button class="modal-close" onclick="closeAddToListModal()"></button> <div class="section-title">Local Books Library</div>
<h2 class="modal-title" id="modal-title">Add to Library</h2> <button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<div class="modal-body"> <path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/>
<div class="modal-fields-grid"> </svg>
<span id="scan-text">Scan Library</span>
<div class="form-group"> </button>
<label for="entry-status">Status</label>
<select id="entry-status" class="form-input">
<option value="CURRENT">Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Plan to Read</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rereading</option>
</select>
</div> </div>
<div class="local-filters">
<div class="form-group"> <div class="filter-group">
<label for="entry-progress" id="progress-label">Chapters Read</label> <button class="filter-btn active" data-filter="all">All</button>
<input type="number" id="entry-progress" class="form-input" min="0" max="0" placeholder="0"> <button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div> </div>
<div class="filter-group">
<div class="form-group"> <button class="filter-btn" data-sort="az">AZ</button>
<label for="entry-score">Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div> </div>
</div> </div>
<div class="local-library-grid" id="local-entries-grid">
<div class="form-group"> <div class="skeleton-card"></div>
<label for="entry-repeat-count">Re-read Count</label> <div class="skeleton-card"></div>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div> </div>
</section>
<div class="form-group notes-group"> </main>
<label for="entry-notes">Notes</label> <main id="online-content">
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>
<main>
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<div class="section-title">Continue Reading</div> <div class="section-title">Continue Reading</div>
@@ -226,7 +132,7 @@
<script src="/src/scripts/utils/list-modal-manager.js"></script> <script src="/src/scripts/utils/list-modal-manager.js"></script>
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/books/books.js"></script> <script src="/src/scripts/books/books.js"></script>
<script src="/src/scripts/local-library-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/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>

View File

@@ -0,0 +1,66 @@
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content modal-list">
<button class="modal-close" onclick="closeAddToListModal()"></button>
<h2 class="modal-title" id="modal-title">Add to List</h2>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="CURRENT">Watching/Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rewatching</option>
</select>
</div>
<div class="form-group">
<label>Episodes Watched</label>
<input type="number" id="entry-progress" class="form-input" min="0" placeholder="0">
</div>
<div class="form-group">
<label>Your Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Rewatch Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,69 @@
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<button class="nav-button" data-page="anime" onclick="window.location.href='/anime'">Anime</button>
<button class="nav-button" data-page="books" onclick="window.location.href='/books'">Books</button>
<button class="nav-button" data-page="gallery" onclick="window.location.href='/gallery'">Gallery</button>
<button class="nav-button" data-page="schedule" onclick="window.location.href='/schedule'">Schedule</button>
<button class="nav-button" data-page="my-list" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" data-page="marketplace" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<button class="dropdown-item" id="nav-settings">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82V22a2 2 0 1 1-4 0v-.18a1.65 1.65 0 0 0-.33-1.82 1.65 1.65 0 0 0-1-.6 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33H2a2 2 0 1 1 0-4h.18a1.65 1.65 0 0 0 1.82-.33 1.65 1.65 0 0 0 .6-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6c.37 0 .72-.14 1-.6A1.65 1.65 0 0 0 10.33 2.18V2a2 2 0 1 1 4 0v.18a1.65 1.65 0 0 0 .33 1.82c.28.46.63.6 1 .6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c0 .37.14.72.6 1 .46.28.6.63.6 1z"/>
</svg>
<span>Settings</span>
</button>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,278 @@
<div id="settings-modal" class="modal hidden" onclick="if(event.target === this) window.toggleSettingsModal(true)">
<div class="modal-overlay"></div>
<div class="modal-content">
<aside class="modal-sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">Settings</h2>
</div>
<nav id="config-nav" class="nav-list">
</nav>
<div class="sidebar-footer">
<button onclick="window.toggleSettingsModal(true)" class="btn-exit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Close
</button>
</div>
</aside>
<main class="modal-main">
<form id="config-form" class="config-wrapper">
<div id="config-section-content" class="section-container">
<div class="skeleton-loader">
<div class="skeleton title-skeleton"></div>
<div class="skeleton field-skeleton"></div>
<div class="skeleton field-skeleton"></div>
<div class="skeleton field-skeleton"></div>
</div>
</div>
<div class="modal-footer-sticky">
<p class="footer-hint">Changes are applied immediately after saving.</p>
<button type="submit" class="btn-primary">Save Changes</button>
</div>
</form>
</main>
</div>
</div>
<style>
/* --- AMOLED THEME VARIABLES --- */
:root {
--amoled-black: #000000;
--amoled-surface: #080808;
--amoled-field: #0e0e0e;
--amoled-border: rgba(255, 255, 255, 0.08);
--accent-purple: #8b5cf6;
--accent-glow: rgba(139, 92, 246, 0.15);
--text-main: #ffffff;
--text-dim: #a1a1aa;
}
/* --- MODAL BASE --- */
.modal {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal.hidden { display: none !important; }
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(12px);
z-index: -1;
}
.modal-content {
position: relative;
display: flex;
flex-direction: row; /* Horizontal layout */
width: 95%;
max-width: 1200px; /* Increased size */
height: 85vh;
background: var(--amoled-black);
border: var(--amoled-border);
border-radius: 28px;
overflow: hidden;
box-shadow: 0 0 0 1px var(--amoled-border), 0 24px 60px rgba(0,0,0,0.8);
animation: modalScaleUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* --- SIDEBAR --- */
.modal-sidebar {
width: 280px;
background: var(--amoled-surface);
border-right: var(--amoled-border);
display: flex;
flex-direction: column;
padding: 2rem;
flex-shrink: 0;
}
.sidebar-title {
font-size: 1.4rem;
font-weight: 800;
margin-bottom: 2.5rem;
color: var(--text-main);
letter-spacing: -0.02em;
}
.nav-list { flex: 1; }
.nav-item {
padding: 12px 16px;
border-radius: 14px;
cursor: pointer;
color: var(--text-dim);
transition: all 0.2s ease;
margin-bottom: 6px;
font-weight: 500;
display: flex;
align-items: center;
gap: 12px;
text-transform: capitalize;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
}
.nav-item.active {
background: var(--accent-glow);
color: var(--accent-purple);
box-shadow: inset 3px 0 0 var(--accent-purple);
}
/* --- MAIN CONTENT & DYNAMIC INPUTS --- */
.modal-main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--amoled-black);
min-width: 0;
}
.config-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.section-container {
flex: 1;
padding: 3.5rem;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #222 transparent;
}
/* Styles for the injected section content */
.config-group {
margin-bottom: 2.5rem;
animation: fadeInSection 0.4s ease-out;
}
.config-group label {
display: block;
font-size: 0.75rem;
color: var(--accent-purple);
margin-bottom: 0.8rem;
letter-spacing: 0.05em;
font-weight: 800;
text-transform: uppercase;
}
.config-input {
width: 100%;
padding: 1rem 1.2rem;
background: var(--amoled-field);
border: 1px solid #1a1a1a;
border-radius: 14px;
color: #fff;
font-size: 1rem;
transition: all 0.25s ease;
}
.config-input:focus {
outline: none;
border-color: var(--accent-purple);
background: #121212;
box-shadow: 0 0 0 4px var(--accent-glow);
}
/* --- FOOTER --- */
.modal-footer-sticky {
padding: 1.5rem 3.5rem;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
border-top: var(--amoled-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-hint {
font-size: 0.85rem;
color: var(--text-dim);
}
/* --- BUTTONS --- */
.btn-primary {
padding: 0.8rem 2.2rem;
background: #ffffff;
color: #000000;
border: none;
border-radius: 100px;
font-weight: 700;
cursor: pointer;
transition: transform 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
background: #f0f0f0;
}
.btn-exit {
background: #111;
border: 1px solid #222;
color: #ef4444;
padding: 10px;
border-radius: 12px;
width: 100%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 600;
margin-top: auto;
}
/* --- ANIMATIONS & SKELETON --- */
@keyframes modalScaleUp {
from { opacity: 0; transform: scale(0.97) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes fadeInSection {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.skeleton-loader { display: flex; flex-direction: column; gap: 2rem; }
.skeleton {
background: linear-gradient(90deg, #080808 25%, #121212 50%, #080808 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 12px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.title-skeleton { height: 35px; width: 40%; }
.field-skeleton { height: 55px; width: 100%; }
/* Responsive Mobile View */
@media (max-width: 850px) {
.modal-content { flex-direction: column; height: 95vh; width: 100vw; border-radius: 0; }
.modal-sidebar { width: 100%; height: auto; border-right: none; border-bottom: var(--amoled-border); padding: 1rem; }
.sidebar-title { margin-bottom: 1rem; font-size: 1.2rem; }
.section-container { padding: 2rem; }
.modal-footer-sticky { padding: 1.5rem 2rem; }
}
</style>

View File

@@ -53,8 +53,8 @@
gap: 1.25rem; gap: 1.25rem;
} }
.info-item h4 { margin: 0 0 0.25rem 0; font-size: 0.85rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; } .info-item h4 { margin: 0 0 0.25rem 0; font-size: 0.85rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
.info-item span { font-weight: 600; font-size: 1rem; color: var(--text-primary); } .info-item span { font-weight: 600; font-size: 1rem; color: var(--color-text-primary); }
.character-list { .character-list {
display: flex; display: flex;
@@ -180,7 +180,7 @@
transition: 0.2s; transition: 0.2s;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--color-text-secondary);
} }
.episode-btn:hover { .episode-btn:hover {

View File

@@ -0,0 +1,132 @@
.library-mode-btn {
padding: 0.6rem 1.2rem;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--radius-full);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.2s;
}
.library-mode-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.library-mode-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.library-mode-btn svg {
width: 18px;
height: 18px;
}
.local-library-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
padding: 1rem 0;
}
.local-card {
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.local-card:hover {
transform: translateY(-8px);
}
.local-card-info {
padding: 0.8rem 0;
}
.local-card-title {
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.5rem;
}
.match-status {
font-size: 0.85rem;
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
}
.status-linked {
background: rgba(34, 197, 94, 0.2);
color: var(--color-success);
}
.status-unlinked {
background: rgba(239, 68, 68, 0.2);
color: var(--color-danger);
}
.hidden {
display: none !important;
}
.skeleton-card {
width: 100%;
aspect-ratio: 2/3;
background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.hero-wrapper {
position: relative;
}
.library-mode-btn.icon-only {
position: absolute;
bottom: 2rem;
right: 2rem;
z-index: 50;
}
.hero-mode-switch .library-mode-btn {
backdrop-filter: blur(10px);
}
.local-filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.4rem 0.9rem;
border-radius: 999px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
color: #bbb;
cursor: pointer;
}
.filter-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}

View File

@@ -27,70 +27,6 @@
</div> </div>
</div> </div>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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 active">Gallery</button>
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<!-- Mejorado el contenedor de usuario con dropdown más completo -->
<div class="nav-right">
<div class="search-wrapper">
<input type="text" id="main-search-input" class="search-input" placeholder="Search in gallery..." autocomplete="off">
<div class="search-results">
<button id="favorites-toggle-nav" class="fav-toggle-btn" title="Mostrar favoritos" style="margin: 10px; width: auto; font-size: 0.85rem;">
<i class="far fa-heart"></i>
<span class="fav-text">Favorites Mode</span>
</button>
</div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/placeholder.svg" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/placeholder.svg" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<main class="gallery-main"> <main class="gallery-main">
<div class="gallery-hero-placeholder"></div> <div class="gallery-hero-placeholder"></div>

View File

@@ -26,65 +26,6 @@
</div> </div>
</div> </div>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<button class="nav-button">Anime</button>
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
<button class="nav-button active" 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='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<!-- Mejorado el contenedor de usuario con dropdown más completo -->
<div class="nav-right">
<div class="search-wrapper" id="global-search-wrapper" style="visibility: hidden;width: 250px;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" class="search-input" placeholder="Search site..." autocomplete="off">
<div class="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/placeholder.svg" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/placeholder.svg" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<a href="/gallery" class="back-btn"> <a href="/gallery" class="back-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
Back to Gallery Back to Gallery

View File

@@ -25,68 +25,6 @@
</div> </div>
</div> </div>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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='/gallery'">Gallery</button>
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
<button class="nav-button active">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
<h1 class="page-title">My List</h1> <h1 class="page-title">My List</h1>
@@ -189,77 +127,6 @@
<div id="list-container" class="list-grid"></div> <div id="list-container" class="list-grid"></div>
</div> </div>
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content">
<button class="modal-close" onclick="window.ListModalManager.close()"></button>
<h2 class="modal-title" id="modal-title">Edit List Entry</h2>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="CURRENT">Current</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rewatching/Rereading</option>
</select>
</div>
<div class="form-group">
<label for="entry-progress" id="progress-label">Progress</label>
<input type="number" id="entry-progress" class="form-input" min="0">
</div>
<div class="form-group">
<label>Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1">
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Repeat Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="window.ListModalManager.close()">Cancel</button>
<button class="btn-danger" id="modal-delete-btn" style="display:none;">Delete</button>
<button class="btn-primary" id="modal-save-btn">Save Changes</button>
</div>
</div>
</div>
<div id="updateToast" class="hidden"> <div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p> <p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank"> <a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">

View File

@@ -24,68 +24,6 @@
</div> </div>
</div> </div>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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='/gallery'">Gallery</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">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="hero-spacer"></div> <div class="hero-spacer"></div>
<main> <main>

View File

@@ -29,68 +29,6 @@
<div class="ambient-bg" id="ambientBg"></div> <div class="ambient-bg" id="ambientBg"></div>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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='/gallery'">Gallery</button>
<button class="nav-button active">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="calendar-wrapper"> <div class="calendar-wrapper">
<div class="calendar-controls"> <div class="calendar-controls">
<div class="month-selector"> <div class="month-selector">

View File

@@ -10,11 +10,13 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@fastify/static": "^8.3.0", "@fastify/static": "^8.3.0",
"adm-zip": "^0.5.16",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bindings": "^1.5.0", "bindings": "^1.5.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"js-yaml": "^4.1.1",
"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", "node-cron": "^4.2.1",
@@ -22,6 +24,7 @@
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -401,6 +404,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/adm-zip": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
"integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/bcrypt": { "node_modules/@types/bcrypt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -481,6 +494,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/adm-zip": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -604,6 +626,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/atomic-sleep": { "node_modules/atomic-sleep": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1780,6 +1808,18 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-ref-resolver": { "node_modules/json-schema-ref-resolver": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",

View File

@@ -13,11 +13,13 @@
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@fastify/static": "^8.3.0", "@fastify/static": "^8.3.0",
"adm-zip": "^0.5.16",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bindings": "^1.5.0", "bindings": "^1.5.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"js-yaml": "^4.1.1",
"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", "node-cron": "^4.2.1",
@@ -25,6 +27,7 @@
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

View File

@@ -10,6 +10,7 @@ const { initDatabase } = require("./dist/shared/database");
const { loadExtensions } = require("./dist/shared/extensions"); const { loadExtensions } = require("./dist/shared/extensions");
const {refreshTrendingAnime, refreshTopAiringAnime} = require("./dist/api/anime/anime.service"); const {refreshTrendingAnime, refreshTopAiringAnime} = require("./dist/api/anime/anime.service");
const {refreshPopularBooks, refreshTrendingBooks} = require("./dist/api/books/books.service"); const {refreshPopularBooks, refreshTrendingBooks} = require("./dist/api/books/books.service");
const { ensureConfigFile } = require("./dist/shared/config");
const dotenv = require("dotenv"); const dotenv = require("dotenv");
dotenv.config(); dotenv.config();
@@ -23,6 +24,8 @@ const galleryRoutes = require("./dist/api/gallery/gallery.routes");
const userRoutes = require("./dist/api/user/user.routes"); const userRoutes = require("./dist/api/user/user.routes");
const listRoutes = require("./dist/api/list/list.routes"); const listRoutes = require("./dist/api/list/list.routes");
const anilistRoute = require("./dist/api/anilist/anilist"); const anilistRoute = require("./dist/api/anilist/anilist");
const localRoutes = require("./dist/api/local/local.routes");
const configRoutes = require("./dist/api/config/config.routes");
fastify.addHook("preHandler", async (request) => { fastify.addHook("preHandler", async (request) => {
const auth = request.headers.authorization; const auth = request.headers.authorization;
@@ -63,15 +66,19 @@ fastify.register(galleryRoutes, { prefix: "/api" });
fastify.register(userRoutes, { prefix: "/api" }); 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" });
fastify.register(localRoutes, { prefix: "/api" });
fastify.register(configRoutes, { prefix: "/api" });
const sleep = ms => new Promise(r => setTimeout(r, ms)); const sleep = ms => new Promise(r => setTimeout(r, ms));
const start = async () => { const start = async () => {
try { try {
ensureConfigFile()
initDatabase("anilist"); initDatabase("anilist");
initDatabase("favorites"); initDatabase("favorites");
initDatabase("cache"); initDatabase("cache");
initDatabase("userdata"); initDatabase("userdata");
initDatabase("local_library");
const refreshAll = async () => { const refreshAll = async () => {
await refreshTrendingAnime(); await refreshTrendingAnime();

View File

@@ -0,0 +1,43 @@
import {FastifyReply, FastifyRequest} from 'fastify';
import {getConfig, setConfig} from '../../shared/config';
export async function getFullConfig(req: FastifyRequest, reply: FastifyReply) {
try {
return getConfig();
} catch (err) {
return { error: "Error loading config" };
}
}
export async function getConfigSection(req: FastifyRequest<{ Params: { section: string } }>, reply: FastifyReply) {
try {
const { section } = req.params;
const config = getConfig();
if (config[section] === undefined) {
return { error: "Section not found" };
}
return { [section]: config[section] };
} catch (err) {
return { error: "Error loading config section" };
}
}
export async function updateConfig(req: FastifyRequest<{ Body: any }>, reply: FastifyReply) {
try {
return setConfig(req.body);
} catch (err) {
return { error: "Error updating config" };
}
}
export async function updateConfigSection(req: FastifyRequest<{ Params: { section: string }, Body: any }>, reply: FastifyReply) {
try {
const { section } = req.params;
const updatedConfig = setConfig({ [section]: req.body });
return { [section]: updatedConfig[section] };
} catch (err) {
return { error: "Error updating config section" };
}
}

View File

@@ -0,0 +1,11 @@
import { FastifyInstance } from 'fastify';
import * as controller from './config.controller';
async function configRoutes(fastify: FastifyInstance) {
fastify.get('/config', controller.getFullConfig);
fastify.get('/config/:section', controller.getConfigSection);
fastify.post('/config', controller.updateConfig);
fastify.post('/config/:section', controller.updateConfigSection);
}
export default configRoutes;

View File

@@ -0,0 +1,357 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { getConfig as loadConfig, setConfig as saveConfig } from '../../shared/config.js';
import { queryOne, queryAll, run } from '../../shared/database.js';
import crypto from 'crypto';
import fs from "fs";
import { PathLike } from "node:fs";
import path from "path";
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service";
import AdmZip from 'adm-zip';
type SetConfigBody = {
library?: {
anime?: string | null;
manga?: string | null;
novels?: string | null;
};
};
type ScanQuery = {
mode?: 'full' | 'incremental';
};
type Params = {
type: 'anime' | 'manga' | 'novels';
id?: string;
};
async function resolveEntryMetadata(entry: any, type: string) {
let metadata = null;
let matchedId = entry.matched_id;
if (!matchedId) {
const query = entry.folder_name;
const results = type === 'anime'
? await searchAnimeLocal(query)
: await searchBooksAniList(query);
const first = results?.[0];
if (first?.id) {
matchedId = first.id;
await run(
`UPDATE local_entries
SET matched_id = ?, matched_source = 'anilist'
WHERE id = ?`,
[matchedId, entry.id],
'local_library'
);
}
}
if (matchedId) {
metadata = type === 'anime'
? await getAnimeById(matchedId)
: await getBookById(matchedId);
}
return {
id: entry.id,
type: entry.type,
matched: !!matchedId,
metadata
};
}
export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) {
try {
const mode = request.query.mode || 'incremental';
const config = loadConfig();
if (!config.library) {
return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' });
}
if (mode === 'full') {
await run(`DELETE FROM local_files`, [], 'local_library');
await run(`DELETE FROM local_entries`, [], 'local_library');
}
for (const [type, basePath] of Object.entries(config.library)) {
if (!basePath || !fs.existsSync(<PathLike>basePath)) continue;
const dirs = fs.readdirSync(<string>basePath, { withFileTypes: true }).filter(d => d.isDirectory());
for (const dir of dirs) {
const fullPath = path.join(<string>basePath, dir.name);
const id = crypto.createHash('sha1').update(fullPath).digest('hex');
const now = Date.now();
const existing = await queryOne(`SELECT id FROM local_entries WHERE id = ?`, [id], 'local_library');
if (existing) {
await run(`UPDATE local_entries SET last_scan = ? WHERE id = ?`, [now, id], 'local_library');
await run(`DELETE FROM local_files WHERE entry_id = ?`, [id], 'local_library');
} else {
await run(
`INSERT INTO local_entries (id, type, path, folder_name, last_scan) VALUES (?, ?, ?, ?, ?)`,
[id, type, fullPath, dir.name, now],
'local_library'
);
}
const files = fs.readdirSync(fullPath, { withFileTypes: true })
.filter(f => f.isFile())
.sort((a, b) => a.name.localeCompare(b.name));
let unit = 1;
for (const file of files) {
await run(
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
VALUES (?, ?, ?, ?)`,
[crypto.randomUUID(), id, path.join(fullPath, file.name), unit],
'local_library'
);
unit++;
}
}
}
return { status: 'OK' };
} catch (err) {
return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' });
}
}
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try {
const { type } = request.params;
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
} catch {
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
}
}
export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try {
const { type, id } = request.params as { type: string, id: string };
const entry = await queryOne(
`SELECT * FROM local_entries WHERE matched_id = ? AND type = ?`,
[Number(id), type],
'local_library'
);
if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
}
const [details, files] = await Promise.all([
resolveEntryMetadata(entry, type),
queryAll(
`SELECT id, file_path, unit_number FROM local_files WHERE entry_id = ? ORDER BY unit_number ASC`,
[id],
'local_library'
)
]);
return { ...details, files };
} catch {
return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' });
}
}
export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
const { id, unit } = request.params as any;
const file = await queryOne(
`SELECT file_path FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[id, unit],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) {
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
}
const stat = fs.statSync(file.file_path);
const range = request.headers.range;
if (!range) {
reply
.header('Content-Length', stat.size)
.header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path);
}
const parts = range.replace(/bytes=/, '').split('-');
const start = Number(parts[0]);
let end = parts[1] ? Number(parts[1]) : stat.size - 1;
if (
Number.isNaN(start) ||
Number.isNaN(end) ||
start < 0 ||
end < start ||
end >= stat.size
) {
end = stat.size - 1;
}
reply
.status(206)
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
.header('Accept-Ranges', 'bytes')
.header('Content-Length', end - start + 1)
.header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path, { start, end });
}
type MatchBody = {
source: 'anilist';
matched_id: number | null;
};
export async function matchEntry(
request: FastifyRequest<{ Body: MatchBody }>,
reply: FastifyReply
) {
const { id, type } = request.params as any;
const { source, matched_id } = request.body;
const entry = await queryOne(
`SELECT id FROM local_entries WHERE id = ? AND type = ?`,
[id, type],
'local_library'
);
if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
}
await run(
`UPDATE local_entries
SET matched_source = ?, matched_id = ?
WHERE id = ?`,
[source, matched_id, id],
'local_library'
);
return { status: 'OK', matched: !!matched_id };
}
export async function getUnits(
request: FastifyRequest<{ Params: Params }>,
reply: FastifyReply
) {
try {
const { type, id } = request.params as { type: string, id: string };
// Buscar la entrada por matched_id
const entry = await queryOne(
`SELECT id, type, matched_id FROM local_entries WHERE matched_id = ? AND type = ?`,
[Number(id), type],
'local_library'
);
if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
}
// Obtener todos los archivos/unidades ordenados
const files = await queryAll(
`SELECT id, file_path, unit_number FROM local_files
WHERE entry_id = ?
ORDER BY unit_number ASC`,
[entry.id],
'local_library'
);
// Formatear la respuesta según el tipo
const units = files.map((file: any) => {
const fileName = path.basename(file.file_path);
const fileExt = path.extname(file.file_path).toLowerCase();
// Detectar si es un archivo comprimido (capítulo único) o carpeta
const isDirectory = fs.existsSync(file.file_path) &&
fs.statSync(file.file_path).isDirectory();
return {
id: file.id,
number: file.unit_number,
name: fileName,
type: type === 'anime' ? 'episode' : 'chapter',
format: fileExt === '.cbz' ? 'cbz' : 'file',
path: file.file_path
};
});
return {
entry_id: entry.id,
matched_id: entry.matched_id,
type: entry.type,
total: units.length,
units
};
} catch (err) {
console.error('Error getting units:', err);
return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' });
}
}
export async function getCbzPages(request: FastifyRequest, reply: FastifyReply) {
const { unitId } = request.params as any;
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) {
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
}
const zip = new AdmZip(file.file_path);
const pages = zip.getEntries()
.filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }))
.map((_, i) =>
`/api/library/manga/cbz/${unitId}/page/${i}`
);
return { pages };
}
export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) {
const { unitId, page } = request.params as any;
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file) return reply.status(404).send();
const zip = new AdmZip(file.file_path);
const images = zip.getEntries()
.filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }));
const entry = images[page];
if (!entry) return reply.status(404).send();
reply
.header('Content-Type', 'image/jpeg')
.send(entry.getData());
}

View File

@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import * as controller from './local.controller';
async function localRoutes(fastify: FastifyInstance) {
fastify.post('/library/scan', controller.scanLibrary);
fastify.get('/library/:type', controller.listEntries);
fastify.get('/library/:type/:id', controller.getEntry);
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
fastify.post('/library/:type/:id/match', controller.matchEntry);
fastify.get('/library/:type/:id/units', controller.getUnits);
fastify.get('/library/:type/cbz/:unitId/pages', controller.getCbzPages);
fastify.get('/library/:type/cbz/:unitId/page/:page', controller.getCbzPage);
}
export default localRoutes;

View File

@@ -1,6 +1,7 @@
let animeData = null; let animeData = null;
let extensionName = null; let extensionName = null;
let animeId = null; let animeId = null;
let isLocal = false;
const episodePagination = Object.create(PaginationManager); const episodePagination = Object.create(PaginationManager);
episodePagination.init(12, renderEpisodes); episodePagination.init(12, renderEpisodes);
@@ -13,6 +14,29 @@ document.addEventListener('DOMContentLoaded', () => {
setupEpisodeSearch(); setupEpisodeSearch();
}); });
function markAsLocal() {
isLocal = true;
const pill = document.getElementById('local-pill');
if (!pill) return;
pill.textContent = 'Local';
pill.style.display = 'inline-flex';
pill.style.background = 'rgba(34,197,94,.2)';
pill.style.color = '#22c55e';
pill.style.borderColor = 'rgba(34,197,94,.3)';
}
async function checkLocalLibraryEntry() {
try {
const res = await fetch(`/api/library/anime/${animeId}`);
if (!res.ok) return;
markAsLocal();
} catch (e) {
}
}
async function loadAnime() { async function loadAnime() {
try { try {
@@ -24,6 +48,7 @@ async function loadAnime() {
extensionName = urlData.extensionName; extensionName = urlData.extensionName;
animeId = urlData.entityId; animeId = urlData.entityId;
await checkLocalLibraryEntry();
const fetchUrl = extensionName const fetchUrl = extensionName
? `/api/anime/${animeId}?source=${extensionName}` ? `/api/anime/${animeId}?source=${extensionName}`
@@ -38,7 +63,7 @@ async function loadAnime() {
} }
animeData = data; animeData = data;
animeData.entry_type = 'ANIME';
const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName); const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName);
updatePageTitle(metadata.title); updatePageTitle(metadata.title);
@@ -142,8 +167,8 @@ function setupWatchButton() {
const watchBtn = document.getElementById('watch-btn'); const watchBtn = document.getElementById('watch-btn');
if (watchBtn) { if (watchBtn) {
watchBtn.onclick = () => { watchBtn.onclick = () => {
const url = URLUtils.buildWatchUrl(animeId, 1, extensionName); const source = isLocal ? 'local' : (extensionName || 'anilist');
window.location.href = url; window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
}; };
} }
} }
@@ -226,8 +251,8 @@ function createEpisodeButton(num, container) {
btn.className = 'episode-btn'; btn.className = 'episode-btn';
btn.innerText = `Ep ${num}`; btn.innerText = `Ep ${num}`;
btn.onclick = () => { btn.onclick = () => {
const url = URLUtils.buildWatchUrl(animeId, num, extensionName); const source = isLocal ? 'local' : (extensionName || 'anilist');
window.location.href = url; window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
}; };
container.appendChild(btn); container.appendChild(btn);
} }

View File

@@ -92,6 +92,7 @@ function startHeroCycle() {
async function updateHeroUI(anime) { async function updateHeroUI(anime) {
if(!anime) return; if(!anime) return;
anime.entry_type = 'ANIME';
const title = anime.title.english || anime.title.romaji || "Unknown Title"; const title = anime.title.english || anime.title.romaji || "Unknown Title";
const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A'; const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A';

View File

@@ -7,6 +7,7 @@ let currentExtension = '';
let plyrInstance; let plyrInstance;
let hlsInstance; let hlsInstance;
let totalEpisodes = 0; let totalEpisodes = 0;
let animeTitle = "";
let aniSkipData = null; let aniSkipData = null;
let isAnilist = false; let isAnilist = false;
@@ -17,13 +18,28 @@ const firstKey = params.keys().next().value;
let extName; let extName;
if (firstKey) extName = firstKey; if (firstKey) extName = firstKey;
const href = extName // URL de retroceso: Si es local, volvemos a la vista de Anilist normal
const href = (extName && extName !== 'local')
? `/anime/${extName}/${animeId}` ? `/anime/${extName}/${animeId}`
: `/anime/${animeId}`; : `/anime/${animeId}`;
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}`;
let localEntryId = null;
async function checkLocal() {
try {
const res = await fetch(`/api/library/anime/${animeId}`);
if (!res.ok) return;
const data = await res.json();
localEntryId = data.id;
} catch {}
}
async function loadAniSkip(malId, episode, duration) { async function loadAniSkip(malId, episode, duration) {
try { try {
const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`); const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`);
@@ -37,9 +53,10 @@ async function loadAniSkip(malId, episode, duration) {
} }
async function loadMetadata() { async function loadMetadata() {
checkLocal();
try { try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist"; const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`;
const res = await fetch(`/api/anime/${animeId}${extQuery}`); const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`);
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error) {
@@ -49,13 +66,7 @@ async function loadMetadata() {
const isAnilistFormat = data.title && (data.title.romaji || data.title.english); const isAnilistFormat = data.title && (data.title.romaji || data.title.english);
let title = ''; let title = '', description = '', coverImage = '', averageScore = '', format = '', seasonYear = '', season = '';
let description = '';
let coverImage = '';
let averageScore = '';
let format = '';
let seasonYear = '';
let season = '';
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';
@@ -85,6 +96,7 @@ async function loadMetadata() {
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;
document.title = `Watching ${title} - Ep ${currentEpisode}`; document.title = `Watching ${title} - Ep ${currentEpisode}`;
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
@@ -96,7 +108,8 @@ async function loadMetadata() {
document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--'); document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--');
document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg'; document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg';
if (extName) { // Solo cargamos episodios de extensión si hay extensión real y no es local
if (extName && extName !== 'local') {
await loadExtensionEpisodes(); await loadExtensionEpisodes();
} else { } else {
if (data.nextAiringEpisode?.episode) { if (data.nextAiringEpisode?.episode) {
@@ -108,12 +121,7 @@ async function loadMetadata() {
} }
const simpleEpisodes = []; const simpleEpisodes = [];
for (let i = 1; i <= totalEpisodes; i++) { for (let i = 1; i <= totalEpisodes; i++) {
simpleEpisodes.push({ simpleEpisodes.push({ number: i, title: null, thumbnail: null, isDub: false });
number: i,
title: null,
thumbnail: null,
isDub: false
});
} }
populateEpisodeCarousel(simpleEpisodes); populateEpisodeCarousel(simpleEpisodes);
} }
@@ -128,72 +136,30 @@ async function loadMetadata() {
} }
async function applyAniSkip(video) { async function applyAniSkip(video) {
if (!isAnilist || !malId) { if (!isAnilist || !malId) return;
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));
aniSkipData = await loadAniSkip( if (!aniSkipData || aniSkipData.length === 0) return;
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 = []; const markers = [];
aniSkipData.forEach(item => { aniSkipData.forEach(item => {
const { startTime, endTime } = item.interval; const { startTime, endTime } = item.interval;
if (item.skipType === 'op') {
op = { start: startTime, end: endTime };
markers.push({ markers.push({
start: startTime, start: startTime,
end: endTime, end: endTime,
label: 'Opening' label: item.skipType === 'op' ? 'Opening' : 'Ending'
});
}); });
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) { if (plyrInstance && markers.length > 0) {
console.log('Creating visual markers:', markers);
// Esperar a que el player esté completamente cargado
setTimeout(() => { setTimeout(() => {
const progressContainer = document.querySelector('.plyr__progress'); const progressContainer = document.querySelector('.plyr__progress');
if (!progressContainer) { if (!progressContainer) return;
console.error('Progress container not found');
return;
}
// Eliminar markers anteriores si existen
const oldMarkers = progressContainer.querySelector('.plyr__markers'); const oldMarkers = progressContainer.querySelector('.plyr__markers');
if (oldMarkers) oldMarkers.remove(); if (oldMarkers) oldMarkers.remove();
// Crear contenedor de markers
const markersContainer = document.createElement('div'); const markersContainer = document.createElement('div');
markersContainer.className = 'plyr__markers'; markersContainer.className = 'plyr__markers';
@@ -215,56 +181,36 @@ async function applyAniSkip(video) {
markersContainer.appendChild(markerElement); markersContainer.appendChild(markerElement);
}); });
progressContainer.appendChild(markersContainer); progressContainer.appendChild(markersContainer);
console.log('Visual markers created successfully');
}, 500); }, 500);
} }
} }
async function loadExtensionEpisodes() { async function loadExtensionEpisodes() {
try { try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist"; const res = await fetch(`/api/anime/${animeId}/episodes?source=${extName}`);
const res = await fetch(`/api/anime/${animeId}/episodes${extQuery}`);
const data = await res.json(); const data = await res.json();
totalEpisodes = Array.isArray(data) ? data.length : 0; totalEpisodes = Array.isArray(data) ? data.length : 0;
populateEpisodeCarousel(Array.isArray(data) ? data : []);
if (Array.isArray(data) && data.length > 0) {
populateEpisodeCarousel(data);
} else {
const fallback = [];
for (let i = 1; i <= totalEpisodes; i++) {
fallback.push({ number: i, title: null, thumbnail: null });
}
populateEpisodeCarousel(fallback);
}
} catch (e) { } catch (e) {
console.error("Error cargando episodios por extensión:", e); console.error("Error cargando episodios:", e);
totalEpisodes = 0;
} }
} }
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) => {
const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1); const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1);
if (!epNumber) return; if (!epNumber) return;
const extParam = extName ? `?${extName}` : ""; const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== ''; const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== '';
const link = document.createElement('a'); const link = document.createElement('a');
link.href = `/watch/${animeId}/${epNumber}${extParam}`; link.href = `/watch/${animeId}/${epNumber}${extParam}`;
link.classList.add('carousel-item'); link.classList.add('carousel-item');
link.dataset.episode = epNumber;
if (!hasThumbnail) link.classList.add('no-thumbnail');
if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel'); if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel');
const imgContainer = document.createElement('div'); const imgContainer = document.createElement('div');
@@ -272,21 +218,15 @@ function populateEpisodeCarousel(episodesData) {
if (hasThumbnail) { if (hasThumbnail) {
const img = document.createElement('img'); const img = document.createElement('img');
img.classList.add('carousel-item-img');
img.src = ep.thumbnail; img.src = ep.thumbnail;
img.alt = `Episode ${epNumber} Thumbnail`; img.classList.add('carousel-item-img');
imgContainer.appendChild(img); imgContainer.appendChild(img);
} }
link.appendChild(imgContainer); link.appendChild(imgContainer);
const info = document.createElement('div'); const info = document.createElement('div');
info.classList.add('carousel-item-info'); info.classList.add('carousel-item-info');
info.innerHTML = `<p>Ep ${epNumber}: ${ep.title || 'Untitled'}</p>`;
const title = document.createElement('p');
title.innerText = `Ep ${epNumber}: ${ep.title || 'Untitled'}`;
info.appendChild(title);
link.appendChild(info); link.appendChild(info);
carousel.appendChild(link); carousel.appendChild(link);
}); });
@@ -297,28 +237,27 @@ async function loadExtensions() {
const res = await fetch('/api/extensions/anime'); const res = await fetch('/api/extensions/anime');
const data = await res.json(); const data = await res.json();
const select = document.getElementById('extension-select'); const select = document.getElementById('extension-select');
let extensions = data.extensions || [];
if (extName === 'local' && !extensions.includes('local')) {
extensions.push('local');
}
if (data.extensions && data.extensions.length > 0) {
select.innerHTML = ''; select.innerHTML = '';
data.extensions.forEach(ext => { extensions.forEach(ext => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = opt.innerText = ext; opt.value = opt.innerText = ext;
select.appendChild(opt); select.appendChild(opt);
}); });
if (typeof extName === 'string' && data.extensions.includes(extName)) { if (extName && extensions.includes(extName)) {
select.value = extName; select.value = extName;
} else { } else if (extensions.length > 0) {
select.selectedIndex = 0; select.value = extensions[0];
} }
currentExtension = select.value; currentExtension = select.value;
onExtensionChange(); onExtensionChange();
} else {
select.innerHTML = '<option>No Extensions</option>';
select.disabled = true;
setLoading("No anime extensions found.");
}
} catch (error) { } catch (error) {
console.error("Extension Error:", error); console.error("Extension Error:", error);
} }
@@ -327,83 +266,69 @@ async function loadExtensions() {
async function onExtensionChange() { async function onExtensionChange() {
const select = document.getElementById('extension-select'); const select = document.getElementById('extension-select');
currentExtension = select.value; currentExtension = select.value;
setLoading("Fetching extension settings...");
if (currentExtension === 'local') {
document.getElementById('sd-toggle').style.display = 'none';
document.getElementById('server-select').style.display = 'none';
loadStream();
return;
}
setLoading("Fetching extension settings...");
try { try {
const res = await fetch(`/api/extensions/${currentExtension}/settings`); const res = await fetch(`/api/extensions/${currentExtension}/settings`);
const settings = await res.json(); const settings = await res.json();
const toggle = document.getElementById('sd-toggle'); const toggle = document.getElementById('sd-toggle');
if (settings.supportsDub) { toggle.style.display = settings.supportsDub ? 'flex' : 'none';
toggle.style.display = 'flex';
setAudioMode('sub'); setAudioMode('sub');
} else {
toggle.style.display = 'none';
setAudioMode('sub');
}
const serverSelect = document.getElementById('server-select'); const serverSelect = document.getElementById('server-select');
serverSelect.innerHTML = ''; serverSelect.innerHTML = '';
if (settings.episodeServers && settings.episodeServers.length > 0) { if (settings.episodeServers?.length > 0) {
settings.episodeServers.forEach(srv => { settings.episodeServers.forEach(srv => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = srv; opt.value = opt.innerText = srv;
opt.innerText = srv;
serverSelect.appendChild(opt); serverSelect.appendChild(opt);
}); });
serverSelect.style.display = 'block'; serverSelect.style.display = 'block';
} else { } else {
serverSelect.style.display = 'none'; serverSelect.style.display = 'none';
} }
loadStream(); loadStream();
} catch (error) { } catch (error) {
console.error(error); setLoading("Failed to load settings.");
setLoading("Failed to load extension settings.");
} }
} }
function toggleAudioMode() {
const newMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(newMode);
loadStream();
}
function setAudioMode(mode) {
audioMode = mode;
const toggle = document.getElementById('sd-toggle');
const subOpt = document.getElementById('opt-sub');
const dubOpt = document.getElementById('opt-dub');
toggle.setAttribute('data-state', mode);
subOpt.classList.toggle('active', mode === 'sub');
dubOpt.classList.toggle('active', mode === 'dub');
}
async function loadStream() { async function loadStream() {
if (!currentExtension) return; if (!currentExtension) return;
if (currentExtension === 'local') {
if (!localEntryId) {
setLoading("No existe en local");
return;
}
const localUrl = `/api/library/stream/anime/${localEntryId}/${currentEpisode}`;
playVideo(localUrl, []);
document.getElementById('loading-overlay').style.display = 'none';
return;
}
const serverSelect = document.getElementById('server-select'); const serverSelect = document.getElementById('server-select');
const server = serverSelect.value || "default"; const server = serverSelect.value || "default";
setLoading(`Loading stream (${audioMode})...`); setLoading(`Loading stream (${audioMode})...`);
try { try {
let sourc = "&source=anilist"; const sourc = (extName && extName !== 'local') ? `&source=${extName}` : "&source=anilist";
if (extName){
sourc = `&source=${extName}`;
}
const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`; const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`;
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error || !data.videoSources?.length) {
setLoading(`Error: ${data.error}`); setLoading(data.error || "No video sources.");
return;
}
if (!data.videoSources || data.videoSources.length === 0) {
setLoading("No video sources found.");
return; return;
} }
@@ -415,33 +340,31 @@ async function loadStream() {
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`; if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`; if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
playVideo(proxyUrl, data.videoSources[0].subtitles || data.subtitles); playVideo(proxyUrl, source.subtitles || data.subtitles || []);
document.getElementById('loading-overlay').style.display = 'none'; document.getElementById('loading-overlay').style.display = 'none';
} catch (error) { } catch (error) {
setLoading("Stream error. Check console."); setLoading("Stream error.");
console.error(error);
} }
} }
function playVideo(url, subtitles = []) { function playVideo(url, subtitles = []) {
const video = document.getElementById('player'); const video = document.getElementById('player');
const isLocal = url.includes('/api/library/stream/');
if (Hls.isSupported()) { if (!isLocal && Hls.isSupported()) {
if (hlsInstance) hlsInstance.destroy(); if (hlsInstance) hlsInstance.destroy();
hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false }); hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false });
hlsInstance.loadSource(url); hlsInstance.loadSource(url);
hlsInstance.attachMedia(video); hlsInstance.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else {
if (hlsInstance) hlsInstance.destroy();
video.src = url; video.src = url;
} }
if (plyrInstance) plyrInstance.destroy(); if (plyrInstance) plyrInstance.destroy();
while (video.textTracks.length > 0) video.removeChild(video.textTracks[0]);
const existingTracks = video.querySelectorAll('track');
existingTracks.forEach(track => track.remove());
subtitles.forEach(sub => { subtitles.forEach(sub => {
if (!sub.url) return;
const track = document.createElement('track'); const track = document.createElement('track');
track.kind = 'captions'; track.kind = 'captions';
track.label = sub.language || 'Unknown'; track.label = sub.language || 'Unknown';
@@ -454,74 +377,23 @@ 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', () => { video.addEventListener('loadedmetadata', () => applyAniSkip(video));
applyAniSkip(video);
});
let alreadyTriggered = false;
video.addEventListener('timeupdate', () => {
if (!video.duration) return;
const percent = (video.currentTime / video.duration) * 100;
if (percent >= 80 && !alreadyTriggered) {
alreadyTriggered = true;
sendProgress();
}
});
video.play().catch(() => console.log("Autoplay blocked"));
}
function setLoading(message) {
const overlay = document.getElementById('loading-overlay');
const text = document.getElementById('loading-text');
overlay.style.display = 'flex';
text.innerText = message;
}
const extParam = extName ? `?${extName}` : "";
document.getElementById('prev-btn').onclick = () => {
if (currentEpisode > 1) {
window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
}
};
document.getElementById('next-btn').onclick = () => {
if (currentEpisode < totalEpisodes || totalEpisodes === 0) {
window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
}
};
if (currentEpisode <= 1) {
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;
const source = (extName && extName !== 'local') ? extName : "anilist";
const source = extName
? extName
: "anilist";
const body = { const body = {
entry_id: animeId, entry_id: animeId,
source: source, source: source,
entry_type: "ANIME", entry_type: "ANIME",
status: 'CURRENT', status: 'CURRENT',
progress: source === 'anilist' progress: currentEpisode
? Math.floor(currentEpisode)
: currentEpisode
}; };
try { try {
@@ -538,5 +410,38 @@ async function sendProgress() {
} }
} }
// Botones y Toggle
document.getElementById('sd-toggle').onclick = () => {
audioMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(audioMode);
loadStream();
};
function setAudioMode(mode) {
const toggle = document.getElementById('sd-toggle');
toggle.setAttribute('data-state', mode);
document.getElementById('opt-sub').classList.toggle('active', mode === 'sub');
document.getElementById('opt-dub').classList.toggle('active', mode === 'dub');
}
function setLoading(message) {
document.getElementById('loading-text').innerText = message;
document.getElementById('loading-overlay').style.display = 'flex';
}
const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
document.getElementById('prev-btn').onclick = () => {
if (currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
};
document.getElementById('next-btn').onclick = () => {
if (currentEpisode < totalEpisodes || totalEpisodes === 0) window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
};
if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
setInterval(() => {
if (plyrInstance && !plyrInstance.paused) sendProgress();
}, 60000);
loadMetadata(); loadMetadata();
loadExtensions(); loadExtensions();

View File

@@ -43,6 +43,43 @@ async function loadMeUI() {
} }
} }
// Variable para saber si el modal ya fue cargado
let settingsModalLoaded = false;
document.getElementById('nav-settings').addEventListener('click', openSettings)
async function openSettings() {
if (!settingsModalLoaded) {
try {
const res = await fetch('/views/components/settings-modal.html')
const html = await res.text()
document.body.insertAdjacentHTML('beforeend', html)
settingsModalLoaded = true;
// Esperar un momento para que el DOM se actualice
await new Promise(resolve => setTimeout(resolve, 50));
// Ahora cargar los settings
if (window.toggleSettingsModal) {
await window.toggleSettingsModal(false);
}
} catch (err) {
console.error('Error loading settings modal:', err);
}
} else {
if (window.toggleSettingsModal) {
await window.toggleSettingsModal(false);
}
}
}
function closeSettings() {
const modal = document.getElementById('settings-modal');
if (modal) {
modal.classList.add('hidden');
}
}
function setupDropdown() { function setupDropdown() {
const userAvatarBtn = document.querySelector(".user-avatar-btn") const userAvatarBtn = document.querySelector(".user-avatar-btn")
const navDropdown = document.getElementById("nav-dropdown") const navDropdown = document.getElementById("nav-dropdown")

View File

@@ -7,7 +7,7 @@ let allChapters = [];
let filteredChapters = []; let filteredChapters = [];
let availableExtensions = []; let availableExtensions = [];
let isLocal = false;
const chapterPagination = Object.create(PaginationManager); const chapterPagination = Object.create(PaginationManager);
chapterPagination.init(12, () => renderChapterTable()); chapterPagination.init(12, () => renderChapterTable());
@@ -16,6 +16,40 @@ document.addEventListener('DOMContentLoaded', () => {
setupModalClickOutside(); setupModalClickOutside();
}); });
async function checkLocalLibraryEntry() {
try {
const res = await fetch(`/api/library/manga/${bookId}`);
if (!res.ok) return;
const data = await res.json();
if (data.matched) {
isLocal = true;
const pill = document.getElementById('local-pill');
if (pill) {
pill.textContent = 'Local';
pill.style.display = 'inline-flex';
pill.style.background = 'rgba(34, 197, 94, 0.2)';
pill.style.color = '#22c55e';
pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
}
}
} catch (e) {
console.error("Error checking local status:", e);
}
}
function markAsLocal() {
isLocal = true;
const pill = document.getElementById('local-pill');
if (pill) {
pill.textContent = 'Local';
pill.style.display = 'inline-flex';
pill.style.background = 'rgba(34, 197, 94, 0.2)';
pill.style.color = '#22c55e';
pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
}
}
async function init() { async function init() {
try { try {
const urlData = URLUtils.parseEntityPath('book'); const urlData = URLUtils.parseEntityPath('book');
@@ -27,7 +61,7 @@ async function init() {
extensionName = urlData.extensionName; extensionName = urlData.extensionName;
bookId = urlData.entityId; bookId = urlData.entityId;
bookSlug = urlData.slug; bookSlug = urlData.slug;
await checkLocalLibraryEntry();
await loadBookMetadata(); await loadBookMetadata();
await loadAvailableExtensions(); await loadAvailableExtensions();
@@ -69,7 +103,8 @@ async function loadBookMetadata() {
bookData = raw; bookData = raw;
const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName); const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName);
bookData.entry_type =
metadata.format === 'MANGA' ? 'MANGA' : 'NOVEL';
updatePageTitle(metadata.title); updatePageTitle(metadata.title);
updateMetadata(metadata); updateMetadata(metadata);
updateExtensionPill(); updateExtensionPill();
@@ -172,32 +207,46 @@ async function loadChapters(targetProvider = null) {
const tbody = document.getElementById('chapters-body'); const tbody = document.getElementById('chapters-body');
if (!tbody) return; if (!tbody) return;
// Si no se pasa provider, intentamos pillar el del select o el primero disponible
if (!targetProvider) { if (!targetProvider) {
const select = document.getElementById('provider-filter'); const select = document.getElementById('provider-filter');
targetProvider = select ? select.value : (availableExtensions[0] || 'all'); targetProvider = select ? select.value : (availableExtensions[0] || 'all');
} }
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extension for chapters...</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Loading chapters...</td></tr>';
try { try {
let fetchUrl;
let isLocalRequest = targetProvider === 'local';
if (isLocalRequest) {
// Nuevo endpoint para archivos locales
fetchUrl = `/api/library/manga/${bookId}/units`;
} else {
const source = extensionName || 'anilist'; const source = extensionName || 'anilist';
// Añadimos el query param 'provider' para que el backend filtre fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
let fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
if (targetProvider !== 'all') {
fetchUrl += `&provider=${targetProvider}`;
} }
const res = await fetch(fetchUrl); const res = await fetch(fetchUrl);
const data = await res.json(); const data = await res.json();
// Mapeo de datos: Si es local usamos 'units', si no, usamos 'chapters'
if (isLocalRequest) {
allChapters = (data.units || []).map((unit, idx) => ({
number: unit.number,
title: unit.name,
provider: 'local',
index: idx, // ✅ índice (0,1,2…)
format: unit.format
}));
} else {
allChapters = data.chapters || []; allChapters = data.chapters || [];
filteredChapters = [...allChapters]; }
filteredChapters = [...allChapters];
applyChapterFilter(); applyChapterFilter();
const totalEl = document.getElementById('total-chapters'); const totalEl = document.getElementById('total-chapters');
if (allChapters.length === 0) { if (allChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
if (totalEl) totalEl.innerText = "0 Found"; if (totalEl) totalEl.innerText = "0 Found";
@@ -207,7 +256,6 @@ async function loadChapters(targetProvider = null) {
if (totalEl) totalEl.innerText = `${allChapters.length} Found`; if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
setupReadButton(); setupReadButton();
chapterPagination.setTotalItems(filteredChapters.length); chapterPagination.setTotalItems(filteredChapters.length);
chapterPagination.reset(); chapterPagination.reset();
renderChapterTable(); renderChapterTable();
@@ -234,16 +282,26 @@ function applyChapterFilter() {
function setupProviderFilter() { function setupProviderFilter() {
const select = document.getElementById('provider-filter'); const select = document.getElementById('provider-filter');
if (!select || availableExtensions.length === 0) return; if (!select) return;
select.style.display = 'inline-block'; select.style.display = 'inline-block';
select.innerHTML = ''; select.innerHTML = '';
// Opción para cargar todo
const allOpt = document.createElement('option'); const allOpt = document.createElement('option');
allOpt.value = 'all'; allOpt.value = 'all';
allOpt.innerText = 'Load All (Slower)'; allOpt.innerText = 'Load All (Slower)';
select.appendChild(allOpt); select.appendChild(allOpt);
// NUEVO: Si es local, añadimos la opción 'local' al principio
if (isLocal) {
const localOpt = document.createElement('option');
localOpt.value = 'local';
localOpt.innerText = 'Local';
select.appendChild(localOpt);
}
// Añadir extensiones normales
availableExtensions.forEach(ext => { availableExtensions.forEach(ext => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = ext; opt.value = ext;
@@ -251,7 +309,10 @@ function setupProviderFilter() {
select.appendChild(opt); select.appendChild(opt);
}); });
if (extensionName && availableExtensions.includes(extensionName)) { // Lógica de selección automática
if (isLocal) {
select.value = 'local'; // Prioridad si es local
} else if (extensionName && availableExtensions.includes(extensionName)) {
select.value = extensionName; select.value = extensionName;
} else if (availableExtensions.length > 0) { } else if (availableExtensions.length > 0) {
select.value = availableExtensions[0]; select.value = availableExtensions[0];
@@ -313,7 +374,14 @@ function renderChapterTable() {
} }
function openReader(chapterId, provider) { function openReader(chapterId, provider) {
window.location.href = URLUtils.buildReadUrl(bookId, chapterId, provider, extensionName); const effectiveExtension = extensionName || 'anilist';
window.location.href = URLUtils.buildReadUrl(
bookId, // SIEMPRE anilist
chapterId, // número normal
provider, // 'local' o extensión
extensionName || 'anilist'
);
} }
function setupModalClickOutside() { function setupModalClickOutside() {

View File

@@ -55,7 +55,8 @@ function startHeroCycle() {
async function updateHeroUI(book) { async function updateHeroUI(book) {
if(!book) return; if(!book) return;
book.entry_type =
book.format === 'MANGA' ? 'MANGA' : 'NOVEL';
const title = book.title.english || book.title.romaji; const title = book.title.english || book.title.romaji;
const desc = book.description || "No description available."; const desc = book.description || "No description available.";
const poster = (book.coverImage && (book.coverImage.extraLarge || book.coverImage.large)) || ''; const poster = (book.coverImage && (book.coverImage.extraLarge || book.coverImage.large)) || '';

View File

@@ -129,11 +129,44 @@ async function loadChapter() {
if (!source) { if (!source) {
source = 'anilist'; source = 'anilist';
} }
const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`; let newEndpoint;
if (provider === 'local') {
newEndpoint = `/api/library/manga/${bookId}/units`;
} else {
newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
}
try { try {
const res = await fetch(newEndpoint); const res = await fetch(newEndpoint);
const data = await res.json(); const data = await res.json();
if (provider === 'local') {
const unit = data.units[Number(chapter)];
if (!unit) {
reader.innerHTML = '<div class="loading-container"><span>Chapter not found</span></div>';
return;
}
if (unit.format === 'cbz') {
chapterLabel.textContent = unit.name; // ✅
document.title = unit.name;
const pagesRes = await fetch(
`/api/library/manga/cbz/${unit.id}/pages`
);
const pagesData = await pagesRes.json();
currentType = 'manga';
updateSettingsVisibility();
applyStyles();
currentPages = pagesData.pages.map(url => ({ url }));
reader.innerHTML = '';
loadManga(currentPages);
return;
}
}
if (data.title) { if (data.title) {
chapterLabel.textContent = data.title; chapterLabel.textContent = data.title;
@@ -160,8 +193,13 @@ async function loadChapter() {
reader.innerHTML = ''; reader.innerHTML = '';
if (data.type === 'manga') { if (data.type === 'manga') {
if (provider === 'local' && data.format === 'cbz') {
currentPages = data.pages.map(url => ({ url }));
loadManga(currentPages);
} else {
currentPages = data.pages || []; currentPages = data.pages || [];
loadManga(currentPages); loadManga(currentPages);
}
} else if (data.type === 'ln') { } else if (data.type === 'ln') {
loadLN(data.content); loadLN(data.content);
} }
@@ -281,7 +319,9 @@ function createImageElement(page, index) {
img.className = 'page-img'; img.className = 'page-img';
img.dataset.index = index; img.dataset.index = index;
const url = buildProxyUrl(page.url, page.headers); const url = provider === 'local'
? page.url
: buildProxyUrl(page.url, page.headers);
const placeholder = "/public/assets/placeholder.svg"; const placeholder = "/public/assets/placeholder.svg";
img.onerror = () => { img.onerror = () => {

View File

@@ -1,5 +1,5 @@
const providerSelector = document.getElementById('provider-selector'); const providerSelector = document.getElementById('provider-selector');
const searchInput = document.getElementById('main-search-input'); const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('gallery-results'); const resultsContainer = document.getElementById('gallery-results');
let currentPage = 1; let currentPage = 1;
@@ -299,7 +299,7 @@ async function searchGallery(isLoadMore = false) {
const msg = favoritesMode const msg = favoritesMode
? (query ? 'No favorites found matching your search' : 'You don\'t have any favorite images yet') ? (query ? 'No favorites found matching your search' : 'You don\'t have any favorite images yet')
: 'No results found'; : 'No results found';
resultsContainer.innerHTML = `<p style="text-align:center;color:var(--text-secondary);padding:4rem;font-size:1.1rem;">${msg}</p>`; resultsContainer.innerHTML = `<p style="text-align:center;color:var(--color-text-secondary);padding:4rem;font-size:1.1rem;">${msg}</p>`;
} }
if (msnry) msnry.layout(); if (msnry) msnry.layout();

View File

@@ -0,0 +1,89 @@
let activeFilter = 'all';
let activeSort = 'az';
let isLocalMode = false;
let localEntries = [];
function toggleLibraryMode() {
isLocalMode = !isLocalMode;
const btn = document.getElementById('library-mode-btn');
const onlineContent = document.getElementById('online-content');
const localContent = document.getElementById('local-content');
if (isLocalMode) {
btn.classList.add('active');
onlineContent.classList.add('hidden');
localContent.classList.remove('hidden');
loadLocalEntries();
} else {
btn.classList.remove('active');
onlineContent.classList.remove('hidden');
localContent.classList.add('hidden');
}
}
async function loadLocalEntries() {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(6);
try {
// Cambiado a endpoint de libros
const response = await fetch('/api/library/manga');
const entries = await response.json();
localEntries = entries;
if (entries.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No books found in your local library.</p>';
return;
}
renderLocalEntries(entries);
} catch (err) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local books.</p>';
}
}
function renderLocalEntries(entries) {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = entries.map(entry => {
const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id;
const cover = entry.metadata?.coverImage?.extraLarge || '/public/assets/placeholder.jpg';
const chapters = entry.metadata?.chapters || '??';
return `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${chapters} Chapters
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
}
async function scanLocalLibrary() {
const btnText = document.getElementById('scan-text');
btnText.innerText = "Scanning...";
try {
// Asumiendo que el scan de libros usa este query param
const response = await fetch('/api/library/scan?mode=incremental', { method: 'POST' });
if (response.ok) {
await loadLocalEntries();
if (window.NotificationUtils) NotificationUtils.show('Library scanned!', 'success');
}
} catch (err) {
if (window.NotificationUtils) NotificationUtils.show('Scan failed', 'error');
} finally {
btnText.innerText = "Scan Library";
}
}
function viewLocalEntry(id) {
if (id) window.location.href = `/book/${id}`;
}

View File

@@ -0,0 +1,209 @@
let activeFilter = 'all';
let activeSort = 'az';
let isLocalMode = false;
let localEntries = [];
function toggleLibraryMode() {
isLocalMode = !isLocalMode;
const btn = document.getElementById('library-mode-btn');
const onlineContent = document.getElementById('online-content');
const localContent = document.getElementById('local-content');
const svg = btn.querySelector('svg');
const label = btn.querySelector('span');
if (isLocalMode) {
// LOCAL MODE
btn.classList.add('active');
onlineContent.classList.add('hidden');
localContent.classList.remove('hidden');
loadLocalEntries();
svg.innerHTML = `
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
`;
} else {
// ONLINE MODE
btn.classList.remove('active');
onlineContent.classList.remove('hidden');
localContent.classList.add('hidden');
svg.innerHTML = `
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
`;
}
}
async function loadLocalEntries() {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(8);
try {
const response = await fetch('/api/library/anime');
const entries = await response.json();
localEntries = entries;
if (entries.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No anime found in your local library. Click "Scan Library" to scan your folders.</p>';
return;
}
// Renderizar grid
grid.innerHTML = entries.map(entry => {
const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id;
const cover = entry.metadata?.coverImage?.extraLarge || entry.metadata?.coverImage?.large || '/public/assets/placeholder.jpg';
const score = entry.metadata?.averageScore || '--';
const episodes = entry.metadata?.episodes || '??';
return `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${score}% • ${episodes} Eps
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
} catch (err) {
console.error('Error loading local entries:', err);
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local library. Make sure the backend is running.</p>';
}
}
async function scanLocalLibrary() {
const btnText = document.getElementById('scan-text');
const originalText = btnText.innerText;
btnText.innerText = "Scanning...";
try {
const response = await fetch('/api/library/scan?mode=incremental', {
method: 'POST'
});
if (response.ok) {
await loadLocalEntries();
// Mostrar notificación de éxito si tienes sistema de notificaciones
if (window.NotificationUtils) {
NotificationUtils.show('Library scanned successfully!', 'success');
}
} else {
throw new Error('Scan failed');
}
} catch (err) {
console.error("Scan failed", err);
alert("Failed to scan library. Check console for details.");
// Mostrar notificación de error si tienes sistema de notificaciones
if (window.NotificationUtils) {
NotificationUtils.show('Failed to scan library', 'error');
}
} finally {
btnText.innerText = originalText;
}
}
function viewLocalEntry(anilistId) {
if (!anilistId) {
console.warn('Anime not linked');
return;
}
window.location.href = `/anime/${anilistId}`;
}
function renderLocalEntries(entries) {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = entries.map(entry => {
const title = entry.metadata?.title?.romaji
|| entry.metadata?.title?.english
|| entry.id;
const cover =
entry.metadata?.coverImage?.extraLarge
|| entry.metadata?.coverImage?.large
|| '/public/assets/placeholder.jpg';
const score = entry.metadata?.averageScore || '--';
const episodes = entry.metadata?.episodes || '??';
return `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${score}% • ${episodes} Eps
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
}
function applyLocalFilters() {
let filtered = [...localEntries];
if (activeFilter === 'linked') {
filtered = filtered.filter(e => e.matched);
}
if (activeFilter === 'unlinked') {
filtered = filtered.filter(e => !e.matched);
}
if (activeSort === 'az') {
filtered.sort((a, b) =>
(a.metadata?.title?.romaji || a.id)
.localeCompare(b.metadata?.title?.romaji || b.id)
);
}
if (activeSort === 'za') {
filtered.sort((a, b) =>
(b.metadata?.title?.romaji || b.id)
.localeCompare(a.metadata?.title?.romaji || a.id)
);
}
renderLocalEntries(filtered);
}
document.addEventListener('click', e => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
if (btn.dataset.filter) {
activeFilter = btn.dataset.filter;
}
if (btn.dataset.sort) {
activeSort = btn.dataset.sort;
}
btn
.closest('.local-filters')
.querySelectorAll('.filter-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyLocalFilters();
});

View File

@@ -0,0 +1,218 @@
const API_BASE = '/api/config';
let currentConfig = {};
let activeSection = '';
let modal, navContainer, formContent, form;
window.toggleSettingsModal = async (forceClose = false) => {
modal = document.getElementById('settings-modal');
navContainer = document.getElementById('config-nav');
formContent = document.getElementById('config-section-content');
form = document.getElementById('config-form');
if (!modal) {
console.error('Modal not found');
return;
}
if (forceClose) {
modal.classList.add('hidden');
} else {
const isHidden = modal.classList.contains('hidden');
if (isHidden) {
// Abrir modal
modal.classList.remove('hidden');
await loadSettings();
} else {
// Cerrar modal
modal.classList.add('hidden');
}
}
};
async function loadSettings() {
if (!formContent) {
console.error('Form content not found');
return;
}
// Mostrar loading
formContent.innerHTML = `
<div class="skeleton-loader">
<div class="skeleton title-skeleton"></div>
<div class="skeleton text-skeleton"></div>
<div class="skeleton text-skeleton"></div>
</div>
`;
try {
const res = await fetch(API_BASE);
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
if (data.error) throw new Error(data.error);
currentConfig = data;
renderNav();
// Seleccionar la primera sección si no hay ninguna activa
if (!activeSection || !currentConfig[activeSection]) {
activeSection = Object.keys(currentConfig)[0];
}
switchSection(activeSection);
} catch (err) {
console.error('Error loading settings:', err);
formContent.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<p style="color: var(--color-danger); margin-bottom: 1rem;">Failed to load settings</p>
<p style="color: var(--color-text-muted); font-size: 0.9rem;">${err.message}</p>
</div>
`;
}
}
function renderNav() {
if (!navContainer) return;
navContainer.innerHTML = '';
Object.keys(currentConfig).forEach(section => {
const btn = document.createElement('div');
btn.className = `nav-item ${section === activeSection ? 'active' : ''}`;
btn.textContent = section;
btn.onclick = () => switchSection(section);
navContainer.appendChild(btn);
});
}
function switchSection(section) {
if (!currentConfig[section]) return;
activeSection = section;
renderNav();
const sectionData = currentConfig[section];
formContent.innerHTML = `
<h2 class="section-title" style="margin-bottom: 2rem; text-transform: capitalize;">
${section.replace(/_/g, ' ')}
</h2>
`;
Object.entries(sectionData).forEach(([key, value]) => {
const group = document.createElement('div');
group.className = 'config-group';
const isBool = typeof value === 'boolean';
const inputId = `input-${section}-${key}`;
const label = key.replace(/_/g, ' ');
if (isBool) {
group.innerHTML = `
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="${inputId}" name="${key}" ${value ? 'checked' : ''}>
<label for="${inputId}" style="margin: 0; cursor: pointer;">${label}</label>
</div>
`;
} else {
group.innerHTML = `
<label for="${inputId}">${label}</label>
<input class="config-input" id="${inputId}" name="${key}"
type="${typeof value === 'number' ? 'number' : 'text'}"
value="${value}">
`;
}
formContent.appendChild(group);
});
}
// Setup form submit handler
document.addEventListener('DOMContentLoaded', () => {
// Usar delegación de eventos ya que el form se carga dinámicamente
document.addEventListener('submit', async (e) => {
if (e.target.id === 'config-form') {
e.preventDefault();
await saveSettings();
}
});
});
async function saveSettings() {
if (!form || !activeSection) return;
const updatedData = {};
Object.keys(currentConfig[activeSection]).forEach(key => {
const input = form.elements[key];
if (!input) return;
if (input.type === 'checkbox') {
updatedData[key] = input.checked;
} else if (input.type === 'number') {
updatedData[key] = Number(input.value);
} else {
updatedData[key] = input.value;
}
});
try {
const res = await fetch(`${API_BASE}/${activeSection}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedData)
});
if (res.ok) {
currentConfig[activeSection] = updatedData;
// Mostrar notificación de éxito
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: var(--color-success, #10b981);
color: white;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10000;
animation: slideIn 0.3s ease-out;
`;
notification.textContent = 'Settings saved successfully!';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => notification.remove(), 300);
}, 2000);
} else {
throw new Error('Failed to save settings');
}
} catch (err) {
console.error('Error saving settings:', err);
alert('Error saving settings: ' + err.message);
}
}
// Añadir estilos para las animaciones (solo si no existen)
if (!document.getElementById('settings-animations')) {
const animationStyles = document.createElement('style');
animationStyles.id = 'settings-animations';
animationStyles.textContent = `
@keyframes slideIn {
from { transform: translateX(400px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(400px); opacity: 0; }
}
`;
document.head.appendChild(animationStyles);
}

View File

@@ -83,7 +83,7 @@ const ListModalManager = {
document.getElementById('progress-label'); document.getElementById('progress-label');
if (this.isInList && this.currentEntry) { if (this.isInList && this.currentEntry) {
document.getElementById('entry-status').value = this.currentEntry.status || 'PLANNING'; document.getElementById('entry-status').value = this.normalizeStatus(this.currentEntry.status);
document.getElementById('entry-progress').value = this.currentEntry.progress || 0; document.getElementById('entry-progress').value = this.currentEntry.progress || 0;
document.getElementById('entry-score').value = this.currentEntry.score || ''; document.getElementById('entry-score').value = this.currentEntry.score || '';
document.getElementById('entry-start-date').value = this.currentEntry.start_date?.split('T')[0] || ''; document.getElementById('entry-start-date').value = this.currentEntry.start_date?.split('T')[0] || '';
@@ -131,6 +131,12 @@ const ListModalManager = {
document.getElementById('add-list-modal').classList.add('active'); document.getElementById('add-list-modal').classList.add('active');
}, },
normalizeStatus(status) {
if (!status) return 'PLANNING';
if (status === 'WATCHING' || status === 'READING') return 'CURRENT';
return status;
},
close() { close() {
document.getElementById('add-list-modal').classList.remove('active'); document.getElementById('add-list-modal').classList.remove('active');
}, },
@@ -212,15 +218,21 @@ const ListModalManager = {
} }
}; };
document.addEventListener('DOMContentLoaded', () => { async function loadListModal() {
if (document.getElementById('add-list-modal')) return;
const res = await fetch('/views/components/list-modal.html');
const html = await res.text();
document.body.insertAdjacentHTML('beforeend', html);
const modal = document.getElementById('add-list-modal'); const modal = document.getElementById('add-list-modal');
if (modal) {
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target.id === 'add-list-modal') { if (e.target.id === 'add-list-modal') {
ListModalManager.close(); ListModalManager.close();
} }
}); });
} }
});
document.addEventListener('DOMContentLoaded', loadListModal);
window.ListModalManager = ListModalManager; window.ListModalManager = ListModalManager;

View File

@@ -0,0 +1,71 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'js-yaml';
const BASE_DIR = path.join(os.homedir(), 'WaifuBoards');
const CONFIG_PATH = path.join(BASE_DIR, 'config.yaml');
const DEFAULT_CONFIG = {
library: {
anime: null,
manga: null,
novels: null
}
};
function ensureConfigFile() {
if (!fs.existsSync(BASE_DIR)) {
fs.mkdirSync(BASE_DIR, { recursive: true });
}
if (!fs.existsSync(CONFIG_PATH)) {
fs.writeFileSync(
CONFIG_PATH,
yaml.dump(DEFAULT_CONFIG),
'utf8'
);
}
}
export function getConfig() {
ensureConfigFile();
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
return yaml.load(raw) || DEFAULT_CONFIG;
}
export function setConfig(partialConfig) {
ensureConfigFile();
const current = getConfig();
const next = deepMerge(current, partialConfig);
fs.writeFileSync(
CONFIG_PATH,
yaml.dump(next),
'utf8'
);
return next;
}
function deepMerge(target, source) {
for (const key in source) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
module.exports = {
ensureConfigFile,
getConfig,
setConfig,
};

View File

@@ -2,7 +2,7 @@ const sqlite3 = require('sqlite3').verbose();
const os = require("os"); const os = require("os");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB} = require('./schemas'); const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB, ensureLocalLibrarySchema } = require('./schemas');
const databases = new Map(); const databases = new Map();
@@ -10,7 +10,8 @@ const DEFAULT_PATHS = {
anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'), anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'),
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"), favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
cache: path.join(os.homedir(), "WaifuBoards", "cache.db"), cache: path.join(os.homedir(), "WaifuBoards", "cache.db"),
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db") userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db"),
local_library: path.join(os.homedir(), "WaifuBoards", "local_library.db")
}; };
function initDatabase(name = 'anilist', dbPath = null, readOnly = false) { function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
@@ -49,6 +50,11 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
databases.set(name, db); databases.set(name, db);
if (name === "local_library") {
ensureLocalLibrarySchema(db)
.catch(err => console.error("Error creating local library schema:", err));
}
if (name === "anilist") { if (name === "anilist") {
ensureAnilistSchema(db) ensureAnilistSchema(db)
.then(() => ensureExtensionsTable(db)) .then(() => ensureExtensionsTable(db))

View File

@@ -2,6 +2,54 @@ const sqlite3 = require('sqlite3').verbose();
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
async function ensureLocalLibrarySchema(db) {
await run(db, `
CREATE TABLE IF NOT EXISTS local_entries (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
path TEXT NOT NULL,
folder_name TEXT NOT NULL,
matched_id INTEGER,
matched_source TEXT,
last_scan INTEGER NOT NULL
)
`);
await run(db, `
CREATE TABLE IF NOT EXISTS local_files (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
file_path TEXT NOT NULL,
unit_number INTEGER,
FOREIGN KEY (entry_id) REFERENCES local_entries(id)
)
`);
await run(db, `
CREATE INDEX IF NOT EXISTS idx_local_entries_type
ON local_entries(type)
`);
await run(db, `
CREATE INDEX IF NOT EXISTS idx_local_entries_matched
ON local_entries(matched_id)
`);
await run(db, `
CREATE INDEX IF NOT EXISTS idx_local_files_entry
ON local_files(entry_id)
`);
}
function run(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, err => {
if (err) reject(err);
else resolve();
});
});
}
async function ensureUserDataDB(dbPath) { async function ensureUserDataDB(dbPath) {
const dir = path.dirname(dbPath); const dir = path.dirname(dbPath);
@@ -230,5 +278,6 @@ module.exports = {
ensureAnilistSchema, ensureAnilistSchema,
ensureExtensionsTable, ensureExtensionsTable,
ensureCacheTable, ensureCacheTable,
ensureFavoritesDB ensureFavoritesDB,
ensureLocalLibrarySchema
}; };

View File

@@ -2,80 +2,144 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
let cachedNavbar: string | null = null;
function getNavbarHTML(activePage: string, showSearch: boolean = true): string {
if (!cachedNavbar) {
const navbarPath = path.join(__dirname, '..', '..', 'views', 'components', 'navbar.html');
cachedNavbar = fs.readFileSync(navbarPath, 'utf-8');
}
let navbar = cachedNavbar;
const pages = ['anime', 'books', 'gallery', 'schedule', 'my-list', 'marketplace'];
pages.forEach(page => {
const regex = new RegExp(`(<button class="nav-button[^"]*)"\\s+data-page="${page}"`, 'g');
if (page === activePage) {
navbar = navbar.replace(regex, `$1 active" data-page="${page}"`);
}
});
if (!showSearch) {
navbar = navbar.replace(
'<div class="search-wrapper">',
'<div class="search-wrapper" style="visibility: hidden;">'
);
}
return navbar;
}
function injectNavbar(htmlContent: string, activePage: string, showSearch: boolean = true): string {
const navbar = getNavbarHTML(activePage, showSearch);
return htmlContent.replace(/<body[^>]*>/, `$&\n${navbar}`);
}
async function viewsRoutes(fastify: FastifyInstance) { async function viewsRoutes(fastify: FastifyInstance) {
fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'users.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'users.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'anime', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/my-list', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/my-list', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'list.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'list.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'my-list', false);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/books', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/books', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'books.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'books.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'books', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/schedule', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/schedule', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'schedule.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'schedule.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'schedule', false);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/gallery', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/gallery', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'gallery.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'gallery.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'gallery', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/marketplace', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/marketplace', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'marketplace.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'marketplace.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'marketplace', false);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/gallery/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/gallery/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'gallery', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/gallery/favorites/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/gallery/favorites/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
const htmlWithNavbar = injectNavbar(html, 'gallery', true);
reply.type('text/html').send(htmlWithNavbar);
}); });
fastify.get('/anime/:id', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/anime/:id', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/anime/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/anime/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'book.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/book/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/book/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'book.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
}); });
fastify.get('/read/:provider/:chapter/*', (req: FastifyRequest, reply: FastifyReply) => { fastify.get('/read/:provider/:chapter/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'read.html')); const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'read.html');
reply.type('text/html').send(stream); const html = fs.readFileSync(htmlPath, 'utf-8');
reply.type('text/html').send(html);
});
fastify.setNotFoundHandler((req, reply) => {
const htmlPath = path.join(__dirname, '..', '..', 'views', '404.html');
const html = fs.readFileSync(htmlPath, 'utf-8');
reply.code(404).type('text/html').send(html);
}); });
} }

127
docker/views/404.html Normal file
View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - WaifuBoard</title>
<link rel="stylesheet" href="/views/css/globals.css">
<link rel="stylesheet" href="/views/css/components/navbar.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<script src="/src/scripts/titlebar.js"></script>
<style>
.error-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--spacing-2xl);
background: var(--color-bg-base);
}
.error-code {
font-size: 6rem;
font-weight: 900;
margin: 0;
color: var(--color-primary);
}
.error-message {
font-size: 1.1rem;
color: var(--color-text-secondary);
margin: var(--spacing-md) 0 var(--spacing-xl);
max-width: 420px;
}
.error-actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
}
</style>
</head>
<body>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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='/gallery'">Gallery</button>
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<main class="error-container">
<div>
<h1 class="error-code">404</h1>
<p class="error-message">
This page doesnt exist.
</p>
<div class="error-actions">
<button class="btn-primary" onclick="location.href='/'">Home</button>
<button class="btn-blur" onclick="history.back()">Back</button>
</div>
</div>
</main>
<script src="/src/scripts/utils/auth-utils.js"></script>
</body>
</html>

View File

@@ -27,130 +27,6 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content modal-list">
<button class="modal-close" onclick="closeAddToListModal()">
</button>
<h2 class="modal-title" id="modal-title">Add to List</h2>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="WATCHING">Watching</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rewatching</option>
</select>
</div>
<div class="form-group">
<label>Episodes Watched</label>
<input
type="number"
id="entry-progress"
class="form-input"
min="0"
placeholder="0"
/>
</div>
<div class="form-group">
<label>Your Score (0-10)</label>
<input
type="number"
id="entry-score"
class="form-input"
min="0"
max="10"
step="0.1"
placeholder="Optional"
/>
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date"
>Start Date</label
>
<input
type="date"
id="entry-start-date"
class="form-input"
/>
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input
type="date"
id="entry-end-date"
class="form-input"
/>
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count"
>Rewatch Count</label
>
<input
type="number"
id="entry-repeat-count"
class="form-input"
min="0"
/>
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea
id="entry-notes"
class="form-input notes-textarea"
rows="4"
placeholder="Personal notes..."
></textarea>
</div>
<div class="form-group checkbox-group">
<input
type="checkbox"
id="entry-is-private"
class="form-checkbox"
/>
<label for="entry-is-private"
>Mark as Private</label
>
</div>
</div>
</div>
<div class="modal-actions">
<button
class="btn-danger"
id="modal-delete-btn"
onclick="deleteFromList()"
>
Remove
</button>
<button
class="btn-secondary"
onclick="closeAddToListModal()"
>
Cancel
</button>
<button class="btn-primary" onclick="saveToList()">
Save Changes
</button>
</div>
</div>
</div>
<a href="/anime" class="back-btn"> <a href="/anime" class="back-btn">
<svg <svg
width="20" width="20"
@@ -219,6 +95,7 @@
id="extension-pill" id="extension-pill"
style="display: none; background: #8b5cf6" style="display: none; background: #8b5cf6"
></div> ></div>
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill score" id="score">--% Score</div> <div class="pill score" id="score">--% Score</div>
<div class="pill" id="year">----</div> <div class="pill" id="year">----</div>
<div class="pill" id="genres">Action</div> <div class="pill" id="genres">Action</div>

View File

@@ -9,71 +9,10 @@
<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/local-library.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">
</head> </head>
<body> <body>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<button class="nav-button active">Anime</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='/schedule'">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="hero-wrapper"> <div class="hero-wrapper">
<div class="hero-background"> <div class="hero-background">
<img id="hero-bg-media" alt=""> <img id="hero-bg-media" alt="">
@@ -108,77 +47,17 @@
</div> </div>
</div> </div>
</div> </div>
</div> <button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<div class="modal-overlay" id="add-list-modal"> <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<div class="modal-content modal-list"> <polyline points="9 22 9 12 15 12 15 22"/>
<button class="modal-close" onclick="closeAddToListModal()"></button> </svg>
<h2 class="modal-title" id="modal-title">Add to List</h2> </button>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="WATCHING">Watching/Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rewatching</option>
</select>
</div>
<div class="form-group">
<label>Episodes Watched</label>
<input type="number" id="entry-progress" class="form-input" min="0" placeholder="0">
</div>
<div class="form-group">
<label>Your Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Rewatch Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div> </div>
</div>
<div class="modal-actions"> <!-- Online Mode Content -->
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button> <main id="online-content">
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>
<main>
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<div class="section-title">Continue watching</div> <div class="section-title">Continue watching</div>
@@ -195,13 +74,11 @@
</div> </div>
</section> </section>
<section class="section"> <section class="section">
<div class="section-header"><div class="section-title">Trending This Season</div></div> <div class="section-header"><div class="section-title">Trending This Season</div></div>
<div class="carousel-wrapper"> <div class="carousel-wrapper">
<button class="scroll-btn left" onclick="scrollCarousel('trending', -1)"></button> <button class="scroll-btn left" onclick="scrollCarousel('trending', -1)"></button>
<div class="carousel" id="trending"> <div class="carousel" id="trending">
<div class="card"><div class="card-img-wrap skeleton"></div></div> <div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div> <div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div> <div class="card"><div class="card-img-wrap skeleton"></div></div>
@@ -211,7 +88,6 @@
</div> </div>
</section> </section>
<section class="section"> <section class="section">
<div class="section-header"><div class="section-title">Top Airing Now</div></div> <div class="section-header"><div class="section-title">Top Airing Now</div></div>
<div class="carousel-wrapper"> <div class="carousel-wrapper">
@@ -227,14 +103,49 @@
</section> </section>
</main> </main>
<!-- Local Library Mode Content -->
<main id="local-content" class="hidden">
<section class="section">
<div class="section-header">
<div class="section-title">Local Anime Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/>
<path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="watching">Watching</button>
<button class="filter-btn" data-filter="completed">Completed</button>
<button class="filter-btn" data-filter="unwatched">Unwatched</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
<button class="filter-btn" data-sort="recent">Recent</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<div id="updateToast" class="hidden"> <div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p> <p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">
<a
id="downloadButton"
href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases"
target="_blank"
>
Click To Download Click To Download
</a> </a>
</div> </div>
@@ -245,7 +156,9 @@
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<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/local-library.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
<script src="/src/scripts/settings.js"></script>
</body> </body>
</html> </html>

View File

@@ -13,72 +13,6 @@
</head> </head>
<body> <body>
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content">
<button class="modal-close" onclick="closeAddToListModal()"></button>
<h2 class="modal-title" id="modal-title">Add to Library</h2>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label for="entry-status">Status</label>
<select id="entry-status" class="form-input">
<option value="CURRENT">Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Plan to Read</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rereading</option>
</select>
</div>
<div class="form-group">
<label for="entry-progress" id="progress-label">Chapters Read</label>
<input type="number" id="entry-progress" class="form-input" min="0" max="0" placeholder="0">
</div>
<div class="form-group">
<label for="entry-score">Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Re-read Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>
<a href="/books" class="back-btn"> <a href="/books" class="back-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
Back to Books Back to Books
@@ -128,6 +62,7 @@
<div class="meta-row"> <div class="meta-row">
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div> <div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill score" id="score">--% Score</div> <div class="pill score" id="score">--% Score</div>
<div class="pill" id="genres">Action</div> <div class="pill" id="genres">Action</div>
</div> </div>

View File

@@ -9,70 +9,10 @@
<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/local-library.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">
</head> </head>
<body> <body>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
<button class="nav-button active">Books</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='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" class="search-input" id="search-input" placeholder="Search books..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="hero-wrapper"> <div class="hero-wrapper">
<div class="hero-background"> <div class="hero-background">
<img id="hero-bg-media" src="" alt=""> <img id="hero-bg-media" src="" alt="">
@@ -97,75 +37,40 @@
</div> </div>
</div> </div>
</div> </div>
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
</div> </div>
<main id="local-content" class="hidden">
<div class="modal-overlay" id="add-list-modal"> <section class="section">
<div class="modal-content modal-list"> <div class="section-header">
<button class="modal-close" onclick="closeAddToListModal()"></button> <div class="section-title">Local Books Library</div>
<h2 class="modal-title" id="modal-title">Add to Library</h2> <button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<div class="modal-body"> <path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/>
<div class="modal-fields-grid"> </svg>
<span id="scan-text">Scan Library</span>
<div class="form-group"> </button>
<label for="entry-status">Status</label>
<select id="entry-status" class="form-input">
<option value="CURRENT">Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Plan to Read</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rereading</option>
</select>
</div> </div>
<div class="local-filters">
<div class="form-group"> <div class="filter-group">
<label for="entry-progress" id="progress-label">Chapters Read</label> <button class="filter-btn active" data-filter="all">All</button>
<input type="number" id="entry-progress" class="form-input" min="0" max="0" placeholder="0"> <button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div> </div>
<div class="filter-group">
<div class="form-group"> <button class="filter-btn" data-sort="az">AZ</button>
<label for="entry-score">Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div> </div>
</div> </div>
<div class="local-library-grid" id="local-entries-grid">
<div class="form-group"> <div class="skeleton-card"></div>
<label for="entry-repeat-count">Re-read Count</label> <div class="skeleton-card"></div>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div> </div>
</section>
<div class="form-group notes-group"> </main>
<label for="entry-notes">Notes</label> <main id="online-content">
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>
<main>
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<div class="section-title">Continue Reading</div> <div class="section-title">Continue Reading</div>
@@ -214,7 +119,7 @@
<script src="/src/scripts/utils/list-modal-manager.js"></script> <script src="/src/scripts/utils/list-modal-manager.js"></script>
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/books/books.js"></script> <script src="/src/scripts/books/books.js"></script>
<script src="/src/scripts/local-library-books.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>

View File

@@ -0,0 +1,66 @@
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content modal-list">
<button class="modal-close" onclick="closeAddToListModal()"></button>
<h2 class="modal-title" id="modal-title">Add to List</h2>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="CURRENT">Watching/Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rewatching</option>
</select>
</div>
<div class="form-group">
<label>Episodes Watched</label>
<input type="number" id="entry-progress" class="form-input" min="0" placeholder="0">
</div>
<div class="form-group">
<label>Your Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Rewatch Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,69 @@
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<button class="nav-button" data-page="anime" onclick="window.location.href='/anime'">Anime</button>
<button class="nav-button" data-page="books" onclick="window.location.href='/books'">Books</button>
<button class="nav-button" data-page="gallery" onclick="window.location.href='/gallery'">Gallery</button>
<button class="nav-button" data-page="schedule" onclick="window.location.href='/schedule'">Schedule</button>
<button class="nav-button" data-page="my-list" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" data-page="marketplace" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<button class="dropdown-item" id="nav-settings">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82V22a2 2 0 1 1-4 0v-.18a1.65 1.65 0 0 0-.33-1.82 1.65 1.65 0 0 0-1-.6 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33H2a2 2 0 1 1 0-4h.18a1.65 1.65 0 0 0 1.82-.33 1.65 1.65 0 0 0 .6-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6c.37 0 .72-.14 1-.6A1.65 1.65 0 0 0 10.33 2.18V2a2 2 0 1 1 4 0v.18a1.65 1.65 0 0 0 .33 1.82c.28.46.63.6 1 .6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c0 .37.14.72.6 1 .46.28.6.63.6 1z"/>
</svg>
<span>Settings</span>
</button>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,278 @@
<div id="settings-modal" class="modal hidden" onclick="if(event.target === this) window.toggleSettingsModal(true)">
<div class="modal-overlay"></div>
<div class="modal-content">
<aside class="modal-sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">Settings</h2>
</div>
<nav id="config-nav" class="nav-list">
</nav>
<div class="sidebar-footer">
<button onclick="window.toggleSettingsModal(true)" class="btn-exit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Close
</button>
</div>
</aside>
<main class="modal-main">
<form id="config-form" class="config-wrapper">
<div id="config-section-content" class="section-container">
<div class="skeleton-loader">
<div class="skeleton title-skeleton"></div>
<div class="skeleton field-skeleton"></div>
<div class="skeleton field-skeleton"></div>
<div class="skeleton field-skeleton"></div>
</div>
</div>
<div class="modal-footer-sticky">
<p class="footer-hint">Changes are applied immediately after saving.</p>
<button type="submit" class="btn-primary">Save Changes</button>
</div>
</form>
</main>
</div>
</div>
<style>
/* --- AMOLED THEME VARIABLES --- */
:root {
--amoled-black: #000000;
--amoled-surface: #080808;
--amoled-field: #0e0e0e;
--amoled-border: rgba(255, 255, 255, 0.08);
--accent-purple: #8b5cf6;
--accent-glow: rgba(139, 92, 246, 0.15);
--text-main: #ffffff;
--text-dim: #a1a1aa;
}
/* --- MODAL BASE --- */
.modal {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal.hidden { display: none !important; }
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(12px);
z-index: -1;
}
.modal-content {
position: relative;
display: flex;
flex-direction: row; /* Horizontal layout */
width: 95%;
max-width: 1200px; /* Increased size */
height: 85vh;
background: var(--amoled-black);
border: var(--amoled-border);
border-radius: 28px;
overflow: hidden;
box-shadow: 0 0 0 1px var(--amoled-border), 0 24px 60px rgba(0,0,0,0.8);
animation: modalScaleUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* --- SIDEBAR --- */
.modal-sidebar {
width: 280px;
background: var(--amoled-surface);
border-right: var(--amoled-border);
display: flex;
flex-direction: column;
padding: 2rem;
flex-shrink: 0;
}
.sidebar-title {
font-size: 1.4rem;
font-weight: 800;
margin-bottom: 2.5rem;
color: var(--text-main);
letter-spacing: -0.02em;
}
.nav-list { flex: 1; }
.nav-item {
padding: 12px 16px;
border-radius: 14px;
cursor: pointer;
color: var(--text-dim);
transition: all 0.2s ease;
margin-bottom: 6px;
font-weight: 500;
display: flex;
align-items: center;
gap: 12px;
text-transform: capitalize;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
}
.nav-item.active {
background: var(--accent-glow);
color: var(--accent-purple);
box-shadow: inset 3px 0 0 var(--accent-purple);
}
/* --- MAIN CONTENT & DYNAMIC INPUTS --- */
.modal-main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--amoled-black);
min-width: 0;
}
.config-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.section-container {
flex: 1;
padding: 3.5rem;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #222 transparent;
}
/* Styles for the injected section content */
.config-group {
margin-bottom: 2.5rem;
animation: fadeInSection 0.4s ease-out;
}
.config-group label {
display: block;
font-size: 0.75rem;
color: var(--accent-purple);
margin-bottom: 0.8rem;
letter-spacing: 0.05em;
font-weight: 800;
text-transform: uppercase;
}
.config-input {
width: 100%;
padding: 1rem 1.2rem;
background: var(--amoled-field);
border: 1px solid #1a1a1a;
border-radius: 14px;
color: #fff;
font-size: 1rem;
transition: all 0.25s ease;
}
.config-input:focus {
outline: none;
border-color: var(--accent-purple);
background: #121212;
box-shadow: 0 0 0 4px var(--accent-glow);
}
/* --- FOOTER --- */
.modal-footer-sticky {
padding: 1.5rem 3.5rem;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
border-top: var(--amoled-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-hint {
font-size: 0.85rem;
color: var(--text-dim);
}
/* --- BUTTONS --- */
.btn-primary {
padding: 0.8rem 2.2rem;
background: #ffffff;
color: #000000;
border: none;
border-radius: 100px;
font-weight: 700;
cursor: pointer;
transition: transform 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
background: #f0f0f0;
}
.btn-exit {
background: #111;
border: 1px solid #222;
color: #ef4444;
padding: 10px;
border-radius: 12px;
width: 100%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 600;
margin-top: auto;
}
/* --- ANIMATIONS & SKELETON --- */
@keyframes modalScaleUp {
from { opacity: 0; transform: scale(0.97) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes fadeInSection {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.skeleton-loader { display: flex; flex-direction: column; gap: 2rem; }
.skeleton {
background: linear-gradient(90deg, #080808 25%, #121212 50%, #080808 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 12px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.title-skeleton { height: 35px; width: 40%; }
.field-skeleton { height: 55px; width: 100%; }
/* Responsive Mobile View */
@media (max-width: 850px) {
.modal-content { flex-direction: column; height: 95vh; width: 100vw; border-radius: 0; }
.modal-sidebar { width: 100%; height: auto; border-right: none; border-bottom: var(--amoled-border); padding: 1rem; }
.sidebar-title { margin-bottom: 1rem; font-size: 1.2rem; }
.section-container { padding: 2rem; }
.modal-footer-sticky { padding: 1.5rem 2rem; }
}
</style>

View File

@@ -53,36 +53,16 @@
gap: 1.25rem; gap: 1.25rem;
} }
.info-item h4 { .info-item h4 { margin: 0 0 0.25rem 0; font-size: 0.85rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
margin: 0 0 0.25rem 0; .info-item span { font-weight: 600; font-size: 1rem; color: var(--color-text-primary); }
font-size: 0.85rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-item span {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
}
.character-list { .character-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
.character-item { .character-item { display: flex; align-items: center; gap: 0.75rem; font-size: 0.95rem; }
display: flex; .char-dot { width: 6px; height: 6px; background: var(--color-primary); border-radius: 50%; }
align-items: center;
gap: 0.75rem;
font-size: 0.95rem;
}
.char-dot {
width: 6px;
height: 6px;
background: var(--color-primary);
border-radius: 50%;
}
.main-content { .main-content {
display: flex; display: flex;
@@ -119,11 +99,7 @@
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
} }
.pill.score { .pill.score { background: rgba(34, 197, 94, 0.2); color: #4ade80; border-color: rgba(34, 197, 94, 0.2); }
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
border-color: rgba(34, 197, 94, 0.2);
}
.action-row { .action-row {
display: flex; display: flex;
@@ -143,9 +119,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
transition: transition: transform 0.2s, box-shadow 0.2s;
transform 0.2s,
box-shadow 0.2s;
} }
.btn-watch:hover { .btn-watch:hover {
@@ -188,21 +162,8 @@
.episodes-section { .episodes-section {
margin-top: 4rem; margin-top: 4rem;
} }
.section-title { .section-title { font-size: 1.8rem; font-weight: 800; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.8rem; }
font-size: 1.8rem; .section-title::before { content: ''; width: 4px; height: 28px; background: var(--color-primary); border-radius: 2px; }
font-weight: 800;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.8rem;
}
.section-title::before {
content: "";
width: 4px;
height: 28px;
background: var(--color-primary);
border-radius: 2px;
}
.episodes-grid { .episodes-grid {
display: grid; display: grid;
@@ -219,7 +180,7 @@
transition: 0.2s; transition: 0.2s;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--color-text-secondary);
} }
.episode-btn:hover { .episode-btn:hover {
@@ -230,14 +191,8 @@
} }
@keyframes slideUp { @keyframes slideUp {
from { from { opacity: 0; transform: translateY(60px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(60px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
@@ -246,24 +201,11 @@
margin-top: -100px; margin-top: -100px;
padding: 0 1.5rem 4rem 1.5rem; padding: 0 1.5rem 4rem 1.5rem;
} }
.poster-card { .poster-card { width: 220px; margin: 0 auto; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
width: 220px; .main-content { text-align: center; align-items: center; }
margin: 0 auto; .anime-title { font-size: 2.5rem; }
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); .meta-row { justify-content: center; }
} .sidebar { display: none; }
.main-content {
text-align: center;
align-items: center;
}
.anime-title {
font-size: 2.5rem;
}
.meta-row {
justify-content: center;
}
.sidebar {
display: none;
}
} }
.read-more-btn { .read-more-btn {
@@ -279,9 +221,7 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
} }
.read-more-btn:hover { .read-more-btn:hover { text-decoration: underline; }
text-decoration: underline;
}
.episodes-header-row { .episodes-header-row {
display: flex; display: flex;
@@ -321,10 +261,7 @@
} }
.episode-search-input::-webkit-outer-spin-button, .episode-search-input::-webkit-outer-spin-button,
.episode-search-input::-webkit-inner-spin-button { .episode-search-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
-webkit-appearance: none;
margin: 0;
}
.pagination-controls { .pagination-controls {
display: flex; display: flex;
@@ -358,118 +295,3 @@
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
} }
@media (max-width: 768px) {
.content-container,
.section,
.container {
width: 100%;
max-width: 100vw;
overflow-x: hidden;
padding-left: 1rem;
padding-right: 1rem;
}
.content-container {
display: flex;
flex-direction: column;
margin-top: -50px;
padding: 1rem 1.25rem 4rem 1.25rem;
gap: 2rem;
}
.sidebar {
display: flex !important;
flex-direction: column;
align-items: center;
width: 100%;
order: 1;
}
.poster-card {
width: 180px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
margin-bottom: 1rem;
}
.info-grid {
width: 100%;
padding: 1rem;
}
.main-content {
order: 2;
text-align: center;
align-items: center;
}
.anime-title {
font-size: 2.2rem;
line-height: 1.1;
margin-bottom: 1rem;
}
.meta-row {
justify-content: center;
gap: 0.5rem;
}
.pill {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
.action-row {
flex-direction: column;
width: 100%;
gap: 0.8rem;
}
.btn-watch,
.btn-secondary {
width: 100%;
justify-content: center;
padding: 1rem;
}
.description-box {
margin-top: 2rem;
padding: 1.25rem;
font-size: 1rem;
text-align: left;
}
.episodes-section {
margin-top: 3rem;
order: 3;
}
.episodes-header-row {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.episode-search-wrapper {
width: 100%;
}
.episode-search-input {
width: 100%;
text-align: left;
}
.episodes-grid {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 0.75rem;
}
.episode-btn {
padding: 0.8rem 0.5rem;
font-size: 0.9rem;
}
.pagination-controls {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,132 @@
.library-mode-btn {
padding: 0.6rem 1.2rem;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--radius-full);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.2s;
}
.library-mode-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.library-mode-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.library-mode-btn svg {
width: 18px;
height: 18px;
}
.local-library-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
padding: 1rem 0;
}
.local-card {
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.local-card:hover {
transform: translateY(-8px);
}
.local-card-info {
padding: 0.8rem 0;
}
.local-card-title {
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.5rem;
}
.match-status {
font-size: 0.85rem;
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
}
.status-linked {
background: rgba(34, 197, 94, 0.2);
color: var(--color-success);
}
.status-unlinked {
background: rgba(239, 68, 68, 0.2);
color: var(--color-danger);
}
.hidden {
display: none !important;
}
.skeleton-card {
width: 100%;
aspect-ratio: 2/3;
background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.hero-wrapper {
position: relative;
}
.library-mode-btn.icon-only {
position: absolute;
bottom: 2rem;
right: 2rem;
z-index: 50;
}
.hero-mode-switch .library-mode-btn {
backdrop-filter: blur(10px);
}
.local-filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.4rem 0.9rem;
border-radius: 999px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
color: #bbb;
cursor: pointer;
}
.filter-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}

View File

@@ -15,70 +15,6 @@
</head> </head>
<body> <body>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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 active">Gallery</button>
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<!-- Mejorado el contenedor de usuario con dropdown más completo -->
<div class="nav-right">
<div class="search-wrapper">
<input type="text" id="main-search-input" class="search-input" placeholder="Search in gallery..." autocomplete="off">
<div class="search-results">
<button id="favorites-toggle-nav" class="fav-toggle-btn" title="Mostrar favoritos" style="margin: 10px; width: auto; font-size: 0.85rem;">
<i class="far fa-heart"></i>
<span class="fav-text">Favorites Mode</span>
</button>
</div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/placeholder.svg" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/placeholder.svg" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<main class="gallery-main"> <main class="gallery-main">
<div class="gallery-hero-placeholder"></div> <div class="gallery-hero-placeholder"></div>

View File

@@ -14,65 +14,6 @@
</head> </head>
<body> <body>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<button class="nav-button">Anime</button>
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
<button class="nav-button active" 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='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<!-- Mejorado el contenedor de usuario con dropdown más completo -->
<div class="nav-right">
<div class="search-wrapper" id="global-search-wrapper" style="visibility: hidden;width: 250px;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" class="search-input" placeholder="Search site..." autocomplete="off">
<div class="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/placeholder.svg" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/placeholder.svg" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<a href="/gallery" class="back-btn"> <a href="/gallery" class="back-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
Back to Gallery Back to Gallery

View File

@@ -13,68 +13,6 @@
</head> </head>
<body> <body>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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='/gallery'">Gallery</button>
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
<button class="nav-button active">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
<h1 class="page-title">My List</h1> <h1 class="page-title">My List</h1>
@@ -177,77 +115,6 @@
<div id="list-container" class="list-grid"></div> <div id="list-container" class="list-grid"></div>
</div> </div>
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content">
<button class="modal-close" onclick="window.ListModalManager.close()"></button>
<h2 class="modal-title" id="modal-title">Edit List Entry</h2>
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="CURRENT">Current</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
<option value="DROPPED">Dropped</option>
<option value="REPEATING">Rewatching/Rereading</option>
</select>
</div>
<div class="form-group">
<label for="entry-progress" id="progress-label">Progress</label>
<input type="number" id="entry-progress" class="form-input" min="0">
</div>
<div class="form-group">
<label>Score (0-10)</label>
<input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1">
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Repeat Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="window.ListModalManager.close()">Cancel</button>
<button class="btn-danger" id="modal-delete-btn" style="display:none;">Delete</button>
<button class="btn-primary" id="modal-save-btn">Save Changes</button>
</div>
</div>
</div>
<div id="updateToast" class="hidden"> <div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p> <p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank"> <a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">

View File

@@ -12,68 +12,6 @@
</head> </head>
<body> <body>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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='/gallery'">Gallery</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">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="hero-spacer"></div> <div class="hero-spacer"></div>
<main> <main>

View File

@@ -16,68 +16,6 @@
<body> <body>
<div class="ambient-bg" id="ambientBg"></div> <div class="ambient-bg" id="ambientBg"></div>
<nav class="navbar" id="navbar">
<a href="#" class="nav-brand">
<div class="brand-icon">
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
</div>
WaifuBoard
</a>
<div class="nav-center">
<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='/gallery'">Gallery</button>
<button class="nav-button active">Schedule</button>
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div>
<div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
<div class="search-results" id="search-results"></div>
</div>
<div class="nav-user" id="nav-user" style="display:none;">
<div class="user-avatar-btn">
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
<div class="online-indicator"></div>
</div>
<div class="nav-dropdown" id="nav-dropdown">
<div class="dropdown-header">
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
<div class="dropdown-user-info">
<div class="dropdown-username" id="nav-username"></div>
</div>
</div>
<a href="/my-list" class="dropdown-item">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>My List</span>
</a>
<button class="dropdown-item logout-item" id="nav-logout">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<div class="calendar-wrapper"> <div class="calendar-wrapper">
<div class="calendar-controls"> <div class="calendar-controls">
<div class="month-selector"> <div class="month-selector">