Compare commits
35 Commits
v2.0.0-rc.
...
c28948f6e9
| Author | SHA1 | Date | |
|---|---|---|---|
| c28948f6e9 | |||
| 48e1939d2a | |||
| 6222e7736f | |||
| 03d8337d89 | |||
| d49f739565 | |||
| 295cab93f3 | |||
| 4bca41f6a2 | |||
| bc74aa8116 | |||
| cc0b0a891e | |||
| 76391f74d2 | |||
| 487e24a20a | |||
| 25ea30f086 | |||
| 6075dcf149 | |||
| dbce12b708 | |||
| 9943c5d010 | |||
| cbacf2ea07 | |||
| 90231f6608 | |||
| a26f03f024 | |||
| 16cf6b3d4f | |||
| 4811c4535a | |||
| d6a99bfeb4 | |||
| b8f560141c | |||
| 2cf475931c | |||
| 41dddef354 | |||
| d54b0bcdef | |||
| c7ed97a452 | |||
| 1ebac7ee15 | |||
| 7490f269b0 | |||
| c7f919fe18 | |||
| 7c85d91b85 | |||
| c11a1eed35 | |||
| 7544f56ba9 | |||
| 1a01d29f19 | |||
| 315c2e911b | |||
| 28ff6ccc68 |
16
.gitignore
vendored
@@ -1,5 +1,11 @@
|
||||
node_modules
|
||||
electron
|
||||
dist
|
||||
.env
|
||||
build
|
||||
desktop/node_modules
|
||||
desktop/electron
|
||||
desktop/dist
|
||||
desktop/.env
|
||||
desktop/build
|
||||
|
||||
docker/node_modules
|
||||
docker/electron
|
||||
docker/dist
|
||||
docker/.env
|
||||
docker/build
|
||||
|
||||
44
README.md
@@ -1,31 +1,55 @@
|
||||
# 🎀 WaifuBoard
|
||||
|
||||
**Lightweight all-in-one app for boorus, manga and light novels — no sources included, total freedom via extensions.**
|
||||
**Lightweight all-in-one app for boorus, anime, manga and light novels — no sources included, total freedom via extensions.**
|
||||
|
||||
<img src="public/banner.png" alt="WaifuBoard Hero" width="100%"/>
|
||||
<img src="assets/hero.png" alt="WaifuBoard Hero" width="100%"/>
|
||||
<div align="center">
|
||||
|
||||
[](https://waifuboard.app)
|
||||
[](https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases/latest)
|
||||
[](https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions)
|
||||
|
||||
**[Website](https://waifuboard.app)** • **[Documentation](https://waifuboard.app/docs)** • **[Download Latest](https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases/latest)** • **[Discord (soon)](#)**
|
||||
**[Website](https://waifuboard.app)** • **[Documentation](https://waifuboard.app/docs)** • **[Download Latest](https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases/latest)** • **[Discord](https://discord.gg/DAVNf7erRJ)**
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
## 🚀 What is WaifuBoard?
|
||||
|
||||
**A lightweight and privacy-friendly desktop app designed around modular extensions.** WaifuBoard delivers a clean, organized reading experience for boorus, manga, and light novels, powered entirely by community-made sources.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Lightweight
|
||||
- Discord Rich Presence
|
||||
- Super clean & fast UI
|
||||
- Built-in **Extension Marketplace**
|
||||
- Local lists & anilist integration
|
||||
- Multi user support
|
||||
- Server version (Coming soon!)
|
||||
- Fully open-source & community-driven
|
||||
- Future mobile ports planned
|
||||
|
||||
> ⚠️ **Note**
|
||||
>
|
||||
> Release candidates may contain breaking changes and unresolved bugs.
|
||||
> Expect frequent updates and major changes between versions.
|
||||
|
||||
## 🖼️ Gallery
|
||||
|
||||
### 👥 Multi-User Profiles
|
||||
<img src="assets/users.gif" alt="Multi-user profiles" width="100%"/>
|
||||
|
||||
### 📖 Reader
|
||||
|
||||
#### Manga
|
||||
<img src="assets/manga.png" alt="Manga reader" width="100%"/>
|
||||
|
||||
#### Manhwa
|
||||
<img src="assets/manhwa.png" alt="Manhwa reader" width="100%"/>
|
||||
|
||||
#### Light Novel
|
||||
<img src="assets/novel.gif" alt="Light novel reader" width="100%"/>
|
||||
|
||||
### 📅 Schedule & Tracking
|
||||
<img src="assets/schedule.png" alt="Anime schedule" width="100%"/>
|
||||
|
||||
### 📚 Lists (Local & AniList)
|
||||
<img src="assets/list.gif" alt="Lists and tracking" width="100%"/>
|
||||
|
||||
|
||||
## 🖥️ Download & Platform Support
|
||||
|
||||
|
||||
BIN
assets/hero.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
assets/list.gif
Normal file
|
After Width: | Height: | Size: 6.2 MiB |
BIN
assets/manga.png
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
assets/manhwa.png
Normal file
|
After Width: | Height: | Size: 577 KiB |
BIN
assets/novel.gif
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/schedule.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
assets/users.gif
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
5
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
electron
|
||||
dist
|
||||
.env
|
||||
build
|
||||
229
desktop/loading.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Waifuboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-bg: #09090b;
|
||||
--color-primary: #8b5cf6;
|
||||
--color-text: #ffffff;
|
||||
--color-text-dim: #a1a1aa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #18181b 0%, var(--color-bg) 100%);
|
||||
color: var(--color-text);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 400px;
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.05),
|
||||
0 20px 40px rgba(0,0,0,0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
user-drag: none;
|
||||
}
|
||||
|
||||
.bg-particle {
|
||||
position: absolute;
|
||||
background: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
animation: particleFloat 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bg-particle:nth-child(1) {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
top: 20%;
|
||||
left: 10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.bg-particle:nth-child(2) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
top: 60%;
|
||||
left: 80%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.bg-particle:nth-child(3) {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
top: 40%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
.bg-particle:nth-child(4) {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
top: 70%;
|
||||
left: 70%;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.splash-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
width: 100%;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.brand-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-container.horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: contain;
|
||||
animation: iconPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 5px solid rgba(139, 92, 246, 0.2);
|
||||
border-top: 5px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 12px;
|
||||
color: #a1a1aa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes iconPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.4));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
filter: drop-shadow(0 0 16px rgba(139, 92, 246, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes particleFloat {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: translateY(-100px) scale(1);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-200px) scale(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-particle"></div>
|
||||
<div class="bg-particle"></div>
|
||||
<div class="bg-particle"></div>
|
||||
<div class="bg-particle"></div>
|
||||
|
||||
<div class="splash-container">
|
||||
<div class="brand-container horizontal">
|
||||
<img src="public/assets/waifuboards.ico" alt="Waifuboard Logo" class="brand-icon">
|
||||
<h1 class="brand-name">Waifuboard</h1>
|
||||
</div>
|
||||
|
||||
<div class="spinner-wrapper">
|
||||
<div class="spinner"></div>
|
||||
<span class="loading-text">Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
144
desktop/main.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const { fork } = require('child_process');
|
||||
const path = require('path');
|
||||
const log = require('electron-log');
|
||||
|
||||
const sessionId = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
log.transports.file.resolvePath = () => path.join(app.getPath('userData'), 'logs', `${sessionId}.log`);
|
||||
|
||||
log.transports.file.level = 'info';
|
||||
log.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}';
|
||||
|
||||
let win;
|
||||
let backend;
|
||||
const net = require('net');
|
||||
|
||||
function waitForServer(port, host = '127.0.0.1', timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const socket = new net.Socket();
|
||||
|
||||
socket
|
||||
.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
})
|
||||
.once('error', () => {
|
||||
socket.destroy();
|
||||
if (Date.now() - start > timeout) {
|
||||
reject(new Error('Backend timeout'));
|
||||
} else {
|
||||
setTimeout(check, 200);
|
||||
}
|
||||
})
|
||||
.connect(port, host);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function startBackend() {
|
||||
backend = fork(path.join(__dirname, 'server.js'), [], {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
IS_PACKAGED: app.isPackaged ? 'true' : 'false'
|
||||
}
|
||||
});
|
||||
|
||||
log.info('Starting backend process...');
|
||||
|
||||
backend.stdout.on('data', (data) => {
|
||||
log.info(`[Backend]: ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
backend.stderr.on('data', (data) => {
|
||||
log.error(`[Backend ERROR]: ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
backend.on('exit', (code) => {
|
||||
log.warn(`Backend process exited with code: ${code}`);
|
||||
});
|
||||
}
|
||||
|
||||
let splash;
|
||||
|
||||
function createSplash() {
|
||||
splash = new BrowserWindow({
|
||||
width: 400,
|
||||
height: 300,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
hasShadow: false,
|
||||
backgroundColor: '#00000000'
|
||||
});
|
||||
|
||||
splash.loadFile('loading.html');
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
frame: false,
|
||||
titleBarStyle: "hidden",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
|
||||
win.setMenu(null);
|
||||
win.maximize();
|
||||
|
||||
win.loadURL('http://localhost:54322');
|
||||
|
||||
win.on('closed', () => {
|
||||
win = null;
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.on("win:minimize", () => win.minimize());
|
||||
ipcMain.on("win:maximize", () => {
|
||||
if (win.isMaximized()) {
|
||||
win.unmaximize();
|
||||
} else {
|
||||
win.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.on("win:close", () => win.close());
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('Critical unhandled error in Main:', err);
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
startBackend();
|
||||
createSplash();
|
||||
try {
|
||||
await waitForServer(54322);
|
||||
createWindow();
|
||||
splash.close();
|
||||
} catch (e) {
|
||||
splash.close();
|
||||
log.error(e);
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
log.info('Closing all windows...');
|
||||
if (backend) {
|
||||
backend.kill();
|
||||
log.info('Backend process terminated.');
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
798
package-lock.json → desktop/package-lock.json
generated
@@ -4,10 +4,8 @@
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "tsc && node server.js",
|
||||
"electron": "tsc && electron .",
|
||||
"dist": "npm run build && electron-builder"
|
||||
"start": "tsc && electron .",
|
||||
"dist": "tsc && electron-builder"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -15,18 +13,24 @@
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@ryuziii/discord-rpc": "^1.0.1-rc.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"@xhayper/discord-rpc": "^1.3.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bindings": "^1.5.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"electron-log": "^5.4.3",
|
||||
"epub": "^1.3.0",
|
||||
"fastify": "^5.6.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"playwright-chromium": "^1.57.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
@@ -47,10 +51,15 @@
|
||||
"package.json",
|
||||
"views/**/*",
|
||||
"src/scripts/**/*",
|
||||
"public/assets/*"
|
||||
"public/assets/*",
|
||||
"loading.html"
|
||||
],
|
||||
"extraResources": [
|
||||
"./.env"
|
||||
{
|
||||
"from": "C:\\Users\\synta\\AppData\\Local\\ms-playwright\\chromium_headless_shell-1200",
|
||||
"to": "playwright/chromium"
|
||||
},
|
||||
".env"
|
||||
],
|
||||
"win": {
|
||||
"target": "portable",
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
@@ -4,18 +4,22 @@ const fastify = require("fastify")({
|
||||
|
||||
const path = require("path");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const cron = require("node-cron");
|
||||
const { initHeadless } = require("./electron/shared/headless");
|
||||
const { initDatabase } = require("./electron/shared/database");
|
||||
const { loadExtensions } = require("./electron/shared/extensions");
|
||||
const { ensureConfigFile } = require("./electron/shared/config");
|
||||
const { init } = require("./electron/api/rpc/rpc.controller");
|
||||
const {refreshTrendingAnime, refreshTopAiringAnime} = require("./electron/api/anime/anime.service");
|
||||
const {refreshPopularBooks, refreshTrendingBooks} = require("./electron/api/books/books.service");
|
||||
|
||||
const dotenv = require("dotenv");
|
||||
const envPath = process.resourcesPath
|
||||
? path.join(process.resourcesPath, ".env")
|
||||
: path.join(__dirname, ".env");
|
||||
|
||||
// Attempt to load it and log the result to be sure
|
||||
dotenv.config({ path: envPath });
|
||||
const isPackaged = process.env.IS_PACKAGED === "true";
|
||||
const envPath = isPackaged
|
||||
? path.join(process.resourcesPath, ".env")
|
||||
: path.join(__dirname, ".env");
|
||||
|
||||
dotenv.config({ path: envPath, override: false });
|
||||
const viewsRoutes = require("./electron/views/views.routes");
|
||||
const animeRoutes = require("./electron/api/anime/anime.routes");
|
||||
const booksRoutes = require("./electron/api/books/books.routes");
|
||||
@@ -26,39 +30,8 @@ const rpcRoutes = require("./electron/api/rpc/rpc.routes");
|
||||
const userRoutes = require("./electron/api/user/user.routes");
|
||||
const listRoutes = require("./electron/api/list/list.routes");
|
||||
const anilistRoute = require("./electron/api/anilist/anilist");
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
try {
|
||||
console.log("--- DEBUGGING PATHS ---");
|
||||
|
||||
// 1. Check where we are currently running
|
||||
console.log("Current Directory:", __dirname);
|
||||
|
||||
// 2. Check if 'electron' exists
|
||||
const electronPath = path.join(__dirname, "electron");
|
||||
if (fs.existsSync(electronPath)) {
|
||||
console.log("✅ electron folder found.");
|
||||
} else {
|
||||
console.log("❌ electron folder missing!");
|
||||
}
|
||||
|
||||
// 3. Check 'electron/api/rpc' specifically
|
||||
const rpcPath = path.join(__dirname, "electron", "api", "rpc");
|
||||
if (fs.existsSync(rpcPath)) {
|
||||
console.log("✅ electron/api/rpc folder found. Contents:");
|
||||
// LIST EVERYTHING INSIDE THE RPC FOLDER
|
||||
console.log(fs.readdirSync(rpcPath));
|
||||
} else {
|
||||
console.log(`❌ electron/api/rpc folder NOT found at: ${rpcPath}`);
|
||||
// Check parent folder to see what IS there
|
||||
const parent = path.join(__dirname, "electron", "api");
|
||||
console.log("Contents of electron/api:", fs.readdirSync(parent));
|
||||
}
|
||||
console.log("-----------------------");
|
||||
} catch (e) {
|
||||
console.log("Debug Error:", e);
|
||||
}
|
||||
const localRoutes = require("./electron/api/local/local.routes");
|
||||
const configRoutes = require("./electron/api/config/config.routes");
|
||||
|
||||
fastify.addHook("preHandler", async (request) => {
|
||||
const auth = request.headers.authorization;
|
||||
@@ -100,21 +73,46 @@ fastify.register(rpcRoutes, { prefix: "/api" });
|
||||
fastify.register(userRoutes, { prefix: "/api" });
|
||||
fastify.register(anilistRoute, { 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 start = async () => {
|
||||
try {
|
||||
ensureConfigFile()
|
||||
initDatabase("anilist");
|
||||
initDatabase("favorites");
|
||||
initDatabase("cache");
|
||||
initDatabase("userdata");
|
||||
initDatabase("local_library");
|
||||
init();
|
||||
|
||||
const refreshAll = async () => {
|
||||
await refreshTrendingAnime();
|
||||
await sleep(300);
|
||||
await refreshTopAiringAnime();
|
||||
await sleep(300);
|
||||
await refreshTrendingBooks();
|
||||
await sleep(300);
|
||||
await refreshPopularBooks();
|
||||
};
|
||||
|
||||
cron.schedule("*/30 * * * *", async () => {
|
||||
try {
|
||||
await refreshAll();
|
||||
console.log("cache refreshed");
|
||||
} catch (e) {
|
||||
console.error("refresh failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
await loadExtensions();
|
||||
await initHeadless();
|
||||
await refreshAll();
|
||||
|
||||
await fastify.listen({ port: 54322, host: "0.0.0.0" });
|
||||
console.log(`Server running at http://localhost:54322`);
|
||||
|
||||
await initHeadless();
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
@@ -3,7 +3,6 @@ import { queryAll, queryOne } from '../../shared/database';
|
||||
import {Anime, Episode, Extension, StreamData} from '../types';
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const TTL = 60 * 60 * 6;
|
||||
|
||||
const ANILIST_URL = "https://graphql.anilist.co";
|
||||
|
||||
@@ -79,6 +78,54 @@ const MEDIA_FIELDS = `
|
||||
}
|
||||
`;
|
||||
|
||||
export async function refreshTrendingAnime(): Promise<void> {
|
||||
const query = `
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, {});
|
||||
const list = data?.Page?.media || [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await queryOne("DELETE FROM trending");
|
||||
|
||||
let rank = 1;
|
||||
for (const anime of list) {
|
||||
await queryOne(
|
||||
"INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||
[rank++, anime.id, JSON.stringify(anime), now]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshTopAiringAnime(): Promise<void> {
|
||||
const query = `
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, {});
|
||||
const list = data?.Page?.media || [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await queryOne("DELETE FROM top_airing");
|
||||
|
||||
let rank = 1;
|
||||
for (const anime of list) {
|
||||
await queryOne(
|
||||
"INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||
[rank++, anime.id, JSON.stringify(anime), now]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAniList(query: string, variables: any) {
|
||||
const res = await fetch(ANILIST_URL, {
|
||||
method: "POST",
|
||||
@@ -119,76 +166,16 @@ export async function getAnimeById(id: string | number): Promise<Anime | { error
|
||||
|
||||
export async function getTrendingAnime(): Promise<Anime[]> {
|
||||
const rows = await queryAll(
|
||||
"SELECT full_data, updated_at FROM trending ORDER BY rank ASC LIMIT 10"
|
||||
"SELECT full_data FROM trending ORDER BY rank ASC LIMIT 10"
|
||||
);
|
||||
|
||||
if (rows.length) {
|
||||
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||
if (!expired) {
|
||||
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, {});
|
||||
const list = data?.Page?.media || [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await queryOne("DELETE FROM trending");
|
||||
let rank = 1;
|
||||
|
||||
for (const anime of list) {
|
||||
await queryOne(
|
||||
"INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||
[rank++, anime.id, JSON.stringify(anime), now]
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
||||
}
|
||||
|
||||
export async function getTopAiringAnime(): Promise<Anime[]> {
|
||||
const rows = await queryAll(
|
||||
"SELECT full_data, updated_at FROM top_airing ORDER BY rank ASC LIMIT 10"
|
||||
"SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10"
|
||||
);
|
||||
|
||||
if (rows.length) {
|
||||
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||
if (!expired) {
|
||||
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, {});
|
||||
const list = data?.Page?.media || [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await queryOne("DELETE FROM top_airing");
|
||||
let rank = 1;
|
||||
|
||||
for (const anime of list) {
|
||||
await queryOne(
|
||||
"INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||
[rank++, anime.id, JSON.stringify(anime), now]
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
||||
}
|
||||
|
||||
export async function searchAnimeLocal(query: string): Promise<Anime[]> {
|
||||
@@ -87,15 +87,16 @@ export async function getChapters(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const source = req.query.source || 'anilist';
|
||||
const provider = req.query.provider;
|
||||
|
||||
const isExternal = source !== 'anilist';
|
||||
return await booksService.getChaptersForBook(id, isExternal);
|
||||
} catch {
|
||||
return await booksService.getChaptersForBook(id, isExternal, provider);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return { chapters: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getChapterContent(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { bookId, chapter, provider } = req.params;
|
||||
@@ -4,7 +4,6 @@ import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions'
|
||||
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const TTL = 60 * 60 * 6;
|
||||
const ANILIST_URL = "https://graphql.anilist.co";
|
||||
|
||||
async function fetchAniList(query: string, variables: any) {
|
||||
@@ -134,25 +133,14 @@ export async function getBookById(id: string | number): Promise<Book | { error:
|
||||
return { error: "Book not found" };
|
||||
}
|
||||
|
||||
export async function getTrendingBooks(): Promise<Book[]> {
|
||||
const rows = await queryAll(
|
||||
"SELECT full_data, updated_at FROM trending_books ORDER BY rank ASC LIMIT 10"
|
||||
);
|
||||
|
||||
if (rows.length) {
|
||||
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||
if (!expired) {
|
||||
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshTrendingBooks(): Promise<void> {
|
||||
const query = `
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: MANGA, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: MANGA, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, {});
|
||||
const list = data?.Page?.media || [];
|
||||
@@ -167,30 +155,16 @@ export async function getTrendingBooks(): Promise<Book[]> {
|
||||
[rank++, book.id, JSON.stringify(book), now]
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
|
||||
export async function getPopularBooks(): Promise<Book[]> {
|
||||
const rows = await queryAll(
|
||||
"SELECT full_data, updated_at FROM popular_books ORDER BY rank ASC LIMIT 10"
|
||||
);
|
||||
|
||||
if (rows.length) {
|
||||
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||
if (!expired) {
|
||||
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshPopularBooks(): Promise<void> {
|
||||
const query = `
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: MANGA, sort: POPULARITY_DESC) { ${MEDIA_FIELDS} }
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: MANGA, sort: POPULARITY_DESC) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, {});
|
||||
const list = data?.Page?.media || [];
|
||||
@@ -205,10 +179,21 @@ export async function getPopularBooks(): Promise<Book[]> {
|
||||
[rank++, book.id, JSON.stringify(book), now]
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export async function getTrendingBooks(): Promise<Book[]> {
|
||||
const rows = await queryAll(
|
||||
"SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10"
|
||||
);
|
||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
||||
}
|
||||
|
||||
export async function getPopularBooks(): Promise<Book[]> {
|
||||
const rows = await queryAll(
|
||||
"SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10"
|
||||
);
|
||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
||||
}
|
||||
|
||||
export async function searchBooksLocal(query: string): Promise<Book[]> {
|
||||
if (!query || query.length < 2) {
|
||||
43
desktop/src/api/config/config.controller.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
11
desktop/src/api/config/config.routes.ts
Normal 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;
|
||||
@@ -1,6 +1,59 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap, saveExtensionFile, deleteExtensionFile } from '../../shared/extensions';
|
||||
import { ExtensionNameRequest } from '../types';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
'anime-board': 'anime',
|
||||
'image-board': 'image',
|
||||
'book-board': 'book',
|
||||
};
|
||||
|
||||
function extractProp(source: string, prop: string): string | null {
|
||||
const m = source.match(new RegExp(`this\\.${prop}\\s*=\\s*["']([^"']+)["']`));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function isNewer(remote: string, local?: string | null) {
|
||||
if (!local) return true;
|
||||
return remote !== local;
|
||||
}
|
||||
|
||||
export async function updateExtensions(req: any, reply: FastifyReply) {
|
||||
const updated: string[] = [];
|
||||
|
||||
for (const name of getExtensionsList()) {
|
||||
const ext = getExtension(name);
|
||||
if (!ext) continue;
|
||||
|
||||
const type = ext.type;
|
||||
if (!TYPE_MAP[type]) continue;
|
||||
|
||||
const fileName = ext.__fileName;
|
||||
const remoteUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${TYPE_MAP[type]}/${fileName}`;
|
||||
|
||||
let remoteSrc: string;
|
||||
try {
|
||||
const res = await fetch(remoteUrl);
|
||||
if (!res.ok) continue;
|
||||
remoteSrc = await res.text();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteVersion = extractProp(remoteSrc, 'version');
|
||||
const localVersion = ext.version ?? null;
|
||||
if (!remoteVersion) continue;
|
||||
|
||||
if (isNewer(remoteVersion, localVersion)) {
|
||||
await saveExtensionFile(fileName, remoteUrl);
|
||||
updated.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return { updated };
|
||||
}
|
||||
|
||||
export async function getExtensions(req: FastifyRequest, reply: FastifyReply) {
|
||||
return { extensions: getExtensionsList() };
|
||||
@@ -37,24 +90,33 @@ export async function getExtensionSettings(req: ExtensionNameRequest, reply: Fas
|
||||
}
|
||||
|
||||
export async function installExtension(req: any, reply: FastifyReply) {
|
||||
const { fileName } = req.body;
|
||||
const { url } = req.body;
|
||||
|
||||
if (!fileName || !fileName.endsWith('.js')) {
|
||||
return reply.code(400).send({ error: "Invalid extension fileName provided" });
|
||||
if (!url || typeof url !== 'string' || !url.endsWith('.js')) {
|
||||
return reply.code(400).send({ error: "Invalid extension URL provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = url.split('/').pop();
|
||||
|
||||
const downloadUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${fileName}`
|
||||
if (!fileName) {
|
||||
return reply.code(400).send({ error: "Could not determine file name from URL" });
|
||||
}
|
||||
|
||||
await saveExtensionFile(fileName, downloadUrl);
|
||||
await saveExtensionFile(fileName, url);
|
||||
|
||||
req.server.log.info(`Extension installed: ${fileName}`);
|
||||
return reply.code(200).send({ success: true, message: `Extension ${fileName} installed successfully.` });
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
message: `Extension ${fileName} installed successfully.`,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
req.server.log.error(`Failed to install extension ${fileName}:`, error);
|
||||
return reply.code(500).send({ success: false, error: `Failed to install extension ${fileName}.` });
|
||||
req.server.log.error(`Failed to install extension from ${url}:`, error);
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: "Failed to install extension.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as controller from './extensions.controller';
|
||||
async function extensionsRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/extensions', controller.getExtensions);
|
||||
fastify.get('/extensions/anime', controller.getAnimeExtensions);
|
||||
fastify.post('/extensions/update', controller.updateExtensions);
|
||||
fastify.get('/extensions/book', controller.getBookExtensions);
|
||||
fastify.get('/extensions/gallery', controller.getGalleryExtensions);
|
||||
fastify.get('/extensions/:name/settings', controller.getExtensionSettings);
|
||||
185
desktop/src/api/local/local.controller.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import fs from 'fs';
|
||||
import * as service from './local.service';
|
||||
|
||||
type ScanQuery = {
|
||||
mode?: 'full' | 'incremental';
|
||||
};
|
||||
|
||||
type Params = {
|
||||
type: 'anime' | 'manga' | 'novels';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type MatchBody = {
|
||||
source: 'anilist';
|
||||
matched_id: number | null;
|
||||
};
|
||||
|
||||
export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) {
|
||||
try {
|
||||
const mode = request.query.mode || 'incremental';
|
||||
return await service.performLibraryScan(mode);
|
||||
} catch (err: any) {
|
||||
if (err.message === 'NO_LIBRARY_CONFIGURED') {
|
||||
return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' });
|
||||
}
|
||||
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 service.getEntriesByType(type);
|
||||
return entries;
|
||||
} 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 service.getEntryDetails(type, id);
|
||||
|
||||
if (!entry) {
|
||||
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
|
||||
}
|
||||
|
||||
return entry;
|
||||
} 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 fileInfo = await service.getFileForStreaming(id, unit);
|
||||
|
||||
if (!fileInfo) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const { filePath, stat } = fileInfo;
|
||||
const range = request.headers.range;
|
||||
|
||||
if (!range) {
|
||||
reply
|
||||
.header('Content-Length', stat.size)
|
||||
.header('Content-Type', 'video/mp4');
|
||||
return fs.createReadStream(filePath);
|
||||
}
|
||||
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = Number(parts[0]);
|
||||
const end = parts[1] ? Number(parts[1]) : stat.size - 1;
|
||||
|
||||
if (
|
||||
Number.isNaN(start) ||
|
||||
Number.isNaN(end) ||
|
||||
start < 0 ||
|
||||
start >= stat.size ||
|
||||
end < start ||
|
||||
end >= stat.size
|
||||
) {
|
||||
return reply.status(416).send({ error: 'INVALID_RANGE' });
|
||||
}
|
||||
|
||||
const contentLength = end - start + 1;
|
||||
|
||||
reply
|
||||
.status(206)
|
||||
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Content-Length', contentLength)
|
||||
.header('Content-Type', 'video/mp4');
|
||||
|
||||
return fs.createReadStream(filePath, { start, end });
|
||||
}
|
||||
|
||||
export async function matchEntry(
|
||||
request: FastifyRequest<{ Body: MatchBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { id, type } = request.params as any;
|
||||
const { source, matched_id } = request.body;
|
||||
|
||||
const result = await service.updateEntryMatch(id, type, source, matched_id);
|
||||
|
||||
if (!result) {
|
||||
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const units = await service.getEntryUnits(id);
|
||||
|
||||
if (!units) {
|
||||
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
|
||||
}
|
||||
|
||||
return units;
|
||||
} catch (err) {
|
||||
console.error('Error getting units:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getManifest(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { unitId } = request.params as any;
|
||||
|
||||
try {
|
||||
const manifest = await service.getUnitManifest(unitId);
|
||||
|
||||
if (!manifest) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
return manifest;
|
||||
} catch (err: any) {
|
||||
if (err.message === 'UNSUPPORTED_FORMAT') {
|
||||
return reply.status(400).send({ error: 'UNSUPPORTED_FORMAT' });
|
||||
}
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_MANIFEST' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPage(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { unitId, resId } = request.params as any;
|
||||
|
||||
const resource = await service.getUnitResource(unitId, resId);
|
||||
|
||||
if (!resource) {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
|
||||
if (resource.type === 'image') {
|
||||
if (resource.data) {
|
||||
return reply
|
||||
.header('Content-Type', 'image/jpeg')
|
||||
.send(resource.data);
|
||||
}
|
||||
|
||||
if (resource.path && resource.size) {
|
||||
reply
|
||||
.header('Content-Length', resource.size)
|
||||
.header('Content-Type', 'image/jpeg');
|
||||
|
||||
return fs.createReadStream(resource.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (resource.type === 'html') {
|
||||
return reply
|
||||
.header('Content-Type', 'text/html; charset=utf-8')
|
||||
.send(resource.data);
|
||||
}
|
||||
|
||||
return reply.status(400).send();
|
||||
}
|
||||
15
desktop/src/api/local/local.routes.ts
Normal 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/:id/units', controller.getUnits);
|
||||
fastify.get('/library/:unitId/manifest', controller.getManifest);
|
||||
fastify.get('/library/:unitId/resource/:resId', controller.getPage);
|
||||
}
|
||||
|
||||
export default localRoutes;
|
||||
454
desktop/src/api/local/local.service.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { getConfig as loadConfig } 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 } from "../books/books.service";
|
||||
import AdmZip from 'adm-zip';
|
||||
import EPub from 'epub';
|
||||
|
||||
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
|
||||
const NOVEL_EXTS = ['.epub', '.pdf', '.txt', '.md', '.docx', '.mobi'];
|
||||
|
||||
export 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);
|
||||
|
||||
let picked = null;
|
||||
|
||||
if (type !== 'anime' && Array.isArray(results)) {
|
||||
console.log(type);
|
||||
if (entry.type === 'novels') {
|
||||
picked = results.find(r => r.format === 'NOVEL');
|
||||
} else if (entry.type === 'manga') {
|
||||
picked = results.find(r => r.format !== 'NOVEL');
|
||||
}
|
||||
}
|
||||
|
||||
picked ??= results?.[0];
|
||||
|
||||
if (picked?.id) {
|
||||
matchedId = picked.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 performLibraryScan(mode: 'full' | 'incremental' = 'incremental') {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.library) {
|
||||
throw new 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() ||
|
||||
(type === 'manga' && f.isDirectory())
|
||||
)
|
||||
.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' };
|
||||
}
|
||||
|
||||
export async function getEntriesByType(type: string) {
|
||||
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
|
||||
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
|
||||
}
|
||||
|
||||
export async function getEntryDetails(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 null;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function getFileForStreaming(id: string, unit: string) {
|
||||
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 null;
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: file.file_path,
|
||||
stat: fs.statSync(file.file_path)
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateEntryMatch(id: string, type: string, source: string, matchedId: number | null) {
|
||||
const entry = await queryOne(
|
||||
`SELECT id FROM local_entries WHERE id = ? AND type = ?`,
|
||||
[id, type],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await run(
|
||||
`UPDATE local_entries
|
||||
SET matched_source = ?, matched_id = ?
|
||||
WHERE id = ?`,
|
||||
[source, matchedId, id],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
return { status: 'OK', matched: !!matchedId };
|
||||
}
|
||||
|
||||
function isImageFolder(folderPath: string): boolean {
|
||||
if (!fs.existsSync(folderPath)) return false;
|
||||
if (!fs.statSync(folderPath).isDirectory()) return false;
|
||||
|
||||
const files = fs.readdirSync(folderPath);
|
||||
return files.some(f => MANGA_IMAGE_EXTS.includes(path.extname(f).toLowerCase()));
|
||||
}
|
||||
|
||||
export async function getEntryUnits(id: string) {
|
||||
const entry = await queryOne(
|
||||
`SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`,
|
||||
[Number(id)],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
const units = files
|
||||
.map((file: any) => {
|
||||
const fileExt = path.extname(file.file_path).toLowerCase();
|
||||
const isDir = fs.existsSync(file.file_path) &&
|
||||
fs.statSync(file.file_path).isDirectory();
|
||||
|
||||
if (entry.type === 'manga') {
|
||||
if (MANGA_ARCHIVES.includes(fileExt)) {
|
||||
return {
|
||||
id: file.id,
|
||||
number: file.unit_number,
|
||||
name: path.basename(file.file_path),
|
||||
type: 'chapter',
|
||||
format: fileExt.replace('.', ''),
|
||||
path: file.file_path
|
||||
};
|
||||
}
|
||||
|
||||
if (isDir && isImageFolder(file.file_path)) {
|
||||
return {
|
||||
id: file.id,
|
||||
number: file.unit_number,
|
||||
name: path.basename(file.file_path),
|
||||
type: 'chapter',
|
||||
format: 'folder',
|
||||
path: file.file_path
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.type === 'novels') {
|
||||
if (NOVEL_EXTS.includes(fileExt)) {
|
||||
return {
|
||||
id: file.id,
|
||||
number: file.unit_number,
|
||||
name: path.basename(file.file_path),
|
||||
type: 'chapter',
|
||||
format: fileExt.replace('.', ''),
|
||||
path: file.file_path
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.type === 'anime') {
|
||||
return {
|
||||
id: file.id,
|
||||
number: file.unit_number,
|
||||
name: path.basename(file.file_path),
|
||||
type: 'episode',
|
||||
format: fileExt.replace('.', ''),
|
||||
path: file.file_path
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
entry_id: entry.id,
|
||||
matched_id: entry.matched_id,
|
||||
type: entry.type,
|
||||
total: units.length,
|
||||
units
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUnitManifest(unitId: string) {
|
||||
const file = await queryOne(
|
||||
`SELECT file_path FROM local_files WHERE id = ?`,
|
||||
[unitId],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
if (!file || !fs.existsSync(file.file_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = path.extname(file.file_path).toLowerCase();
|
||||
|
||||
if (['.cbz', '.cbr', '.zip'].includes(ext)) {
|
||||
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) => ({
|
||||
id: i,
|
||||
url: `/api/library/${unitId}/resource/${i}`
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'manga',
|
||||
format: 'archive',
|
||||
pages
|
||||
};
|
||||
}
|
||||
|
||||
if (fs.statSync(file.file_path).isDirectory()) {
|
||||
const pages = fs.readdirSync(file.file_path)
|
||||
.filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||
.map((_, i) => ({
|
||||
id: i,
|
||||
url: `/api/library/${unitId}/resource/${i}`
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'manga',
|
||||
format: 'folder',
|
||||
pages
|
||||
};
|
||||
}
|
||||
|
||||
if (ext === '.epub') {
|
||||
return {
|
||||
type: 'ln',
|
||||
format: 'epub',
|
||||
url: `/api/library/${unitId}/resource/epub`
|
||||
};
|
||||
}
|
||||
|
||||
if (['.txt', '.md'].includes(ext)) {
|
||||
return {
|
||||
type: 'ln',
|
||||
format: 'text',
|
||||
url: `/api/library/${unitId}/resource/text`
|
||||
};
|
||||
}
|
||||
|
||||
if (ext === '.pdf') {
|
||||
return {
|
||||
type: 'ln',
|
||||
format: 'pdf',
|
||||
url: `/api/library/${unitId}/resource/pdf`
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('UNSUPPORTED_FORMAT');
|
||||
}
|
||||
|
||||
export async function getUnitResource(unitId: string, resId: string) {
|
||||
const file = await queryOne(
|
||||
`SELECT file_path FROM local_files WHERE id = ?`,
|
||||
[unitId],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const ext = path.extname(file.file_path).toLowerCase();
|
||||
|
||||
if (['.cbz', '.zip', '.cbr'].includes(ext)) {
|
||||
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[Number(resId)];
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
type: 'image',
|
||||
data: entry.getData()
|
||||
};
|
||||
}
|
||||
|
||||
if (fs.statSync(file.file_path).isDirectory()) {
|
||||
const images = fs.readdirSync(file.file_path)
|
||||
.filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
|
||||
const img = images[Number(resId)];
|
||||
if (!img) return null;
|
||||
|
||||
const imgPath = path.join(file.file_path, img);
|
||||
const stat = fs.statSync(imgPath);
|
||||
|
||||
return {
|
||||
type: 'image',
|
||||
path: imgPath,
|
||||
size: stat.size
|
||||
};
|
||||
}
|
||||
|
||||
if (ext === '.epub') {
|
||||
const html = await parseEpubToHtml(file.file_path);
|
||||
|
||||
return {
|
||||
type: 'html',
|
||||
data: html
|
||||
};
|
||||
}
|
||||
|
||||
if (['.txt', '.md'].includes(ext)) {
|
||||
const text = fs.readFileSync(file.file_path, 'utf8');
|
||||
|
||||
return {
|
||||
type: 'html',
|
||||
data: `<div class="ln-content"><pre>${text}</pre></div>`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseEpubToHtml(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const epub = new EPub(filePath);
|
||||
|
||||
epub.on('end', async () => {
|
||||
let html = '';
|
||||
|
||||
for (const id of epub.flow.map(f => f.id)) {
|
||||
const chapter = await new Promise<string>((res, rej) => {
|
||||
epub.getChapter(id, (err, text) => {
|
||||
if (err) rej(err);
|
||||
else res(text);
|
||||
});
|
||||
});
|
||||
|
||||
html += `<section class="ln-chapter">${chapter}</section>`;
|
||||
}
|
||||
|
||||
resolve(html);
|
||||
});
|
||||
|
||||
epub.on('error', reject);
|
||||
epub.parse();
|
||||
});
|
||||
}
|
||||
99
desktop/src/api/rpc/rp.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Client } from "@xhayper/discord-rpc";
|
||||
|
||||
let rpcClient: Client | null = null;
|
||||
let reconnectTimer: NodeJS.Timeout | null = null;
|
||||
let connected = false;
|
||||
|
||||
type RPCMode = "watching" | "reading" | string;
|
||||
|
||||
interface RPCData {
|
||||
details?: string;
|
||||
state?: string;
|
||||
mode?: string;
|
||||
startTimestamp?: number;
|
||||
endTimestamp?: number;
|
||||
paused?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
function attemptReconnect(clientId: string) {
|
||||
connected = false;
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
console.log('Discord RPC: Trying to reconnect...');
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
initRPC(clientId);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
export function initRPC(clientId: string) {
|
||||
if (rpcClient) {
|
||||
try { rpcClient.destroy(); } catch {}
|
||||
rpcClient = null;
|
||||
}
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
rpcClient = new Client({ clientId });
|
||||
|
||||
rpcClient.on("ready", () => {
|
||||
connected = true;
|
||||
console.log("Discord RPC conectado");
|
||||
|
||||
setTimeout(() => {
|
||||
setActivity({ details: "Browsing", state: "In App" });
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
rpcClient.on("disconnected", () => {
|
||||
connected = false;
|
||||
attemptReconnect(clientId);
|
||||
});
|
||||
|
||||
rpcClient.on("error", () => {
|
||||
if (connected) attemptReconnect(clientId);
|
||||
});
|
||||
|
||||
rpcClient.login().catch(() => {
|
||||
attemptReconnect(clientId);
|
||||
});
|
||||
}
|
||||
|
||||
export function setActivity(data: RPCData = {}) {
|
||||
if (!rpcClient || !connected) return;
|
||||
|
||||
let type = 0;
|
||||
if (data.mode === "watching") type = 3;
|
||||
if (data.mode === "reading") type = 0;
|
||||
|
||||
const activity: any = {
|
||||
details: data.details,
|
||||
state: data.state,
|
||||
type,
|
||||
instance: false
|
||||
};
|
||||
|
||||
if (data.paused) {
|
||||
activity.largeImageText = "⏸ ";
|
||||
delete activity.startTimestamp;
|
||||
delete activity.endTimestamp;
|
||||
} else {
|
||||
activity.largeImageKey = "bigpicture";
|
||||
activity.largeImageText = data.version ?? "v2.0.0";
|
||||
|
||||
if (data.startTimestamp && data.endTimestamp) {
|
||||
activity.startTimestamp = data.startTimestamp;
|
||||
activity.endTimestamp = data.endTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
rpcClient.user?.setActivity(activity);
|
||||
}
|
||||
@@ -11,16 +11,29 @@ export function init() {
|
||||
}
|
||||
|
||||
export async function setRPC(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { details, state, mode } = request.body as {
|
||||
const {
|
||||
details,
|
||||
state,
|
||||
mode,
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
paused
|
||||
} = request.body as {
|
||||
details?: string;
|
||||
state?: string;
|
||||
mode?: "watching" | "reading" | string;
|
||||
startTimestamp?: number;
|
||||
endTimestamp?: number;
|
||||
paused?: boolean;
|
||||
};
|
||||
|
||||
setActivity({
|
||||
details,
|
||||
state,
|
||||
mode
|
||||
mode,
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
paused
|
||||
});
|
||||
|
||||
return reply.send({ ok: true });
|
||||
@@ -168,20 +168,39 @@ export async function deleteUser(req: FastifyRequest, reply: FastifyReply) {
|
||||
const { id } = req.params as { id: string };
|
||||
const userId = parseInt(id, 10);
|
||||
|
||||
const body = (req.body ?? {}) as { password?: string };
|
||||
const password = body.password;
|
||||
|
||||
if (!userId || isNaN(userId)) {
|
||||
return reply.code(400).send({ error: "Invalid user id" });
|
||||
}
|
||||
|
||||
const result = await userService.deleteUser(userId);
|
||||
|
||||
if (result && result.changes > 0) {
|
||||
return { success: true, message: "User deleted successfully" };
|
||||
} else {
|
||||
const user = await userService.getUserById(userId);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (user.has_password) {
|
||||
if (!password) {
|
||||
return reply.code(401).send({ error: "Password required" });
|
||||
}
|
||||
|
||||
const isValid = await userService.verifyPassword(userId, password);
|
||||
if (!isValid) {
|
||||
return reply.code(401).send({ error: "Incorrect password" });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await userService.deleteUser(userId);
|
||||
|
||||
if (result.changes > 0) {
|
||||
return reply.send({ success: true });
|
||||
}
|
||||
|
||||
return reply.code(500).send({ error: "Failed to delete user" });
|
||||
|
||||
} catch (err) {
|
||||
console.error("Delete User Error:", (err as Error).message);
|
||||
console.error("Delete User Error:", err);
|
||||
return reply.code(500).send({ error: "Failed to delete user" });
|
||||
}
|
||||
}
|
||||
@@ -246,16 +265,17 @@ export async function changePassword(req: FastifyRequest, reply: FastifyReply) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
// Si el usuario tiene contraseña actual, debe proporcionar la contraseña actual
|
||||
if (user.has_password && currentPassword) {
|
||||
const isValid = await userService.verifyPassword(userId, currentPassword);
|
||||
if (user.has_password) {
|
||||
if (!currentPassword) {
|
||||
return reply.code(401).send({ error: "Current password required" });
|
||||
}
|
||||
|
||||
const isValid = await userService.verifyPassword(userId, currentPassword);
|
||||
if (!isValid) {
|
||||
return reply.code(401).send({ error: "Current password is incorrect" });
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar la contraseña (null para eliminarla, string para establecerla)
|
||||
await userService.updateUser(userId, { password: newPassword });
|
||||
|
||||
return reply.send({
|
||||
186
desktop/src/api/user/user.service.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import {queryAll, queryOne, run} from '../../shared/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const USER_DB_NAME = 'userdata';
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
profile_picture_url: string | null;
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
export async function userExists(id: number): Promise<boolean> {
|
||||
const sql = 'SELECT 1 FROM User WHERE id = ?';
|
||||
const row = await queryOne(sql, [id], USER_DB_NAME);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function createUser(username: string, profilePictureUrl?: string, password?: string): Promise<{ lastID: number }> {
|
||||
let passwordHash = null;
|
||||
|
||||
if (password && password.trim()) {
|
||||
passwordHash = await bcrypt.hash(password.trim(), SALT_ROUNDS);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO User (username, profile_picture_url, password_hash)
|
||||
VALUES (?, ?, ?)
|
||||
`;
|
||||
const params = [username, profilePictureUrl || null, passwordHash];
|
||||
|
||||
const result = await run(sql, params, USER_DB_NAME);
|
||||
|
||||
return { lastID: result.lastID };
|
||||
}
|
||||
|
||||
export async function updateUser(userId: number, updates: any): Promise<any> {
|
||||
const fields: string[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
|
||||
if (updates.username !== undefined) {
|
||||
fields.push('username = ?');
|
||||
values.push(updates.username);
|
||||
}
|
||||
|
||||
if (updates.profilePictureUrl !== undefined) {
|
||||
fields.push('profile_picture_url = ?');
|
||||
values.push(updates.profilePictureUrl);
|
||||
}
|
||||
|
||||
if (updates.password !== undefined) {
|
||||
if (updates.password === null || updates.password === '') {
|
||||
// Eliminar contraseña
|
||||
fields.push('password_hash = ?');
|
||||
values.push(null);
|
||||
} else {
|
||||
// Actualizar contraseña
|
||||
const hash = await bcrypt.hash(updates.password.trim(), SALT_ROUNDS);
|
||||
fields.push('password_hash = ?');
|
||||
values.push(hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return { changes: 0, lastID: userId };
|
||||
}
|
||||
|
||||
const setClause = fields.join(', ');
|
||||
const sql = `UPDATE User SET ${setClause} WHERE id = ?`;
|
||||
values.push(userId);
|
||||
|
||||
return await run(sql, values, USER_DB_NAME);
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number): Promise<any> {
|
||||
await run(
|
||||
`DELETE FROM ListEntry WHERE user_id = ?`,
|
||||
[userId],
|
||||
USER_DB_NAME
|
||||
);
|
||||
|
||||
await run(
|
||||
`DELETE FROM UserIntegration WHERE user_id = ?`,
|
||||
[userId],
|
||||
USER_DB_NAME
|
||||
);
|
||||
|
||||
await run(
|
||||
`DELETE FROM favorites WHERE user_id = ?`,
|
||||
[userId],
|
||||
'favorites'
|
||||
);
|
||||
|
||||
const result = await run(
|
||||
`DELETE FROM User WHERE id = ?`,
|
||||
[userId],
|
||||
USER_DB_NAME
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getAllUsers(): Promise<User[]> {
|
||||
const sql = `
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
profile_picture_url,
|
||||
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
|
||||
FROM User
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
const users = await queryAll(sql, [], USER_DB_NAME);
|
||||
|
||||
return users.map((user: any) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profile_picture_url: user.profile_picture_url || null,
|
||||
has_password: !!user.has_password
|
||||
})) as User[];
|
||||
}
|
||||
|
||||
export async function getUserById(id: number): Promise<User | null> {
|
||||
const sql = `
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
profile_picture_url,
|
||||
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
|
||||
FROM User
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
const user = await queryOne(sql, [id], USER_DB_NAME);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profile_picture_url: user.profile_picture_url || null,
|
||||
has_password: !!user.has_password
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPassword(userId: number, password: string): Promise<boolean> {
|
||||
const sql = 'SELECT password_hash FROM User WHERE id = ?';
|
||||
const user = await queryOne(sql, [userId], USER_DB_NAME);
|
||||
|
||||
if (!user || !user.password_hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await bcrypt.compare(password, user.password_hash);
|
||||
}
|
||||
|
||||
export async function getAniListIntegration(userId: number) {
|
||||
const sql = `
|
||||
SELECT anilist_user_id, expires_at
|
||||
FROM UserIntegration
|
||||
WHERE user_id = ? AND platform = ?
|
||||
`;
|
||||
|
||||
const row = await queryOne(sql, [userId, "AniList"], USER_DB_NAME);
|
||||
|
||||
if (!row) {
|
||||
return { connected: false };
|
||||
}
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
anilistUserId: row.anilist_user_id,
|
||||
expiresAt: row.expires_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeAniListIntegration(userId: number) {
|
||||
const sql = `
|
||||
DELETE FROM UserIntegration
|
||||
WHERE user_id = ? AND platform = ?
|
||||
`;
|
||||
|
||||
return run(sql, [userId, "AniList"], USER_DB_NAME);
|
||||
}
|
||||
327
desktop/src/scripts/anime/anime.js
Normal file
@@ -0,0 +1,327 @@
|
||||
let animeData = null;
|
||||
let extensionName = null;
|
||||
let animeId = null;
|
||||
let isLocal = false;
|
||||
|
||||
const episodePagination = Object.create(PaginationManager);
|
||||
episodePagination.init(12, renderEpisodes);
|
||||
|
||||
YouTubePlayerUtils.init('player');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadAnime();
|
||||
setupDescriptionModal();
|
||||
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() {
|
||||
try {
|
||||
|
||||
const urlData = URLUtils.parseEntityPath('anime');
|
||||
if (!urlData) {
|
||||
showError("Invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
extensionName = urlData.extensionName;
|
||||
animeId = urlData.entityId;
|
||||
await checkLocalLibraryEntry();
|
||||
|
||||
const fetchUrl = extensionName
|
||||
? `/api/anime/${animeId}?source=${extensionName}`
|
||||
: `/api/anime/${animeId}?source=anilist`;
|
||||
|
||||
const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
showError("Anime Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
animeData = data;
|
||||
animeData.entry_type = 'ANIME';
|
||||
const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName);
|
||||
|
||||
updatePageTitle(metadata.title);
|
||||
updateMetadata(metadata);
|
||||
updateDescription(data.description || data.summary);
|
||||
updateCharacters(metadata.characters);
|
||||
updateExtensionPill();
|
||||
|
||||
setupWatchButton();
|
||||
|
||||
const hasTrailer = YouTubePlayerUtils.playTrailer(
|
||||
metadata.trailer,
|
||||
'player',
|
||||
metadata.banner
|
||||
);
|
||||
|
||||
setupEpisodes(metadata.episodes);
|
||||
|
||||
await setupAddToListButton();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading anime:', err);
|
||||
showError("Error loading anime");
|
||||
}
|
||||
}
|
||||
|
||||
function updatePageTitle(title) {
|
||||
document.title = `${title} | WaifuBoard`;
|
||||
document.getElementById('title').innerText = title;
|
||||
}
|
||||
|
||||
function updateMetadata(metadata) {
|
||||
|
||||
if (metadata.poster) {
|
||||
document.getElementById('poster').src = metadata.poster;
|
||||
}
|
||||
|
||||
document.getElementById('score').innerText = `${metadata.score}% Score`;
|
||||
|
||||
document.getElementById('year').innerText = metadata.year;
|
||||
|
||||
document.getElementById('genres').innerText = metadata.genres;
|
||||
|
||||
document.getElementById('format').innerText = metadata.format;
|
||||
|
||||
document.getElementById('status').innerText = metadata.status;
|
||||
|
||||
document.getElementById('season').innerText = metadata.season;
|
||||
|
||||
document.getElementById('studio').innerText = metadata.studio;
|
||||
|
||||
document.getElementById('episodes').innerText = metadata.episodes;
|
||||
}
|
||||
|
||||
function updateDescription(rawDescription) {
|
||||
const desc = MediaMetadataUtils.truncateDescription(rawDescription, 4);
|
||||
|
||||
document.getElementById('description-preview').innerHTML = desc.short;
|
||||
document.getElementById('full-description').innerHTML = desc.full;
|
||||
|
||||
const readMoreBtn = document.getElementById('read-more-btn');
|
||||
if (desc.isTruncated) {
|
||||
readMoreBtn.style.display = 'inline-flex';
|
||||
} else {
|
||||
readMoreBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateCharacters(characters) {
|
||||
const container = document.getElementById('char-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (characters.length > 0) {
|
||||
characters.forEach(char => {
|
||||
container.innerHTML += `
|
||||
<div class="character-item">
|
||||
<div class="char-dot"></div> ${char.name}
|
||||
</div>`;
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="character-item" style="color: #666;">
|
||||
No character data available
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateExtensionPill() {
|
||||
const pill = document.getElementById('extension-pill');
|
||||
if (!pill) return;
|
||||
|
||||
if (extensionName) {
|
||||
pill.textContent = extensionName.charAt(0).toUpperCase() + extensionName.slice(1).toLowerCase();
|
||||
pill.style.display = 'inline-flex';
|
||||
} else {
|
||||
pill.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function setupWatchButton() {
|
||||
const watchBtn = document.getElementById('watch-btn');
|
||||
if (watchBtn) {
|
||||
watchBtn.onclick = () => {
|
||||
const source = isLocal ? 'local' : (extensionName || 'anilist');
|
||||
window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function setupAddToListButton() {
|
||||
const btn = document.getElementById('add-to-list-btn');
|
||||
if (!btn || !animeData) return;
|
||||
|
||||
ListModalManager.currentData = animeData;
|
||||
const entryType = ListModalManager.getEntryType(animeData);
|
||||
|
||||
await ListModalManager.checkIfInList(animeId, extensionName || 'anilist', entryType);
|
||||
|
||||
const tempBtn = document.querySelector('.hero-buttons .btn-blur');
|
||||
if (tempBtn) {
|
||||
ListModalManager.updateButton('.hero-buttons .btn-blur');
|
||||
} else {
|
||||
|
||||
updateCustomAddButton();
|
||||
}
|
||||
|
||||
btn.onclick = () => ListModalManager.open(animeData, extensionName || 'anilist');
|
||||
}
|
||||
|
||||
function updateCustomAddButton() {
|
||||
const btn = document.getElementById('add-to-list-btn');
|
||||
if (!btn) return;
|
||||
|
||||
if (ListModalManager.isInList) {
|
||||
btn.innerHTML = `
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
In Your List
|
||||
`;
|
||||
btn.style.background = 'rgba(34, 197, 94, 0.2)';
|
||||
btn.style.color = '#22c55e';
|
||||
btn.style.borderColor = 'rgba(34, 197, 94, 0.3)';
|
||||
} else {
|
||||
btn.innerHTML = '+ Add to List';
|
||||
btn.style.background = null;
|
||||
btn.style.color = null;
|
||||
btn.style.borderColor = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupEpisodes(totalEpisodes) {
|
||||
|
||||
const limitedTotal = Math.min(Math.max(totalEpisodes, 1), 5000);
|
||||
|
||||
episodePagination.setTotalItems(limitedTotal);
|
||||
renderEpisodes();
|
||||
}
|
||||
|
||||
function renderEpisodes() {
|
||||
const grid = document.getElementById('episodes-grid');
|
||||
if (!grid) return;
|
||||
|
||||
grid.innerHTML = '';
|
||||
|
||||
const range = episodePagination.getPageRange();
|
||||
const start = range.start + 1;
|
||||
|
||||
const end = range.end;
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
createEpisodeButton(i, grid);
|
||||
}
|
||||
|
||||
episodePagination.renderControls(
|
||||
'pagination-controls',
|
||||
'page-info',
|
||||
'prev-page',
|
||||
'next-page'
|
||||
);
|
||||
}
|
||||
|
||||
function createEpisodeButton(num, container) {
|
||||
const btn = document.createElement('div');
|
||||
btn.className = 'episode-btn';
|
||||
btn.innerText = `Ep ${num}`;
|
||||
btn.onclick = () => {
|
||||
const source = isLocal ? 'local' : (extensionName || 'anilist');
|
||||
window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
|
||||
};
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
function setupDescriptionModal() {
|
||||
const modal = document.getElementById('desc-modal');
|
||||
if (!modal) return;
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'desc-modal') {
|
||||
closeDescriptionModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openDescriptionModal() {
|
||||
document.getElementById('desc-modal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeDescriptionModal() {
|
||||
document.getElementById('desc-modal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function setupEpisodeSearch() {
|
||||
const searchInput = document.getElementById('ep-search');
|
||||
if (!searchInput) return;
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
const grid = document.getElementById('episodes-grid');
|
||||
const totalEpisodes = episodePagination.totalItems;
|
||||
|
||||
if (val > 0 && val <= totalEpisodes) {
|
||||
grid.innerHTML = '';
|
||||
createEpisodeButton(val, grid);
|
||||
document.getElementById('pagination-controls').style.display = 'none';
|
||||
} else if (!e.target.value) {
|
||||
renderEpisodes();
|
||||
} else {
|
||||
grid.innerHTML = '<div style="color:#666; width:100%; text-align:center;">Episode not found</div>';
|
||||
document.getElementById('pagination-controls').style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
document.getElementById('title').innerText = message;
|
||||
}
|
||||
|
||||
function saveToList() {
|
||||
if (!animeId) return;
|
||||
ListModalManager.save(animeId, extensionName || 'anilist');
|
||||
}
|
||||
|
||||
function deleteFromList() {
|
||||
if (!animeId) return;
|
||||
ListModalManager.delete(animeId, extensionName || 'anilist');
|
||||
}
|
||||
|
||||
function closeAddToListModal() {
|
||||
ListModalManager.close();
|
||||
}
|
||||
|
||||
window.openDescriptionModal = openDescriptionModal;
|
||||
window.closeDescriptionModal = closeDescriptionModal;
|
||||
window.changePage = (delta) => {
|
||||
if (delta > 0) episodePagination.nextPage();
|
||||
else episodePagination.prevPage();
|
||||
};
|
||||
@@ -92,6 +92,7 @@ function startHeroCycle() {
|
||||
|
||||
async function updateHeroUI(anime) {
|
||||
if(!anime) return;
|
||||
anime.entry_type = 'ANIME';
|
||||
|
||||
const title = anime.title.english || anime.title.romaji || "Unknown Title";
|
||||
const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A';
|
||||
487
desktop/src/scripts/anime/player.js
Normal file
@@ -0,0 +1,487 @@
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const animeId = pathParts[2];
|
||||
const currentEpisode = parseInt(pathParts[3]);
|
||||
|
||||
let audioMode = 'sub';
|
||||
let currentExtension = '';
|
||||
let plyrInstance;
|
||||
let hlsInstance;
|
||||
let totalEpisodes = 0;
|
||||
let animeTitle = "";
|
||||
let aniSkipData = null;
|
||||
|
||||
let isAnilist = false;
|
||||
let malId = null;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const firstKey = params.keys().next().value;
|
||||
let extName;
|
||||
if (firstKey) extName = firstKey;
|
||||
|
||||
// URL de retroceso: Si es local, volvemos a la vista de Anilist normal
|
||||
const href = (extName && extName !== 'local')
|
||||
? `/anime/${extName}/${animeId}`
|
||||
: `/anime/${animeId}`;
|
||||
|
||||
document.getElementById('back-link').href = href;
|
||||
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 null;
|
||||
const data = await res.json();
|
||||
return data.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAniSkip(malId, episode, duration) {
|
||||
try {
|
||||
const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.results || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading AniSkip data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetadata() {
|
||||
localEntryId = await checkLocal();
|
||||
try {
|
||||
const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`;
|
||||
const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error("Error from API:", data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const isAnilistFormat = data.title && (data.title.romaji || data.title.english);
|
||||
|
||||
let title = '', description = '', coverImage = '', averageScore = '', format = '', seasonYear = '', season = '';
|
||||
|
||||
if (isAnilistFormat) {
|
||||
title = data.title.romaji || data.title.english || data.title.native || 'Anime Title';
|
||||
description = data.description || 'No description available.';
|
||||
coverImage = data.coverImage?.large || data.coverImage?.medium || '';
|
||||
averageScore = data.averageScore ? `${data.averageScore}%` : '--';
|
||||
format = data.format || '--';
|
||||
season = data.season ? data.season.charAt(0) + data.season.slice(1).toLowerCase() : '';
|
||||
seasonYear = data.seasonYear || '';
|
||||
} else {
|
||||
title = data.title || 'Anime Title';
|
||||
description = data.summary || 'No description available.';
|
||||
coverImage = data.image || '';
|
||||
averageScore = data.score ? `${Math.round(data.score * 10)}%` : '--';
|
||||
format = '--';
|
||||
season = data.season || '';
|
||||
seasonYear = data.year || '';
|
||||
}
|
||||
|
||||
if (isAnilistFormat && data.idMal) {
|
||||
isAnilist = true;
|
||||
malId = data.idMal;
|
||||
} else {
|
||||
isAnilist = false;
|
||||
malId = null;
|
||||
}
|
||||
|
||||
document.getElementById('anime-title-details').innerText = title;
|
||||
document.getElementById('anime-title-details2').innerText = title;
|
||||
animeTitle = title;
|
||||
document.title = `Watching ${title} - Ep ${currentEpisode}`;
|
||||
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = description;
|
||||
document.getElementById('detail-description').innerText = tempDiv.textContent || tempDiv.innerText || 'No description available.';
|
||||
|
||||
document.getElementById('detail-format').innerText = format;
|
||||
document.getElementById('detail-score').innerText = averageScore;
|
||||
document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--');
|
||||
document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg';
|
||||
|
||||
// Solo cargamos episodios de extensión si hay extensión real y no es local
|
||||
if (extName && extName !== 'local') {
|
||||
await loadExtensionEpisodes();
|
||||
} else {
|
||||
if (data.nextAiringEpisode?.episode) {
|
||||
totalEpisodes = data.nextAiringEpisode.episode - 1;
|
||||
} else if (data.episodes) {
|
||||
totalEpisodes = data.episodes;
|
||||
} else {
|
||||
totalEpisodes = 12;
|
||||
}
|
||||
const simpleEpisodes = [];
|
||||
for (let i = 1; i <= totalEpisodes; i++) {
|
||||
simpleEpisodes.push({ number: i, title: null, thumbnail: null, isDub: false });
|
||||
}
|
||||
populateEpisodeCarousel(simpleEpisodes);
|
||||
}
|
||||
|
||||
if (currentEpisode >= totalEpisodes && totalEpisodes > 0) {
|
||||
document.getElementById('next-btn').disabled = true;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading metadata:', error);
|
||||
}
|
||||
await loadExtensions();
|
||||
}
|
||||
|
||||
async function applyAniSkip(video) {
|
||||
if (!isAnilist || !malId) return;
|
||||
|
||||
aniSkipData = await loadAniSkip(malId, currentEpisode, Math.floor(video.duration));
|
||||
|
||||
if (!aniSkipData || aniSkipData.length === 0) return;
|
||||
|
||||
const markers = [];
|
||||
aniSkipData.forEach(item => {
|
||||
const { startTime, endTime } = item.interval;
|
||||
markers.push({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
label: item.skipType === 'op' ? 'Opening' : 'Ending'
|
||||
});
|
||||
});
|
||||
|
||||
if (plyrInstance && markers.length > 0) {
|
||||
setTimeout(() => {
|
||||
const progressContainer = document.querySelector('.plyr__progress');
|
||||
if (!progressContainer) return;
|
||||
|
||||
const oldMarkers = progressContainer.querySelector('.plyr__markers');
|
||||
if (oldMarkers) oldMarkers.remove();
|
||||
|
||||
const markersContainer = document.createElement('div');
|
||||
markersContainer.className = 'plyr__markers';
|
||||
|
||||
markers.forEach(marker => {
|
||||
const markerElement = document.createElement('div');
|
||||
markerElement.className = 'plyr__marker';
|
||||
markerElement.dataset.label = marker.label;
|
||||
|
||||
const startPercent = (marker.start / video.duration) * 100;
|
||||
const widthPercent = ((marker.end - marker.start) / video.duration) * 100;
|
||||
|
||||
markerElement.style.left = `${startPercent}%`;
|
||||
markerElement.style.width = `${widthPercent}%`;
|
||||
|
||||
markerElement.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
video.currentTime = marker.start;
|
||||
});
|
||||
|
||||
markersContainer.appendChild(markerElement);
|
||||
});
|
||||
progressContainer.appendChild(markersContainer);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExtensionEpisodes() {
|
||||
try {
|
||||
const res = await fetch(`/api/anime/${animeId}/episodes?source=${extName}`);
|
||||
const data = await res.json();
|
||||
totalEpisodes = Array.isArray(data) ? data.length : 0;
|
||||
populateEpisodeCarousel(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
console.error("Error cargando episodios:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function populateEpisodeCarousel(episodesData) {
|
||||
const carousel = document.getElementById('episode-carousel');
|
||||
carousel.innerHTML = '';
|
||||
|
||||
episodesData.forEach((ep, index) => {
|
||||
const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1);
|
||||
if (!epNumber) return;
|
||||
|
||||
const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
|
||||
const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== '';
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `/watch/${animeId}/${epNumber}${extParam}`;
|
||||
link.classList.add('carousel-item');
|
||||
if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel');
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.classList.add('carousel-item-img-container');
|
||||
|
||||
if (hasThumbnail) {
|
||||
const img = document.createElement('img');
|
||||
img.src = ep.thumbnail;
|
||||
img.classList.add('carousel-item-img');
|
||||
imgContainer.appendChild(img);
|
||||
}
|
||||
|
||||
link.appendChild(imgContainer);
|
||||
const info = document.createElement('div');
|
||||
info.classList.add('carousel-item-info');
|
||||
info.innerHTML = `<p>Ep ${epNumber}: ${ep.title || 'Untitled'}</p>`;
|
||||
link.appendChild(info);
|
||||
carousel.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadExtensions() {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/anime');
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('extension-select');
|
||||
let extensions = data.extensions || [];
|
||||
|
||||
if (extName === 'local' && !extensions.includes('local')) {
|
||||
extensions.push('local');
|
||||
}
|
||||
|
||||
select.innerHTML = '';
|
||||
extensions.forEach(ext => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = opt.innerText = ext;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
if (extName && extensions.includes(extName)) {
|
||||
select.value = extName;
|
||||
} else if (extensions.length > 0) {
|
||||
select.value = extensions[0];
|
||||
}
|
||||
|
||||
currentExtension = select.value;
|
||||
onExtensionChange();
|
||||
} catch (error) {
|
||||
console.error("Extension Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function onExtensionChange() {
|
||||
const select = document.getElementById('extension-select');
|
||||
currentExtension = select.value;
|
||||
|
||||
if (currentExtension === 'local') {
|
||||
document.getElementById('sd-toggle').style.display = 'none';
|
||||
document.getElementById('server-select').style.display = 'none';
|
||||
loadStream();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading("Fetching extension settings...");
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/${currentExtension}/settings`);
|
||||
const settings = await res.json();
|
||||
|
||||
const toggle = document.getElementById('sd-toggle');
|
||||
toggle.style.display = settings.supportsDub ? 'flex' : 'none';
|
||||
setAudioMode('sub');
|
||||
|
||||
const serverSelect = document.getElementById('server-select');
|
||||
serverSelect.innerHTML = '';
|
||||
if (settings.episodeServers?.length > 0) {
|
||||
settings.episodeServers.forEach(srv => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = opt.innerText = srv;
|
||||
serverSelect.appendChild(opt);
|
||||
});
|
||||
serverSelect.style.display = 'block';
|
||||
} else {
|
||||
serverSelect.style.display = 'none';
|
||||
}
|
||||
loadStream();
|
||||
} catch (error) {
|
||||
setLoading("Failed to load settings.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStream() {
|
||||
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 server = serverSelect.value || "default";
|
||||
setLoading(`Loading stream (${audioMode})...`);
|
||||
|
||||
try {
|
||||
const sourc = (extName && extName !== 'local') ? `&source=${extName}` : "&source=anilist";
|
||||
const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error || !data.videoSources?.length) {
|
||||
setLoading(data.error || "No video sources.");
|
||||
return;
|
||||
}
|
||||
|
||||
const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0];
|
||||
const headers = data.headers || {};
|
||||
|
||||
let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`;
|
||||
if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
|
||||
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
|
||||
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
|
||||
|
||||
playVideo(proxyUrl, source.subtitles || data.subtitles || []);
|
||||
document.getElementById('loading-overlay').style.display = 'none';
|
||||
} catch (error) {
|
||||
setLoading("Stream error.");
|
||||
}
|
||||
}
|
||||
|
||||
function playVideo(url, subtitles = []) {
|
||||
const video = document.getElementById('player');
|
||||
const isLocal = url.includes('/api/library/stream/');
|
||||
|
||||
if (!isLocal && Hls.isSupported()) {
|
||||
if (hlsInstance) hlsInstance.destroy();
|
||||
hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false });
|
||||
hlsInstance.loadSource(url);
|
||||
hlsInstance.attachMedia(video);
|
||||
} else {
|
||||
if (hlsInstance) hlsInstance.destroy();
|
||||
video.src = url;
|
||||
}
|
||||
|
||||
if (plyrInstance) plyrInstance.destroy();
|
||||
while (video.textTracks.length > 0) video.removeChild(video.textTracks[0]);
|
||||
|
||||
subtitles.forEach(sub => {
|
||||
const track = document.createElement('track');
|
||||
track.kind = 'captions';
|
||||
track.label = sub.language || 'Unknown';
|
||||
track.srclang = (sub.language || '').slice(0, 2).toLowerCase();
|
||||
track.src = sub.url;
|
||||
if (sub.default || sub.language?.toLowerCase().includes('english')) track.default = true;
|
||||
video.appendChild(track);
|
||||
});
|
||||
|
||||
plyrInstance = new Plyr(video, {
|
||||
captions: { active: true, update: true, language: 'en' },
|
||||
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
|
||||
settings: ['captions', 'quality', 'speed']
|
||||
});
|
||||
|
||||
video.addEventListener('loadedmetadata', () => applyAniSkip(video));
|
||||
|
||||
// LÓGICA DE RPC (Discord)
|
||||
let rpcActive = false;
|
||||
video.addEventListener("play", () => {
|
||||
if (!video.duration) return;
|
||||
const elapsed = Math.floor(video.currentTime);
|
||||
const start = Math.floor(Date.now() / 1000) - elapsed;
|
||||
const end = start + Math.floor(video.duration);
|
||||
sendRPC({ startTimestamp: start, endTimestamp: end });
|
||||
rpcActive = true;
|
||||
});
|
||||
|
||||
video.addEventListener("pause", () => {
|
||||
if (rpcActive) sendRPC({ paused: true });
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
if (video.paused || !rpcActive) return;
|
||||
const elapsed = Math.floor(video.currentTime);
|
||||
const start = Math.floor(Date.now() / 1000) - elapsed;
|
||||
const end = start + Math.floor(video.duration);
|
||||
sendRPC({ startTimestamp: start, endTimestamp: end });
|
||||
});
|
||||
}
|
||||
|
||||
function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) {
|
||||
fetch("/api/rpc", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
details: animeTitle,
|
||||
state: `Episode ${currentEpisode}`,
|
||||
mode: "watching",
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
paused
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async function sendProgress() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
const source = (extName && extName !== 'local') ? extName : "anilist";
|
||||
|
||||
const body = {
|
||||
entry_id: animeId,
|
||||
source: source,
|
||||
entry_type: "ANIME",
|
||||
status: 'CURRENT',
|
||||
progress: currentEpisode
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch('/api/list/entry', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error updating progress:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -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() {
|
||||
const userAvatarBtn = document.querySelector(".user-avatar-btn")
|
||||
const navDropdown = document.getElementById("nav-dropdown")
|
||||
@@ -6,6 +6,8 @@ let bookSlug = null;
|
||||
let allChapters = [];
|
||||
let filteredChapters = [];
|
||||
|
||||
let availableExtensions = [];
|
||||
let isLocal = false;
|
||||
const chapterPagination = Object.create(PaginationManager);
|
||||
chapterPagination.init(12, () => renderChapterTable());
|
||||
|
||||
@@ -14,9 +16,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setupModalClickOutside();
|
||||
});
|
||||
|
||||
async function checkLocalLibraryEntry() {
|
||||
try {
|
||||
const libraryType =
|
||||
bookData?.entry_type === 'NOVEL' ? 'novels' : 'manga';
|
||||
|
||||
const res = await fetch(`/api/library/${libraryType}/${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);
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
|
||||
const urlData = URLUtils.parseEntityPath('book');
|
||||
if (!urlData) {
|
||||
showError("Book Not Found");
|
||||
@@ -26,9 +52,10 @@ async function init() {
|
||||
extensionName = urlData.extensionName;
|
||||
bookId = urlData.entityId;
|
||||
bookSlug = urlData.slug;
|
||||
|
||||
await loadBookMetadata();
|
||||
await checkLocalLibraryEntry();
|
||||
|
||||
await loadAvailableExtensions();
|
||||
await loadChapters();
|
||||
|
||||
await setupAddToListButton();
|
||||
@@ -39,11 +66,23 @@ async function init() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableExtensions() {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/book');
|
||||
const data = await res.json();
|
||||
availableExtensions = data.extensions || [];
|
||||
|
||||
setupProviderFilter();
|
||||
} catch (err) {
|
||||
console.error("Error fetching extensions:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookMetadata() {
|
||||
const source = extensionName || 'anilist';
|
||||
const fetchUrl = `/api/book/${bookId}?source=${source}`;
|
||||
|
||||
const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() });
|
||||
const res = await fetch(fetchUrl);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error || !data) {
|
||||
@@ -55,7 +94,8 @@ async function loadBookMetadata() {
|
||||
bookData = raw;
|
||||
|
||||
const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName);
|
||||
|
||||
bookData.entry_type =
|
||||
metadata.format === 'MANGA' ? 'MANGA' : 'NOVEL';
|
||||
updatePageTitle(metadata.title);
|
||||
updateMetadata(metadata);
|
||||
updateExtensionPill();
|
||||
@@ -154,39 +194,61 @@ function updateCustomAddButton() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChapters() {
|
||||
async function loadChapters(targetProvider = null) {
|
||||
const tbody = document.getElementById('chapters-body');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extensions for chapters...</td></tr>';
|
||||
if (!targetProvider) {
|
||||
const select = document.getElementById('provider-filter');
|
||||
targetProvider = select ? select.value : (availableExtensions[0] || 'all');
|
||||
}
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Loading chapters...</td></tr>';
|
||||
|
||||
try {
|
||||
const source = extensionName || 'anilist';
|
||||
const fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
||||
let fetchUrl;
|
||||
let isLocalRequest = targetProvider === 'local';
|
||||
|
||||
const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() });
|
||||
if (isLocalRequest) {
|
||||
// Nuevo endpoint para archivos locales
|
||||
fetchUrl = `/api/library/${bookId}/units`;
|
||||
} else {
|
||||
const source = extensionName || 'anilist';
|
||||
fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
||||
if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
|
||||
}
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
const data = await res.json();
|
||||
|
||||
allChapters = data.chapters || [];
|
||||
filteredChapters = [...allChapters];
|
||||
// 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 || [];
|
||||
}
|
||||
|
||||
filteredChapters = [...allChapters];
|
||||
applyChapterFilter();
|
||||
|
||||
const totalEl = document.getElementById('total-chapters');
|
||||
|
||||
if (allChapters.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found on loaded extensions.</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";
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
|
||||
|
||||
setupProviderFilter();
|
||||
|
||||
setupReadButton();
|
||||
|
||||
chapterPagination.setTotalItems(filteredChapters.length);
|
||||
chapterPagination.reset();
|
||||
renderChapterTable();
|
||||
|
||||
} catch (err) {
|
||||
@@ -213,42 +275,42 @@ function setupProviderFilter() {
|
||||
const select = document.getElementById('provider-filter');
|
||||
if (!select) return;
|
||||
|
||||
const providers = [...new Set(allChapters.map(ch => ch.provider))];
|
||||
|
||||
if (providers.length === 0) return;
|
||||
|
||||
select.style.display = 'inline-block';
|
||||
select.innerHTML = '<option value="all">All Providers</option>';
|
||||
select.innerHTML = '';
|
||||
|
||||
providers.forEach(prov => {
|
||||
// Opción para cargar todo
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = 'all';
|
||||
allOpt.innerText = 'Load All (Slower)';
|
||||
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 => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = prov;
|
||||
opt.innerText = prov;
|
||||
opt.value = ext;
|
||||
opt.innerText = ext.charAt(0).toUpperCase() + ext.slice(1);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
if (extensionName) {
|
||||
const extensionProvider = providers.find(
|
||||
p => p.toLowerCase() === extensionName.toLowerCase()
|
||||
);
|
||||
|
||||
if (extensionProvider) {
|
||||
select.value = extensionProvider;
|
||||
filteredChapters = allChapters.filter(ch => ch.provider === extensionProvider);
|
||||
}
|
||||
// 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;
|
||||
} else if (availableExtensions.length > 0) {
|
||||
select.value = availableExtensions[0];
|
||||
}
|
||||
|
||||
select.onchange = (e) => {
|
||||
const selected = e.target.value;
|
||||
if (selected === 'all') {
|
||||
filteredChapters = [...allChapters];
|
||||
} else {
|
||||
filteredChapters = allChapters.filter(ch => ch.provider === selected);
|
||||
}
|
||||
|
||||
chapterPagination.reset();
|
||||
chapterPagination.setTotalItems(filteredChapters.length);
|
||||
renderChapterTable();
|
||||
select.onchange = () => {
|
||||
loadChapters(select.value);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,7 +365,14 @@ function renderChapterTable() {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -55,7 +55,8 @@ function startHeroCycle() {
|
||||
|
||||
async function updateHeroUI(book) {
|
||||
if(!book) return;
|
||||
|
||||
book.entry_type =
|
||||
book.format === 'MANGA' ? 'MANGA' : 'NOVEL';
|
||||
const title = book.title.english || book.title.romaji;
|
||||
const desc = book.description || "No description available.";
|
||||
const poster = (book.coverImage && (book.coverImage.extraLarge || book.coverImage.large)) || '';
|
||||
@@ -97,7 +97,7 @@ function applyStyles() {
|
||||
document.documentElement.style.setProperty('--ln-max-width', config.ln.maxWidth + 'px');
|
||||
document.documentElement.style.setProperty('--ln-font-family', config.ln.fontFamily);
|
||||
document.documentElement.style.setProperty('--ln-text-color', config.ln.textColor);
|
||||
document.documentElement.style.setProperty('--bg-base', config.ln.bg);
|
||||
document.documentElement.style.setProperty('--color-bg-base', config.ln.bg);
|
||||
document.documentElement.style.setProperty('--ln-text-align', config.ln.textAlign);
|
||||
}
|
||||
|
||||
@@ -129,11 +129,54 @@ async function loadChapter() {
|
||||
if (!source) {
|
||||
source = 'anilist';
|
||||
}
|
||||
const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
|
||||
let newEndpoint;
|
||||
|
||||
if (provider === 'local') {
|
||||
newEndpoint = `/api/library/${bookId}/units`;
|
||||
} else {
|
||||
newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(newEndpoint);
|
||||
const data = await res.json();
|
||||
if (provider === 'local') {
|
||||
const unit = data.units[Number(chapter)];
|
||||
if (!unit) return;
|
||||
|
||||
chapterLabel.textContent = unit.name;
|
||||
document.title = unit.name;
|
||||
|
||||
const manifestRes = await fetch(`/api/library/${unit.id}/manifest`);
|
||||
const manifest = await manifestRes.json();
|
||||
|
||||
reader.innerHTML = '';
|
||||
|
||||
// ===== MANGA =====
|
||||
if (manifest.type === 'manga') {
|
||||
currentType = 'manga';
|
||||
updateSettingsVisibility();
|
||||
applyStyles();
|
||||
|
||||
currentPages = manifest.pages;
|
||||
loadManga(currentPages);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== LN =====
|
||||
if (manifest.type === 'ln') {
|
||||
currentType = 'ln';
|
||||
updateSettingsVisibility();
|
||||
applyStyles();
|
||||
|
||||
const contentRes = await fetch(manifest.url);
|
||||
const html = await contentRes.text();
|
||||
|
||||
loadLN(html);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (data.title) {
|
||||
chapterLabel.textContent = data.title;
|
||||
@@ -293,7 +336,9 @@ function createImageElement(page, index) {
|
||||
img.className = 'page-img';
|
||||
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";
|
||||
|
||||
img.onerror = () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
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');
|
||||
|
||||
let currentPage = 1;
|
||||
@@ -299,7 +299,7 @@ async function searchGallery(isLoadMore = false) {
|
||||
const msg = favoritesMode
|
||||
? (query ? 'No favorites found matching your search' : 'You don\'t have any favorite images yet')
|
||||
: '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();
|
||||
380
desktop/src/scripts/list.js
Normal file
@@ -0,0 +1,380 @@
|
||||
const API_BASE = '/api';
|
||||
let currentList = [];
|
||||
let filteredList = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadList();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
function getEntryLink(item) {
|
||||
const isAnime = item.entry_type?.toUpperCase() === 'ANIME';
|
||||
const baseRoute = isAnime ? '/anime' : '/book';
|
||||
const source = item.source || 'anilist';
|
||||
|
||||
if (source === 'anilist') {
|
||||
return `${baseRoute}/${item.entry_id}`;
|
||||
} else {
|
||||
return `${baseRoute}/${source}/${item.entry_id}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function populateSourceFilter() {
|
||||
const select = document.getElementById('source-filter');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = `
|
||||
<option value="all">All Sources</option>
|
||||
<option value="anilist">AniList</option>
|
||||
`;
|
||||
|
||||
try {
|
||||
const [animeRes, bookRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/extensions/anime`),
|
||||
fetch(`${API_BASE}/extensions/book`)
|
||||
]);
|
||||
|
||||
const extensions = new Set();
|
||||
|
||||
if (animeRes.ok) {
|
||||
const data = await animeRes.json();
|
||||
(data.extensions || []).forEach(ext => extensions.add(ext));
|
||||
}
|
||||
|
||||
if (bookRes.ok) {
|
||||
const data = await bookRes.json();
|
||||
(data.extensions || []).forEach(ext => extensions.add(ext));
|
||||
}
|
||||
|
||||
extensions.forEach(extName => {
|
||||
const lower = extName.toLowerCase();
|
||||
if (lower !== 'anilist' && lower !== 'local') {
|
||||
const option = document.createElement('option');
|
||||
option.value = extName;
|
||||
option.textContent = extName.charAt(0).toUpperCase() + extName.slice(1);
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading extensions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocalList(entryData, action) {
|
||||
const entryId = entryData.entry_id;
|
||||
const source = entryData.source;
|
||||
|
||||
const findIndex = (list) => list.findIndex(e =>
|
||||
e.entry_id === entryId && e.source === source
|
||||
);
|
||||
|
||||
const currentIndex = findIndex(currentList);
|
||||
if (currentIndex !== -1) {
|
||||
if (action === 'update') {
|
||||
|
||||
currentList[currentIndex] = { ...currentList[currentIndex], ...entryData };
|
||||
} else if (action === 'delete') {
|
||||
currentList.splice(currentIndex, 1);
|
||||
}
|
||||
} else if (action === 'update') {
|
||||
|
||||
currentList.push(entryData);
|
||||
}
|
||||
|
||||
filteredList = [...currentList];
|
||||
|
||||
updateStats();
|
||||
applyFilters();
|
||||
window.ListModalManager.close();
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const view = btn.dataset.view;
|
||||
const container = document.getElementById('list-container');
|
||||
if (view === 'list') {
|
||||
container.classList.add('list-view');
|
||||
} else {
|
||||
container.classList.remove('list-view');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('status-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('source-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('type-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('sort-filter').addEventListener('change', applyFilters);
|
||||
|
||||
document.querySelector('.search-input').addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
if (query) {
|
||||
filteredList = currentList.filter(item =>
|
||||
item.title?.toLowerCase().includes(query)
|
||||
);
|
||||
} else {
|
||||
filteredList = [...currentList];
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
document.getElementById('modal-save-btn')?.addEventListener('click', async () => {
|
||||
|
||||
const entryToSave = window.ListModalManager.currentEntry || window.ListModalManager.currentData;
|
||||
|
||||
if (!entryToSave) return;
|
||||
|
||||
const success = await window.ListModalManager.save(entryToSave.entry_id, entryToSave.source);
|
||||
|
||||
if (success) {
|
||||
|
||||
const updatedEntry = window.ListModalManager.currentEntry;
|
||||
updatedEntry.updated_at = new Date().toISOString();
|
||||
|
||||
updateLocalList(updatedEntry, 'update');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
document.getElementById('modal-delete-btn')?.addEventListener('click', async () => {
|
||||
const entryToDelete = window.ListModalManager.currentEntry || window.ListModalManager.currentData;
|
||||
|
||||
if (!entryToDelete) return;
|
||||
|
||||
const success = await window.ListModalManager.delete(entryToDelete.entry_id, entryToDelete.source);
|
||||
|
||||
if (success) {
|
||||
updateLocalList(entryToDelete, 'delete');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
document.getElementById('add-list-modal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'add-list-modal') {
|
||||
window.ListModalManager.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const loadingState = document.getElementById('loading-state');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const container = document.getElementById('list-container');
|
||||
|
||||
await populateSourceFilter();
|
||||
|
||||
try {
|
||||
loadingState.style.display = 'flex';
|
||||
emptyState.style.display = 'none';
|
||||
container.innerHTML = '';
|
||||
|
||||
const response = await fetch(`${API_BASE}/list`, {
|
||||
headers: window.AuthUtils.getSimpleAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load list');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
currentList = data.results || [];
|
||||
filteredList = [...currentList];
|
||||
|
||||
loadingState.style.display = 'none';
|
||||
|
||||
if (currentList.length === 0) {
|
||||
emptyState.style.display = 'flex';
|
||||
} else {
|
||||
updateStats();
|
||||
applyFilters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading list:', error);
|
||||
loadingState.style.display = 'none';
|
||||
if (window.NotificationUtils) {
|
||||
window.NotificationUtils.error('Failed to load your list. Please try again.');
|
||||
} else {
|
||||
alert('Failed to load your list. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
|
||||
const total = currentList.length;
|
||||
const watching = currentList.filter(item => item.status === 'WATCHING').length;
|
||||
const completed = currentList.filter(item => item.status === 'COMPLETED').length;
|
||||
const planning = currentList.filter(item => item.status === 'PLANNING').length;
|
||||
|
||||
document.getElementById('total-count').textContent = total;
|
||||
document.getElementById('watching-count').textContent = watching;
|
||||
document.getElementById('completed-count').textContent = completed;
|
||||
document.getElementById('planned-count').textContent = planning;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const statusFilter = document.getElementById('status-filter').value;
|
||||
const sourceFilter = document.getElementById('source-filter').value;
|
||||
const typeFilter = document.getElementById('type-filter').value;
|
||||
const sortFilter = document.getElementById('sort-filter').value;
|
||||
|
||||
let filtered = [...filteredList];
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.status === statusFilter);
|
||||
}
|
||||
|
||||
if (sourceFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.source === sourceFilter);
|
||||
}
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.entry_type === typeFilter);
|
||||
}
|
||||
|
||||
switch (sortFilter) {
|
||||
case 'title':
|
||||
filtered.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
break;
|
||||
case 'score':
|
||||
filtered.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
break;
|
||||
case 'progress':
|
||||
filtered.sort((a, b) => (b.progress || 0) - (a.progress || 0));
|
||||
break;
|
||||
case 'updated':
|
||||
default:
|
||||
|
||||
filtered.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||
break;
|
||||
}
|
||||
|
||||
renderList(filtered);
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
const container = document.getElementById('list-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (items.length === 0) {
|
||||
|
||||
if (currentList.length === 0) {
|
||||
document.getElementById('empty-state').style.display = 'flex';
|
||||
} else {
|
||||
|
||||
container.innerHTML = '<div class="empty-state"><p>No entries match your filters</p></div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
|
||||
items.forEach(item => {
|
||||
const element = createListItem(item);
|
||||
container.appendChild(element);
|
||||
});
|
||||
}
|
||||
|
||||
function createListItem(item) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'list-item';
|
||||
|
||||
const itemLink = getEntryLink(item);
|
||||
|
||||
const posterUrl = item.poster || '/public/assets/placeholder.svg';
|
||||
const progress = item.progress || 0;
|
||||
|
||||
const totalUnits = item.entry_type === 'ANIME' ?
|
||||
item.total_episodes || 0 :
|
||||
item.total_chapters || 0;
|
||||
|
||||
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
|
||||
const score = item.score ? item.score.toFixed(1) : null;
|
||||
const repeatCount = item.repeat_count || 0;
|
||||
|
||||
const entryType = (item.entry_type).toUpperCase();
|
||||
let unitLabel = 'units';
|
||||
if (entryType === 'ANIME') {
|
||||
unitLabel = 'episodes';
|
||||
} else if (entryType === 'MANGA') {
|
||||
unitLabel = 'chapters';
|
||||
} else if (entryType === 'NOVEL') {
|
||||
unitLabel = 'chapters/volumes';
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
'CURRENT': entryType === 'ANIME' ? 'Watching' : 'Reading',
|
||||
'COMPLETED': 'Completed',
|
||||
'PLANNING': 'Planning',
|
||||
'PAUSED': 'Paused',
|
||||
'DROPPED': 'Dropped',
|
||||
'REPEATING': entryType === 'ANIME' ? 'Rewatching' : 'Rereading'
|
||||
};
|
||||
|
||||
const extraInfo = [];
|
||||
if (repeatCount > 0) {
|
||||
extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${repeatCount}</span>`);
|
||||
}
|
||||
if (item.is_private) {
|
||||
extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>');
|
||||
}
|
||||
|
||||
const entryDataString = JSON.stringify(item).replace(/'/g, ''');
|
||||
|
||||
div.innerHTML = `
|
||||
<a href="${itemLink}" class="item-poster-link">
|
||||
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.png'">
|
||||
</a>
|
||||
<div class="item-content">
|
||||
<div>
|
||||
<a href="${itemLink}" style="text-decoration:none; color:inherit;">
|
||||
<h3 class="item-title">${item.title || 'Unknown Title'}</h3>
|
||||
</a>
|
||||
<div class="item-meta">
|
||||
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
|
||||
<span class="meta-pill type-pill">${entryType}</span>
|
||||
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
|
||||
${extraInfo.join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" style="width: ${progressPercent}%"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
<span>${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel}</span> ${score ? `<span class="score-badge">⭐ ${score}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="edit-icon-btn" data-entry='${entryDataString}'>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path d="M15.232 5.232l3.536 3.536m-2.036-5.808a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L15.232 5.232z"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const editBtn = div.querySelector('.edit-icon-btn');
|
||||
editBtn.addEventListener('click', (e) => {
|
||||
try {
|
||||
const entryData = JSON.parse(e.currentTarget.dataset.entry);
|
||||
|
||||
window.ListModalManager.isInList = true;
|
||||
window.ListModalManager.currentEntry = entryData;
|
||||
window.ListModalManager.currentData = entryData;
|
||||
|
||||
window.ListModalManager.open(entryData, entryData.source);
|
||||
} catch (error) {
|
||||
console.error('Error parsing entry data for modal:', error);
|
||||
if (window.NotificationUtils) {
|
||||
window.NotificationUtils.error('Could not open modal. Check HTML form IDs.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
106
desktop/src/scripts/local-library-books.js
Normal file
@@ -0,0 +1,106 @@
|
||||
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 {
|
||||
const [mangaRes, novelRes] = await Promise.all([
|
||||
fetch('/api/library/manga'),
|
||||
fetch('/api/library/novels')
|
||||
]);
|
||||
|
||||
const [manga, novel] = await Promise.all([
|
||||
mangaRes.json(),
|
||||
novelRes.json()
|
||||
]);
|
||||
|
||||
localEntries = [
|
||||
...manga.map(e => ({ ...e, type: 'manga' })),
|
||||
...novel.map(e => ({ ...e, type: 'novel' }))
|
||||
];
|
||||
|
||||
if (localEntries.length === 0) {
|
||||
grid.innerHTML = '<p style="grid-column:1/-1;text-align:center;padding:3rem;">No books found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
renderLocalEntries(localEntries);
|
||||
} catch {
|
||||
grid.innerHTML = '<p style="grid-column:1/-1;text-align:center;color:var(--color-danger);padding:3rem;">Error loading library.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function filterLocal(type) {
|
||||
if (type === 'all') renderLocalEntries(localEntries);
|
||||
else renderLocalEntries(localEntries.filter(e => e.type === type));
|
||||
}
|
||||
|
||||
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="badge">${entry.type}</div>
|
||||
<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}`;
|
||||
}
|
||||
209
desktop/src/scripts/local-library.js
Normal 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();
|
||||
});
|
||||
262
desktop/src/scripts/marketplace.js
Normal file
@@ -0,0 +1,262 @@
|
||||
const ORIGINAL_MARKETPLACE_URL = 'https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/marketplace.json';
|
||||
const MARKETPLACE_JSON_URL = `/api/proxy?url=${encodeURIComponent(ORIGINAL_MARKETPLACE_URL)}`;
|
||||
const INSTALLED_EXTENSIONS_API = '/api/extensions';
|
||||
const UPDATE_EXTENSIONS_API = '/api/extensions/update';
|
||||
|
||||
const marketplaceContent = document.getElementById('marketplace-content');
|
||||
const filterSelect = document.getElementById('extension-filter');
|
||||
const updateAllBtn = document.getElementById('btn-update-all');
|
||||
|
||||
const modal = document.getElementById('customModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalMessage = document.getElementById('modalMessage');
|
||||
const modalConfirmBtn = document.getElementById('modalConfirmButton');
|
||||
const modalCloseBtn = document.getElementById('modalCloseButton');
|
||||
|
||||
let marketplaceMetadata = {};
|
||||
let installedExtensions = [];
|
||||
let currentTab = 'marketplace';
|
||||
|
||||
async function loadMarketplace() {
|
||||
showSkeletons();
|
||||
try {
|
||||
const [metaRes, installedRes] = await Promise.all([
|
||||
fetch(MARKETPLACE_JSON_URL).then(res => res.json()),
|
||||
fetch(INSTALLED_EXTENSIONS_API).then(res => res.json())
|
||||
]);
|
||||
|
||||
marketplaceMetadata = metaRes.extensions;
|
||||
installedExtensions = (installedRes.extensions || []).map(e => e.toLowerCase());
|
||||
|
||||
initTabs();
|
||||
renderGroupedView();
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.addEventListener('change', () => renderGroupedView());
|
||||
}
|
||||
|
||||
if (updateAllBtn) {
|
||||
updateAllBtn.onclick = handleUpdateAll;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading marketplace:', error);
|
||||
marketplaceContent.innerHTML = `<div class="error-msg">Error al cargar el marketplace.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
const tabs = document.querySelectorAll('.tab-button');
|
||||
tabs.forEach(tab => {
|
||||
tab.onclick = () => {
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentTab = tab.dataset.tab;
|
||||
|
||||
if (updateAllBtn) {
|
||||
if (currentTab === 'installed') {
|
||||
updateAllBtn.classList.remove('hidden');
|
||||
} else {
|
||||
updateAllBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
renderGroupedView();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUpdateAll() {
|
||||
const originalText = updateAllBtn.innerText;
|
||||
try {
|
||||
updateAllBtn.disabled = true;
|
||||
updateAllBtn.innerText = 'Updating...';
|
||||
|
||||
const res = await fetch(UPDATE_EXTENSIONS_API, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Update failed');
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.updated && data.updated.length > 0) {
|
||||
|
||||
const list = data.updated.join(', ');
|
||||
window.NotificationUtils.success(`Updated: ${list}`);
|
||||
|
||||
await loadMarketplace();
|
||||
} else {
|
||||
window.NotificationUtils.info('Everything is up to date.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update All Error:', error);
|
||||
window.NotificationUtils.error('Failed to perform bulk update.');
|
||||
} finally {
|
||||
updateAllBtn.disabled = false;
|
||||
updateAllBtn.innerText = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupedView() {
|
||||
marketplaceContent.innerHTML = '';
|
||||
const activeFilter = filterSelect.value;
|
||||
const groups = {};
|
||||
|
||||
let listToRender = [];
|
||||
|
||||
if (currentTab === 'marketplace') {
|
||||
for (const [id, data] of Object.entries(marketplaceMetadata)) {
|
||||
listToRender.push({
|
||||
id,
|
||||
...data,
|
||||
isInstalled: installedExtensions.includes(id.toLowerCase())
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const [id, data] of Object.entries(marketplaceMetadata)) {
|
||||
if (installedExtensions.includes(id.toLowerCase())) {
|
||||
listToRender.push({ id, ...data, isInstalled: true });
|
||||
}
|
||||
}
|
||||
|
||||
installedExtensions.forEach(id => {
|
||||
const existsInMeta = Object.keys(marketplaceMetadata).some(k => k.toLowerCase() === id);
|
||||
if (!existsInMeta) {
|
||||
listToRender.push({
|
||||
id: id,
|
||||
name: id.charAt(0).toUpperCase() + id.slice(1),
|
||||
type: 'Local',
|
||||
author: 'Unknown',
|
||||
isInstalled: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
listToRender.forEach(ext => {
|
||||
const type = ext.type || 'Other';
|
||||
if (activeFilter !== 'All' && type !== activeFilter) return;
|
||||
if (!groups[type]) groups[type] = [];
|
||||
groups[type].push(ext);
|
||||
});
|
||||
|
||||
const sortedTypes = Object.keys(groups).sort();
|
||||
|
||||
if (sortedTypes.length === 0) {
|
||||
marketplaceContent.innerHTML = `<p class="empty-msg">No extensions found for this criteria.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
sortedTypes.forEach(type => {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'category-group';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'marketplace-section-title';
|
||||
title.innerText = type.replace('-', ' ');
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'marketplace-grid';
|
||||
|
||||
groups[type].forEach(ext => grid.appendChild(createCard(ext)));
|
||||
|
||||
section.appendChild(title);
|
||||
section.appendChild(grid);
|
||||
marketplaceContent.appendChild(section);
|
||||
});
|
||||
}
|
||||
|
||||
function createCard(ext) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `extension-card ${ext.nsfw ? 'nsfw-ext' : ''} ${ext.broken ? 'broken-ext' : ''}`;
|
||||
|
||||
const iconUrl = `https://www.google.com/s2/favicons?domain=${ext.domain}&sz=128`;
|
||||
|
||||
let buttonHtml = '';
|
||||
if (ext.isInstalled) {
|
||||
buttonHtml = `<button class="extension-action-button btn-uninstall">Uninstall</button>`;
|
||||
} else if (ext.broken) {
|
||||
buttonHtml = `<button class="extension-action-button" style="background: #4b5563; cursor: not-allowed;" disabled>Broken</button>`;
|
||||
} else {
|
||||
buttonHtml = `<button class="extension-action-button btn-install">Install</button>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<img class="extension-icon" src="${iconUrl}" onerror="this.src='/public/assets/waifuboards.ico'">
|
||||
<div class="card-content-wrapper">
|
||||
<h3 class="extension-name">${ext.name}</h3>
|
||||
<span class="extension-author">by ${ext.author || 'Unknown'}</span>
|
||||
<p class="extension-description">${ext.description || 'No description available.'}</p>
|
||||
<div class="extension-tags">
|
||||
<span class="extension-status-badge badge-${ext.isInstalled ? 'installed' : (ext.broken ? 'local' : 'available')}">
|
||||
${ext.isInstalled ? 'Installed' : (ext.broken ? 'Broken' : 'Available')}
|
||||
</span>
|
||||
${ext.nsfw ? '<span class="extension-status-badge badge-local">NSFW</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
${buttonHtml}
|
||||
`;
|
||||
|
||||
const btn = card.querySelector('.extension-action-button');
|
||||
if (!ext.broken || ext.isInstalled) {
|
||||
btn.onclick = () => ext.isInstalled ? promptUninstall(ext) : handleInstall(ext);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function showModal(title, message, showConfirm = false, onConfirm = null) {
|
||||
modalTitle.innerText = title;
|
||||
modalMessage.innerText = message;
|
||||
if (showConfirm) {
|
||||
modalConfirmBtn.classList.remove('hidden');
|
||||
modalConfirmBtn.onclick = () => { hideModal(); if (onConfirm) onConfirm(); };
|
||||
} else {
|
||||
modalConfirmBtn.classList.add('hidden');
|
||||
}
|
||||
modalCloseBtn.onclick = hideModal;
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideModal() { modal.classList.add('hidden'); }
|
||||
|
||||
async function handleInstall(ext) {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: ext.entry })
|
||||
});
|
||||
if (res.ok) {
|
||||
installedExtensions.push(ext.id.toLowerCase());
|
||||
renderGroupedView();
|
||||
window.NotificationUtils.success(`${ext.name} installed!`);
|
||||
}
|
||||
} catch (e) { window.NotificationUtils.error('Install failed.'); }
|
||||
}
|
||||
|
||||
function promptUninstall(ext) {
|
||||
showModal('Confirm', `Uninstall ${ext.name}?`, true, () => handleUninstall(ext));
|
||||
}
|
||||
|
||||
async function handleUninstall(ext) {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/uninstall', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fileName: ext.id + '.js' })
|
||||
});
|
||||
if (res.ok) {
|
||||
installedExtensions = installedExtensions.filter(id => id !== ext.id.toLowerCase());
|
||||
renderGroupedView();
|
||||
window.NotificationUtils.info(`${ext.name} uninstalled.`);
|
||||
}
|
||||
} catch (e) { window.NotificationUtils.error('Uninstall failed.'); }
|
||||
}
|
||||
|
||||
function showSkeletons() {
|
||||
marketplaceContent.innerHTML = `
|
||||
<div class="marketplace-grid">
|
||||
${Array(3).fill('<div class="extension-card skeleton"></div>').join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadMarketplace);
|
||||
218
desktop/src/scripts/settings.js
Normal 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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const Gitea_OWNER = "ItsSkaiya";
|
||||
const Gitea_REPO = "WaifuBoard";
|
||||
const CURRENT_VERSION = "v2.0.0-rc.0";
|
||||
const CURRENT_VERSION = "v2.0.0-rc.2";
|
||||
const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
let currentVersionDisplay;
|
||||
@@ -694,30 +694,102 @@ window.handleDeleteConfirmation = function(userId) {
|
||||
|
||||
closeModal();
|
||||
|
||||
if (user.has_password) {
|
||||
modalAniList.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content" style="max-width:400px;">
|
||||
<div class="modal-header">
|
||||
<h2>Confirm Deletion</h2>
|
||||
<button class="modal-close" onclick="closeModal()">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="deleteWithPasswordForm">
|
||||
<p style="margin-bottom:1.25rem; color:var(--color-text-secondary)">
|
||||
Enter your password to permanently delete
|
||||
<b>${user.username}</b>
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="deletePassword">Password</label>
|
||||
<div class="password-toggle-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="deletePassword"
|
||||
required
|
||||
placeholder="Enter password"
|
||||
autofocus
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle-btn"
|
||||
onclick="togglePasswordVisibility('deletePassword', this)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="closeModal()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-disconnect">
|
||||
Delete Profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
modalAniList.classList.add('active');
|
||||
|
||||
document
|
||||
.getElementById('deleteWithPasswordForm')
|
||||
.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('deletePassword').value;
|
||||
handleConfirmedDeleteUser(userId, password);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmationModal(
|
||||
'Confirm Deletion',
|
||||
`Are you absolutely sure you want to delete profile ${user.username}? This action cannot be undone.`,
|
||||
`Are you absolutely sure you want to delete profile ${user.username}?`,
|
||||
`handleConfirmedDeleteUser(${userId})`
|
||||
);
|
||||
};
|
||||
|
||||
window.handleConfirmedDeleteUser = async function(userId) {
|
||||
window.handleConfirmedDeleteUser = async function(userId, password = null) {
|
||||
closeModal();
|
||||
showUserToast('Deleting user...', 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' });
|
||||
const options = { method: 'DELETE' };
|
||||
|
||||
if (password) {
|
||||
options.headers = { 'Content-Type': 'application/json' };
|
||||
options.body = JSON.stringify({ password });
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/users/${userId}`, options);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Error deleting user');
|
||||
throw new Error(error.error);
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
showUserToast('User deleted successfully!', 'success');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showUserToast('Error deleting user', 'error');
|
||||
showUserToast(err.message || 'Error deleting user', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ const ListModalManager = {
|
||||
document.getElementById('progress-label');
|
||||
|
||||
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-score').value = this.currentEntry.score || '';
|
||||
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');
|
||||
},
|
||||
|
||||
normalizeStatus(status) {
|
||||
if (!status) return 'PLANNING';
|
||||
if (status === 'WATCHING' || status === 'READING') return 'CURRENT';
|
||||
return status;
|
||||
},
|
||||
|
||||
close() {
|
||||
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');
|
||||
if (modal) {
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'add-list-modal') {
|
||||
ListModalManager.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'add-list-modal') {
|
||||
ListModalManager.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadListModal);
|
||||
|
||||
window.ListModalManager = ListModalManager;
|
||||
71
desktop/src/shared/config.js
Normal 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,
|
||||
};
|
||||
@@ -2,7 +2,7 @@ const sqlite3 = require('sqlite3').verbose();
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
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();
|
||||
|
||||
@@ -10,7 +10,8 @@ const DEFAULT_PATHS = {
|
||||
anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'),
|
||||
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.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) {
|
||||
@@ -49,6 +50,11 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
|
||||
|
||||
databases.set(name, db);
|
||||
|
||||
if (name === "local_library") {
|
||||
ensureLocalLibrarySchema(db)
|
||||
.catch(err => console.error("Error creating local library schema:", err));
|
||||
}
|
||||
|
||||
if (name === "anilist") {
|
||||
ensureAnilistSchema(db)
|
||||
.then(() => ensureExtensionsTable(db))
|
||||
@@ -46,7 +46,6 @@ async function loadExtensions() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadExtension(fileName) {
|
||||
const homeDir = os.homedir();
|
||||
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
|
||||
@@ -77,6 +76,7 @@ async function loadExtension(fileName) {
|
||||
}
|
||||
|
||||
const name = instance.constructor.name;
|
||||
instance.__fileName = fileName;
|
||||
instance.scrape = scrape;
|
||||
instance.cheerio = cheerio;
|
||||
extensions.set(name, instance);
|
||||
@@ -114,6 +114,14 @@ async function saveExtensionFile(fileName, downloadUrl) {
|
||||
file.on('finish', async () => {
|
||||
file.close(async () => {
|
||||
try {
|
||||
const extName = fileName.replace('.js', '');
|
||||
|
||||
for (const key of extensions.keys()) {
|
||||
if (key.toLowerCase() === extName.toLowerCase()) {
|
||||
extensions.delete(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await loadExtension(fileName);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
147
desktop/src/shared/headless.js
Normal file
@@ -0,0 +1,147 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { chromium } = require("playwright-core");
|
||||
|
||||
let browser;
|
||||
let context;
|
||||
const BLOCK_LIST = [
|
||||
"google-analytics", "doubleclick", "facebook", "twitter",
|
||||
"adsystem", "analytics", "tracker", "pixel", "quantserve", "newrelic"
|
||||
];
|
||||
|
||||
function isPackaged() {
|
||||
return process.env.IS_PACKAGED === "true";
|
||||
}
|
||||
|
||||
function getChromiumPath() {
|
||||
if (isPackaged()) {
|
||||
return path.join(
|
||||
process.resourcesPath,
|
||||
"playwright",
|
||||
"chromium",
|
||||
"chrome-headless-shell-win64",
|
||||
"chrome-headless-shell.exe"
|
||||
);
|
||||
}
|
||||
|
||||
return chromium.executablePath();
|
||||
}
|
||||
|
||||
async function initHeadless() {
|
||||
if (browser) return;
|
||||
|
||||
const exePath = getChromiumPath();
|
||||
|
||||
if (!fs.existsSync(exePath)) {
|
||||
throw new Error("Chromium not found: " + exePath);
|
||||
}
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: exePath,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-translate",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
"--no-zygote",
|
||||
]
|
||||
});
|
||||
context = await browser.newContext({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36"
|
||||
});
|
||||
}
|
||||
|
||||
async function turboScroll(page) {
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
let last = 0;
|
||||
let same = 0;
|
||||
const timer = setInterval(() => {
|
||||
const h = document.body.scrollHeight;
|
||||
window.scrollTo(0, h);
|
||||
if (h === last) {
|
||||
same++;
|
||||
if (same >= 5) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
same = 0;
|
||||
last = h;
|
||||
}
|
||||
}, 20);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function scrape(url, handler, options = {}) {
|
||||
const {
|
||||
waitUntil = "domcontentloaded",
|
||||
waitSelector = null,
|
||||
timeout = 10000,
|
||||
scrollToBottom = false,
|
||||
renderWaitTime = 0,
|
||||
loadImages = true
|
||||
} = options;
|
||||
if (!browser) await initHeadless();
|
||||
const page = await context.newPage();
|
||||
let collectedRequests = [];
|
||||
await page.route("**/*", (route) => {
|
||||
const req = route.request();
|
||||
const rUrl = req.url().toLowerCase();
|
||||
const type = req.resourceType();
|
||||
|
||||
collectedRequests.push({
|
||||
url: req.url(),
|
||||
method: req.method(),
|
||||
resourceType: type
|
||||
});
|
||||
|
||||
if (type === "font" || type === "media" || type === "manifest")
|
||||
return route.abort();
|
||||
|
||||
if (BLOCK_LIST.some(k => rUrl.includes(k)))
|
||||
return route.abort();
|
||||
|
||||
if (!loadImages && (
|
||||
type === "image" || rUrl.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
|
||||
)) return route.abort();
|
||||
route.continue();
|
||||
});
|
||||
await page.goto(url, { waitUntil, timeout });
|
||||
if (waitSelector) {
|
||||
try {
|
||||
await page.waitForSelector(waitSelector, { timeout });
|
||||
} catch {}
|
||||
}
|
||||
if (scrollToBottom) {
|
||||
await turboScroll(page);
|
||||
}
|
||||
if (renderWaitTime > 0) {
|
||||
await new Promise(r => setTimeout(r, renderWaitTime));
|
||||
}
|
||||
const result = await handler(page);
|
||||
await page.close();
|
||||
|
||||
return { result, requests: collectedRequests };
|
||||
}
|
||||
|
||||
async function closeScraper() {
|
||||
if (context) await context.close();
|
||||
if (browser) await browser.close();
|
||||
context = null;
|
||||
browser = null;
|
||||
}
|
||||
module.exports = {
|
||||
initHeadless,
|
||||
scrape,
|
||||
closeScraper
|
||||
};
|
||||
@@ -2,6 +2,54 @@ const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require("path");
|
||||
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) {
|
||||
const dir = path.dirname(dbPath);
|
||||
|
||||
@@ -230,5 +278,6 @@ module.exports = {
|
||||
ensureAnilistSchema,
|
||||
ensureExtensionsTable,
|
||||
ensureCacheTable,
|
||||
ensureFavoritesDB
|
||||
ensureFavoritesDB,
|
||||
ensureLocalLibrarySchema
|
||||
};
|
||||
146
desktop/src/views/views.routes.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import * as fs from 'fs';
|
||||
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) {
|
||||
|
||||
fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'users.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html');
|
||||
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) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'list.html');
|
||||
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) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'books.html');
|
||||
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) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'schedule.html');
|
||||
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) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'gallery.html');
|
||||
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) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'marketplace.html');
|
||||
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) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html');
|
||||
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) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html');
|
||||
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) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.get('/anime/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'book.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.get('/book/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'book.html');
|
||||
const html = fs.readFileSync(htmlPath, 'utf-8');
|
||||
reply.type('text/html').send(html);
|
||||
});
|
||||
|
||||
fastify.get('/read/:provider/:chapter/*', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'read.html');
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export default viewsRoutes;
|
||||
139
desktop/views/404.html
Normal 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 doesn’t 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>
|
||||
@@ -34,75 +34,6 @@
|
||||
</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">
|
||||
<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
|
||||
@@ -158,6 +89,7 @@
|
||||
|
||||
<div class="meta-row">
|
||||
<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" id="year">----</div>
|
||||
<div class="pill" id="genres">Action</div>
|
||||
179
desktop/views/anime/animes.html
Normal file
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>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/hero.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/local-library.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>
|
||||
</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>
|
||||
|
||||
<div class="hero-wrapper">
|
||||
<div class="hero-background">
|
||||
<img id="hero-bg-media" alt="">
|
||||
<div id="player" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 120vw; height: 120vh; pointer-events: none; opacity: 0; transition: opacity 1s;"></div>
|
||||
</div>
|
||||
<div class="hero-vignette"></div>
|
||||
|
||||
<div class="hero-content">
|
||||
<div class="hero-poster-card">
|
||||
<div class="skeleton poster-skeleton" id="hero-poster-skeleton"></div>
|
||||
<img id="hero-poster" alt="" style="display: none;" onload="this.style.display='block'; document.getElementById('hero-poster-skeleton').style.display='none'">
|
||||
</div>
|
||||
<div class="hero-text">
|
||||
<div id="hero-loading-ui">
|
||||
<div class="skeleton title-skeleton"></div>
|
||||
<div class="skeleton text-skeleton" style="width: 40%"></div>
|
||||
<div class="skeleton text-skeleton" style="width: 100%; height: 4em;"></div>
|
||||
</div>
|
||||
|
||||
<div id="hero-real-ui" style="display: none;">
|
||||
<h1 class="hero-title" id="hero-title"></h1>
|
||||
<div class="hero-meta">
|
||||
<span class="score-badge" id="hero-score"></span>
|
||||
<span id="hero-year"></span>
|
||||
<span id="hero-type"></span>
|
||||
</div>
|
||||
<p class="hero-desc" id="hero-desc"></p>
|
||||
<div class="hero-buttons">
|
||||
<button class="btn-primary" id="watch-btn">Watch Now</button>
|
||||
<button class="btn-blur">+ Add to List</button>
|
||||
</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>
|
||||
|
||||
<!-- Online Mode Content -->
|
||||
<main id="online-content">
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Continue watching</div>
|
||||
</div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('my-status', -1)">‹</button>
|
||||
<div class="carousel" id="my-status">
|
||||
<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>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('my-status', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header"><div class="section-title">Trending This Season</div></div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('trending', -1)">‹</button>
|
||||
<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>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('trending', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header"><div class="section-title">Top Airing Now</div></div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('top-airing', -1)">‹</button>
|
||||
<div class="carousel" id="top-airing">
|
||||
<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>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('top-airing', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
</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">A–Z</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">
|
||||
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
|
||||
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">
|
||||
Click To Download
|
||||
</a>
|
||||
</div>
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<script src="/src/scripts/utils/search-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/youtube-player-utils.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/rpc-inapp.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
<script src="/src/scripts/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,72 +25,6 @@
|
||||
</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">
|
||||
<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
|
||||
@@ -140,6 +74,7 @@
|
||||
|
||||
<div class="meta-row">
|
||||
<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" id="genres">Action</div>
|
||||
</div>
|
||||
140
desktop/views/books/books.html
Normal file
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WaifuBoard Books</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/hero.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
<link rel="stylesheet" href="/views/css/components/local-library.css">
|
||||
</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>
|
||||
|
||||
<div class="hero-wrapper">
|
||||
<div class="hero-background">
|
||||
<img id="hero-bg-media" src="" alt="">
|
||||
</div>
|
||||
<div class="hero-vignette"></div>
|
||||
|
||||
<div class="hero-content">
|
||||
<div class="hero-poster-card">
|
||||
<img id="hero-poster" src="" alt="">
|
||||
</div>
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-title" id="hero-title">Loading...</h1>
|
||||
<div class="hero-meta">
|
||||
<span class="score-badge" id="hero-score"></span>
|
||||
<span id="hero-year"></span>
|
||||
<span id="hero-type"></span>
|
||||
</div>
|
||||
<p class="hero-desc" id="hero-desc"></p>
|
||||
<div class="hero-buttons">
|
||||
<button class="btn-primary" id="read-btn">Read Now</button>
|
||||
<button class="btn-blur">+ Add to Library</button>
|
||||
</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>
|
||||
<main id="local-content" class="hidden">
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Local Books 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="unlinked">Unlinked</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn" data-sort="az">A–Z</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="local-library-grid" id="local-entries-grid">
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<main id="online-content">
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Continue Reading</div>
|
||||
</div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('my-status-books', -1)">‹</button>
|
||||
<div class="carousel" id="my-status-books"></div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('my-status-books', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header"><div class="section-title">Trending Books</div></div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('trending', -1)">‹</button>
|
||||
<div class="carousel" id="trending"></div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('trending', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header"><div class="section-title">All Time Popular</div></div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('popular', -1)">‹</button>
|
||||
<div class="carousel" id="popular"></div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('popular', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="updateToast" class="hidden">
|
||||
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
|
||||
|
||||
<a
|
||||
id="downloadButton"
|
||||
href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases"
|
||||
target="_blank"
|
||||
>
|
||||
Click To Download
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
<script src="/src/scripts/utils/notification-utils.js"></script>
|
||||
<script src="/src/scripts/utils/search-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/books/books.js"></script>
|
||||
<script src="/src/scripts/local-library-books.js"></script>
|
||||
<script src="/src/scripts/updateNotifier.js"></script>
|
||||
<script src="/src/scripts/rpc-inapp.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
66
desktop/views/components/list-modal.html
Normal 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>
|
||||
69
desktop/views/components/navbar.html
Normal 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>
|
||||
278
desktop/views/components/settings-modal.html
Normal 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>
|
||||
@@ -53,8 +53,8 @@
|
||||
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 span { font-weight: 600; font-size: 1rem; color: var(--text-primary); }
|
||||
.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(--color-text-primary); }
|
||||
|
||||
.character-list {
|
||||
display: flex;
|
||||
@@ -180,7 +180,7 @@
|
||||
transition: 0.2s;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.episode-btn:hover {
|
||||
830
desktop/views/css/anime/watch.css
Normal file
@@ -0,0 +1,830 @@
|
||||
.top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: 0.7rem 1.5rem;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--transition-smooth);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.watch-container {
|
||||
max-width: 1600px;
|
||||
margin: var(--spacing-2xl) auto;
|
||||
padding: 0 var(--spacing-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.player-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.player-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.sd-toggle {
|
||||
display: flex;
|
||||
background: var(--color-bg-elevated);
|
||||
border: var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sd-option {
|
||||
padding: 0.6rem 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
z-index: 2;
|
||||
transition: color var(--transition-base);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.sd-option.active {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sd-bg {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
width: calc(50% - 4px);
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-full);
|
||||
transition: transform var(--transition-smooth);
|
||||
box-shadow: 0 4px 12px var(--color-primary-glow);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sd-toggle[data-state="dub"] .sd-bg {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.source-select {
|
||||
appearance: none;
|
||||
background-color: var(--color-bg-elevated);
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1.2rem center;
|
||||
border: var(--border-subtle);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.7rem 2.8rem 0.7rem 1.2rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-width: 160px;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.source-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
.source-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
.video-container {
|
||||
aspect-ratio: 16/9;
|
||||
width: 100%;
|
||||
background: var(--color-bg-base);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px var(--glass-border);
|
||||
position: relative;
|
||||
transition: box-shadow var(--transition-smooth);
|
||||
}
|
||||
|
||||
.video-container:hover {
|
||||
box-shadow:
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px var(--color-primary),
|
||||
var(--shadow-glow);
|
||||
}
|
||||
|
||||
#player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-bg-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.episode-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-lg);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.episode-info h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
}
|
||||
.episode-info p {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
background: var(--color-bg-elevated);
|
||||
border: var(--border-subtle);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.nav-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.episode-carousel-compact {
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel-header {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding: 0 var(--spacing-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.carousel-header h2 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.04em;
|
||||
border-left: 4px solid var(--color-primary);
|
||||
padding-left: var(--spacing-md);
|
||||
}
|
||||
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.carousel-arrow-mini {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.carousel-arrow-mini:hover {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.carousel-arrow-mini[style*="opacity: 0.3"] {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-muted);
|
||||
border-color: var(--border-subtle);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.episode-carousel-compact-list {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-xl);
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black var(--spacing-md),
|
||||
black calc(100% - var(--spacing-md)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.episode-carousel-compact-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.carousel-item {
|
||||
flex: 0 0 200px;
|
||||
height: 112px;
|
||||
|
||||
background: var(--color-bg-card);
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all var(--transition-base);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
scroll-snap-align: start;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.carousel-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
transform: scale(1.02);
|
||||
box-shadow: var(--shadow-md), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.carousel-item.active-ep-carousel {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-primary),
|
||||
var(--shadow-md);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.carousel-item.active-ep-carousel::after {
|
||||
content: "WATCHING";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
letter-spacing: 0.05em;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.carousel-item-img-container {
|
||||
height: 70px;
|
||||
background: var(--color-bg-elevated);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-item-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-smooth);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.carousel-item:hover .carousel-item-img {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carousel-item-info {
|
||||
flex: 1;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.carousel-item-info p {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.carousel-item-info p::before {
|
||||
content: attr(data-episode-number);
|
||||
color: var(--color-primary);
|
||||
font-weight: 800;
|
||||
margin-right: var(--spacing-xs);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.carousel-item.no-thumbnail {
|
||||
flex: 0 0 160px;
|
||||
height: 90px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 2px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.carousel-item.no-thumbnail .carousel-item-info {
|
||||
padding: var(--spacing-sm);
|
||||
background: transparent;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.carousel-item.no-thumbnail .carousel-item-info p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.carousel-item.no-thumbnail:hover {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.carousel-item.no-thumbnail.active-ep-carousel .carousel-item-info p {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.anime-details,
|
||||
.anime-extra-content {
|
||||
max-width: 1600px;
|
||||
margin: var(--spacing-2xl) auto;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xl);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.details-cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.details-cover h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 220px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.details-content h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.meta-badge {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: var(--color-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.meta-badge.meta-score {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
.details-description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.characters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.characters-header h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
border-left: 5px solid var(--color-primary);
|
||||
padding-left: var(--spacing-md);
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.expand-btn:hover {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
.expand-btn svg {
|
||||
transition: transform var(--transition-smooth);
|
||||
}
|
||||
.expand-btn[data-expanded="true"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.characters-carousel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
align-content: flex-start;
|
||||
overflow: hidden;
|
||||
|
||||
height: 208px;
|
||||
transition: height 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
padding: 0 var(--spacing-sm);
|
||||
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.characters-carousel::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.characters-carousel.expanded {
|
||||
height: auto;
|
||||
max-height: 3200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
|
||||
-ms-overflow-style: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.characters-carousel.expanded::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.characters-carousel.expanded::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 92, 246, 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.characters-carousel.expanded::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plyr--video {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.plyr__controls {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.9) 0%,
|
||||
rgba(0, 0, 0, 0.5) 50%,
|
||||
transparent 100%
|
||||
) !important;
|
||||
padding: 1rem 1.5rem 1.5rem !important;
|
||||
}
|
||||
.plyr--full-ui input[type="range"] {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.plyr__control:hover {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
.plyr__menu__container {
|
||||
background: var(--glass-bg) !important;
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
}
|
||||
.watch-container {
|
||||
padding-top: 5rem;
|
||||
}
|
||||
|
||||
.details-cover {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.details-cover h1 {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.watch-container {
|
||||
padding: 5rem 1rem 2rem 1rem;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.player-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.source-select {
|
||||
width: 100%;
|
||||
background-position: right 1.5rem center;
|
||||
}
|
||||
|
||||
.episode-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.episode-info {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.episode-info h1 {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
justify-content: center;
|
||||
padding: 0.8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.details-cover {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.details-cover {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.details-cover h1 {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.details-content h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.characters-carousel {
|
||||
justify-content: center;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
width: calc(50% - 0.75rem);
|
||||
flex: 0 0 calc(50% - 0.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
.plyr__progress {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plyr__markers {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.plyr__marker {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: rgba(255, 215, 0, 0.8); /* Color dorado para Opening */
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.plyr__marker[data-label*="Ending"] {
|
||||
background: rgba(255, 100, 100, 0.8); /* Color rojo para Ending */
|
||||
}
|
||||
|
||||
.plyr__marker:hover {
|
||||
height: 120%;
|
||||
width: 4px;
|
||||
background: rgba(255, 215, 0, 1);
|
||||
}
|
||||
|
||||
.plyr__marker[data-label*="Ending"]:hover {
|
||||
background: rgba(255, 100, 100, 1);
|
||||
}
|
||||
|
||||
/* Tooltip para mostrar el label */
|
||||
.plyr__marker::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.plyr__marker:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.plyr__marker {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
cursor: pointer;
|
||||
}
|
||||