diff --git a/.gitignore b/.gitignore index d09ab9d..a3d465c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/desktop/.dockerignore b/desktop/.dockerignore new file mode 100644 index 0000000..f675cdb --- /dev/null +++ b/desktop/.dockerignore @@ -0,0 +1,8 @@ +node_modules +electron +dist +.env +build +.gitignore +Dockerfile +.dockerignore diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..d09ab9d --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,5 @@ +node_modules +electron +dist +.env +build diff --git a/desktop/Dockerfile b/desktop/Dockerfile new file mode 100644 index 0000000..08a5094 --- /dev/null +++ b/desktop/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci + +RUN npm uninstall @ryuziii/discord-rpc + +COPY . . + +EXPOSE 54322 + +CMD ["npm", "run", "start"] diff --git a/main.js b/desktop/main.js similarity index 100% rename from main.js rename to desktop/main.js diff --git a/package-lock.json b/desktop/package-lock.json similarity index 100% rename from package-lock.json rename to desktop/package-lock.json diff --git a/package.json b/desktop/package.json similarity index 100% rename from package.json rename to desktop/package.json diff --git a/preload.js b/desktop/preload.js similarity index 100% rename from preload.js rename to desktop/preload.js diff --git a/public/assets/avatar.png b/desktop/public/assets/avatar.png similarity index 100% rename from public/assets/avatar.png rename to desktop/public/assets/avatar.png diff --git a/public/assets/placeholder.svg b/desktop/public/assets/placeholder.svg similarity index 100% rename from public/assets/placeholder.svg rename to desktop/public/assets/placeholder.svg diff --git a/public/assets/waifuboards.ico b/desktop/public/assets/waifuboards.ico similarity index 100% rename from public/assets/waifuboards.ico rename to desktop/public/assets/waifuboards.ico diff --git a/server.js b/desktop/server.js similarity index 100% rename from server.js rename to desktop/server.js diff --git a/src/api/anilist/anilist.service.ts b/desktop/src/api/anilist/anilist.service.ts similarity index 100% rename from src/api/anilist/anilist.service.ts rename to desktop/src/api/anilist/anilist.service.ts diff --git a/src/api/anilist/anilist.ts b/desktop/src/api/anilist/anilist.ts similarity index 100% rename from src/api/anilist/anilist.ts rename to desktop/src/api/anilist/anilist.ts diff --git a/src/api/anime/anime.controller.ts b/desktop/src/api/anime/anime.controller.ts similarity index 100% rename from src/api/anime/anime.controller.ts rename to desktop/src/api/anime/anime.controller.ts diff --git a/src/api/anime/anime.routes.ts b/desktop/src/api/anime/anime.routes.ts similarity index 100% rename from src/api/anime/anime.routes.ts rename to desktop/src/api/anime/anime.routes.ts diff --git a/src/api/anime/anime.service.ts b/desktop/src/api/anime/anime.service.ts similarity index 100% rename from src/api/anime/anime.service.ts rename to desktop/src/api/anime/anime.service.ts diff --git a/src/api/books/books.controller.ts b/desktop/src/api/books/books.controller.ts similarity index 100% rename from src/api/books/books.controller.ts rename to desktop/src/api/books/books.controller.ts diff --git a/src/api/books/books.routes.ts b/desktop/src/api/books/books.routes.ts similarity index 100% rename from src/api/books/books.routes.ts rename to desktop/src/api/books/books.routes.ts diff --git a/src/api/books/books.service.ts b/desktop/src/api/books/books.service.ts similarity index 100% rename from src/api/books/books.service.ts rename to desktop/src/api/books/books.service.ts diff --git a/src/api/extensions/extensions.controller.ts b/desktop/src/api/extensions/extensions.controller.ts similarity index 100% rename from src/api/extensions/extensions.controller.ts rename to desktop/src/api/extensions/extensions.controller.ts diff --git a/src/api/extensions/extensions.routes.ts b/desktop/src/api/extensions/extensions.routes.ts similarity index 100% rename from src/api/extensions/extensions.routes.ts rename to desktop/src/api/extensions/extensions.routes.ts diff --git a/src/api/gallery/gallery.controller.ts b/desktop/src/api/gallery/gallery.controller.ts similarity index 100% rename from src/api/gallery/gallery.controller.ts rename to desktop/src/api/gallery/gallery.controller.ts diff --git a/src/api/gallery/gallery.routes.ts b/desktop/src/api/gallery/gallery.routes.ts similarity index 100% rename from src/api/gallery/gallery.routes.ts rename to desktop/src/api/gallery/gallery.routes.ts diff --git a/src/api/gallery/gallery.service.ts b/desktop/src/api/gallery/gallery.service.ts similarity index 100% rename from src/api/gallery/gallery.service.ts rename to desktop/src/api/gallery/gallery.service.ts diff --git a/src/api/list/list.controller.ts b/desktop/src/api/list/list.controller.ts similarity index 100% rename from src/api/list/list.controller.ts rename to desktop/src/api/list/list.controller.ts diff --git a/src/api/list/list.routes.ts b/desktop/src/api/list/list.routes.ts similarity index 100% rename from src/api/list/list.routes.ts rename to desktop/src/api/list/list.routes.ts diff --git a/src/api/list/list.service.ts b/desktop/src/api/list/list.service.ts similarity index 100% rename from src/api/list/list.service.ts rename to desktop/src/api/list/list.service.ts diff --git a/src/api/proxy/proxy.controller.ts b/desktop/src/api/proxy/proxy.controller.ts similarity index 100% rename from src/api/proxy/proxy.controller.ts rename to desktop/src/api/proxy/proxy.controller.ts diff --git a/src/api/proxy/proxy.routes.ts b/desktop/src/api/proxy/proxy.routes.ts similarity index 100% rename from src/api/proxy/proxy.routes.ts rename to desktop/src/api/proxy/proxy.routes.ts diff --git a/src/api/proxy/proxy.service.ts b/desktop/src/api/proxy/proxy.service.ts similarity index 100% rename from src/api/proxy/proxy.service.ts rename to desktop/src/api/proxy/proxy.service.ts diff --git a/src/api/rpc/rp.service.ts b/desktop/src/api/rpc/rp.service.ts similarity index 100% rename from src/api/rpc/rp.service.ts rename to desktop/src/api/rpc/rp.service.ts diff --git a/src/api/rpc/rpc.controller.ts b/desktop/src/api/rpc/rpc.controller.ts similarity index 100% rename from src/api/rpc/rpc.controller.ts rename to desktop/src/api/rpc/rpc.controller.ts diff --git a/src/api/rpc/rpc.routes.ts b/desktop/src/api/rpc/rpc.routes.ts similarity index 100% rename from src/api/rpc/rpc.routes.ts rename to desktop/src/api/rpc/rpc.routes.ts diff --git a/src/api/types.ts b/desktop/src/api/types.ts similarity index 100% rename from src/api/types.ts rename to desktop/src/api/types.ts diff --git a/src/api/user/user.controller.ts b/desktop/src/api/user/user.controller.ts similarity index 100% rename from src/api/user/user.controller.ts rename to desktop/src/api/user/user.controller.ts diff --git a/src/api/user/user.routes.ts b/desktop/src/api/user/user.routes.ts similarity index 100% rename from src/api/user/user.routes.ts rename to desktop/src/api/user/user.routes.ts diff --git a/src/api/user/user.service.ts b/desktop/src/api/user/user.service.ts similarity index 100% rename from src/api/user/user.service.ts rename to desktop/src/api/user/user.service.ts diff --git a/src/scripts/anime/anime.js b/desktop/src/scripts/anime/anime.js similarity index 100% rename from src/scripts/anime/anime.js rename to desktop/src/scripts/anime/anime.js diff --git a/src/scripts/anime/animes.js b/desktop/src/scripts/anime/animes.js similarity index 100% rename from src/scripts/anime/animes.js rename to desktop/src/scripts/anime/animes.js diff --git a/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js similarity index 100% rename from src/scripts/anime/player.js rename to desktop/src/scripts/anime/player.js diff --git a/src/scripts/auth-guard.js b/desktop/src/scripts/auth-guard.js similarity index 100% rename from src/scripts/auth-guard.js rename to desktop/src/scripts/auth-guard.js diff --git a/src/scripts/books/book.js b/desktop/src/scripts/books/book.js similarity index 100% rename from src/scripts/books/book.js rename to desktop/src/scripts/books/book.js diff --git a/src/scripts/books/books.js b/desktop/src/scripts/books/books.js similarity index 100% rename from src/scripts/books/books.js rename to desktop/src/scripts/books/books.js diff --git a/src/scripts/books/reader.js b/desktop/src/scripts/books/reader.js similarity index 100% rename from src/scripts/books/reader.js rename to desktop/src/scripts/books/reader.js diff --git a/src/scripts/gallery/gallery.js b/desktop/src/scripts/gallery/gallery.js similarity index 100% rename from src/scripts/gallery/gallery.js rename to desktop/src/scripts/gallery/gallery.js diff --git a/src/scripts/gallery/image.js b/desktop/src/scripts/gallery/image.js similarity index 100% rename from src/scripts/gallery/image.js rename to desktop/src/scripts/gallery/image.js diff --git a/src/scripts/list.js b/desktop/src/scripts/list.js similarity index 100% rename from src/scripts/list.js rename to desktop/src/scripts/list.js diff --git a/src/scripts/marketplace.js b/desktop/src/scripts/marketplace.js similarity index 100% rename from src/scripts/marketplace.js rename to desktop/src/scripts/marketplace.js diff --git a/src/scripts/rpc-inapp.js b/desktop/src/scripts/rpc-inapp.js similarity index 100% rename from src/scripts/rpc-inapp.js rename to desktop/src/scripts/rpc-inapp.js diff --git a/src/scripts/schedule/schedule.js b/desktop/src/scripts/schedule/schedule.js similarity index 100% rename from src/scripts/schedule/schedule.js rename to desktop/src/scripts/schedule/schedule.js diff --git a/src/scripts/titlebar.js b/desktop/src/scripts/titlebar.js similarity index 100% rename from src/scripts/titlebar.js rename to desktop/src/scripts/titlebar.js diff --git a/src/scripts/updateNotifier.js b/desktop/src/scripts/updateNotifier.js similarity index 100% rename from src/scripts/updateNotifier.js rename to desktop/src/scripts/updateNotifier.js diff --git a/src/scripts/users.js b/desktop/src/scripts/users.js similarity index 100% rename from src/scripts/users.js rename to desktop/src/scripts/users.js diff --git a/src/scripts/utils/auth-utils.js b/desktop/src/scripts/utils/auth-utils.js similarity index 100% rename from src/scripts/utils/auth-utils.js rename to desktop/src/scripts/utils/auth-utils.js diff --git a/src/scripts/utils/continue-watching-manager.js b/desktop/src/scripts/utils/continue-watching-manager.js similarity index 100% rename from src/scripts/utils/continue-watching-manager.js rename to desktop/src/scripts/utils/continue-watching-manager.js diff --git a/src/scripts/utils/list-modal-manager.js b/desktop/src/scripts/utils/list-modal-manager.js similarity index 100% rename from src/scripts/utils/list-modal-manager.js rename to desktop/src/scripts/utils/list-modal-manager.js diff --git a/src/scripts/utils/media-metadata-utils.js b/desktop/src/scripts/utils/media-metadata-utils.js similarity index 100% rename from src/scripts/utils/media-metadata-utils.js rename to desktop/src/scripts/utils/media-metadata-utils.js diff --git a/src/scripts/utils/notification-utils.js b/desktop/src/scripts/utils/notification-utils.js similarity index 100% rename from src/scripts/utils/notification-utils.js rename to desktop/src/scripts/utils/notification-utils.js diff --git a/src/scripts/utils/pagination-manager.js b/desktop/src/scripts/utils/pagination-manager.js similarity index 100% rename from src/scripts/utils/pagination-manager.js rename to desktop/src/scripts/utils/pagination-manager.js diff --git a/src/scripts/utils/search-manager.js b/desktop/src/scripts/utils/search-manager.js similarity index 100% rename from src/scripts/utils/search-manager.js rename to desktop/src/scripts/utils/search-manager.js diff --git a/src/scripts/utils/url-utils.js b/desktop/src/scripts/utils/url-utils.js similarity index 100% rename from src/scripts/utils/url-utils.js rename to desktop/src/scripts/utils/url-utils.js diff --git a/src/scripts/utils/youtube-player-utils.js b/desktop/src/scripts/utils/youtube-player-utils.js similarity index 100% rename from src/scripts/utils/youtube-player-utils.js rename to desktop/src/scripts/utils/youtube-player-utils.js diff --git a/src/shared/database.js b/desktop/src/shared/database.js similarity index 100% rename from src/shared/database.js rename to desktop/src/shared/database.js diff --git a/src/shared/extensions.js b/desktop/src/shared/extensions.js similarity index 100% rename from src/shared/extensions.js rename to desktop/src/shared/extensions.js diff --git a/src/shared/headless.js b/desktop/src/shared/headless.js similarity index 100% rename from src/shared/headless.js rename to desktop/src/shared/headless.js diff --git a/src/shared/queries.js b/desktop/src/shared/queries.js similarity index 100% rename from src/shared/queries.js rename to desktop/src/shared/queries.js diff --git a/src/shared/schemas.js b/desktop/src/shared/schemas.js similarity index 100% rename from src/shared/schemas.js rename to desktop/src/shared/schemas.js diff --git a/src/views/views.routes.ts b/desktop/src/views/views.routes.ts similarity index 100% rename from src/views/views.routes.ts rename to desktop/src/views/views.routes.ts diff --git a/tsconfig.json b/desktop/tsconfig.json similarity index 100% rename from tsconfig.json rename to desktop/tsconfig.json diff --git a/views/anime/anime.html b/desktop/views/anime/anime.html similarity index 100% rename from views/anime/anime.html rename to desktop/views/anime/anime.html diff --git a/views/anime/animes.html b/desktop/views/anime/animes.html similarity index 100% rename from views/anime/animes.html rename to desktop/views/anime/animes.html diff --git a/views/anime/watch.html b/desktop/views/anime/watch.html similarity index 100% rename from views/anime/watch.html rename to desktop/views/anime/watch.html diff --git a/views/books/book.html b/desktop/views/books/book.html similarity index 100% rename from views/books/book.html rename to desktop/views/books/book.html diff --git a/views/books/books.html b/desktop/views/books/books.html similarity index 100% rename from views/books/books.html rename to desktop/views/books/books.html diff --git a/views/books/read.html b/desktop/views/books/read.html similarity index 100% rename from views/books/read.html rename to desktop/views/books/read.html diff --git a/views/css/anime/anime.css b/desktop/views/css/anime/anime.css similarity index 100% rename from views/css/anime/anime.css rename to desktop/views/css/anime/anime.css diff --git a/views/css/anime/watch.css b/desktop/views/css/anime/watch.css similarity index 100% rename from views/css/anime/watch.css rename to desktop/views/css/anime/watch.css diff --git a/views/css/books/book.css b/desktop/views/css/books/book.css similarity index 100% rename from views/css/books/book.css rename to desktop/views/css/books/book.css diff --git a/views/css/books/reader.css b/desktop/views/css/books/reader.css similarity index 100% rename from views/css/books/reader.css rename to desktop/views/css/books/reader.css diff --git a/views/css/components/anilist-modal.css b/desktop/views/css/components/anilist-modal.css similarity index 100% rename from views/css/components/anilist-modal.css rename to desktop/views/css/components/anilist-modal.css diff --git a/views/css/components/hero.css b/desktop/views/css/components/hero.css similarity index 100% rename from views/css/components/hero.css rename to desktop/views/css/components/hero.css diff --git a/views/css/components/navbar.css b/desktop/views/css/components/navbar.css similarity index 100% rename from views/css/components/navbar.css rename to desktop/views/css/components/navbar.css diff --git a/views/css/components/titlebar.css b/desktop/views/css/components/titlebar.css similarity index 100% rename from views/css/components/titlebar.css rename to desktop/views/css/components/titlebar.css diff --git a/views/css/components/updateNotifier.css b/desktop/views/css/components/updateNotifier.css similarity index 100% rename from views/css/components/updateNotifier.css rename to desktop/views/css/components/updateNotifier.css diff --git a/views/css/gallery/gallery.css b/desktop/views/css/gallery/gallery.css similarity index 100% rename from views/css/gallery/gallery.css rename to desktop/views/css/gallery/gallery.css diff --git a/views/css/gallery/image.css b/desktop/views/css/gallery/image.css similarity index 100% rename from views/css/gallery/image.css rename to desktop/views/css/gallery/image.css diff --git a/views/css/globals.css b/desktop/views/css/globals.css similarity index 100% rename from views/css/globals.css rename to desktop/views/css/globals.css diff --git a/views/css/list.css b/desktop/views/css/list.css similarity index 100% rename from views/css/list.css rename to desktop/views/css/list.css diff --git a/views/css/marketplace.css b/desktop/views/css/marketplace.css similarity index 100% rename from views/css/marketplace.css rename to desktop/views/css/marketplace.css diff --git a/views/css/schedule/schedule.css b/desktop/views/css/schedule/schedule.css similarity index 100% rename from views/css/schedule/schedule.css rename to desktop/views/css/schedule/schedule.css diff --git a/views/css/users.css b/desktop/views/css/users.css similarity index 100% rename from views/css/users.css rename to desktop/views/css/users.css diff --git a/views/gallery/gallery.html b/desktop/views/gallery/gallery.html similarity index 100% rename from views/gallery/gallery.html rename to desktop/views/gallery/gallery.html diff --git a/views/gallery/image.html b/desktop/views/gallery/image.html similarity index 100% rename from views/gallery/image.html rename to desktop/views/gallery/image.html diff --git a/views/list.html b/desktop/views/list.html similarity index 100% rename from views/list.html rename to desktop/views/list.html diff --git a/views/marketplace.html b/desktop/views/marketplace.html similarity index 100% rename from views/marketplace.html rename to desktop/views/marketplace.html diff --git a/views/schedule.html b/desktop/views/schedule.html similarity index 100% rename from views/schedule.html rename to desktop/views/schedule.html diff --git a/views/users.html b/desktop/views/users.html similarity index 100% rename from views/users.html rename to desktop/views/users.html diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..f675cdb --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,8 @@ +node_modules +electron +dist +.env +build +.gitignore +Dockerfile +.dockerignore diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..d09ab9d --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,5 @@ +node_modules +electron +dist +.env +build diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..f3206c7 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/playwright:v1.50.0-jammy + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci + +RUN npx playwright install --with-deps chromium + +COPY . . + +EXPOSE 54322 + +CMD ["npm", "run", "start"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..0b03e29 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,53 @@ +# πŸŽ€ WaifuBoard + +**Lightweight all-in-one app for boorus, manga and light novels β€” no sources included, total freedom via extensions.** + +WaifuBoard Hero +
+ +[![Windows](https://img.shields.io/badge/Windows-SUPPORTED-0078D6?style=for-the-badge&logo=windows&logoColor=white)](https://waifuboard.app) +[![Latest](https://img.shields.io/gitea/v/release/ItsSkaiya/WaifuBoard?gitea_url=https://git.waifuboard.app&style=for-the-badge)](https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases/latest) +[![Extensions](https://img.shields.io/badge/Extensions-Repository-8257e5?style=for-the-badge)](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)](#)** + +
+ + +## πŸš€ 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** +- Fully open-source & community-driven +- Future mobile ports planned + +## πŸ–₯️ Download & Platform Support + +| Platform | Status | Link | +|------------|-----------------|-----------------------------------------------------------| +| Windows | βœ… Available now | [Latest Release](https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases/latest) | +| Linux | ⏳ Coming soon | β€” | +| macOS | ⏳ Coming soon | β€” | + +## πŸ“¦ Extensions & Marketplace + +WaifuBoard ships empty. **You decide exactly what sources you want.** + +**Official Extensions Repository** β†’ https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions
+**Extension Development Docs** β†’ https://waifuboard.app/docs + +## ⚠️ Legal Disclaimer + +**WaifuBoard does not host, store, or distribute any content. +All material is retrieved in real time through third-party extensions installed by the user. +You are fully responsible for ensuring that the sources you access comply with the laws of your region.** + +--- + +**Designed for the otakus by an otaku.** πŸ’œ diff --git a/docker/main.js b/docker/main.js new file mode 100644 index 0000000..33c0460 --- /dev/null +++ b/docker/main.js @@ -0,0 +1,41 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const { fork } = require('child_process'); +const path = require('path'); + +let win; +let backend; + +function startBackend() { + backend = fork(path.join(__dirname, 'server.js')); +} + +function createWindow() { + win = new BrowserWindow({ + width: 1200, + height: 800, + frame: false, + titleBarStyle: "hidden", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + nodeIntegration: false, + contextIsolation: true + } + }); + + win.setMenu(null); + win.loadURL('http://localhost:54322'); +} + +ipcMain.on("win:minimize", () => win.minimize()); +ipcMain.on("win:maximize", () => win.maximize()); +ipcMain.on("win:close", () => win.close()); + +app.whenReady().then(() => { + startBackend(); + createWindow(); +}); + +app.on('window-all-closed', () => { + if (backend) backend.kill(); + app.quit(); +}); diff --git a/docker/package-lock.json b/docker/package-lock.json new file mode 100644 index 0000000..46aab53 --- /dev/null +++ b/docker/package-lock.json @@ -0,0 +1,3975 @@ +{ + "name": "waifu-board", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "waifu-board", + "version": "2.0.0", + "license": "ISC", + "dependencies": { + "@fastify/static": "^8.3.0", + "bcrypt": "^6.0.0", + "bindings": "^1.5.0", + "cheerio": "^1.1.2", + "dotenv": "^17.2.3", + "fastify": "^5.6.2", + "jsonwebtoken": "^9.0.3", + "node-addon-api": "^8.5.0", + "playwright-chromium": "^1.57.0", + "sqlite3": "^5.1.7" + }, + "devDependencies": { + "@types/bcrypt": "^6.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.0.0", + "node-gyp": "^12.1.0", + "ts-node": "^10.9.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", + "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cacache": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", + "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cheerio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.0.tgz", + "integrity": "sha512-vXiThu1/rlos7EGu8TuNZQEg2e9TvhH9dmS4T4ZVzB7Ao1agEZ6EG3sn5n+hZRYUgduISd1HpngFzAZiDGm5vQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.1.1.tgz", + "integrity": "sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.6.2.tgz", + "integrity": "sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.0", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.3.0.tgz", + "integrity": "sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "devOptional": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-fetch-happen": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", + "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "promise-retry": "^2.0.1", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.0.tgz", + "integrity": "sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-fetch/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.1.0.tgz", + "integrity": "sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.2", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/node-gyp/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", + "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", + "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, + "node_modules/playwright-chromium": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-chromium/-/playwright-chromium-1.57.0.tgz", + "integrity": "sha512-GCVVTbmIDrZuBxWYoQ70rehRXMb3Q7ccENe63a+rGTWwypeVAgh/DD5o5QQ898oer5pdIv3vGINUlEkHtOZQEw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/sqlite3/node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/sqlite3/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/sqlite3/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/sqlite3/node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sqlite3/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sqlite3/node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/sqlite3/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/sqlite3/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sqlite3/node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sqlite3/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/sqlite3/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sqlite3/node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/sqlite3/node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/sqlite3/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/sqlite3/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/sqlite3/node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/sqlite3/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/sqlite3/node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sqlite3/node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sqlite3/node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/sqlite3/node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/sqlite3/node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/ssri": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", + "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", + "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/unique-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", + "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/docker/package.json b/docker/package.json new file mode 100644 index 0000000..d89160c --- /dev/null +++ b/docker/package.json @@ -0,0 +1,34 @@ +{ + "name": "waifu-board", + "version": "2.0.0", + "description": "", + "main": "main.js", + "scripts": { + "build": "tsc", + "start": "tsc && node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@fastify/static": "^8.3.0", + "bcrypt": "^6.0.0", + "bindings": "^1.5.0", + "cheerio": "^1.1.2", + "dotenv": "^17.2.3", + "fastify": "^5.6.2", + "jsonwebtoken": "^9.0.3", + "node-addon-api": "^8.5.0", + "playwright-chromium": "^1.57.0", + "sqlite3": "^5.1.7" + }, + "devDependencies": { + "@types/bcrypt": "^6.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.0.0", + "node-gyp": "^12.1.0", + "ts-node": "^10.9.0", + "typescript": "^5.3.0" + } +} diff --git a/docker/preload.js b/docker/preload.js new file mode 100644 index 0000000..47cc6dc --- /dev/null +++ b/docker/preload.js @@ -0,0 +1,10 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("electronAPI", { + isElectron: true, + win: { + minimize: () => ipcRenderer.send("win:minimize"), + maximize: () => ipcRenderer.send("win:maximize"), + close: () => ipcRenderer.send("win:close") + } +}); diff --git a/docker/public/assets/avatar.png b/docker/public/assets/avatar.png new file mode 100644 index 0000000..0989209 Binary files /dev/null and b/docker/public/assets/avatar.png differ diff --git a/docker/public/assets/placeholder.svg b/docker/public/assets/placeholder.svg new file mode 100644 index 0000000..a8c9d51 --- /dev/null +++ b/docker/public/assets/placeholder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docker/public/assets/waifuboards.ico b/docker/public/assets/waifuboards.ico new file mode 100644 index 0000000..12deec3 Binary files /dev/null and b/docker/public/assets/waifuboards.ico differ diff --git a/docker/server.js b/docker/server.js new file mode 100644 index 0000000..47bc1ca --- /dev/null +++ b/docker/server.js @@ -0,0 +1,87 @@ +const fastify = require("fastify")({ + logger: { level: "error" }, +}); + +const path = require("path"); +const jwt = require("jsonwebtoken"); +const { initHeadless } = require("./electron/shared/headless"); +const { initDatabase } = require("./electron/shared/database"); +const { loadExtensions } = require("./electron/shared/extensions"); +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 viewsRoutes = require("./electron/views/views.routes"); +const animeRoutes = require("./electron/api/anime/anime.routes"); +const booksRoutes = require("./electron/api/books/books.routes"); +const proxyRoutes = require("./electron/api/proxy/proxy.routes"); +const extensionsRoutes = require("./electron/api/extensions/extensions.routes"); +const galleryRoutes = require("./electron/api/gallery/gallery.routes"); +const userRoutes = require("./electron/api/user/user.routes"); +const listRoutes = require("./electron/api/list/list.routes"); +const anilistRoute = require("./electron/api/anilist/anilist"); + +fastify.addHook("preHandler", async (request) => { + const auth = request.headers.authorization; + if (!auth) return; + + try { + const token = auth.replace("Bearer ", ""); + request.user = jwt.verify(token, process.env.JWT_SECRET); + } catch (e) { + return reply.code(401).send({ error: "Invalid token" }); + } +}); + +fastify.register(require("@fastify/static"), { + root: path.join(__dirname, "public"), + prefix: "/public/", + decorateReply: false, +}); + +fastify.register(require("@fastify/static"), { + root: path.join(__dirname, "views"), + prefix: "/views/", + decorateReply: false, +}); + +fastify.register(require("@fastify/static"), { + root: path.join(__dirname, "src", "scripts"), + prefix: "/src/scripts/", + decorateReply: false, +}); + +fastify.register(viewsRoutes); +fastify.register(animeRoutes, { prefix: "/api" }); +fastify.register(booksRoutes, { prefix: "/api" }); +fastify.register(proxyRoutes, { prefix: "/api" }); +fastify.register(extensionsRoutes, { prefix: "/api" }); +fastify.register(galleryRoutes, { prefix: "/api" }); +fastify.register(userRoutes, { prefix: "/api" }); +fastify.register(anilistRoute, { prefix: "/api" }); +fastify.register(listRoutes, { prefix: "/api" }); + +const start = async () => { + try { + initDatabase("anilist"); + initDatabase("favorites"); + initDatabase("cache"); + initDatabase("userdata"); + + await loadExtensions(); + + await fastify.listen({ port: 54322, host: "0.0.0.0" }); + console.log(`Server is now running!`); + + await initHeadless(); + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +}; + +start(); diff --git a/docker/src/api/anilist/anilist.service.ts b/docker/src/api/anilist/anilist.service.ts new file mode 100644 index 0000000..842cf6f --- /dev/null +++ b/docker/src/api/anilist/anilist.service.ts @@ -0,0 +1,564 @@ +import { queryOne } from '../../shared/database'; + +const USER_DB = 'userdata'; + +// ConfiguraciΓ³n de reintentos +const RETRY_CONFIG = { + maxRetries: 3, + initialDelay: 1000, + maxDelay: 5000, + backoffMultiplier: 2 +}; + +// Helper para hacer requests con reintentos y manejo de errores +async function fetchWithRetry( + url: string, + options: RequestInit, + retries = RETRY_CONFIG.maxRetries +): Promise { + let lastError: Error | null = null; + let delay = RETRY_CONFIG.initialDelay; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout + + const response = await fetch(url, { + ...options, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + // Si es rate limit (429), esperamos mΓ‘s tiempo + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : delay; + + if (attempt < retries) { + console.warn(`Rate limited. Esperando ${waitTime}ms antes de reintentar...`); + await new Promise(resolve => setTimeout(resolve, waitTime)); + delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay); + continue; + } + } + + // Si es un error de servidor (5xx), reintentamos + if (response.status >= 500 && attempt < retries) { + console.warn(`Error del servidor (${response.status}). Reintentando en ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay); + continue; + } + + return response; + } catch (error) { + lastError = error as Error; + + if (attempt < retries && ( + error instanceof Error && ( + error.name === 'AbortError' || + error.message.includes('fetch') || + error.message.includes('network') + ) + )) { + console.warn(`Error de conexiΓ³n (intento ${attempt + 1}/${retries + 1}). Reintentando en ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay); + continue; + } + + throw error; + } + } + + throw lastError || new Error('Request failed after all retries'); +} + +export async function getUserAniList(appUserId: number) { + try { + const sql = ` + SELECT access_token, anilist_user_id + FROM UserIntegration + WHERE user_id = ? AND platform = 'AniList'; + `; + + const integration = await queryOne(sql, [appUserId], USER_DB) as any; + if (!integration) return []; + + const { access_token, anilist_user_id } = integration; + if (!access_token || !anilist_user_id) return []; + + const query = ` + query ($userId: Int) { + anime: MediaListCollection(userId: $userId, type: ANIME) { + lists { + entries { + media { + id + title { romaji english userPreferred } + coverImage { extraLarge } + episodes + nextAiringEpisode { episode } + } + status + progress + score + repeat + notes + private + startedAt { year month day } + completedAt { year month day } + } + } + } + + manga: MediaListCollection(userId: $userId, type: MANGA) { + lists { + entries { + media { + id + type + format + title { romaji english userPreferred } + coverImage { extraLarge } + chapters + volumes + } + status + progress + score + repeat + notes + private + startedAt { year month day } + completedAt { year month day } + } + } + } + } + `; + + const res = await fetchWithRetry('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${access_token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + query, + variables: { userId: anilist_user_id } + }), + }); + + if (!res.ok) throw new Error(`AniList API error: ${res.status}`); + + const json = await res.json(); + if (json?.errors?.length) throw new Error(json.errors[0].message); + + const fromFuzzy = (d: any) => { + if (!d?.year) return null; + const m = String(d.month || 1).padStart(2, '0'); + const day = String(d.day || 1).padStart(2, '0'); + return `${d.year}-${m}-${day}`; + }; + + const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => { + const result: any[] = []; + + for (const list of lists || []) { + for (const entry of list.entries || []) { + const media = entry.media; + + const totalEpisodes = + media?.episodes || + (media?.nextAiringEpisode?.episode + ? media.nextAiringEpisode.episode - 1 + : 0); + + const totalChapters = + media?.chapters || + (media?.volumes ? media.volumes * 10 : 0); + + const resolvedType = + type === 'MANGA' && + (media?.format === 'LIGHT_NOVEL' || media?.format === 'NOVEL') + ? 'NOVEL' + : type; + + + result.push({ + user_id: appUserId, + + entry_id: media.id, + source: 'anilist', + + // βœ… AHORA TU FRONT RECIBE NOVEL + entry_type: resolvedType, + + status: entry.status, + progress: entry.progress || 0, + score: entry.score || null, + start_date: fromFuzzy(entry.startedAt), + end_date: fromFuzzy(entry.completedAt), + repeat_count: entry.repeat || 0, + notes: entry.notes || null, + is_private: entry.private ? 1 : 0, + + title: media?.title?.userPreferred + || media?.title?.english + || media?.title?.romaji + || 'Unknown Title', + + poster: media?.coverImage?.extraLarge + || 'https://placehold.co/400x600?text=No+Cover', + + total_episodes: resolvedType === 'ANIME' ? totalEpisodes : undefined, + total_chapters: resolvedType !== 'ANIME' ? totalChapters : undefined, + + updated_at: new Date().toISOString() + }); + } + } + + return result; + }; + + return [ + ...normalize(json?.data?.anime?.lists, 'ANIME'), + ...normalize(json?.data?.manga?.lists, 'MANGA') + ]; + + } catch (error) { + console.error('Error fetching AniList data:', error); + return []; + } +} + +export async function updateAniListEntry(token: string, params: { + mediaId: number | string; + status?: string | null; + progress?: number | null; + score?: number | null; + start_date?: string | null; // YYYY-MM-DD + end_date?: string | null; // YYYY-MM-DD + repeat_count?: number | null; + notes?: string | null; + is_private?: boolean | number | null; +}) { + try { + if (!token) throw new Error('AniList token is required'); + + const mutation = ` + mutation ( + $mediaId: Int, + $status: MediaListStatus, + $progress: Int, + $score: Float, + $startedAt: FuzzyDateInput, + $completedAt: FuzzyDateInput, + $repeat: Int, + $notes: String, + $private: Boolean + ) { + SaveMediaListEntry ( + mediaId: $mediaId, + status: $status, + progress: $progress, + score: $score, + startedAt: $startedAt, + completedAt: $completedAt, + repeat: $repeat, + notes: $notes, + private: $private + ) { + id + status + progress + score + startedAt { year month day } + completedAt { year month day } + repeat + notes + private + } + } + `; + + const toFuzzyDate = (dateStr?: string | null) => { + if (!dateStr) return null; + const [year, month, day] = dateStr.split('-').map(Number); + return { year, month, day }; + }; + + const variables: any = { + mediaId: Number(params.mediaId), + status: params.status ?? undefined, + progress: params.progress ?? undefined, + score: params.score ?? undefined, + startedAt: toFuzzyDate(params.start_date), + completedAt: toFuzzyDate(params.end_date), + repeat: params.repeat_count ?? undefined, + notes: params.notes ?? undefined, + private: typeof params.is_private === 'boolean' + ? params.is_private + : params.is_private === 1 + }; + + const res = await fetchWithRetry('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ query: mutation, variables }), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`AniList update failed: ${res.status} - ${errorText}`); + } + + const json = await res.json(); + + if (json?.errors?.length) { + throw new Error(`AniList GraphQL error: ${json.errors[0].message}`); + } + + return json.data?.SaveMediaListEntry || null; + } catch (error) { + console.error('Error updating AniList entry:', error); + throw error; + } +} + +export async function deleteAniListEntry(token: string, mediaId: number) { + if (!token) throw new Error("AniList token required"); + + try { + // 1️⃣ OBTENER VIEWER + const viewerQuery = `query { Viewer { id name } }`; + + const vRes = await fetchWithRetry('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ query: viewerQuery }), + }); + + const vJson = await vRes.json(); + const userId = vJson?.data?.Viewer?.id; + + if (!userId) throw new Error("Invalid AniList token"); + + // 2️⃣ DETECTAR TIPO REAL DEL MEDIA + const mediaQuery = ` + query ($id: Int) { + Media(id: $id) { + id + type + } + } + `; + + const mTypeRes = await fetchWithRetry('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + query: mediaQuery, + variables: { id: mediaId } + }), + }); + + const mTypeJson = await mTypeRes.json(); + const mediaType = mTypeJson?.data?.Media?.type; + + if (!mediaType) { + throw new Error("Media not found in AniList"); + } + + // 3️⃣ BUSCAR ENTRY CON TIPO REAL + const listQuery = ` + query ($userId: Int, $mediaId: Int, $type: MediaType) { + MediaList(userId: $userId, mediaId: $mediaId, type: $type) { + id + } + } + `; + + const qRes = await fetchWithRetry('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + query: listQuery, + variables: { + userId, + mediaId, + type: mediaType + } + }), + }); + + const qJson = await qRes.json(); + const listEntryId = qJson?.data?.MediaList?.id; + + if (!listEntryId) { + throw new Error("Entry not found in user's AniList"); + } + + // 4️⃣ BORRAR + const mutation = ` + mutation ($id: Int) { + DeleteMediaListEntry(id: $id) { + deleted + } + } + `; + + const delRes = await fetchWithRetry('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + query: mutation, + variables: { id: listEntryId } + }), + }); + + const delJson = await delRes.json(); + + if (delJson?.errors?.length) { + throw new Error(delJson.errors[0].message); + } + + return true; + + } catch (err) { + console.error("AniList DELETE failed:", err); + throw err; + } +} + + +export async function getSingleAniListEntry( + token: string, + mediaId: number, + type: 'ANIME' | 'MANGA' +) { + try { + if (!token) { + throw new Error('AniList token is required'); + } + + // 1️⃣ Obtener userId desde el token + const viewerRes = await fetchWithRetry('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + query: `query { Viewer { id } }` + }) + }); + + const viewerJson = await viewerRes.json(); + const userId = viewerJson?.data?.Viewer?.id; + + if (!userId) { + throw new Error('Failed to get AniList userId'); + } + + // 2️⃣ Query correcta con userId + const query = ` + query ($mediaId: Int, $type: MediaType, $userId: Int) { + MediaList(mediaId: $mediaId, type: $type, userId: $userId) { + id + status + progress + score + repeat + private + notes + startedAt { year month day } + completedAt { year month day } + } + } + `; + + const res = await fetchWithRetry('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + query, + variables: { mediaId, type, userId } + }) + }); + + if (res.status === 404) { + return null; // βœ… No existe entry todavΓ­a β†’ es totalmente vΓ‘lido + } + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`AniList fetch failed: ${res.status} - ${errorText}`); + } + + const json = await res.json(); + + if (json?.errors?.length) { + if (json.errors[0].status === 404) return null; + throw new Error(`GraphQL error: ${json.errors[0].message}`); + } + + const entry = json?.data?.MediaList; + if (!entry) return null; + + return { + entry_id: mediaId, + source: 'anilist', + entry_type: type, + status: entry.status, + progress: entry.progress || 0, + score: entry.score ?? null, + + start_date: entry.startedAt?.year + ? `${entry.startedAt.year}-${String(entry.startedAt.month).padStart(2, '0')}-${String(entry.startedAt.day).padStart(2, '0')}` + : null, + + end_date: entry.completedAt?.year + ? `${entry.completedAt.year}-${String(entry.completedAt.month).padStart(2, '0')}-${String(entry.completedAt.day).padStart(2, '0')}` + : null, + + repeat_count: entry.repeat || 0, + notes: entry.notes || null, + is_private: entry.private ? 1 : 0, + }; + + } catch (error) { + console.error('Error fetching single AniList entry:', error); + throw error; + } +} diff --git a/docker/src/api/anilist/anilist.ts b/docker/src/api/anilist/anilist.ts new file mode 100644 index 0000000..a245daf --- /dev/null +++ b/docker/src/api/anilist/anilist.ts @@ -0,0 +1,91 @@ +import { FastifyInstance } from "fastify"; +import { run } from "../../shared/database"; + +async function anilist(fastify: FastifyInstance) { + fastify.get("/anilist", async (request, reply) => { + try { + const { code, state } = request.query as { code?: string; state?: string }; + + if (!code) return reply.status(400).send("No code"); + if (!state) return reply.status(400).send("No user state"); + + const userId = Number(state); + if (!userId || isNaN(userId)) { + return reply.status(400).send("Invalid user id"); + } + + const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: process.env.ANILIST_CLIENT_ID, + client_secret: process.env.ANILIST_CLIENT_SECRET, + redirect_uri: "http://localhost:54322/api/anilist", + code + }) + }); + + const tokenData = await tokenRes.json(); + + if (!tokenData.access_token) { + console.error("AniList token error:", tokenData); + return reply.status(500).send("Failed to get AniList token"); + } + + const userRes = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `${tokenData.token_type} ${tokenData.access_token}` + }, + body: JSON.stringify({ + query: `query { Viewer { id } }` + }) + }); + + const userData = await userRes.json(); + const anilistUserId = userData?.data?.Viewer?.id; + + if (!anilistUserId) { + console.error("AniList Viewer error:", userData); + return reply.status(500).send("Failed to fetch AniList user"); + } + + const expiresAt = new Date( + Date.now() + tokenData.expires_in * 1000 + ).toISOString(); + + await run( + ` + INSERT INTO UserIntegration + (user_id, platform, access_token, refresh_token, token_type, anilist_user_id, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + token_type = excluded.token_type, + anilist_user_id = excluded.anilist_user_id, + expires_at = excluded.expires_at + `, + [ + userId, + "AniList", + tokenData.access_token, + tokenData.refresh_token, + tokenData.token_type, + anilistUserId, + expiresAt + ], + "userdata" + ); + + return reply.redirect("http://localhost:54322/?anilist=success"); + } catch (e) { + console.error("AniList error:", e); + return reply.redirect("http://localhost:54322/?anilist=error"); + } + }); +} + +export default anilist; \ No newline at end of file diff --git a/docker/src/api/anime/anime.controller.ts b/docker/src/api/anime/anime.controller.ts new file mode 100644 index 0000000..b6bd693 --- /dev/null +++ b/docker/src/api/anime/anime.controller.ts @@ -0,0 +1,107 @@ +import {FastifyReply, FastifyRequest} from 'fastify'; +import * as animeService from './anime.service'; +import {getExtension} from '../../shared/extensions'; +import {Anime, AnimeRequest, SearchRequest, WatchStreamRequest} from '../types'; + +export async function getAnime(req: AnimeRequest, reply: FastifyReply) { + try { + const { id } = req.params; + const source = req.query.source; + + let anime: Anime | { error: string }; + if (source === 'anilist') { + anime = await animeService.getAnimeById(id); + } else { + const ext = getExtension(source); + anime = await animeService.getAnimeInfoExtension(ext, id) + } + + return anime; + } catch (err) { + return { error: "Database error" }; + } +} + +export async function getAnimeEpisodes(req: AnimeRequest, reply: FastifyReply) { + try { + const { id } = req.params; + const source = req.query.source || 'anilist'; + const ext = getExtension(source); + + return await animeService.searchEpisodesInExtension( + ext, + source, + id + ); + } catch (err) { + return { error: "Database error" }; + } +} + +export async function getTrending(req: FastifyRequest, reply: FastifyReply) { + try { + const results = await animeService.getTrendingAnime(); + return { results }; + } catch (err) { + return { results: [] }; + } +} + +export async function getTopAiring(req: FastifyRequest, reply: FastifyReply) { + try { + const results = await animeService.getTopAiringAnime(); + return { results }; + } catch (err) { + return { results: [] }; + } +} + +export async function search(req: SearchRequest, reply: FastifyReply) { + try { + const query = req.query.q; + const results = await animeService.searchAnimeLocal(query); + + if (results.length > 0) { + return { results: results }; + } + + } catch (err) { + return { results: [] }; + } +} + +export async function searchInExtension(req: any, reply: FastifyReply) { + try { + const extensionName = req.params.extension; + const query = req.query.q; + + const ext = getExtension(extensionName); + if (!ext) return { results: [] }; + + const results = await animeService.searchAnimeInExtension(ext, extensionName, query); + return { results }; + } catch { + return { results: [] }; + } +} + +export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) { + try { + const { animeId, episode, server, category, ext, source } = req.query; + + const extension = getExtension(ext); + if (!extension) return { error: "Extension not found" }; + + return await animeService.getStreamData( + extension, + episode, + animeId, + source, + server, + category + ); + } catch (err) { + const error = err as Error; + return { error: error.message }; + } +} \ No newline at end of file diff --git a/docker/src/api/anime/anime.routes.ts b/docker/src/api/anime/anime.routes.ts new file mode 100644 index 0000000..f23976d --- /dev/null +++ b/docker/src/api/anime/anime.routes.ts @@ -0,0 +1,14 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './anime.controller'; + +async function animeRoutes(fastify: FastifyInstance) { + fastify.get('/anime/:id', controller.getAnime); + fastify.get('/anime/:id/:episodes', controller.getAnimeEpisodes); + fastify.get('/trending', controller.getTrending); + fastify.get('/top-airing', controller.getTopAiring); + fastify.get('/search', controller.search); + fastify.get('/search/:extension', controller.searchInExtension); + fastify.get('/watch/stream', controller.getWatchStream); +} + +export default animeRoutes; \ No newline at end of file diff --git a/docker/src/api/anime/anime.service.ts b/docker/src/api/anime/anime.service.ts new file mode 100644 index 0000000..a7216f5 --- /dev/null +++ b/docker/src/api/anime/anime.service.ts @@ -0,0 +1,450 @@ +import { getCache, setCache, getCachedExtension, cacheExtension, getExtensionTitle } from '../../shared/queries'; +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"; + +const MEDIA_FIELDS = ` + id + idMal + title { romaji english native userPreferred } + type + format + status + description + startDate { year month day } + endDate { year month day } + season + seasonYear + episodes + duration + chapters + volumes + countryOfOrigin + isLicensed + source + hashtag + trailer { id site thumbnail } + updatedAt + coverImage { extraLarge large medium color } + bannerImage + genres + synonyms + averageScore + popularity + isLocked + trending + favourites + isAdult + siteUrl + tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult } + relations { + edges { + relationType + node { + id + title { romaji } + type + format + status + } + } + } + studios { + edges { + isMain + node { id name isAnimationStudio } + } + } + nextAiringEpisode { airingAt timeUntilAiring episode } + externalLinks { id url site type language color icon notes } + rankings { id rank type format year season allTime context } + stats { + scoreDistribution { score amount } + statusDistribution { status amount } + } + recommendations(perPage: 7, sort: RATING_DESC) { + nodes { + mediaRecommendation { + id + title { romaji } + coverImage { medium } + format + type + } + } + } +`; + +async function fetchAniList(query: string, variables: any) { + const res = await fetch(ANILIST_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables }) + }); + + const json = await res.json(); + return json?.data; +} + +export async function getAnimeById(id: string | number): Promise { + const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]); + + if (row) return JSON.parse(row.full_data); + + const query = ` + query ($id: Int) { + Media(id: $id, type: ANIME) { ${MEDIA_FIELDS} } + } + `; + + const data = await fetchAniList(query, { id: Number(id) }); + if (!data?.Media) return { error: "Anime not found" }; + + await queryOne( + "INSERT INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)", + [ + data.Media.id, + data.Media.title?.english || data.Media.title?.romaji, + data.Media.updatedAt || 0, + JSON.stringify(data.Media) + ] + ); + + return data.Media; +} + +export async function getTrendingAnime(): Promise { + const rows = await queryAll( + "SELECT full_data, updated_at 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; +} + +export async function getTopAiringAnime(): Promise { + const rows = await queryAll( + "SELECT full_data, updated_at 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; +} + +export async function searchAnimeLocal(query: string): Promise { + if (!query || query.length < 2) return []; + + const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`; + const rows = await queryAll(sql, [`%${query}%`]); + + const localResults: Anime[] = rows + .map((r: { full_data: string }) => JSON.parse(r.full_data)) + .filter((anime: { title: { english: any; romaji: any; native: any; }; synonyms: any; }) => { + const q = query.toLowerCase(); + const titles = [ + anime.title?.english, + anime.title?.romaji, + anime.title?.native, + ...(anime.synonyms || []) + ] + .filter(Boolean) + .map(t => t!.toLowerCase()); + + return titles.some(t => t.includes(q)); + }) + .slice(0, 10); + + if (localResults.length >= 5) { + return localResults; + } + + const gql = ` + query ($search: String) { + Page(page: 1, perPage: 10) { + media(type: ANIME, search: $search) { ${MEDIA_FIELDS} } + } + } + `; + + const data = await fetchAniList(gql, { search: query }); + const remoteResults: Anime[] = data?.Page?.media || []; + + for (const anime of remoteResults) { + await queryOne( + "INSERT OR IGNORE INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)", + [ + anime.id, + anime.title?.english || anime.title?.romaji, + anime.updatedAt || 0, + JSON.stringify(anime) + ] + ); + } + + const merged = [...localResults]; + + for (const anime of remoteResults) { + if (!merged.find(a => a.id === anime.id)) { + merged.push(anime); + } + if (merged.length >= 10) break; + } + + return merged; +} + +export async function getAnimeInfoExtension(ext: Extension | null, id: string): Promise { + if (!ext) return { error: "not found" }; + + const extName = ext.constructor.name; + + const cached = await getCachedExtension(extName, id); + if (cached) { + try { + console.log(`[${extName}] Metadata cache hit for ID: ${id}`); + return JSON.parse(cached.metadata) as Anime; + } catch { + + } + } + + if ((ext.type === 'anime-board') && ext.getMetadata) { + try { + const match = await ext.getMetadata(id); + + if (match) { + const normalized: any = { + title: match.title ?? "Unknown", + summary: match.summary ?? "No summary available", + episodes: Number(match.episodes) || 0, + characters: Array.isArray(match.characters) ? match.characters : [], + season: match.season ?? null, + status: match.status ?? "Unknown", + studio: match.studio ?? "Unknown", + score: Number(match.score) || 0, + year: match.year ?? null, + genres: Array.isArray(match.genres) ? match.genres : [], + image: match.image ?? "" + }; + + await cacheExtension(extName, id, normalized.title, normalized); + return normalized; + } + } catch (e) { + console.error(`Extension getMetadata failed:`, e); + } + } + + return { error: "not found" }; +} + +export async function searchAnimeInExtension(ext: Extension | null, name: string, query: string) { + if (!ext) return []; + + if (ext.type === 'anime-board' && ext.search) { + try { + console.log(`[${name}] Searching for anime: ${query}`); + const matches = await ext.search({ + query: query, + media: { + romajiTitle: query, + englishTitle: query, + startDate: { year: 0, month: 0, day: 0 } + } + }); + + if (matches && matches.length > 0) { + return matches.map(m => ({ + id: m.id, + extensionName: name, + title: { romaji: m.title, english: m.title, native: null }, + coverImage: { large: m.image || '' }, + averageScore: m.rating || m.score || null, + format: 'ANIME', + seasonYear: null, + isExtensionResult: true, + })); + } + } catch (e) { + console.error(`Extension search failed for ${name}:`, e); + } + } + + return []; +} + +export async function searchEpisodesInExtension(ext: Extension | null, name: string, query: string): Promise { + if (!ext) return []; + + const cacheKey = `anime:episodes:${name}:${query}`; + const cached = await getCache(cacheKey); + + if (cached) { + const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; + + if (!isExpired) { + console.log(`[${name}] Episodes cache hit for: ${query}`); + try { + return JSON.parse(cached.result) as Episode[]; + } catch (e) { + console.error(`[${name}] Error parsing cached episodes:`, e); + } + } else { + console.log(`[${name}] Episodes cache expired for: ${query}`); + } + } + + if (ext.type === "anime-board" && ext.search && typeof ext.findEpisodes === "function") { + try { + const title = await getExtensionTitle(name, query); + let mediaId: string; + + if (!title) { + const matches = await ext.search({ + query, + media: { + romajiTitle: query, + englishTitle: query, + startDate: { year: 0, month: 0, day: 0 } + } + }); + + if (!matches || matches.length === 0) return []; + + const res = matches[0]; + if (!res?.id) return []; + + mediaId = res.id; + + } else { + mediaId = query; + } + + const chapterList = await ext.findEpisodes(mediaId); + + if (!Array.isArray(chapterList)) return []; + + const result: Episode[] = chapterList.map(ep => ({ + id: ep.id, + number: ep.number, + url: ep.url, + title: ep.title + })); + + await setCache(cacheKey, result, CACHE_TTL_MS); + + return result; + } catch (e) { + console.error(`Extension search failed for ${name}:`, e); + } + } + + return []; +} + +export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string): Promise { + const providerName = extension.constructor.name; + + const cacheKey = `anime:stream:${providerName}:${id}:${episode}:${server || 'default'}:${category || 'sub'}`; + + const cached = await getCache(cacheKey); + + if (cached) { + const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; + + if (!isExpired) { + console.log(`[${providerName}] Stream data cache hit for episode ${episode}`); + try { + return JSON.parse(cached.result) as StreamData; + } catch (e) { + console.error(`[${providerName}] Error parsing cached stream data:`, e); + } + } else { + console.log(`[${providerName}] Stream data cache expired for episode ${episode}`); + } + } + + if (!extension.findEpisodes || !extension.findEpisodeServer) { + throw new Error("Extension doesn't support required methods"); + } + let episodes; + + if (source === "anilist"){ + const anime: any = await getAnimeById(id) + episodes = await searchEpisodesInExtension(extension, extension.constructor.name, anime.title.romaji); + } + else{ + episodes = await extension.findEpisodes(id); + } + const targetEp = episodes.find(e => e.number === parseInt(episode)); + + if (!targetEp) { + throw new Error("Episode not found"); + } + + const serverName = server || "default"; + const streamData = await extension.findEpisodeServer(targetEp, serverName); + + await setCache(cacheKey, streamData, CACHE_TTL_MS); + return streamData; +} \ No newline at end of file diff --git a/docker/src/api/books/books.controller.ts b/docker/src/api/books/books.controller.ts new file mode 100644 index 0000000..5d07549 --- /dev/null +++ b/docker/src/api/books/books.controller.ts @@ -0,0 +1,116 @@ +import {FastifyReply, FastifyRequest} from 'fastify'; +import * as booksService from './books.service'; +import {getExtension} from '../../shared/extensions'; +import {BookRequest, ChapterRequest, SearchRequest} from '../types'; + +export async function getBook(req: any, reply: FastifyReply) { + try { + const { id } = req.params; + const source = req.query.source; + + let book; + if (source === 'anilist') { + book = await booksService.getBookById(id); + } else { + const ext = getExtension(source); + const result = await booksService.getBookInfoExtension(ext, id); + book = result || null; + } + + return book; + } catch (err) { + return { error: (err as Error).message }; + } +} + + +export async function getTrending(req: FastifyRequest, reply: FastifyReply) { + try { + const results = await booksService.getTrendingBooks(); + return { results }; + } catch (err) { + return { results: [] }; + } +} + +export async function getPopular(req: FastifyRequest, reply: FastifyReply) { + try { + const results = await booksService.getPopularBooks(); + return { results }; + } catch (err) { + return { results: [] }; + } +} + +export async function searchBooks(req: SearchRequest, reply: FastifyReply) { + try { + const query = req.query.q; + + const dbResults = await booksService.searchBooksLocal(query); + if (dbResults.length > 0) { + return { results: dbResults }; + } + + console.log(`[Books] Local DB miss for "${query}", fetching live...`); + const anilistResults = await booksService.searchBooksAniList(query); + if (anilistResults.length > 0) { + return { results: anilistResults }; + } + + return { results: [] }; + + } catch (e) { + const error = e as Error; + console.error("Search Error:", error.message); + return { results: [] }; + } +} + +export async function searchBooksInExtension(req: any, reply: FastifyReply) { + try { + const extensionName = req.params.extension; + const query = req.query.q; + + const ext = getExtension(extensionName); + if (!ext) return { results: [] }; + + const results = await booksService.searchBooksInExtension(ext, extensionName, query); + return { results }; + } catch (e) { + const error = e as Error; + console.error("Search Error:", error.message); + return { results: [] }; + } +} + +export async function getChapters(req: any, reply: FastifyReply) { + try { + const { id } = req.params; + const source = req.query.source || 'anilist'; + + const isExternal = source !== 'anilist'; + return await booksService.getChaptersForBook(id, isExternal); + } catch { + return { chapters: [] }; + } +} + + +export async function getChapterContent(req: any, reply: FastifyReply) { + try { + const { bookId, chapter, provider } = req.params; + const source = req.query.source || 'anilist'; + + const content = await booksService.getChapterContent( + bookId, + chapter, + provider, + source + ); + + return reply.send(content); + } catch (err) { + console.error("getChapterContent error:", (err as Error).message); + return reply.code(500).send({ error: "Error loading chapter" }); + } +} diff --git a/docker/src/api/books/books.routes.ts b/docker/src/api/books/books.routes.ts new file mode 100644 index 0000000..8993d09 --- /dev/null +++ b/docker/src/api/books/books.routes.ts @@ -0,0 +1,14 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './books.controller'; + +async function booksRoutes(fastify: FastifyInstance) { + fastify.get('/book/:id', controller.getBook); + fastify.get('/books/trending', controller.getTrending); + fastify.get('/books/popular', controller.getPopular); + fastify.get('/search/books', controller.searchBooks); + fastify.get('/search/books/:extension', controller.searchBooksInExtension); + fastify.get('/book/:id/chapters', controller.getChapters); + fastify.get('/book/:bookId/:chapter/:provider', controller.getChapterContent); +} + +export default booksRoutes; \ No newline at end of file diff --git a/docker/src/api/books/books.service.ts b/docker/src/api/books/books.service.ts new file mode 100644 index 0000000..6869cf8 --- /dev/null +++ b/docker/src/api/books/books.service.ts @@ -0,0 +1,572 @@ +import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries'; +import { queryOne, queryAll, run } from '../../shared/database'; +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) { + const res = await fetch(ANILIST_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + }, + body: JSON.stringify({ query, variables }) + }); + + if (!res.ok) { + throw new Error(`AniList error ${res.status}`); + } + + const json = await res.json(); + return json?.data; +} +const MEDIA_FIELDS = ` + id + title { + romaji + english + native + userPreferred + } + type + format + status + description + startDate { year month day } + endDate { year month day } + season + seasonYear + episodes + chapters + volumes + duration + genres + synonyms + averageScore + popularity + favourites + isAdult + siteUrl + coverImage { + extraLarge + large + medium + color + } + bannerImage + updatedAt +`; + +export async function getBookById(id: string | number): Promise { + const row = await queryOne( + "SELECT full_data FROM books WHERE id = ?", + [id] + ); + + if (row) { + return JSON.parse(row.full_data); + } + + try { + console.log(`[Book] Local miss for ID ${id}, fetching live...`); + + const query = ` + query ($id: Int) { + Media(id: $id, type: MANGA) { + id idMal title { romaji english native userPreferred } type format status description + startDate { year month day } endDate { year month day } season seasonYear seasonInt + episodes duration chapters volumes countryOfOrigin isLicensed source hashtag + trailer { id site thumbnail } updatedAt coverImage { extraLarge large medium color } + bannerImage genres synonyms averageScore meanScore popularity isLocked trending favourites + tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult userId } + relations { edges { relationType node { id title { romaji } } } } + characters(page: 1, perPage: 10) { nodes { id name { full } } } + studios { nodes { id name isAnimationStudio } } + isAdult nextAiringEpisode { airingAt timeUntilAiring episode } + externalLinks { url site } + rankings { id rank type format year season allTime context } + } + }`; + + const response = await fetch('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + query, + variables: { id: parseInt(id.toString()) } + }) + }); + + const data = await response.json(); + + if (data?.data?.Media) { + const media = data.data.Media; + + const insertSql = ` + INSERT INTO books (id, title, updatedAt, full_data) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = EXCLUDED.title, + updatedAt = EXCLUDED.updatedAt, + full_data = EXCLUDED.full_data; + `; + + await run(insertSql, [ + media.id, + media.title?.userPreferred || media.title?.romaji || media.title?.english || null, + media.updatedAt || Math.floor(Date.now() / 1000), + JSON.stringify(media) + ]); + + return media; + } + } catch (e) { + console.error("Fetch error:", e); + } + + return { error: "Book not found" }; +} + +export async function getTrendingBooks(): Promise { + const rows = await queryAll( + "SELECT full_data, updated_at FROM trending_books ORDER BY rank ASC LIMIT 10" + ); + + if (rows.length) { + const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL; + if (!expired) { + return rows.map((r: { full_data: string }) => JSON.parse(r.full_data)); + } + } + + const query = ` + query { + Page(page: 1, perPage: 10) { + media(type: MANGA, 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_books"); + + let rank = 1; + for (const book of list) { + await queryOne( + "INSERT INTO trending_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", + [rank++, book.id, JSON.stringify(book), now] + ); + } + + return list; +} + + +export async function getPopularBooks(): Promise { + 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)); + } + } + + const query = ` + query { + Page(page: 1, perPage: 10) { + media(type: MANGA, sort: POPULARITY_DESC) { ${MEDIA_FIELDS} } + } + } + `; + + const data = await fetchAniList(query, {}); + const list = data?.Page?.media || []; + const now = Math.floor(Date.now() / 1000); + + await queryOne("DELETE FROM popular_books"); + + let rank = 1; + for (const book of list) { + await queryOne( + "INSERT INTO popular_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", + [rank++, book.id, JSON.stringify(book), now] + ); + } + + return list; +} + + +export async function searchBooksLocal(query: string): Promise { + if (!query || query.length < 2) { + return []; + } + + const sql = `SELECT full_data FROM books WHERE full_data LIKE ? LIMIT 50`; + const rows = await queryAll(sql, [`%${query}%`]); + + const results: Book[] = rows.map((row: { full_data: string; }) => JSON.parse(row.full_data)); + + const clean = results.filter(book => { + const searchTerms = [ + book.title.english, + book.title.romaji, + book.title.native, + ...(book.synonyms || []) + ].filter(Boolean).map(t => t!.toLowerCase()); + + return searchTerms.some(term => term.includes(query.toLowerCase())); + }); + + return clean.slice(0, 10); +} + +export async function searchBooksAniList(query: string): Promise { + const gql = ` + query ($search: String) { + Page(page: 1, perPage: 5) { + media(search: $search, type: MANGA, isAdult: false) { + id title { romaji english native } + coverImage { extraLarge large } + bannerImage description averageScore format + seasonYear startDate { year } + } + } + }`; + + const response = await fetch('https://graphql.anilist.co', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ query: gql, variables: { search: query } }) + }); + + const liveData = await response.json(); + + if (liveData.data && liveData.data.Page.media.length > 0) { + return liveData.data.Page.media; + } + + return []; +} + +export async function getBookInfoExtension(ext: Extension | null, id: string): Promise { + if (!ext) return []; + + const extName = ext.constructor.name; + + const cached = await getCachedExtension(extName, id); + if (cached) { + try { + return JSON.parse(cached.metadata); + } catch {} + } + + if (ext.type === 'book-board' && ext.getMetadata) { + try { + const info = await ext.getMetadata(id); + + if (info) { + const normalized = { + id: info.id ?? id, + title: info.title ?? "", + format: info.format ?? "", + score: typeof info.score === "number" ? info.score : null, + genres: Array.isArray(info.genres) ? info.genres : [], + status: info.status ?? "", + published: info.published ?? "", + summary: info.summary ?? "", + chapters: Number.isFinite(info.chapters) ? info.chapters : 1, + image: typeof info.image === "string" ? info.image : "" + }; + + await cacheExtension(extName, id, normalized.title, normalized); + return [normalized]; + } + } catch (e) { + console.error(`Extension getInfo failed:`, e); + } + } + + return []; +} + +export async function searchBooksInExtension(ext: Extension | null, name: string, query: string): Promise { + if (!ext) return []; + + if ((ext.type === 'book-board') && ext.search) { + + try { + console.log(`[${name}] Searching for book: ${query}`); + + const matches = await ext.search({ + query: query, + media: { + romajiTitle: query, + englishTitle: query, + startDate: { year: 0, month: 0, day: 0 } + } + }); + + if (matches?.length) { + return matches.map(m => ({ + id: m.id, + extensionName: name, + title: { romaji: m.title, english: m.title, native: null }, + coverImage: { large: m.image || '' }, + averageScore: m.rating || m.score || null, + format: m.format, + seasonYear: null, + isExtensionResult: true + })); + } + + } catch (e) { + console.error(`Extension search failed for ${name}:`, e); + } + } + + return []; +} + +async function fetchBookMetadata(id: string): Promise { + try { + const query = `query ($id: Int) { + Media(id: $id, type: MANGA) { + title { romaji english } + startDate { year month day } + } + }`; + + const res = await fetch('https://graphql.anilist.co', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables: { id: parseInt(id) } }) + }); + + const d = await res.json(); + return d.data?.Media || null; + } catch (e) { + console.error("Failed to fetch book metadata:", e); + return null; + } +} + +async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean, origin: string): Promise { + const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`; + const cached = await getCache(cacheKey); + + if (cached) { + const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; + + if (!isExpired) { + console.log(`[${name}] Chapters cache hit for: ${searchTitle}`); + try { + return JSON.parse(cached.result) as ChapterWithProvider[]; + } catch (e) { + console.error(`[${name}] Error parsing cached chapters:`, e); + } + } else { + console.log(`[${name}] Chapters cache expired for: ${searchTitle}`); + } + } + + try { + console.log(`[${name}] Searching chapters for: ${searchTitle}`); + + let mediaId: string; + if (search) { + const matches = await ext.search!({ + query: searchTitle, + media: { + romajiTitle: searchTitle, + englishTitle: searchTitle, + startDate: { year: 0, month: 0, day: 0 } + } + }); + + const best = matches?.[0]; + + if (!best) { return [] } + + mediaId = best.id; + + } else { + const match = await ext.getMetadata(searchTitle); + mediaId = match.id; + } + + const chaps = await ext.findChapters!(mediaId); + + if (!chaps?.length){ + return []; + } + + console.log(`[${name}] Found ${chaps.length} chapters.`); + const result: ChapterWithProvider[] = chaps.map((ch) => ({ + id: ch.id, + number: parseFloat(ch.number.toString()), + title: ch.title, + date: ch.releaseDate, + provider: name, + index: ch.index + })); + + await setCache(cacheKey, result, CACHE_TTL_MS); + return result; + + } catch (e) { + const error = e as Error; + console.error(`Failed to fetch chapters from ${name}:`, error.message); + return []; + } +} + +export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string): Promise<{ chapters: ChapterWithProvider[] }> { + let bookData: Book | null = null; + let searchTitle: string = ""; + + if (!ext) { + const result = await getBookById(id); + if (!result || "error" in result) return { chapters: [] } + bookData = result; + const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean) as string[]; + searchTitle = titles[0]; + } + const bookExtensions = getBookExtensionsMap(); + + let extension; + if (!searchTitle) { + for (const [name, ext] of bookExtensions) { + const title = await getExtensionTitle(name, id) + if (title){ + searchTitle = title; + extension = name; + } + } + } + + const allChapters: any[] = []; + let exts = "anilist"; + if (ext) exts = "ext"; + + for (const [name, ext] of bookExtensions) { + if (onlyProvider && name !== onlyProvider) continue; + if (name == extension) { + const chapters = await searchChaptersInExtension(ext, name, id, false, exts); + allChapters.push(...chapters); + } else { + const chapters = await searchChaptersInExtension(ext, name, searchTitle, true, exts); + allChapters.push(...chapters); + } + } + + return { + chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number)) + }; +} + +export async function getChapterContent(bookId: string, chapterIndex: string, providerName: string, source: string): Promise { + const extensions = getAllExtensions(); + const ext = extensions.get(providerName); + + if (!ext) { + throw new Error("Provider not found"); + } + + const contentCacheKey = `content:${providerName}:${source}:${bookId}:${chapterIndex}`; + const cachedContent = await getCache(contentCacheKey); + + if (cachedContent) { + const isExpired = Date.now() - cachedContent.created_at > CACHE_TTL_MS; + + if (!isExpired) { + console.log(`[${providerName}] Content cache hit for Book ID ${bookId}, Index ${chapterIndex}`); + try { + return JSON.parse(cachedContent.result) as ChapterContent; + } catch (e) { + console.error(`[${providerName}] Error parsing cached content:`, e); + + } + } else { + console.log(`[${providerName}] Content cache expired for Book ID ${bookId}, Index ${chapterIndex}`); + } + } + + const isExternal = source !== 'anilist'; + const chapterList = await getChaptersForBook(bookId, isExternal, providerName); + + if (!chapterList?.chapters || chapterList.chapters.length === 0) { + throw new Error("Chapters not found"); + } + + const providerChapters = chapterList.chapters.filter(c => c.provider === providerName); + const index = parseInt(chapterIndex, 10); + + if (Number.isNaN(index)) { + throw new Error("Invalid chapter index"); + } + + if (!providerChapters[index]) { + throw new Error("Chapter index out of range"); + } + + const selectedChapter = providerChapters[index]; + + const chapterId = selectedChapter.id; + const chapterTitle = selectedChapter.title || null; + const chapterNumber = typeof selectedChapter.number === 'number' ? selectedChapter.number : index; + + try { + if (!ext.findChapterPages) { + throw new Error("Extension doesn't support findChapterPages"); + } + + let contentResult: ChapterContent; + + if (ext.mediaType === "manga") { + const pages = await ext.findChapterPages(chapterId); + contentResult = { + type: "manga", + chapterId, + title: chapterTitle, + number: chapterNumber, + provider: providerName, + pages + }; + } else if (ext.mediaType === "ln") { + const content = await ext.findChapterPages(chapterId); + contentResult = { + type: "ln", + chapterId, + title: chapterTitle, + number: chapterNumber, + provider: providerName, + content + }; + } else { + throw new Error("Unknown mediaType"); + } + + await setCache(contentCacheKey, contentResult, CACHE_TTL_MS); + + return contentResult; + + } catch (err) { + const error = err as Error; + console.error(`[Chapter] Error loading from ${providerName}:`, error.message); + throw err; + } +} \ No newline at end of file diff --git a/docker/src/api/extensions/extensions.controller.ts b/docker/src/api/extensions/extensions.controller.ts new file mode 100644 index 0000000..6b97931 --- /dev/null +++ b/docker/src/api/extensions/extensions.controller.ts @@ -0,0 +1,85 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap, saveExtensionFile, deleteExtensionFile } from '../../shared/extensions'; +import { ExtensionNameRequest } from '../types'; + +export async function getExtensions(req: FastifyRequest, reply: FastifyReply) { + return { extensions: getExtensionsList() }; +} + +export async function getAnimeExtensions(req: FastifyRequest, reply: FastifyReply) { + const animeExtensions = getAnimeExtensionsMap(); + return { extensions: Array.from(animeExtensions.keys()) }; +} + +export async function getBookExtensions(req: FastifyRequest, reply: FastifyReply) { + const bookExtensions = getBookExtensionsMap(); + return { extensions: Array.from(bookExtensions.keys()) }; +} + +export async function getGalleryExtensions(req: FastifyRequest, reply: FastifyReply) { + const galleryExtensions = getGalleryExtensionsMap(); + return { extensions: Array.from(galleryExtensions.keys()) }; +} + +export async function getExtensionSettings(req: ExtensionNameRequest, reply: FastifyReply) { + const { name } = req.params; + const ext = getExtension(name); + + if (!ext) { + return { error: "Extension not found" }; + } + + if (!ext.getSettings) { + return { episodeServers: ["default"], supportsDub: false }; + } + + return ext.getSettings(); +} + +export async function installExtension(req: any, reply: FastifyReply) { + const { fileName } = req.body; + + if (!fileName || !fileName.endsWith('.js')) { + return reply.code(400).send({ error: "Invalid extension fileName provided" }); + } + + try { + + const downloadUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${fileName}` + + await saveExtensionFile(fileName, downloadUrl); + + req.server.log.info(`Extension installed: ${fileName}`); + 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}.` }); + } +} + +export async function uninstallExtension(req: any, reply: FastifyReply) { + const { fileName } = req.body; + + if (!fileName || !fileName.endsWith('.js')) { + return reply.code(400).send({ error: "Invalid extension fileName provided" }); + } + + try { + + await deleteExtensionFile(fileName); + + req.server.log.info(`Extension uninstalled: ${fileName}`); + return reply.code(200).send({ success: true, message: `Extension ${fileName} uninstalled successfully.` }); + + } catch (error) { + + // @ts-ignore + if (error.code === 'ENOENT') { + return reply.code(200).send({ success: true, message: `Extension ${fileName} already uninstalled (file not found).` }); + } + + req.server.log.error(`Failed to uninstall extension ${fileName}:`, error); + return reply.code(500).send({ success: false, error: `Failed to uninstall extension ${fileName}.` }); + } +} \ No newline at end of file diff --git a/docker/src/api/extensions/extensions.routes.ts b/docker/src/api/extensions/extensions.routes.ts new file mode 100644 index 0000000..fd8e0c2 --- /dev/null +++ b/docker/src/api/extensions/extensions.routes.ts @@ -0,0 +1,14 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './extensions.controller'; + +async function extensionsRoutes(fastify: FastifyInstance) { + fastify.get('/extensions', controller.getExtensions); + fastify.get('/extensions/anime', controller.getAnimeExtensions); + fastify.get('/extensions/book', controller.getBookExtensions); + fastify.get('/extensions/gallery', controller.getGalleryExtensions); + fastify.get('/extensions/:name/settings', controller.getExtensionSettings); + fastify.post('/extensions/install', controller.installExtension); + fastify.post('/extensions/uninstall', controller.uninstallExtension); +} + +export default extensionsRoutes; \ No newline at end of file diff --git a/docker/src/api/gallery/gallery.controller.ts b/docker/src/api/gallery/gallery.controller.ts new file mode 100644 index 0000000..63c007e --- /dev/null +++ b/docker/src/api/gallery/gallery.controller.ts @@ -0,0 +1,126 @@ +import {FastifyReply, FastifyRequest} from 'fastify'; +import * as galleryService from './gallery.service'; + +export async function searchInExtension(req: any, reply: FastifyReply) { + try { + const provider = req.query.provider; + const query = req.query.q || ''; + const page = parseInt(req.query.page as string) || 1; + const perPage = parseInt(req.query.perPage as string) || 48; + + if (!provider) { + return reply.code(400).send({ error: "Missing provider" }); + } + + return await galleryService.searchInExtension(provider, query, page, perPage); + + } catch (err) { + console.error("Gallery SearchInExtension Error:", (err as Error).message); + + return { + results: [], + total: 0, + page: 1, + hasNextPage: false + }; + } +} + +export async function getInfo(req: any, reply: FastifyReply) { + try { + const { id } = req.params; + const provider = req.query.provider; + + return await galleryService.getGalleryInfo(id, provider); + } catch (err) { + const error = err as Error; + console.error("Gallery Info Error:", error.message); + return reply.code(404).send({ error: "Gallery item not found" }); + } +} + +export async function getFavorites(req: any, reply: FastifyReply) { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }); + + const favorites = await galleryService.getFavorites(req.user.id); + return { favorites }; + } catch (err) { + console.error("Get Favorites Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to retrieve favorites" }); + } +} + +export async function getFavoriteById(req: any, reply: FastifyReply) { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }); + + const { id } = req.params as { id: string }; + + const favorite = await galleryService.getFavoriteById(id, req.user.id); + + if (!favorite) { + return reply.code(404).send({ error: "Favorite not found" }); + } + + return { favorite }; + + } catch (err) { + console.error("Get Favorite By ID Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to retrieve favorite" }); + } +} + +export async function addFavorite(req: any, reply: FastifyReply) { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }); + + const { id, title, image_url, thumbnail_url, tags, provider, headers } = req.body; + + if (!id || !title || !image_url || !thumbnail_url) { + return reply.code(400).send({ + error: "Missing required fields" + }); + } + + const result = await galleryService.addFavorite({ + id, + user_id: req.user.id, + title, + image_url, + thumbnail_url, + tags: tags || '', + provider: provider || "", + headers: headers || "" + }); + + if (result.success) { + return reply.code(201).send(result); + } else { + return reply.code(409).send(result); + } + + } catch (err) { + console.error("Add Favorite Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to add favorite" }); + } +} + +export async function removeFavorite(req: any, reply: FastifyReply) { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }); + + const { id } = req.params; + + const result = await galleryService.removeFavorite(id, req.user.id); + + if (result.success) { + return { success: true, message: "Favorite removed successfully" }; + } else { + return reply.code(404).send({ error: "Favorite not found" }); + } + } catch (err) { + console.error("Remove Favorite Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to remove favorite" }); + } +} \ No newline at end of file diff --git a/docker/src/api/gallery/gallery.routes.ts b/docker/src/api/gallery/gallery.routes.ts new file mode 100644 index 0000000..5da3ac5 --- /dev/null +++ b/docker/src/api/gallery/gallery.routes.ts @@ -0,0 +1,13 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './gallery.controller'; + +async function galleryRoutes(fastify: FastifyInstance) { + fastify.get('/gallery/fetch/:id', controller.getInfo); + fastify.get('/gallery/search/provider', controller.searchInExtension); + fastify.get('/gallery/favorites', controller.getFavorites); + fastify.get('/gallery/favorites/:id', controller.getFavoriteById); + fastify.post('/gallery/favorites', controller.addFavorite); + fastify.delete('/gallery/favorites/:id', controller.removeFavorite); +} + +export default galleryRoutes; \ No newline at end of file diff --git a/docker/src/api/gallery/gallery.service.ts b/docker/src/api/gallery/gallery.service.ts new file mode 100644 index 0000000..1b51165 --- /dev/null +++ b/docker/src/api/gallery/gallery.service.ts @@ -0,0 +1,178 @@ +import { getAllExtensions, getExtension } from '../../shared/extensions'; +import { GallerySearchResult, GalleryInfo, Favorite, FavoriteResult } from '../types'; +import { getDatabase } from '../../shared/database'; + +export async function getGalleryInfo(id: string, providerName?: string): Promise { + const extensions = getAllExtensions(); + + if (providerName) { + const ext = extensions.get(providerName); + if (ext && ext.type === 'image-board' && ext.getInfo) { + try { + console.log(`[Gallery] Getting info from ${providerName} for: ${id}`); + const info = await ext.getInfo(id); + return { + id: info.id ?? id, + provider: providerName, + image: info.image, + tags: info.tags, + title: info.title, + headers: info.headers + }; + } catch (e) { + const error = e as Error; + console.error(`[Gallery] Failed to get info from ${providerName}:`, error.message); + throw new Error(`Failed to get gallery info from ${providerName}`); + } + } + throw new Error("Provider not found or doesn't support getInfo"); + } + + for (const [name, ext] of extensions) { + if (ext.type === 'gallery' && ext.getInfo) { + try { + console.log(`[Gallery] Trying to get info from ${name} for: ${id}`); + const info = await ext.getInfo(id); + return { + ...info, + provider: name + }; + } catch { + continue; + } + } + } + + throw new Error("Gallery item not found in any extension"); +} + +export async function searchInExtension(providerName: string, query: string, page: number = 1, perPage: number = 48): Promise { + const ext = getExtension(providerName); + + try { + console.log(`[Gallery] Searching ONLY in ${providerName} for: ${query}`); + const results = await ext.search(query, page, perPage); + + const normalizedResults = (results?.results ?? []).map((r: any) => ({ + id: r.id, + image: r.image, + tags: r.tags, + title: r.title, + headers: r.headers, + provider: providerName + })); + + return { + page: results.page ?? page, + hasNextPage: !!results.hasNextPage, + results: normalizedResults + }; + + } catch (e) { + const error = e as Error; + console.error(`[Gallery] Search failed in ${providerName}:`, error.message); + + return { + total: 0, + next: 0, + previous: 0, + pages: 0, + page, + hasNextPage: false, + results: [] + }; + } +} + +export async function getFavorites(userId: number): Promise { + const db = getDatabase("favorites"); + + return new Promise((resolve) => { + db.all( + 'SELECT * FROM favorites WHERE user_id = ?', + [userId], + (err: Error | null, rows: Favorite[]) => { + if (err) { + console.error('Error getting favorites:', err.message); + resolve([]); + } else { + resolve(rows); + } + } + ); + }); +} + +export async function getFavoriteById(id: string, userId: number): Promise { + const db = getDatabase("favorites"); + + return new Promise((resolve) => { + db.get( + 'SELECT * FROM favorites WHERE id = ? AND user_id = ?', + [id, userId], + (err: Error | null, row: Favorite | undefined) => { + if (err) { + console.error('Error getting favorite by id:', err.message); + resolve(null); + } else { + resolve(row || null); + } + } + ); + }); +} + +export async function addFavorite(fav: Favorite & { user_id: number }): Promise { + const db = getDatabase("favorites"); + + return new Promise((resolve) => { + const stmt = ` + INSERT INTO favorites (id, user_id, title, image_url, thumbnail_url, tags, headers, provider) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + + db.run( + stmt, + [ + fav.id, + fav.user_id, + fav.title, + fav.image_url, + fav.thumbnail_url, + fav.tags || "", + fav.headers || "", + fav.provider || "" + ], + function (err: any) { + if (err) { + if (err.code && err.code.includes('SQLITE_CONSTRAINT')) { + resolve({ success: false, error: 'Item is already a favorite.' }); + } else { + console.error('Error adding favorite:', err.message); + resolve({ success: false, error: err.message }); + } + } else { + resolve({ success: true, id: fav.id }); + } + } + ); + }); +} + +export async function removeFavorite(id: string, userId: number): Promise { + const db = getDatabase("favorites"); + + return new Promise((resolve) => { + const stmt = 'DELETE FROM favorites WHERE id = ? AND user_id = ?'; + + db.run(stmt, [id, userId], function (err: Error | null) { + if (err) { + console.error('Error removing favorite:', err.message); + resolve({ success: false, error: err.message }); + } else { + // @ts-ignore + resolve({ success: this.changes > 0 }); + } + }); + }); +} \ No newline at end of file diff --git a/docker/src/api/list/list.controller.ts b/docker/src/api/list/list.controller.ts new file mode 100644 index 0000000..9234dc9 --- /dev/null +++ b/docker/src/api/list/list.controller.ts @@ -0,0 +1,166 @@ +import {FastifyReply, FastifyRequest} from 'fastify'; +import * as listService from './list.service'; + +interface UserRequest extends FastifyRequest { + user?: { id: number }; +} + +interface EntryParams { + entryId: string; + +} + +interface SingleEntryQuery { + source: string; + entry_type: string; +} + +export async function getList(req: UserRequest, reply: FastifyReply) { + const userId = req.user?.id; + if (!userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + try { + const results = await listService.getUserList(userId); + return { results }; + } catch (err) { + console.error(err); + return reply.code(500).send({ error: "Failed to retrieve list" }); + } +} + +export async function getSingleEntry(req: UserRequest, reply: FastifyReply) { + const userId = req.user?.id; + const { entryId } = req.params as EntryParams; + const { source, entry_type } = req.query as SingleEntryQuery; + + if (!userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + if (!entryId || !source || !entry_type) { + return reply.code(400).send({ error: "Missing required identifier: entryId, source, or entry_type." }); + } + + try { + + const entry = await listService.getSingleListEntry( + userId, + entryId, + source, + entry_type + ); + + if (!entry) { + + return reply.code(404).send({ found: false, message: "Entry not found in user list." }); + } + + return { found: true, entry: entry }; + } catch (err) { + console.error(err); + return reply.code(500).send({ error: "Failed to retrieve list entry" }); + } +} + +export async function upsertEntry(req: UserRequest, reply: FastifyReply) { + const userId = req.user?.id; + const body = req.body as any; + + if (!userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + if (!body.entry_id || !body.source || !body.status || !body.entry_type) { + return reply.code(400).send({ + error: "Missing required fields (entry_id, source, status, entry_type)." + }); + } + + try { + const entryData = { + user_id: userId, + entry_id: body.entry_id, + external_id: body.external_id, + source: body.source, + entry_type: body.entry_type, + status: body.status, + progress: body.progress || 0, + score: body.score || null, + start_date: body.start_date || null, + end_date: body.end_date || null, + repeat_count: body.repeat_count ?? 0, + notes: body.notes || null, + is_private: body.is_private ?? 0 + }; + + const result = await listService.upsertListEntry(entryData); + + return { success: true, changes: result.changes }; + } catch (err) { + console.error(err); + return reply.code(500).send({ error: "Failed to save list entry" }); + } +} + +export async function deleteEntry(req: UserRequest, reply: FastifyReply) { + const userId = req.user?.id; + const { entryId } = req.params as EntryParams; + const { source } = req.query as { source?: string }; // βœ… VIENE DEL FRONT + + if (!userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + if (!entryId || !source) { + return reply.code(400).send({ error: "Missing entryId or source." }); + } + + try { + const result = await listService.deleteListEntry( + userId, + entryId, + source + ); + + if (result.success) { + return { success: true, external: result.external }; + } else { + return reply.code(404).send({ + error: "Entry not found or unauthorized to delete." + }); + } + } catch (err) { + console.error(err); + return reply.code(500).send({ error: "Failed to delete list entry" }); + } +} + +export async function getListByFilter(req: UserRequest, reply: FastifyReply) { + const userId = req.user?.id; + const { status, entry_type } = req.query as any; + + if (!userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + if (!status && !entry_type) { + return reply.code(400).send({ + error: "At least one filter is required (status or entry_type)." + }); + } + + try { + const results = await listService.getUserListByFilter( + userId, + status, + entry_type + ); + + return { results }; + } catch (err) { + console.error(err); + return reply.code(500).send({ error: "Failed to retrieve filtered list" }); + } +} \ No newline at end of file diff --git a/docker/src/api/list/list.routes.ts b/docker/src/api/list/list.routes.ts new file mode 100644 index 0000000..9f28aa6 --- /dev/null +++ b/docker/src/api/list/list.routes.ts @@ -0,0 +1,12 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './list.controller'; + +async function listRoutes(fastify: FastifyInstance) { + fastify.get('/list', controller.getList); + fastify.get('/list/entry/:entryId', controller.getSingleEntry); + fastify.post('/list/entry', controller.upsertEntry); + fastify.delete('/list/entry/:entryId', controller.deleteEntry); + fastify.get('/list/filter', controller.getListByFilter); +} + +export default listRoutes; \ No newline at end of file diff --git a/docker/src/api/list/list.service.ts b/docker/src/api/list/list.service.ts new file mode 100644 index 0000000..5a0782c --- /dev/null +++ b/docker/src/api/list/list.service.ts @@ -0,0 +1,584 @@ +import {queryAll, run, queryOne} from '../../shared/database'; +import {getExtension} from '../../shared/extensions'; +import * as animeService from '../anime/anime.service'; +import * as booksService from '../books/books.service'; +import * as aniListService from '../anilist/anilist.service'; + +interface ListEntryData { + entry_type: any; + user_id: number; + entry_id: number; + + source: string; + + status: string; + + progress: number; + score: number | null; +} + +const USER_DB = 'userdata'; + +export async function upsertListEntry(entry: any) { + const { + user_id, + entry_id, + source, + entry_type, + status, + progress, + score, + start_date, + end_date, + repeat_count, + notes, + is_private + } = entry; + + let prev: any = null; + + try { + prev = await getSingleListEntry(user_id, entry_id, source, entry_type); + } catch { + prev = null; + + } + + const isNew = !prev; + if (!isNew && prev?.progress != null && progress < prev.progress) { + return { changes: 0, ignored: true }; + } + + const today = new Date().toISOString().slice(0, 10); + + if (prev?.start_date && !entry.start_date) { + entry.start_date = prev.start_date; + } + + if (!prev?.start_date && progress === 1) { + entry.start_date = today; + } + + const total = + prev?.total_episodes ?? + prev?.total_chapters ?? + null; + + if (total && progress >= total) { + entry.status = 'COMPLETED'; + entry.end_date = today; + } + + if (source === 'anilist') { + const token = await getActiveAccessToken(user_id); + + if (token) { + try { + const result = await aniListService.updateAniListEntry(token, { + mediaId: entry.entry_id, + status: entry.status, + progress: entry.progress, + score: entry.score, + start_date: entry.start_date, + end_date: entry.end_date, + repeat_count: entry.repeat_count, + notes: entry.notes, + is_private: entry.is_private + }); + + return { changes: 0, external: true, anilistResult: result }; + } catch (err) { + console.error("Error actualizando AniList:", err); + } + } + } + + const sql = ` + INSERT INTO ListEntry + ( + user_id, entry_id, source, entry_type, status, + progress, score, + start_date, end_date, repeat_count, notes, is_private, + updated_at + ) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id, entry_id) DO UPDATE SET + source = EXCLUDED.source, + entry_type = EXCLUDED.entry_type, + status = EXCLUDED.status, + progress = EXCLUDED.progress, + score = EXCLUDED.score, + start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + repeat_count = EXCLUDED.repeat_count, + notes = EXCLUDED.notes, + is_private = EXCLUDED.is_private, + updated_at = CURRENT_TIMESTAMP; + `; + + const params = [ + entry.user_id, + entry.entry_id, + entry.source, + entry.entry_type, + entry.status, + entry.progress, + entry.score ?? null, + entry.start_date || null, + entry.end_date || null, + entry.repeat_count ?? 0, + entry.notes || null, + entry.is_private ?? 0 + ]; + + try { + const result = await run(sql, params, USER_DB); + return { changes: result.changes, lastID: result.lastID, external: false }; + } catch (error) { + console.error("Error al guardar la entrada de lista:", error); + throw new Error("Error en la base de datos al guardar la entrada."); + } +} + +export async function getUserList(userId: number): Promise { + const sql = ` + SELECT * FROM ListEntry + WHERE user_id = ? + ORDER BY updated_at DESC; + `; + + try { + const dbList = await queryAll(sql, [userId], USER_DB) as ListEntryData[]; + const connected = await isConnected(userId); + + let finalList: any[] = [...dbList]; + + if (connected) { + const anilistEntries = await aniListService.getUserAniList(userId); + const localWithoutAnilist = dbList.filter( + entry => entry.source !== 'anilist' + ); + + finalList = [...anilistEntries, ...localWithoutAnilist]; + } + + const enrichedListPromises = finalList.map(async (entry) => { + if (entry.source === 'anilist' && connected) { + let finalTitle = entry.title; + if (typeof finalTitle === 'object' && finalTitle !== null) { + finalTitle = + finalTitle.userPreferred || + finalTitle.english || + finalTitle.romaji || + 'Unknown Title'; + } + + return { + ...entry, + title: finalTitle, + poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover', + }; + } + + let contentDetails: any | null = null; + const id = entry.entry_id; + const type = entry.entry_type; + const ext = getExtension(entry.source); + + try { + if (type === 'ANIME') { + if(entry.source === 'anilist') { + const anime: any = await animeService.getAnimeById(id); + contentDetails = { + title: anime?.title.english || 'Unknown Anime Title', + poster: anime?.coverImage?.extraLarge || '', + total_episodes: anime?.episodes || 0, + }; + } + else{ + const anime: any = await animeService.getAnimeInfoExtension(ext, id.toString()); + + contentDetails = { + title: anime?.title || 'Unknown Anime Title', + poster: anime?.image || 'https://placehold.co/400x600?text=No+Cover', + total_episodes: anime?.episodes || 0, + }; + } + + } else if (type === 'MANGA' || type === 'NOVEL') { + if(entry.source === 'anilist') { + const book: any = await booksService.getBookById(id); + + contentDetails = { + title: book?.title.english || 'Unknown Book Title', + poster: book?.coverImage?.extraLarge || 'https://placehold.co/400x600?text=No+Cover', + total_chapters: book?.chapters || book?.volumes * 10 || 0, + }; + } + else{ + const book: any = await booksService.getBookInfoExtension(ext, id.toString()); + + contentDetails = { + title: book?.title || 'Unknown Book Title', + poster: book?.image || '', + total_chapters: book?.chapters || book?.volumes * 10 || 0, + }; + } + } + + } catch { + contentDetails = { + title: 'Error Loading Details', + poster: 'https://placehold.co/400x600?text=No+Cover', + }; + } + + let finalTitle = contentDetails?.title || 'Unknown Title'; + let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover'; + + if (typeof finalTitle === 'object' && finalTitle !== null) { + finalTitle = + finalTitle.userPreferred || + finalTitle.english || + finalTitle.romaji || + 'Unknown Title'; + } + + return { + ...entry, + title: finalTitle, + poster: finalPoster, + total_episodes: contentDetails?.total_episodes, + total_chapters: contentDetails?.total_chapters, + }; + }); + + return await Promise.all(enrichedListPromises); + + } catch (error) { + console.error("Error al obtener la lista del usuario:", error); + throw new Error("Error getting list."); + } +} + +export async function deleteListEntry( + userId: number, + entryId: string | number, + source: string +) { + + if (source === 'anilist') { + const token = await getActiveAccessToken(userId); + + if (token) { + try { + await aniListService.deleteAniListEntry( + token, + Number(entryId), + ); + + return { success: true, external: true }; + } catch (err) { + console.error("Error borrando en AniList:", err); + } + } + } + + const sql = ` + DELETE FROM ListEntry + WHERE user_id = ? AND entry_id = ?; + `; + + const result = await run(sql, [userId, entryId], USER_DB); + return { success: result.changes > 0, changes: result.changes, external: false }; +} + +export async function getSingleListEntry( + userId: number, + entryId: string | number, + source: string, + entryType: string +): Promise { + + const localSql = ` + SELECT * FROM ListEntry + WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?; + `; + + const localResult = await queryAll( + localSql, + [userId, entryId, source, entryType], + USER_DB + ) as any[]; + + if (localResult.length > 0) { + const entry = localResult[0]; + + const contentDetails: any = + entryType === 'ANIME' + ? await animeService.getAnimeById(entryId).catch(() => null) + : await booksService.getBookById(entryId).catch(() => null); + + let finalTitle = contentDetails?.title || 'Unknown'; + let finalPoster = contentDetails?.coverImage?.extraLarge || + contentDetails?.image || + 'https://placehold.co/400x600?text=No+Cover'; + + if (typeof finalTitle === 'object') { + finalTitle = + finalTitle.userPreferred || + finalTitle.english || + finalTitle.romaji || + 'Unknown'; + } + + return { + ...entry, + title: finalTitle, + poster: finalPoster, + total_episodes: contentDetails?.episodes, + total_chapters: contentDetails?.chapters, + }; + } + + if (source === 'anilist') { + + const connected = await isConnected(userId); + if (!connected) return null; + + const sql = ` + SELECT access_token + FROM UserIntegration + WHERE user_id = ? AND platform = 'AniList'; + `; + + const integration = await queryOne(sql, [userId], USER_DB) as any; + if (!integration?.access_token) return null; + if (entryType === 'NOVEL') {entryType = 'MANGA'} + + const aniEntry = await aniListService.getSingleAniListEntry( + integration.access_token, + Number(entryId), + entryType as any + ); + + if (!aniEntry) return null; + + const contentDetails: any = + entryType === 'ANIME' + ? await animeService.getAnimeById(entryId).catch(() => null) + : await booksService.getBookById(entryId).catch(() => null); + + let finalTitle = contentDetails?.title || 'Unknown'; + let finalPoster = contentDetails?.coverImage?.extraLarge || + contentDetails?.image || + 'https://placehold.co/400x600?text=No+Cover'; + + if (typeof finalTitle === 'object') { + finalTitle = + finalTitle.userPreferred || + finalTitle.english || + finalTitle.romaji || + 'Unknown'; + } + + return { + user_id: userId, + ...aniEntry, + title: finalTitle, + poster: finalPoster, + total_episodes: contentDetails?.episodes, + total_chapters: contentDetails?.chapters, + }; + } + + return null; +} + +export async function getActiveAccessToken(userId: number): Promise { + const sql = ` + SELECT access_token, expires_at + FROM UserIntegration + WHERE user_id = ? AND platform = 'AniList'; + `; + + try { + const integration = await queryOne(sql, [userId], USER_DB) as any | null; + + if (!integration) { + return null; + + } + + const expiryDate = new Date(integration.expires_at); + const now = new Date(); + + const fiveMinutes = 5 * 60 * 1000; + + if (expiryDate.getTime() < (now.getTime() + fiveMinutes)) { + + console.log(`AniList token for user ${userId} expired or near expiry.`); + return null; + } + + return integration.access_token; + + } catch (error) { + console.error("Error al verificar la integraciΓ³n de AniList:", error); + return null; + } +} + +export async function isConnected(userId: number): Promise { + const token = await getActiveAccessToken(userId); + return !!token; +} + +export async function getUserListByFilter( + userId: number, + status?: string, + entryType?: string +): Promise { + + let sql = ` + SELECT * FROM ListEntry + WHERE user_id = ? + ORDER BY updated_at DESC; + `; + + const params: any[] = [userId]; + + try { + const dbList = await queryAll(sql, params, USER_DB) as ListEntryData[]; + const connected = await isConnected(userId); + + const statusMap: any = { + watching: 'CURRENT', + reading: 'CURRENT', + completed: 'COMPLETED', + paused: 'PAUSED', + dropped: 'DROPPED', + planning: 'PLANNING' + }; + + const mappedStatus = status ? statusMap[status.toLowerCase()] : null; + + let finalList: any[] = []; + + const filteredLocal = dbList.filter((entry) => { + if (mappedStatus && entry.status !== mappedStatus) return false; + + if (entryType) { + if (entryType === 'MANGA') { + + if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false; + } else { + if (entry.entry_type !== entryType) return false; + } + } + + return true; + }); + + let filteredAniList: any[] = []; + + if (connected) { + const anilistEntries = await aniListService.getUserAniList(userId); + + filteredAniList = anilistEntries.filter((entry: any) => { + if (mappedStatus && entry.status !== mappedStatus) return false; + + if (entryType) { + if (entryType === 'MANGA') { + if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false; + } else { + if (entry.entry_type !== entryType) return false; + } + } + + return true; + }); + } + + finalList = [...filteredAniList, ...filteredLocal]; + + const enrichedListPromises = finalList.map(async (entry) => { + + if (entry.source === 'anilist') { + let finalTitle = entry.title; + + if (typeof finalTitle === 'object' && finalTitle !== null) { + finalTitle = + finalTitle.userPreferred || + finalTitle.english || + finalTitle.romaji || + 'Unknown Title'; + } + + return { + ...entry, + title: finalTitle, + poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover', + }; + } + + let contentDetails: any | null = null; + const id = entry.entry_id; + const type = entry.entry_type; + const ext = getExtension(entry.source); + + try { + if (type === 'ANIME') { + const anime: any = await animeService.getAnimeInfoExtension(ext, id.toString()); + + contentDetails = { + title: anime?.title || 'Unknown Anime Title', + poster: anime?.image || '', + total_episodes: anime?.episodes || 0, + }; + + } else if (type === 'MANGA' || type === 'NOVEL') { + const book: any = await booksService.getBookInfoExtension(ext, id.toString()); + + contentDetails = { + title: book?.title || 'Unknown Book Title', + poster: book?.image || '', + total_chapters: book?.chapters || book?.volumes * 10 || 0, + }; + } + + } catch { + contentDetails = { + title: 'Error Loading Details', + poster: 'https://placehold.co/400x600?text=No+Cover', + }; + } + + let finalTitle = contentDetails?.title || 'Unknown Title'; + let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover'; + + if (typeof finalTitle === 'object' && finalTitle !== null) { + finalTitle = + finalTitle.userPreferred || + finalTitle.english || + finalTitle.romaji || + 'Unknown Title'; + } + + return { + ...entry, + title: finalTitle, + poster: finalPoster, + total_episodes: contentDetails?.total_episodes, + total_chapters: contentDetails?.total_chapters, + }; + }); + + return await Promise.all(enrichedListPromises); + + } catch (error) { + console.error("Error al filtrar la lista del usuario:", error); + throw new Error("Error en la base de datos al obtener la lista filtrada."); + } +} \ No newline at end of file diff --git a/docker/src/api/proxy/proxy.controller.ts b/docker/src/api/proxy/proxy.controller.ts new file mode 100644 index 0000000..8638b5c --- /dev/null +++ b/docker/src/api/proxy/proxy.controller.ts @@ -0,0 +1,60 @@ +import {FastifyReply} from 'fastify'; +import {processM3U8Content, proxyRequest, streamToReadable} from './proxy.service'; +import {ProxyRequest} from '../types'; + +export async function handleProxy(req: ProxyRequest, reply: FastifyReply) { + const { url, referer, origin, userAgent } = req.query; + + if (!url) { + return reply.code(400).send({ error: "No URL provided" }); + } + + try { + const { response, contentType, isM3U8, contentLength } = await proxyRequest(url, { + referer, + origin, + userAgent + }); + + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Range'); + reply.header('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges'); + + if (contentType) { + reply.header('Content-Type', contentType); + } + + if (contentLength) { + reply.header('Content-Length', contentLength); + } + + if (contentType?.startsWith('image/') || contentType?.startsWith('video/')) { + reply.header('Cache-Control', 'public, max-age=31536000, immutable'); + } + + reply.header('Accept-Ranges', 'bytes'); + + if (isM3U8) { + const text = await response.text(); + const baseUrl = new URL(response.url); + + const processedContent = processM3U8Content(text, baseUrl, { + referer, + origin, + userAgent + }); + + return reply.send(processedContent); + } + + return reply.send(streamToReadable(response.body!)); + + } catch (err) { + req.server.log.error(err); + + if (!reply.sent) { + return reply.code(500).send({ error: "Internal Server Error" }); + } + } +} \ No newline at end of file diff --git a/docker/src/api/proxy/proxy.routes.ts b/docker/src/api/proxy/proxy.routes.ts new file mode 100644 index 0000000..183daee --- /dev/null +++ b/docker/src/api/proxy/proxy.routes.ts @@ -0,0 +1,8 @@ +import { FastifyInstance } from 'fastify'; +import { handleProxy } from './proxy.controller'; + +async function proxyRoutes(fastify: FastifyInstance) { + fastify.get('/proxy', handleProxy); +} + +export default proxyRoutes; \ No newline at end of file diff --git a/docker/src/api/proxy/proxy.service.ts b/docker/src/api/proxy/proxy.service.ts new file mode 100644 index 0000000..9cfbb1c --- /dev/null +++ b/docker/src/api/proxy/proxy.service.ts @@ -0,0 +1,138 @@ +import { Readable } from 'stream'; + +interface ProxyHeaders { + referer?: string; + origin?: string; + userAgent?: string; +} + +interface ProxyResponse { + response: Response; + contentType: string | null; + isM3U8: boolean; + contentLength: string | null; +} + +export async function proxyRequest(url: string, { referer, origin, userAgent }: ProxyHeaders): Promise { + const headers: Record = { + 'User-Agent': userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'identity', + + 'Connection': 'keep-alive' + }; + + if (referer) headers['Referer'] = referer; + if (origin) headers['Origin'] = origin; + + let lastError: Error | null = null; + const maxRetries = 2; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 60000); + + const response = await fetch(url, { + headers, + redirect: 'follow', + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + + if (response.status === 404 || response.status === 403) { + throw new Error(`Proxy Error: ${response.status} ${response.statusText}`); + } + + if (attempt < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + continue; + } + + throw new Error(`Proxy Error: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get('content-type'); + const contentLength = response.headers.get('content-length'); + const isM3U8 = (contentType && contentType.includes('mpegurl')) || url.includes('.m3u8'); + + return { + response, + contentType, + isM3U8, + contentLength + }; + + } catch (error) { + lastError = error as Error; + + if (attempt === maxRetries - 1) { + throw lastError; + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + throw lastError || new Error('Unknown error in proxyRequest'); +} + +export function processM3U8Content(text: string, baseUrl: URL, { referer, origin, userAgent }: ProxyHeaders): string { + return text.replace(/^(?!#)(?!\s*$).+/gm, (line) => { + line = line.trim(); + let absoluteUrl: string; + + try { + absoluteUrl = new URL(line, baseUrl).href; + } catch (e) { + return line; + } + + const proxyParams = new URLSearchParams(); + proxyParams.set('url', absoluteUrl); + if (referer) proxyParams.set('referer', referer); + if (origin) proxyParams.set('origin', origin); + if (userAgent) proxyParams.set('userAgent', userAgent); + + return `/api/proxy?${proxyParams.toString()}`; + }); +} + +export function streamToReadable(webStream: ReadableStream): Readable { + const reader = webStream.getReader(); + let readTimeout: NodeJS.Timeout; + + return new Readable({ + async read() { + try { + + const timeoutPromise = new Promise((_, reject) => { + readTimeout = setTimeout(() => reject(new Error('Stream read timeout')), 10000); + }); + + const readPromise = reader.read(); + const { done, value } = await Promise.race([readPromise, timeoutPromise]) as any; + + clearTimeout(readTimeout); + + if (done) { + this.push(null); + } else { + this.push(Buffer.from(value)); + } + } catch (error) { + clearTimeout(readTimeout); + this.destroy(error as Error); + } + }, + destroy(error, callback) { + clearTimeout(readTimeout); + reader.cancel().then(() => callback(error)).catch(callback); + } + }); +} \ No newline at end of file diff --git a/docker/src/api/types.ts b/docker/src/api/types.ts new file mode 100644 index 0000000..57d804b --- /dev/null +++ b/docker/src/api/types.ts @@ -0,0 +1,294 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; + +export interface AnimeTitle { + romaji: string; + english: string | null; + native: string | null; + userPreferred?: string; +} + +export interface CoverImage { + extraLarge?: string; + large: string; + medium?: string; + color?: string; +} + +export interface StartDate { + year: number; + month: number; + day: number; +} + +export interface Anime { + updatedAt: any; + id: number | string; + title: AnimeTitle; + coverImage: CoverImage; + bannerImage?: string; + description?: string; + averageScore: number | null; + format: string; + seasonYear: number | null; + startDate?: StartDate; + synonyms?: string[]; + extensionName?: string; + isExtensionResult?: boolean; +} + +export interface Book { + id: number | string; + title: AnimeTitle; + coverImage: CoverImage; + bannerImage?: string; + description?: string; + averageScore: number | null; + format: string; + seasonYear: number | null; + startDate?: StartDate; + synonyms?: string[]; + extensionName?: string; + isExtensionResult?: boolean; +} + +export interface ExtensionSearchOptions { + query: string; + dub?: boolean; + media?: { + romajiTitle: string; + englishTitle: string; + startDate: StartDate; + }; +} + +export interface ExtensionSearchResult { + format: string; + headers: any; + id: string; + title: string; + image?: string; + rating?: number; + score?: number; +} + +export interface Episode { + url: string; + id: string; + number: number; + title?: string; +} + +export interface Chapter { + index: number; + id: string; + number: string | number; + title?: string; + releaseDate?: string; +} + +export interface ChapterWithProvider extends Chapter { + provider: string; + date?: string; +} + +export interface Extension { + getMetadata: any; + type: 'anime-board' | 'book-board' | 'manga-board'; + mediaType?: 'manga' | 'ln'; + search?: (options: ExtensionSearchOptions) => Promise; + findEpisodes?: (id: string) => Promise; + findEpisodeServer?: (episode: Episode, server: string) => Promise; + findChapters?: (id: string) => Promise; + findChapterPages?: (chapterId: string) => Promise; + getSettings?: () => ExtensionSettings; +} + +export interface ExtensionSettings { + episodeServers: string[]; + supportsDub: boolean; +} + +export interface StreamData { + url?: string; + sources?: any[]; + subtitles?: any[]; +} + +export interface MangaChapterContent { + type: 'manga'; + chapterId: string; + title: string | null; + number: number; + provider: string; + pages: any[]; +} + +export interface LightNovelChapterContent { + type: 'ln'; + chapterId: string; + title: string | null; + number: number; + provider: string; + content: any; +} + +export type ChapterContent = MangaChapterContent | LightNovelChapterContent; + +export interface AnimeParams { + id: string; +} + +export interface AnimeQuery { + source?: string; +} + +export interface SearchQuery { + q: string; +} + +export interface ExtensionNameParams { + name: string; +} + +export interface WatchStreamQuery { + source: string; + animeId: string; + episode: string; + server?: string; + category?: string; + ext: string; +} + +export interface BookParams { + id: string; +} + +export interface BookQuery { + ext?: string; +} + +export interface ChapterParams { + bookId: string; + chapter: string; + provider: string; +} + +export interface ProxyQuery { + url: string; + referer?: string; + origin?: string; + userAgent?: string; +} + +export type AnimeRequest = FastifyRequest<{ + Params: AnimeParams; + Querystring: AnimeQuery; +}>; + +export type SearchRequest = FastifyRequest<{ + Querystring: SearchQuery; +}>; + +export type ExtensionNameRequest = FastifyRequest<{ + Params: ExtensionNameParams; +}>; + +export type WatchStreamRequest = FastifyRequest<{ + Querystring: WatchStreamQuery; +}>; + +export type BookRequest = FastifyRequest<{ + Params: BookParams; + Querystring: BookQuery; +}>; + +export type ChapterRequest = FastifyRequest<{ + Params: ChapterParams; +}>; + +export type ProxyRequest = FastifyRequest<{ + Querystring: ProxyQuery; +}>; + +export interface GalleryItemPreview { + id: string; + image: string; + tags: string[]; + type: 'preview'; + provider?: string; +} + +export interface GallerySearchResult { + total: number; + next: number; + previous: number; + pages: number; + page: number; + hasNextPage: boolean; + results: GalleryItemPreview[]; +} + +export interface GalleryInfo { + id: string; + fullImage: string; + resizedImageUrl: string; + tags: string[]; + createdAt: string | null; + publishedBy: string; + rating: string; + comments: any[]; + provider?: string; +} + +export interface GalleryExtension { + type: 'gallery'; + search: (query: string, page: number, perPage: number) => Promise; + getInfo: (id: string) => Promise>; +} + +export interface GallerySearchRequest extends FastifyRequest { + query: { + q?: string; + page?: string; + perPage?: string; + }; +} + +export interface GalleryInfoRequest extends FastifyRequest { + params: { + id: string; + }; + query: { + provider?: string; + }; +} + +export interface AddFavoriteBody { + id: string; + title: string; + image_url: string; + thumbnail_url: string; + tags?: string; + provider: string; + headers: string; +} + +export interface RemoveFavoriteParams { + id: string; +} + +export interface Favorite { + id: string; + title: string; + image_url: string; + thumbnail_url: string; + tags: string; + provider: string; + headers: string; +} + +export interface FavoriteResult { + success: boolean; + error?: string; + id?: string; +} \ No newline at end of file diff --git a/docker/src/api/user/user.controller.ts b/docker/src/api/user/user.controller.ts new file mode 100644 index 0000000..3a89d69 --- /dev/null +++ b/docker/src/api/user/user.controller.ts @@ -0,0 +1,269 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import * as userService from './user.service'; +import {queryOne} from '../../shared/database'; +import jwt from "jsonwebtoken"; + +interface UserIdParams { id: string; } +interface CreateUserBody { + username: string; + profilePictureUrl?: string; + password?: string; +} +interface UpdateUserBody { + username?: string; + profilePictureUrl?: string | null; + password?: string | null; +} +interface LoginBody { + userId: number; + password?: string; +} + +interface DBRunResult { changes: number; lastID: number; } + +export async function getMe(req: any, reply: any) { + const userId = req.user?.id; + + if (!userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const user = await queryOne( + `SELECT username, profile_picture_url FROM User WHERE id = ?`, + [userId], + 'userdata' + ); + + if (!user) { + return reply.code(404).send({ error: "User not found" }); + } + + return reply.send({ + username: user.username, + avatar: user.profile_picture_url + }); +} + +export async function login(req: FastifyRequest, reply: FastifyReply) { + const { userId, password } = req.body as LoginBody; + + if (!userId || typeof userId !== "number" || userId <= 0) { + return reply.code(400).send({ error: "Invalid userId provided" }); + } + + const user = await userService.getUserById(userId); + + if (!user) { + return reply.code(404).send({ error: "User not found in local database" }); + } + + // Si el usuario tiene contraseΓ±a, debe proporcionarla + if (user.has_password) { + if (!password) { + return reply.code(401).send({ + error: "Password required", + requiresPassword: true + }); + } + + const isValid = await userService.verifyPassword(userId, password); + + if (!isValid) { + return reply.code(401).send({ error: "Incorrect password" }); + } + } + + const token = jwt.sign( + { id: userId }, + process.env.JWT_SECRET!, + { expiresIn: "7d" } + ); + + return reply.code(200).send({ + success: true, + token + }); +} + +export async function getAllUsers(req: FastifyRequest, reply: FastifyReply) { + try { + const users: any = await userService.getAllUsers(); + return { users }; + } catch (err) { + console.error("Get All Users Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to retrieve user list" }); + } +} + +export async function createUser(req: FastifyRequest, reply: FastifyReply) { + try { + const { username, profilePictureUrl, password } = req.body as CreateUserBody; + + if (!username) { + return reply.code(400).send({ error: "Missing required field: username" }); + } + + const result: any = await userService.createUser(username, profilePictureUrl, password); + + return reply.code(201).send({ + success: true, + userId: result.lastID, + username + }); + } catch (err) { + if ((err as Error).message.includes('SQLITE_CONSTRAINT')) { + return reply.code(409).send({ error: "Username already exists." }); + } + console.error("Create User Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to create user" }); + } +} + +export async function getUser(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as UserIdParams; + const userId = parseInt(id, 10); + + const user: any = await userService.getUserById(userId); + + if (!user) { + return reply.code(404).send({ error: "User not found" }); + } + + return { user }; + } catch (err) { + console.error("Get User Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to retrieve user" }); + } +} + +export async function updateUser(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as UserIdParams; + const userId = parseInt(id, 10); + const updates = req.body as UpdateUserBody; + + if (Object.keys(updates).length === 0) { + return reply.code(400).send({ error: "No update fields provided" }); + } + + const result: DBRunResult = await userService.updateUser(userId, updates); + + if (result && result.changes > 0) { + return { success: true, message: "User updated successfully" }; + } else { + return reply.code(404).send({ error: "User not found or nothing to update" }); + } + } catch (err) { + if ((err as Error).message.includes('SQLITE_CONSTRAINT')) { + return reply.code(409).send({ error: "Username already exists or is invalid." }); + } + console.error("Update User Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to update user" }); + } +} + +export async function deleteUser(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const userId = parseInt(id, 10); + + 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 { + return reply.code(404).send({ error: "User not found" }); + } + + } catch (err) { + console.error("Delete User Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to delete user" }); + } +} + +export async function getIntegrationStatus(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const userId = parseInt(id, 10); + + if (!userId || isNaN(userId)) { + return reply.code(400).send({ error: "Invalid user id" }); + } + + const integration = await userService.getAniListIntegration(userId); + + return reply.code(200).send(integration); + + } catch (err) { + console.error("Get Integration Status Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to check integration status" }); + } +} + +export async function disconnectAniList(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const userId = parseInt(id, 10); + + if (!userId || isNaN(userId)) { + return reply.code(400).send({ error: "Invalid user id" }); + } + + const result = await userService.removeAniListIntegration(userId); + + if (result.changes === 0) { + return reply.code(404).send({ error: "AniList integration not found" }); + } + + return reply.send({ success: true }); + } catch (err) { + console.error("Disconnect AniList Error:", err); + return reply.code(500).send({ error: "Failed to disconnect AniList" }); + } +} + +export async function changePassword(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const { currentPassword, newPassword } = req.body as { + currentPassword?: string; + newPassword: string | null; + }; + const userId = parseInt(id, 10); + + if (!userId || isNaN(userId)) { + return reply.code(400).send({ error: "Invalid user id" }); + } + + const user = await userService.getUserById(userId); + + if (!user) { + 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 (!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({ + success: true, + message: newPassword ? "Password updated successfully" : "Password removed successfully" + }); + } catch (err) { + console.error("Change Password Error:", err); + return reply.code(500).send({ error: "Failed to change password" }); + } +} \ No newline at end of file diff --git a/docker/src/api/user/user.routes.ts b/docker/src/api/user/user.routes.ts new file mode 100644 index 0000000..7dfed67 --- /dev/null +++ b/docker/src/api/user/user.routes.ts @@ -0,0 +1,17 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './user.controller'; + +async function userRoutes(fastify: FastifyInstance) { + fastify.get('/me',controller.getMe); + fastify.post("/login", controller.login); + fastify.get('/users', controller.getAllUsers); + fastify.post('/users', { bodyLimit: 1024 * 1024 * 50 }, controller.createUser); + fastify.get('/users/:id', controller.getUser); + fastify.put('/users/:id', { bodyLimit: 1024 * 1024 * 50 }, controller.updateUser); + fastify.delete('/users/:id', controller.deleteUser); + fastify.get('/users/:id/integration', controller.getIntegrationStatus); + fastify.delete('/users/:id/integration', controller.disconnectAniList); + fastify.put('/users/:id/password', controller.changePassword); +} + +export default userRoutes; \ No newline at end of file diff --git a/docker/src/api/user/user.service.ts b/docker/src/api/user/user.service.ts new file mode 100644 index 0000000..fadb477 --- /dev/null +++ b/docker/src/api/user/user.service.ts @@ -0,0 +1,186 @@ +import {queryAll, queryOne, run} from '../../shared/database'; +import bcrypt from 'bcrypt'; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} \ No newline at end of file diff --git a/docker/src/scripts/anime/anime.js b/docker/src/scripts/anime/anime.js new file mode 100644 index 0000000..f605e3c --- /dev/null +++ b/docker/src/scripts/anime/anime.js @@ -0,0 +1,301 @@ +let animeData = null; +let extensionName = null; +let animeId = null; + +const episodePagination = Object.create(PaginationManager); +episodePagination.init(12, renderEpisodes); + +YouTubePlayerUtils.init('player'); + +document.addEventListener('DOMContentLoaded', () => { + loadAnime(); + setupDescriptionModal(); + setupEpisodeSearch(); +}); + +async function loadAnime() { + try { + + const urlData = URLUtils.parseEntityPath('anime'); + if (!urlData) { + showError("Invalid URL"); + return; + } + + extensionName = urlData.extensionName; + animeId = urlData.entityId; + + 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; + + 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 += ` +
+
${char.name} +
`; + }); + } else { + container.innerHTML = ` +
+ No character data available +
`; + } +} + +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 url = URLUtils.buildWatchUrl(animeId, 1, extensionName); + window.location.href = url; + }; + } +} + +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 = ` + + + + 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 url = URLUtils.buildWatchUrl(animeId, num, extensionName); + window.location.href = url; + }; + 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 = '
Episode not found
'; + 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(); +}; \ No newline at end of file diff --git a/docker/src/scripts/anime/animes.js b/docker/src/scripts/anime/animes.js new file mode 100644 index 0000000..3f18f32 --- /dev/null +++ b/docker/src/scripts/anime/animes.js @@ -0,0 +1,194 @@ +let trendingAnimes = []; +let currentHeroIndex = 0; +let player; +let heroInterval; + +document.addEventListener('DOMContentLoaded', () => { + SearchManager.init('#search-input', '#search-results', 'anime'); + ContinueWatchingManager.load('my-status', 'watching', 'ANIME'); + fetchContent(); + setInterval(() => fetchContent(true), 60000); +}); + +document.addEventListener('click', (e) => { + if (!e.target.closest('.search-wrapper')) { + const searchResults = document.getElementById('search-results'); + const searchInput = document.getElementById('search-input'); + searchResults.classList.remove('active'); + searchInput.style.borderRadius = '99px'; + } +}); + +function scrollCarousel(id, direction) { + const container = document.getElementById(id); + if(container) { + const scrollAmount = container.clientWidth * 0.75; + container.scrollBy({ left: direction * scrollAmount, behavior: 'smooth' }); + } +} + +var tag = document.createElement('script'); +tag.src = "https://www.youtube.com/iframe_api"; +var firstScriptTag = document.getElementsByTagName('script')[0]; +firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); + +function onYouTubeIframeAPIReady() { + player = new YT.Player('player', { + height: '100%', + width: '100%', + playerVars: { + 'autoplay': 1, + 'controls': 0, + 'mute': 1, + 'loop': 1, + 'showinfo': 0, + 'modestbranding': 1 + }, + events: { + 'onReady': (e) => { + e.target.mute(); + if(trendingAnimes.length) updateHeroVideo(trendingAnimes[currentHeroIndex]); + } + } + }); +} + +async function fetchContent(isUpdate = false) { + try { + const trendingRes = await fetch('/api/trending'); + const trendingData = await trendingRes.json(); + + if (trendingData.results && trendingData.results.length > 0) { + trendingAnimes = trendingData.results; + if (!isUpdate) { + updateHeroUI(trendingAnimes[0]); + startHeroCycle(); + } + renderList('trending', trendingAnimes); + } else if (!isUpdate) { + setTimeout(() => fetchContent(false), 2000); + } + + const topRes = await fetch('/api/top-airing'); + const topData = await topRes.json(); + if (topData.results && topData.results.length > 0) { + renderList('top-airing', topData.results); + } + } catch (e) { + console.error("Fetch Error:", e); + if(!isUpdate) setTimeout(() => fetchContent(false), 5000); + } +} + +function startHeroCycle() { + if(heroInterval) clearInterval(heroInterval); + heroInterval = setInterval(() => { + if(trendingAnimes.length > 0) { + currentHeroIndex = (currentHeroIndex + 1) % trendingAnimes.length; + updateHeroUI(trendingAnimes[currentHeroIndex]); + } + }, 10000); +} + +async function updateHeroUI(anime) { + if(!anime) return; + + const title = anime.title.english || anime.title.romaji || "Unknown Title"; + const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A'; + const year = anime.seasonYear || ''; + const type = anime.format || 'TV'; + const desc = anime.description || 'No description available.'; + const poster = anime.coverImage ? anime.coverImage.extraLarge : ''; + const banner = anime.bannerImage || poster; + + document.getElementById('hero-title').innerText = title; + document.getElementById('hero-desc').innerHTML = desc; + document.getElementById('hero-score').innerText = score; + document.getElementById('hero-year').innerText = year; + document.getElementById('hero-type').innerText = type; + document.getElementById('hero-poster').src = poster; + + const watchBtn = document.getElementById('watch-btn'); + if(watchBtn) watchBtn.onclick = () => window.location.href = `/anime/${anime.id}`; + + const addToListBtn = document.querySelector('.hero-buttons .btn-blur'); + if(addToListBtn) { + ListModalManager.currentData = anime; + const entryType = ListModalManager.getEntryType(anime); + + await ListModalManager.checkIfInList(anime.id, 'anilist', entryType); + ListModalManager.updateButton(); + + addToListBtn.onclick = () => ListModalManager.open(anime, 'anilist'); + } + + const bgImg = document.getElementById('hero-bg-media'); + if(bgImg && bgImg.src !== banner) bgImg.src = banner; + + updateHeroVideo(anime); + + document.getElementById('hero-loading-ui').style.display = 'none'; + document.getElementById('hero-real-ui').style.display = 'block'; +} + +function updateHeroVideo(anime) { + if (!player || !player.loadVideoById) return; + const videoContainer = document.getElementById('player'); + if (anime.trailer && anime.trailer.site === 'youtube' && anime.trailer.id) { + if(player.getVideoData && player.getVideoData().video_id !== anime.trailer.id) { + player.loadVideoById(anime.trailer.id); + player.mute(); + } + videoContainer.style.opacity = "1"; + } else { + videoContainer.style.opacity = "0"; + player.stopVideo(); + } +} + +function renderList(id, list) { + const container = document.getElementById(id); + const firstId = list.length > 0 ? list[0].id : null; + const currentFirstId = container.firstElementChild?.dataset?.id; + if (currentFirstId && parseInt(currentFirstId) === firstId && container.children.length === list.length) { + return; + } + + container.innerHTML = ''; + list.forEach(anime => { + const title = anime.title.english || anime.title.romaji || "Unknown Title"; + const cover = anime.coverImage ? anime.coverImage.large : ''; + const ep = anime.nextAiringEpisode ? 'Ep ' + anime.nextAiringEpisode.episode : (anime.episodes ? anime.episodes + ' Eps' : 'TV'); + const score = anime.averageScore || '--'; + + const el = document.createElement('div'); + el.className = 'card'; + el.dataset.id = anime.id; + + el.onclick = () => window.location.href = `/anime/${anime.id}`; + el.innerHTML = ` +
+
+

${title}

+

${score}% β€’ ${ep}

+
+ `; + container.appendChild(el); + }); +} + +function saveToList() { + const animeId = ListModalManager.currentData ? ListModalManager.currentData.id : null; + if (!animeId) return; + ListModalManager.save(animeId, 'anilist'); +} + +function deleteFromList() { + const animeId = ListModalManager.currentData ? ListModalManager.currentData.id : null; + if (!animeId) return; + ListModalManager.delete(animeId, 'anilist'); +} + +function closeAddToListModal() { + ListModalManager.close(); +} \ No newline at end of file diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js new file mode 100644 index 0000000..72072b7 --- /dev/null +++ b/docker/src/scripts/anime/player.js @@ -0,0 +1,431 @@ +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; + +const params = new URLSearchParams(window.location.search); +const firstKey = params.keys().next().value; +let extName; +if (firstKey) extName = firstKey; + +const href = extName + ? `/anime/${extName}/${animeId}` + : `/anime/${animeId}`; + +document.getElementById('back-link').href = href; +document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`; + +async function loadMetadata() { + try { + const extQuery = extName ? `?source=${extName}` : "?source=anilist"; + const res = await fetch(`/api/anime/${animeId}${extQuery}`); + 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 = ''; + let description = ''; + let coverImage = ''; + let averageScore = ''; + let format = ''; + let seasonYear = ''; + let season = ''; + let episodesCount = 0; + let characters = []; + + 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 || ''; + } + + document.getElementById('anime-title-details').innerText = title; + document.getElementById('anime-title-details2').innerText = title; + document.title = `Watching ${title} - Ep ${currentEpisode}`; + + fetch("/api/rpc", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + details: title, + state: `Episode ${currentEpisode}`, + mode: "watching" + }) + }); + + const tempDiv = document.createElement('div'); + 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'; + + if (extName) { + 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); + } +} + +async function loadExtensionEpisodes() { + try { + const extQuery = extName ? `?source=${extName}` : "?source=anilist"; + const res = await fetch(`/api/anime/${animeId}/episodes${extQuery}`); + const data = await res.json(); + + totalEpisodes = Array.isArray(data) ? data.length : 0; + + if (Array.isArray(data) && data.length > 0) { + populateEpisodeCarousel(data); + } else { + + const fallback = []; + for (let i = 1; i <= totalEpisodes; i++) { + fallback.push({ number: i, title: null, thumbnail: null }); + } + populateEpisodeCarousel(fallback); + } + } catch (e) { + console.error("Error cargando episodios por extensiΓ³n:", e); + totalEpisodes = 0; + } +} + +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}` : ""; + const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== ''; + + const link = document.createElement('a'); + link.href = `/watch/${animeId}/${epNumber}${extParam}`; + link.classList.add('carousel-item'); + link.dataset.episode = epNumber; + + if (!hasThumbnail) link.classList.add('no-thumbnail'); + if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel'); + + const imgContainer = document.createElement('div'); + imgContainer.classList.add('carousel-item-img-container'); + + if (hasThumbnail) { + const img = document.createElement('img'); + img.classList.add('carousel-item-img'); + img.src = ep.thumbnail; + img.alt = `Episode ${epNumber} Thumbnail`; + imgContainer.appendChild(img); + } + + link.appendChild(imgContainer); + + const info = document.createElement('div'); + info.classList.add('carousel-item-info'); + + const title = document.createElement('p'); + title.innerText = `Ep ${epNumber}: ${ep.title || 'Untitled'}`; + + info.appendChild(title); + 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'); + + if (data.extensions && data.extensions.length > 0) { + select.innerHTML = ''; + data.extensions.forEach(ext => { + const opt = document.createElement('option'); + opt.value = opt.innerText = ext; + select.appendChild(opt); + }); + + if (typeof extName === 'string' && data.extensions.includes(extName)) { + select.value = extName; + } else { + select.selectedIndex = 0; + } + + currentExtension = select.value; + onExtensionChange(); + } else { + select.innerHTML = ''; + select.disabled = true; + setLoading("No anime extensions found."); + } + } catch (error) { + console.error("Extension Error:", error); + } +} + +async function onExtensionChange() { + const select = document.getElementById('extension-select'); + currentExtension = select.value; + setLoading("Fetching extension settings..."); + + try { + const res = await fetch(`/api/extensions/${currentExtension}/settings`); + const settings = await res.json(); + + const toggle = document.getElementById('sd-toggle'); + if (settings.supportsDub) { + toggle.style.display = 'flex'; + setAudioMode('sub'); + } else { + toggle.style.display = 'none'; + setAudioMode('sub'); + } + + const serverSelect = document.getElementById('server-select'); + serverSelect.innerHTML = ''; + if (settings.episodeServers && settings.episodeServers.length > 0) { + settings.episodeServers.forEach(srv => { + const opt = document.createElement('option'); + opt.value = srv; + opt.innerText = srv; + serverSelect.appendChild(opt); + }); + serverSelect.style.display = 'block'; + } else { + serverSelect.style.display = 'none'; + } + + loadStream(); + } catch (error) { + console.error(error); + setLoading("Failed to load extension settings."); + } +} + +function toggleAudioMode() { + const newMode = audioMode === 'sub' ? 'dub' : 'sub'; + setAudioMode(newMode); + loadStream(); +} + +function setAudioMode(mode) { + audioMode = mode; + const toggle = document.getElementById('sd-toggle'); + const subOpt = document.getElementById('opt-sub'); + const dubOpt = document.getElementById('opt-dub'); + + toggle.setAttribute('data-state', mode); + subOpt.classList.toggle('active', mode === 'sub'); + dubOpt.classList.toggle('active', mode === 'dub'); +} + +async function loadStream() { + if (!currentExtension) return; + + const serverSelect = document.getElementById('server-select'); + const server = serverSelect.value || "default"; + + setLoading(`Loading stream (${audioMode})...`); + + try { + let sourc = "&source=anilist"; + if (extName){ + sourc = `&source=${extName}`; + } + 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) { + setLoading(`Error: ${data.error}`); + return; + } + + if (!data.videoSources || data.videoSources.length === 0) { + setLoading("No video sources found."); + 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, data.videoSources[0].subtitles || data.subtitles); + document.getElementById('loading-overlay').style.display = 'none'; + } catch (error) { + setLoading("Stream error. Check console."); + console.error(error); + } +} + +function playVideo(url, subtitles = []) { + const video = document.getElementById('player'); + + if (Hls.isSupported()) { + if (hlsInstance) hlsInstance.destroy(); + hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false }); + hlsInstance.loadSource(url); + hlsInstance.attachMedia(video); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + } + + if (plyrInstance) plyrInstance.destroy(); + + while (video.textTracks.length > 0) { + video.removeChild(video.textTracks[0]); + } + + subtitles.forEach(sub => { + if (!sub.url) return; + 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'] + }); + + let alreadyTriggered = false; + + video.addEventListener('timeupdate', () => { + if (!video.duration) return; + + const percent = (video.currentTime / video.duration) * 100; + + if (percent >= 80 && !alreadyTriggered) { + alreadyTriggered = true; + sendProgress(); + } + }); + + + video.play().catch(() => console.log("Autoplay blocked")); +} + +function setLoading(message) { + const overlay = document.getElementById('loading-overlay'); + const text = document.getElementById('loading-text'); + overlay.style.display = 'flex'; + text.innerText = message; +} + +const extParam = extName ? `?${extName}` : ""; + +document.getElementById('prev-btn').onclick = () => { + if (currentEpisode > 1) { + window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`; + } +}; + +document.getElementById('next-btn').onclick = () => { + if (currentEpisode < totalEpisodes || totalEpisodes === 0) { + window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`; + } +}; + +if (currentEpisode <= 1) { + document.getElementById('prev-btn').disabled = true; +} + + +async function sendProgress() { + const token = localStorage.getItem('token'); + if (!token) return; + + const source = extName + ? extName + : "anilist"; + + const body = { + entry_id: animeId, + source: source, + entry_type: "ANIME", + status: 'CURRENT', + progress: source === 'anilist' + ? Math.floor(currentEpisode) + : 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); + } +} + + +loadMetadata(); +loadExtensions(); + diff --git a/docker/src/scripts/auth-guard.js b/docker/src/scripts/auth-guard.js new file mode 100644 index 0000000..ae6a220 --- /dev/null +++ b/docker/src/scripts/auth-guard.js @@ -0,0 +1,81 @@ +;(() => { + const token = localStorage.getItem("token") + + if (!token && window.location.pathname !== "/") { + window.location.href = "/" + } +})() + +async function loadMeUI() { + const token = localStorage.getItem("token") + if (!token) return + + try { + const res = await fetch("/api/me", { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + if (!res.ok) return + + const user = await res.json() + + const navUser = document.getElementById("nav-user") + const navUsername = document.getElementById("nav-username") + const navAvatar = document.getElementById("nav-avatar") + const dropdownAvatar = document.getElementById("dropdown-avatar") + + if (!navUser || !navUsername || !navAvatar) return + + navUser.style.display = "flex" + navUsername.textContent = user.username + + const avatarUrl = user.avatar || "/public/assets/avatar.png" + navAvatar.src = avatarUrl + if (dropdownAvatar) { + dropdownAvatar.src = avatarUrl + } + + setupDropdown() + } catch (e) { + console.error("Failed to load user UI:", e) + } +} + +function setupDropdown() { + const userAvatarBtn = document.querySelector(".user-avatar-btn") + const navDropdown = document.getElementById("nav-dropdown") + const navLogout = document.getElementById("nav-logout") + + if (!userAvatarBtn || !navDropdown || !navLogout) return + + userAvatarBtn.addEventListener("click", (e) => { + e.stopPropagation() + navDropdown.classList.toggle("active") + }) + + document.addEventListener("click", (e) => { + if (!navDropdown.contains(e.target)) { + navDropdown.classList.remove("active") + } + }) + + navDropdown.addEventListener("click", (e) => { + e.stopPropagation() + }) + + navLogout.addEventListener("click", () => { + localStorage.removeItem("token") + window.location.href = "/" + }) + + const dropdownLinks = navDropdown.querySelectorAll("a.dropdown-item") + dropdownLinks.forEach((link) => { + link.addEventListener("click", () => { + navDropdown.classList.remove("active") + }) + }) +} + +loadMeUI() \ No newline at end of file diff --git a/docker/src/scripts/books/book.js b/docker/src/scripts/books/book.js new file mode 100644 index 0000000..c649e84 --- /dev/null +++ b/docker/src/scripts/books/book.js @@ -0,0 +1,342 @@ +let bookData = null; +let extensionName = null; +let bookId = null; +let bookSlug = null; + +let allChapters = []; +let filteredChapters = []; + +const chapterPagination = Object.create(PaginationManager); +chapterPagination.init(12, () => renderChapterTable()); + +document.addEventListener('DOMContentLoaded', () => { + init(); + setupModalClickOutside(); +}); + +async function init() { + try { + + const urlData = URLUtils.parseEntityPath('book'); + if (!urlData) { + showError("Book Not Found"); + return; + } + + extensionName = urlData.extensionName; + bookId = urlData.entityId; + bookSlug = urlData.slug; + + await loadBookMetadata(); + + await loadChapters(); + + await setupAddToListButton(); + + } catch (err) { + console.error("Metadata Error:", err); + showError("Error loading book"); + } +} + +async function loadBookMetadata() { + const source = extensionName || 'anilist'; + const fetchUrl = `/api/book/${bookId}?source=${source}`; + + const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() }); + const data = await res.json(); + + if (data.error || !data) { + showError("Book Not Found"); + return; + } + + const raw = Array.isArray(data) ? data[0] : data; + bookData = raw; + + const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName); + + updatePageTitle(metadata.title); + updateMetadata(metadata); + updateExtensionPill(); +} + +function updatePageTitle(title) { + document.title = `${title} | WaifuBoard Books`; + const titleEl = document.getElementById('title'); + if (titleEl) titleEl.innerText = title; +} + +function updateMetadata(metadata) { + + const descEl = document.getElementById('description'); + if (descEl) descEl.innerHTML = metadata.description; + + const scoreEl = document.getElementById('score'); + if (scoreEl) { + scoreEl.innerText = extensionName + ? `${metadata.score}` + : `${metadata.score}% Score`; + } + + const pubEl = document.getElementById('published-date'); + if (pubEl) pubEl.innerText = metadata.year; + + const statusEl = document.getElementById('status'); + if (statusEl) statusEl.innerText = metadata.status; + + const formatEl = document.getElementById('format'); + if (formatEl) formatEl.innerText = metadata.format; + + const chaptersEl = document.getElementById('chapters'); + if (chaptersEl) chaptersEl.innerText = metadata.chapters; + + const genresEl = document.getElementById('genres'); + if (genresEl) genresEl.innerText = metadata.genres; + + const posterEl = document.getElementById('poster'); + if (posterEl) posterEl.src = metadata.poster; + + const heroBgEl = document.getElementById('hero-bg'); + if (heroBgEl) heroBgEl.src = metadata.banner; +} + +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'; + } +} + +async function setupAddToListButton() { + const btn = document.getElementById('add-to-list-btn'); + if (!btn || !bookData) return; + + ListModalManager.currentData = bookData; + const entryType = ListModalManager.getEntryType(bookData); + const idForCheck = extensionName ? bookSlug : bookId; + + await ListModalManager.checkIfInList( + idForCheck, + extensionName || 'anilist', + entryType + ); + + updateCustomAddButton(); + + btn.onclick = () => ListModalManager.open(bookData, extensionName || 'anilist'); +} + +function updateCustomAddButton() { + const btn = document.getElementById('add-to-list-btn'); + if (!btn) return; + + if (ListModalManager.isInList) { + btn.innerHTML = ` + + + + In Your Library + `; + 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 Library'; + btn.style.background = null; + btn.style.color = null; + btn.style.borderColor = null; + } +} + +async function loadChapters() { + const tbody = document.getElementById('chapters-body'); + if (!tbody) return; + + tbody.innerHTML = 'Searching extensions for chapters...'; + + try { + const source = extensionName || 'anilist'; + const fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; + + const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() }); + const data = await res.json(); + + allChapters = data.chapters || []; + filteredChapters = [...allChapters]; + + applyChapterFilter(); + + const totalEl = document.getElementById('total-chapters'); + + if (allChapters.length === 0) { + tbody.innerHTML = 'No chapters found on loaded extensions.'; + if (totalEl) totalEl.innerText = "0 Found"; + return; + } + + if (totalEl) totalEl.innerText = `${allChapters.length} Found`; + + setupProviderFilter(); + + setupReadButton(); + + chapterPagination.setTotalItems(filteredChapters.length); + renderChapterTable(); + + } catch (err) { + tbody.innerHTML = 'Error loading chapters.'; + console.error(err); + } +} + +function applyChapterFilter() { + const chapterParam = URLUtils.getQueryParam('chapter'); + if (!chapterParam) return; + + const chapterNumber = parseFloat(chapterParam); + if (isNaN(chapterNumber)) return; + + filteredChapters = allChapters.filter( + ch => parseFloat(ch.number) === chapterNumber + ); + + chapterPagination.reset(); +} + +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 = ''; + + providers.forEach(prov => { + const opt = document.createElement('option'); + opt.value = prov; + opt.innerText = prov; + 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); + } + } + + 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(); + }; +} + +function setupReadButton() { + const readBtn = document.getElementById('read-start-btn'); + if (!readBtn || filteredChapters.length === 0) return; + + const firstChapter = filteredChapters[0]; + readBtn.onclick = () => openReader(0, firstChapter.provider); +} + +function renderChapterTable() { + const tbody = document.getElementById('chapters-body'); + if (!tbody) return; + + tbody.innerHTML = ''; + + if (filteredChapters.length === 0) { + tbody.innerHTML = 'No chapters match this filter.'; + chapterPagination.renderControls( + 'pagination', + 'page-info', + 'prev-page', + 'next-page' + ); + return; + } + + const pageItems = chapterPagination.getCurrentPageItems(filteredChapters); + + pageItems.forEach((ch) => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${ch.number} + ${ch.title || 'Chapter ' + ch.number} + ${ch.provider} + + + + `; + tbody.appendChild(row); + }); + + chapterPagination.renderControls( + 'pagination', + 'page-info', + 'prev-page', + 'next-page' + ); +} + +function openReader(chapterId, provider) { + window.location.href = URLUtils.buildReadUrl(bookId, chapterId, provider, extensionName); +} + +function setupModalClickOutside() { + const modal = document.getElementById('add-list-modal'); + if (!modal) return; + + modal.addEventListener('click', (e) => { + if (e.target.id === 'add-list-modal') { + ListModalManager.close(); + } + }); +} + +function showError(message) { + const titleEl = document.getElementById('title'); + if (titleEl) titleEl.innerText = message; +} + +function saveToList() { + const idToSave = extensionName ? bookSlug : bookId; + ListModalManager.save(idToSave, extensionName || 'anilist'); +} + +function deleteFromList() { + const idToDelete = extensionName ? bookSlug : bookId; + ListModalManager.delete(idToDelete, extensionName || 'anilist'); +} + +function closeAddToListModal() { + ListModalManager.close(); +} + +window.openReader = openReader; +window.saveToList = saveToList; +window.deleteFromList = deleteFromList; +window.closeAddToListModal = closeAddToListModal; \ No newline at end of file diff --git a/docker/src/scripts/books/books.js b/docker/src/scripts/books/books.js new file mode 100644 index 0000000..95de71a --- /dev/null +++ b/docker/src/scripts/books/books.js @@ -0,0 +1,134 @@ +let trendingBooks = []; +let currentHeroIndex = 0; +let heroInterval; + +document.addEventListener('DOMContentLoaded', () => { + SearchManager.init('#search-input', '#search-results', 'book'); + ContinueWatchingManager.load('my-status-books', 'reading', 'MANGA'); + init(); +}); + +window.addEventListener('scroll', () => { + const nav = document.getElementById('navbar'); + if (window.scrollY > 50) nav.classList.add('scrolled'); + else nav.classList.remove('scrolled'); +}); + +function scrollCarousel(id, direction) { + const container = document.getElementById(id); + if(container) { + const scrollAmount = container.clientWidth * 0.75; + container.scrollBy({ left: direction * scrollAmount, behavior: 'smooth' }); + } +} + +async function init() { + try { + const res = await fetch('/api/books/trending'); + const data = await res.json(); + + if (data.results && data.results.length > 0) { + trendingBooks = data.results; + updateHeroUI(trendingBooks[0]); + renderList('trending', trendingBooks); + startHeroCycle(); + } + + const resPop = await fetch('/api/books/popular'); + const dataPop = await resPop.json(); + if (dataPop.results) renderList('popular', dataPop.results); + + } catch (e) { + console.error("Books Error:", e); + } +} + +function startHeroCycle() { + if(heroInterval) clearInterval(heroInterval); + heroInterval = setInterval(() => { + if(trendingBooks.length > 0) { + currentHeroIndex = (currentHeroIndex + 1) % trendingBooks.length; + updateHeroUI(trendingBooks[currentHeroIndex]); + } + }, 8000); +} + +async function updateHeroUI(book) { + if(!book) return; + + 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)) || ''; + const banner = book.bannerImage || poster; + + document.getElementById('hero-title').innerText = title; + document.getElementById('hero-desc').innerHTML = desc; + document.getElementById('hero-score').innerText = (book.averageScore || '?') + '% Score'; + document.getElementById('hero-year').innerText = (book.startDate && book.startDate.year) ? book.startDate.year : '????'; + document.getElementById('hero-type').innerText = book.format || 'MANGA'; + + const heroPoster = document.getElementById('hero-poster'); + if(heroPoster) heroPoster.src = poster; + + const bg = document.getElementById('hero-bg-media'); + if(bg) bg.src = banner; + + const readBtn = document.getElementById('read-btn'); + if (readBtn) { + readBtn.onclick = () => window.location.href = `/book/${book.id}`; + } + + const addToListBtn = document.querySelector('.hero-buttons .btn-blur'); + if(addToListBtn) { + ListModalManager.currentData = book; + const entryType = ListModalManager.getEntryType(book); + + await ListModalManager.checkIfInList(book.id, 'anilist', entryType); + ListModalManager.updateButton(); + + addToListBtn.onclick = () => ListModalManager.open(book, 'anilist'); + } +} + +function renderList(id, list) { + const container = document.getElementById(id); + container.innerHTML = ''; + + list.forEach(book => { + const title = book.title.english || book.title.romaji; + const cover = book.coverImage ? book.coverImage.large : ''; + const score = book.averageScore || '--'; + const type = book.format || 'Book'; + + const el = document.createElement('div'); + el.className = 'card'; + + el.onclick = () => { + window.location.href = `/book/${book.id}`; + }; + el.innerHTML = ` +
+
+

${title}

+

${score}% β€’ ${type}

+
+ `; + container.appendChild(el); + }); +} + +function saveToList() { + const bookId = ListModalManager.currentData ? ListModalManager.currentData.id : null; + if (!bookId) return; + ListModalManager.save(bookId, 'anilist'); +} + +function deleteFromList() { + const bookId = ListModalManager.currentData ? ListModalManager.currentData.id : null; + if (!bookId) return; + ListModalManager.delete(bookId, 'anilist'); +} + +function closeAddToListModal() { + ListModalManager.close(); +} \ No newline at end of file diff --git a/docker/src/scripts/books/reader.js b/docker/src/scripts/books/reader.js new file mode 100644 index 0000000..31f78e1 --- /dev/null +++ b/docker/src/scripts/books/reader.js @@ -0,0 +1,695 @@ +const reader = document.getElementById('reader'); +const panel = document.getElementById('settings-panel'); +const overlay = document.getElementById('overlay'); +const settingsBtn = document.getElementById('settings-btn'); +const closePanel = document.getElementById('close-panel'); +const chapterLabel = document.getElementById('chapter-label'); +const prevBtn = document.getElementById('prev-chapter'); +const nextBtn = document.getElementById('next-chapter'); + +const lnSettings = document.getElementById('ln-settings'); +const mangaSettings = document.getElementById('manga-settings'); + +const config = { + ln: { + fontSize: 18, + lineHeight: 1.8, + maxWidth: 750, + fontFamily: '"Georgia", serif', + textColor: '#e5e7eb', + bg: '#14141b', + textAlign: 'justify' + }, + manga: { + direction: 'rtl', + mode: 'auto', + spacing: 16, + imageFit: 'screen', + preloadCount: 3 + } +}; + +let currentType = null; +let currentPages = []; +let observer = null; + +const parts = window.location.pathname.split('/'); + +const bookId = parts[4]; +let chapter = parts[3]; +let provider = parts[2]; + +function loadConfig() { + try { + const saved = localStorage.getItem('readerConfig'); + if (saved) { + const parsed = JSON.parse(saved); + Object.assign(config.ln, parsed.ln || {}); + Object.assign(config.manga, parsed.manga || {}); + } + } catch (e) { + console.error('Error loading config:', e); + } + updateUIFromConfig(); +} + +function saveConfig() { + try { + localStorage.setItem('readerConfig', JSON.stringify(config)); + } catch (e) { + console.error('Error saving config:', e); + } +} + +function updateUIFromConfig() { + document.getElementById('font-size').value = config.ln.fontSize; + document.getElementById('font-size-value').textContent = config.ln.fontSize + 'px'; + + document.getElementById('line-height').value = config.ln.lineHeight; + document.getElementById('line-height-value').textContent = config.ln.lineHeight; + + document.getElementById('max-width').value = config.ln.maxWidth; + document.getElementById('max-width-value').textContent = config.ln.maxWidth + 'px'; + + document.getElementById('font-family').value = config.ln.fontFamily; + document.getElementById('text-color').value = config.ln.textColor; + document.getElementById('bg-color').value = config.ln.bg; + + document.querySelectorAll('[data-align]').forEach(btn => { + btn.classList.toggle('active', btn.dataset.align === config.ln.textAlign); + }); + + document.getElementById('display-mode').value = config.manga.mode; + document.getElementById('image-fit').value = config.manga.imageFit; + document.getElementById('page-spacing').value = config.manga.spacing; + document.getElementById('page-spacing-value').textContent = config.manga.spacing + 'px'; + document.getElementById('preload-count').value = config.manga.preloadCount; + + document.querySelectorAll('[data-direction]').forEach(btn => { + btn.classList.toggle('active', btn.dataset.direction === config.manga.direction); + }); +} + +function applyStyles() { + if (currentType === 'ln') { + document.documentElement.style.setProperty('--ln-font-size', config.ln.fontSize + 'px'); + document.documentElement.style.setProperty('--ln-line-height', config.ln.lineHeight); + 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('--ln-text-align', config.ln.textAlign); + } + + if (currentType === 'manga') { + document.documentElement.style.setProperty('--page-spacing', config.manga.spacing + 'px'); + document.documentElement.style.setProperty('--page-max-width', 900 + 'px'); + document.documentElement.style.setProperty('--manga-max-width', 1400 + 'px'); + + const viewportHeight = window.innerHeight - 64 - 32; + document.documentElement.style.setProperty('--viewport-height', viewportHeight + 'px'); + } +} + +function updateSettingsVisibility() { + lnSettings.classList.toggle('hidden', currentType !== 'ln'); + mangaSettings.classList.toggle('hidden', currentType !== 'manga'); +} + +async function loadChapter() { + reader.innerHTML = ` +
+
+ Loading chapter... +
+ `; + + const urlParams = new URLSearchParams(window.location.search); + let source = urlParams.get('source'); + if (!source) { + source = 'anilist'; + } + const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`; + + try { + const res = await fetch(newEndpoint); + const data = await res.json(); + + if (data.title) { + chapterLabel.textContent = data.title; + document.title = data.title; + } else { + chapterLabel.textContent = `Chapter ${chapter}`; + document.title = `Chapter ${chapter}`; + } + + setupProgressTracking(data, source); + + const res2 = await fetch(`/api/book/${bookId}?source=${source}`); + const data2 = await res2.json(); + + fetch("/api/rpc", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + details: data2.title.romaji ?? data2.title, + state: `Chapter ${data.title}`, + mode: "reading" + }) + }); + if (data.error) { + reader.innerHTML = ` +
+ Error: ${data.error} +
+ `; + return; + } + + currentType = data.type; + updateSettingsVisibility(); + applyStyles(); + reader.innerHTML = ''; + + if (data.type === 'manga') { + currentPages = data.pages || []; + loadManga(currentPages); + } else if (data.type === 'ln') { + loadLN(data.content); + } + } catch (error) { + reader.innerHTML = ` +
+ Error loading chapter: ${error.message} +
+ `; + } +} + +function loadManga(pages) { + if (!pages || pages.length === 0) { + reader.innerHTML = '
No pages found
'; + return; + } + + const container = document.createElement('div'); + container.className = 'manga-container'; + + let isLongStrip = false; + + if (config.manga.mode === 'longstrip') { + isLongStrip = true; + } else if (config.manga.mode === 'auto' && detectLongStrip(pages)) { + isLongStrip = true; + } + + const useDouble = config.manga.mode === 'double' || + (config.manga.mode === 'auto' && !isLongStrip && shouldUseDoublePage(pages)); + + if (useDouble) { + loadDoublePage(container, pages); + } else { + loadSinglePage(container, pages); + } + + reader.appendChild(container); + setupLazyLoading(); + enableMangaPageNavigation(); +} + +function shouldUseDoublePage(pages) { + if (pages.length <= 5) return false; + + const widePages = pages.filter(p => { + if (!p.height || !p.width) return false; + const ratio = p.width / p.height; + return ratio > 1.3; + }); + + if (widePages.length > pages.length * 0.3) return false; + + return true; +} + +function loadSinglePage(container, pages) { + pages.forEach((page, index) => { + const img = createImageElement(page, index); + container.appendChild(img); + }); +} + +function loadDoublePage(container, pages) { + let i = 0; + while (i < pages.length) { + const currentPage = pages[i]; + const nextPage = pages[i + 1]; + + const isWide = currentPage.width && currentPage.height && + (currentPage.width / currentPage.height) > 1.1; + + if (isWide) { + const img = createImageElement(currentPage, i); + container.appendChild(img); + i++; + } else { + const doubleContainer = document.createElement('div'); + doubleContainer.className = 'double-container'; + + const leftPage = createImageElement(currentPage, i); + + if (nextPage) { + const nextIsWide = nextPage.width && nextPage.height && + (nextPage.width / nextPage.height) > 1.3; + + if (nextIsWide) { + const singleImg = createImageElement(currentPage, i); + container.appendChild(singleImg); + i++; + } else { + const rightPage = createImageElement(nextPage, i + 1); + + if (config.manga.direction === 'rtl') { + doubleContainer.appendChild(rightPage); + doubleContainer.appendChild(leftPage); + } else { + doubleContainer.appendChild(leftPage); + doubleContainer.appendChild(rightPage); + } + + container.appendChild(doubleContainer); + i += 2; + } + } else { + const singleImg = createImageElement(currentPage, i); + container.appendChild(singleImg); + i++; + } + } + } +} + +function createImageElement(page, index) { + const img = document.createElement('img'); + img.className = 'page-img'; + img.dataset.index = index; + + const url = buildProxyUrl(page.url, page.headers); + const placeholder = "/public/assets/placeholder.svg"; + + img.onerror = () => { + if (img.src !== placeholder) { + img.src = placeholder; + } + }; + + if (config.manga.mode === 'longstrip' && index > 0) { + img.classList.add('longstrip-fit'); + } else { + if (config.manga.imageFit === 'width') img.classList.add('fit-width'); + else if (config.manga.imageFit === 'height') img.classList.add('fit-height'); + else if (config.manga.imageFit === 'screen') img.classList.add('fit-screen'); + } + + if (index < config.manga.preloadCount) { + img.src = url; + } else { + img.dataset.src = url; + img.loading = 'lazy'; + } + + img.alt = `Page ${index + 1}`; + + return img; +} + +function buildProxyUrl(url, headers = {}) { + const params = new URLSearchParams({ + url + }); + + if (headers.referer) params.append('referer', headers.referer); + if (headers['user-agent']) params.append('ua', headers['user-agent']); + if (headers.cookie) params.append('cookie', headers.cookie); + + return `/api/proxy?${params.toString()}`; +} + +function detectLongStrip(pages) { + if (!pages || pages.length === 0) return false; + + const relevant = pages.slice(1); + const tall = relevant.filter(p => p.height && p.width && (p.height / p.width) > 2); + return tall.length >= 2 || (tall.length / relevant.length) > 0.3; +} + +function setupLazyLoading() { + if (observer) observer.disconnect(); + + observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const img = entry.target; + if (img.dataset.src) { + img.src = img.dataset.src; + delete img.dataset.src; + observer.unobserve(img); + } + } + }); + }, { + rootMargin: '200px' + }); + + document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img)); +} + +function loadLN(html) { + const div = document.createElement('div'); + div.className = 'ln-content'; + div.innerHTML = html; + reader.appendChild(div); +} + +document.getElementById('font-size').addEventListener('input', (e) => { + config.ln.fontSize = parseInt(e.target.value); + document.getElementById('font-size-value').textContent = e.target.value + 'px'; + applyStyles(); + saveConfig(); +}); + +document.getElementById('line-height').addEventListener('input', (e) => { + config.ln.lineHeight = parseFloat(e.target.value); + document.getElementById('line-height-value').textContent = e.target.value; + applyStyles(); + saveConfig(); +}); + +document.getElementById('max-width').addEventListener('input', (e) => { + config.ln.maxWidth = parseInt(e.target.value); + document.getElementById('max-width-value').textContent = e.target.value + 'px'; + applyStyles(); + saveConfig(); +}); + +document.getElementById('font-family').addEventListener('change', (e) => { + config.ln.fontFamily = e.target.value; + applyStyles(); + saveConfig(); +}); + +document.getElementById('text-color').addEventListener('change', (e) => { + config.ln.textColor = e.target.value; + applyStyles(); + saveConfig(); +}); + +document.getElementById('bg-color').addEventListener('change', (e) => { + config.ln.bg = e.target.value; + applyStyles(); + saveConfig(); +}); + +document.querySelectorAll('[data-align]').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('[data-align]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + config.ln.textAlign = btn.dataset.align; + applyStyles(); + saveConfig(); + }); +}); + +document.querySelectorAll('[data-preset]').forEach(btn => { + btn.addEventListener('click', () => { + const preset = btn.dataset.preset; + + const presets = { + dark: { bg: '#14141b', textColor: '#e5e7eb' }, + sepia: { bg: '#f4ecd8', textColor: '#5c472d' }, + light: { bg: '#fafafa', textColor: '#1f2937' }, + amoled: { bg: '#000000', textColor: '#ffffff' } + }; + + if (presets[preset]) { + Object.assign(config.ln, presets[preset]); + document.getElementById('bg-color').value = config.ln.bg; + document.getElementById('text-color').value = config.ln.textColor; + applyStyles(); + saveConfig(); + } + }); +}); + +document.getElementById('display-mode').addEventListener('change', (e) => { + config.manga.mode = e.target.value; + saveConfig(); + loadChapter(); +}); + +document.getElementById('image-fit').addEventListener('change', (e) => { + config.manga.imageFit = e.target.value; + saveConfig(); + loadChapter(); +}); + +document.getElementById('page-spacing').addEventListener('input', (e) => { + config.manga.spacing = parseInt(e.target.value); + document.getElementById('page-spacing-value').textContent = e.target.value + 'px'; + applyStyles(); + saveConfig(); +}); + +document.getElementById('preload-count').addEventListener('change', (e) => { + config.manga.preloadCount = parseInt(e.target.value); + saveConfig(); +}); + +document.querySelectorAll('[data-direction]').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('[data-direction]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + config.manga.direction = btn.dataset.direction; + saveConfig(); + loadChapter(); + }); +}); + +prevBtn.addEventListener('click', () => { + const current = parseInt(chapter); + if (current <= 0) return; + + const newChapter = String(current - 1); + updateURL(newChapter); + window.scrollTo(0, 0); + loadChapter(); +}); + +nextBtn.addEventListener('click', () => { + const newChapter = String(parseInt(chapter) + 1); + updateURL(newChapter); + window.scrollTo(0, 0); + loadChapter(); +}); + +function updateURL(newChapter) { + chapter = newChapter; + const urlParams = new URLSearchParams(window.location.search); + let source = urlParams.get('source'); + + let src; + if (source === 'anilist') { + src= "?source=anilist" + } else { + src= `?source=${source}` + } + const newUrl = `/read/${provider}/${chapter}/${bookId}${src}`; + window.history.pushState({}, '', newUrl); +} + +document.getElementById('back-btn').addEventListener('click', () => { + const parts = window.location.pathname.split('/'); + const mangaId = parts[4]; + + const urlParams = new URLSearchParams(window.location.search); + let source = urlParams.get('source'); + + if (source === 'anilist') { + window.location.href = `/book/${mangaId}`; + } else { + window.location.href = `/book/${source}/${mangaId}`; + } +}); + +settingsBtn.addEventListener('click', () => { + panel.classList.add('open'); + overlay.classList.add('active'); +}); + +closePanel.addEventListener('click', closeSettings); +overlay.addEventListener('click', closeSettings); + +function closeSettings() { + panel.classList.remove('open'); + overlay.classList.remove('active'); +} + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && panel.classList.contains('open')) { + closeSettings(); + } +}); + +function enableMangaPageNavigation() { + if (currentType !== 'manga') return; + const logicalPages = []; + + document.querySelectorAll('.manga-container > *').forEach(el => { + if (el.classList.contains('double-container')) { + logicalPages.push(el); + } else if (el.tagName === 'IMG') { + logicalPages.push(el); + } + }); + + if (logicalPages.length === 0) return; + + function scrollToLogical(index) { + if (index < 0 || index >= logicalPages.length) return; + + const topBar = document.querySelector('.top-bar'); + const offset = topBar ? -topBar.offsetHeight : 0; + + const y = logicalPages[index].getBoundingClientRect().top + + window.pageYOffset + + offset; + + window.scrollTo({ + top: y, + behavior: 'smooth' + }); + } + + function getCurrentLogicalIndex() { + let closest = 0; + let minDist = Infinity; + + logicalPages.forEach((el, i) => { + const rect = el.getBoundingClientRect(); + const dist = Math.abs(rect.top); + if (dist < minDist) { + minDist = dist; + closest = i; + } + }); + + return closest; + } + + const rtl = () => config.manga.direction === 'rtl'; + + document.addEventListener('keydown', (e) => { + if (currentType !== 'manga') return; + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; + + const index = getCurrentLogicalIndex(); + + if (e.key === 'ArrowLeft') { + scrollToLogical(rtl() ? index + 1 : index - 1); + } + if (e.key === 'ArrowRight') { + scrollToLogical(rtl() ? index - 1 : index + 1); + } + }); + + reader.addEventListener('click', (e) => { + if (currentType !== 'manga') return; + + const bounds = reader.getBoundingClientRect(); + const x = e.clientX - bounds.left; + const half = bounds.width / 2; + + const index = getCurrentLogicalIndex(); + + if (x < half) { + scrollToLogical(rtl() ? index + 1 : index - 1); + } else { + scrollToLogical(rtl() ? index - 1 : index + 1); + } + }); +} + +let resizeTimer; +window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + applyStyles(); + }, 250); +}); + +let progressSaved = false; + +function setupProgressTracking(data, source) { + progressSaved = false; + + async function sendProgress(chapterNumber) { + const token = localStorage.getItem('token'); + if (!token) return; + + const body = { + entry_id: bookId, + source: source, + entry_type: data.type === 'manga' ? 'MANGA' : 'NOVEL', + status: 'CURRENT', + progress: source === 'anilist' + ? Math.floor(chapterNumber) + + : chapterNumber + + }; + + 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); + } + } + + function checkProgress() { + const scrollTop = window.scrollY; + const scrollHeight = document.documentElement.scrollHeight - window.innerHeight; + const percent = scrollHeight > 0 ? scrollTop / scrollHeight : 0; + + if (percent >= 0.8 && !progressSaved) { + progressSaved = true; + + const chapterNumber = (typeof data.number !== 'undefined' && data.number !== null) + ? data.number + : Number(chapter); + + sendProgress(chapterNumber); + + window.removeEventListener('scroll', checkProgress); + } + } + + window.removeEventListener('scroll', checkProgress); + window.addEventListener('scroll', checkProgress); +} + +if (!bookId || !chapter || !provider) { + reader.innerHTML = ` +
+ Missing required parameters (bookId, chapter, provider) +
+ `; +} else { + loadConfig(); + loadChapter(); +} \ No newline at end of file diff --git a/docker/src/scripts/gallery/gallery.js b/docker/src/scripts/gallery/gallery.js new file mode 100644 index 0000000..4469637 --- /dev/null +++ b/docker/src/scripts/gallery/gallery.js @@ -0,0 +1,391 @@ +const providerSelector = document.getElementById('provider-selector'); +const searchInput = document.getElementById('main-search-input'); +const resultsContainer = document.getElementById('gallery-results'); + +let currentPage = 1; +let currentProvider = ''; +let currentQuery = ''; +const perPage = 48; +let isLoading = false; +let favorites = new Set(); +let favoritesMode = false; + +let msnry = null; + +const sentinel = document.createElement('div'); +sentinel.id = 'infinite-scroll-sentinel'; +sentinel.style.height = '1px'; +sentinel.style.marginBottom = '300px'; +sentinel.style.display = 'none'; +resultsContainer.parentNode.appendChild(sentinel); + +function getAuthHeaders(extra = {}) { + const token = localStorage.getItem("token"); + return token + ? { ...extra, Authorization: `Bearer ${token}` } + : extra; +} + +function initializeMasonry() { + if (typeof Masonry === 'undefined') { + setTimeout(initializeMasonry, 100); + return; + } + if (msnry) msnry.destroy(); + msnry = new Masonry(resultsContainer, { + itemSelector: '.gallery-card', + columnWidth: '.gallery-card', + percentPosition: true, + gutter: 0, + transitionDuration: '0.4s' + }); + msnry.layout(); +} +initializeMasonry(); + +function getTagsArray(item) { + if (Array.isArray(item.tags)) return item.tags; + if (typeof item.tags === 'string') { + return item.tags + .split(',') + .map(t => t.trim()) + .filter(t => t.length > 0); + } + return []; +} + +function getProxiedImageUrl(item) { + const imageUrl = item.image || item.image_url; + + if (!imageUrl || !item.headers) { + return imageUrl; + } + + let proxyUrl = `/api/proxy?url=${encodeURIComponent(imageUrl)}`; + + const headers = item.headers; + const lowerCaseHeaders = {}; + for (const key in headers) { + if (Object.prototype.hasOwnProperty.call(headers, key)) { + lowerCaseHeaders[key.toLowerCase()] = headers[key]; + } + } + + const referer = lowerCaseHeaders.referer; + if (referer) { + proxyUrl += `&referer=${encodeURIComponent(referer)}`; + } + + const origin = lowerCaseHeaders.origin; + if (origin) { + proxyUrl += `&origin=${encodeURIComponent(origin)}`; + } + + const userAgent = lowerCaseHeaders['user-agent']; + if (userAgent) { + proxyUrl += `&userAgent=${encodeURIComponent(userAgent)}`; + } + + return proxyUrl; +} + +async function loadFavorites() { + try { + const res = await fetch('/api/gallery/favorites', { + headers: getAuthHeaders() + }); + if (!res.ok) return; + const data = await res.json(); + favorites.clear(); + (data.favorites || []).forEach(fav => favorites.add(fav.id)); + } catch (err) { + console.error('Error loading favorites:', err); + } +} + +async function toggleFavorite(item) { + const id = item.id; + const isFav = favorites.has(id); + + try { + if (isFav) { + await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + favorites.delete(id); + } else { + const tagsArray = getTagsArray(item); + + const serializedHeaders = item.headers ? JSON.stringify(item.headers) : ""; + + await fetch('/api/gallery/favorites', { + method: 'POST', + headers: getAuthHeaders({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify({ + id: item.id, + title: item.title || tagsArray.join(', ') || 'Untitled', + image_url: item.image || item.image_url, + thumbnail_url: item.image || item.image_url, + tags: tagsArray.join(','), + provider: item.provider || "", + headers: serializedHeaders + }) + }); + + + favorites.add(id); + } + + document.querySelectorAll(`.gallery-card[data-id="${CSS.escape(id)}"] .fav-btn`).forEach(btn => { + btn.classList.toggle('favorited', !isFav); + btn.innerHTML = !isFav + ? '' + : ''; + }); + + if (providerSelector.value === 'favorites' && isFav) { + setTimeout(() => searchGallery(false), 300); + } + + } catch (err) { + console.error('Error toggling favorite:', err); + alert('Error updating favorite'); + } +} + +function createGalleryCard(item, isLoadMore = false) { + const card = document.createElement('a'); + card.className = 'gallery-card grid-item'; + card.href = `/gallery/${item.provider || currentProvider || 'unknown'}/${encodeURIComponent(item.id)}`; + card.dataset.id = item.id; + + const img = document.createElement('img'); + img.className = 'gallery-card-img'; + + img.src = getProxiedImageUrl(item); + img.alt = getTagsArray(item).join(', ') || 'Image'; + if (isLoadMore) { + img.loading = 'lazy'; + } + + const favBtn = document.createElement('button'); + favBtn.className = 'fav-btn'; + const isFav = favorites.has(item.id); + favBtn.classList.toggle('favorited', isFav); + favBtn.innerHTML = isFav ? '' : ''; + favBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + toggleFavorite(item); + }; + + card.appendChild(favBtn); + card.appendChild(img); + + if (currentProvider !== 'favorites') { + const badge = document.createElement('div'); + badge.className = 'provider-badge'; + badge.textContent = item.provider || 'Global'; + card.appendChild(badge); + } else { + const badge = document.createElement('div'); + badge.className = 'provider-badge'; + badge.textContent = 'Favorites'; + badge.style.background = 'rgba(255,107,107,0.7)'; + card.appendChild(badge); + } + + img.onload = img.onerror = () => { + card.classList.add('is-loaded'); + if (msnry) msnry.layout(); + }; + + return card; +} + +function showSkeletons(count, append = false) { + const skeleton = ``; + if (!append) { + resultsContainer.innerHTML = ''; + initializeMasonry(); + } + const elements = []; + for (let i = 0; i < count; i++) { + const div = document.createElement('div'); + div.innerHTML = skeleton; + const el = div.firstChild; + resultsContainer.appendChild(el); + elements.push(el); + } + if (msnry) { + msnry.appended(elements); + msnry.layout(); + } + sentinel.style.display = 'none'; + observer.unobserve(sentinel); +} + +async function searchGallery(isLoadMore = false) { + if (isLoading) return; + + const query = searchInput.value.trim(); + const provider = providerSelector.value; + const page = isLoadMore ? currentPage + 1 : 1; + + favoritesMode = (provider === 'favorites'); + + currentQuery = query; + currentProvider = provider; + + if (!isLoadMore) { + currentPage = 1; + showSkeletons(perPage); + } else { + showSkeletons(8, true); + } + + isLoading = true; + + try { + let data; + + if (favoritesMode) { + const res = await fetch('/api/gallery/favorites', { + headers: getAuthHeaders() + }); + data = await res.json(); + + let favoritesResults = data.favorites || []; + if (query) { + const lowerQuery = query.toLowerCase(); + favoritesResults = favoritesResults.filter(fav => + (fav.title && fav.title.toLowerCase().includes(lowerQuery)) || + (fav.tags && fav.tags.toLowerCase().includes(lowerQuery)) + ); + } + + data.results = favoritesResults.map(fav => ({ + id: fav.id, + image: fav.image_url, + image_url: fav.image_url, + provider: fav.provider, + tags: fav.tags + })); + data.hasNextPage = false; + + } else if (provider && provider !== '') { + const res = await fetch(`/api/gallery/search/provider?provider=${provider}&q=${encodeURIComponent(query)}&page=${page}&perPage=${perPage}`); + data = await res.json(); + } else { + const res = await fetch(`/api/gallery/search?q=${encodeURIComponent(query)}&page=${page}&perPage=${perPage}`); + data = await res.json(); + } + + const results = data.results || []; + + const skeletons = resultsContainer.querySelectorAll('.gallery-card.skeleton'); + const toRemove = isLoadMore ? Array.from(skeletons).slice(-8) : skeletons; + if (msnry) msnry.remove(toRemove); + toRemove.forEach(el => el.remove()); + + const newCards = results.map((item, index) => createGalleryCard(item, isLoadMore)); + newCards.forEach(card => resultsContainer.appendChild(card)); + if (msnry) msnry.appended(newCards); + + if (results.length === 0 && !isLoadMore) { + const msg = favoritesMode + ? (query ? 'No favorites found matching your search' : 'You don\'t have any favorite images yet') + : 'No results found'; + resultsContainer.innerHTML = `

${msg}

`; + } + + if (msnry) msnry.layout(); + + if (!favoritesMode && data.hasNextPage) { + sentinel.style.display = 'block'; + observer.observe(sentinel); + } else { + sentinel.style.display = 'none'; + observer.unobserve(sentinel); + } + + if (isLoadMore) currentPage++; + else currentPage = 1; + + } catch (err) { + console.error('Error:', err); + if (!isLoadMore) { + resultsContainer.innerHTML = '

Error loading gallery

'; + } + } finally { + isLoading = false; + } +} + +async function loadExtensions() { + try { + const res = await fetch('/api/extensions/gallery'); + const data = await res.json(); + + providerSelector.innerHTML = ''; + + const favoritesOption = document.createElement('option'); + favoritesOption.value = 'favorites'; + favoritesOption.textContent = 'Favorites'; + providerSelector.appendChild(favoritesOption); + + (data.extensions || []).forEach(ext => { + const opt = document.createElement('option'); + opt.value = ext; + opt.textContent = ext.charAt(0).toUpperCase() + ext.slice(1); + providerSelector.appendChild(opt); + }); + + } catch (err) { + console.error('Error loading extensions:', err); + } +} + +providerSelector.addEventListener('change', () => { + if (providerSelector.value === 'favorites') { + searchInput.placeholder = "Search in favorites..."; + } else { + searchInput.placeholder = "Search in gallery..."; + } + searchGallery(false); +}); + +let searchTimeout; + +searchInput.addEventListener('input', () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + searchGallery(false); + }, 500); +}); +searchInput.addEventListener('keydown', e => { + if (e.key === 'Enter') { + clearTimeout(searchTimeout); + searchGallery(false); + } +}); + +const observer = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && !isLoading && !favoritesMode) { + observer.unobserve(sentinel); + searchGallery(true); + } +}, { rootMargin: '1000px' }); + +loadFavorites().then(() => { + loadExtensions().then(() => { + searchGallery(false); + }); +}); + +window.addEventListener('scroll', () => { + document.getElementById('navbar')?.classList.toggle('scrolled', window.scrollY > 50); +}); \ No newline at end of file diff --git a/docker/src/scripts/gallery/image.js b/docker/src/scripts/gallery/image.js new file mode 100644 index 0000000..3a60d8c --- /dev/null +++ b/docker/src/scripts/gallery/image.js @@ -0,0 +1,320 @@ +const itemMainContentContainer = document.getElementById('item-main-content'); +let currentItem = null; + +function getAuthHeaders(extra = {}) { + const token = localStorage.getItem("token"); + return token + ? { ...extra, Authorization: `Bearer ${token}` } + : extra; +} + +function getProxiedItemUrl(url, headers = null) { + if (!url || !headers) { + return url; + } + + let proxyUrl = `/api/proxy?url=${encodeURIComponent(url)}`; + + const lowerCaseHeaders = {}; + for (const key in headers) { + if (Object.prototype.hasOwnProperty.call(headers, key)) { + lowerCaseHeaders[key.toLowerCase()] = headers[key]; + } + } + + const referer = lowerCaseHeaders.referer; + if (referer) { + proxyUrl += `&referer=${encodeURIComponent(referer)}`; + } + + const origin = lowerCaseHeaders.origin; + if (origin) { + proxyUrl += `&origin=${encodeURIComponent(origin)}`; + } + + const userAgent = lowerCaseHeaders['user-agent']; + if (userAgent) { + proxyUrl += `&userAgent=${encodeURIComponent(userAgent)}`; + } + + return proxyUrl; +} + +function getUrlParams() { + const path = window.location.pathname.split('/').filter(s => s); + if (path.length < 3 || path[0] !== 'gallery') return null; + + if (path[1] === 'favorites' && path[2]) { + return { fromFavorites: true, id: path[2] }; + } else { + return { provider: path[1], id: path.slice(2).join('/') }; + } +} + +async function toggleFavorite() { + if (!currentItem?.id) return; + + const btn = document.getElementById('fav-btn'); + const wasFavorited = btn.classList.contains('favorited'); + + try { + if (wasFavorited) { + await fetch(`/api/gallery/favorites/${encodeURIComponent(currentItem.id)}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + } else { + const serializedHeaders = currentItem.headers ? JSON.stringify(currentItem.headers) : ""; + const tagsString = Array.isArray(currentItem.tags) ? currentItem.tags.join(',') : (currentItem.tags || ''); + + await fetch('/api/gallery/favorites', { + method: 'POST', + headers: getAuthHeaders({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify({ + id: currentItem.id, + title: currentItem.title || 'Waifu', + image_url: currentItem.originalImage, + thumbnail_url: currentItem.originalImage, + tags: tagsString, + provider: currentItem.provider || "", + headers: serializedHeaders + }) + }); + + } + + btn.classList.toggle('favorited', !wasFavorited); + btn.innerHTML = !wasFavorited + ? ` Saved!` + : ` Save Image`; + + } catch (err) { + console.error('Error toggling favorite:', err); + alert('Error updating favorites'); + } +} + +function copyLink() { + navigator.clipboard.writeText(window.location.href); + const btn = document.getElementById('copy-link-btn'); + const old = btn.innerHTML; + btn.innerHTML = ` Copied!`; + setTimeout(() => btn.innerHTML = old, 2000); +} + +async function loadSimilarImages(item) { + if (!item.tags || item.tags.length === 0) { + document.getElementById('similar-section').innerHTML = '

No tags available to search for similar images.

'; + return; + } + + const firstTag = item.tags[0]; + const container = document.getElementById('similar-section'); + + try { + const res = await fetch(`/api/gallery/search?q=${encodeURIComponent(firstTag)}&perPage=20`); + if (!res.ok) throw new Error(); + + const data = await res.json(); + const results = (data.results || []) + .filter(r => r.id !== item.id) + .slice(0, 15); + + if (results.length === 0) { + container.innerHTML = '

No similar images found.

'; + return; + } + + container.innerHTML = ` +

+ More images tagged with "${firstTag}" +

+
+ ${results.map(img => { + const imageUrl = img.image || img.image_url || img.thumbnail_url; + const proxiedUrl = getProxiedItemUrl(imageUrl, img.headers); + + return ` + + Similar +
${img.provider || 'Global'}
+
+ `; + }).join('')} +
+ `; + + } catch (err) { + container.innerHTML = '

Could not load similar images.

'; + } +} + +function renderItem(item) { + const proxiedFullImage = getProxiedItemUrl(item.fullImage, item.headers); + + let sourceText; + if (item.fromFavorites) { + sourceText = item.headers && item.provider && item.provider !== 'Favorites' + ? `Source: ${item.provider}` + : 'Favorites'; + } else { + sourceText = `Source: ${item.provider}`; + } + + const originalProviderText = (item.fromFavorites && item.provider && item.provider !== 'Favorites') + ? ` (Original: ${item.provider})` + : ''; + + itemMainContentContainer.innerHTML = ` +
+
+ ${item.title} +
+ +
+
+ + ${sourceText}${originalProviderText} + +

${item.title}

+
+ +
+ + + +
+ +
+

Tags

+
+ ${item.tags.length > 0 + ? item.tags.map(tag => ` + ${tag} + `).join('') + : 'No tags' + } +
+
+
+
+ `; + + document.getElementById('fav-btn').addEventListener('click', toggleFavorite); + document.getElementById('copy-link-btn').addEventListener('click', copyLink); + + loadSimilarImages(item); +} + +function renderError(msg) { + itemMainContentContainer.innerHTML = ` +
+ +

Image Not Available

+

${msg}

+ + Back to Gallery + +
+ `; + + document.getElementById('similar-section').style.display = 'none'; +} + +async function loadFromFavorites(id) { + try { + const res = await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`, { + headers: getAuthHeaders() + }); + if (!res.ok) throw new Error('Not found'); + + const { favorite: fav } = await res.json(); + + const item = { + id: fav.id, + title: fav.title || 'No title', + fullImage: fav.image_url, + originalImage: fav.image_url, + tags: typeof fav.tags === 'string' ? fav.tags.split(',').map(t => t.trim()).filter(Boolean) : (fav.tags || []), + provider: 'Favorites', + fromFavorites: true, + headers: fav.headers + }; + + currentItem = item; + renderItem(item); + document.getElementById('page-title').textContent = `WaifuBoard - ${item.title}`; + + document.getElementById('fav-btn')?.classList.add('favorited'); + const btn = document.getElementById('fav-btn'); + if (btn) { + btn.innerHTML = ` Saved!`; + } + + } catch (err) { + renderError('This image is no longer in your favorites.'); + } +} + +async function loadFromProvider(provider, id) { + try { + const res = await fetch(`/api/gallery/fetch/${id}?provider=${provider}`); + if (!res.ok) throw new Error(); + + const data = await res.json(); + + if (!data.image) throw new Error(); + + const item = { + id, + title: data.title || 'Beautiful Art', + fullImage: data.image, + originalImage: data.image, + tags: data.tags || [], + provider, + headers: data.headers + }; + + currentItem = item; + renderItem(item); + document.getElementById('page-title').textContent = `WaifuBoard - ${item.title}`; + + const favRes = await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`, { + headers: getAuthHeaders() + }); + if (favRes.ok) { + document.getElementById('fav-btn')?.classList.add('favorited'); + const btn = document.getElementById('fav-btn'); + if (btn) { + btn.innerHTML = ` Saved!`; + } + } + + } catch (err) { + renderError('Image not found.'); + } +} + +if (itemMainContentContainer) { + const params = getUrlParams(); + if (!params) { + renderError('Invalid URL'); + } else if (params.fromFavorites) { + loadFromFavorites(params.id); + } else { + loadFromProvider(params.provider, params.id); + } +} else { + document.getElementById('item-content').innerHTML = `

Error: HTML container 'item-main-content' not found. Please update gallery-image.html.

`; + document.getElementById('similar-section').style.display = 'none'; +} + +window.addEventListener('scroll', () => { + document.getElementById('navbar')?.classList.toggle('scrolled', window.scrollY > 50); +}); \ No newline at end of file diff --git a/docker/src/scripts/list.js b/docker/src/scripts/list.js new file mode 100644 index 0000000..fd6dd40 --- /dev/null +++ b/docker/src/scripts/list.js @@ -0,0 +1,368 @@ +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 = ` + + + `; + + try { + const response = await fetch(`${API_BASE}/extensions`); + if (response.ok) { + const data = await response.json(); + const extensions = data.extensions || []; + + extensions.forEach(extName => { + if (extName.toLowerCase() !== 'anilist' && extName.toLowerCase() !== '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 = '

No entries match your filters

'; + } + 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.png'; + 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(`πŸ” ${repeatCount}`); + } + if (item.is_private) { + extraInfo.push('πŸ”’ Private'); + } + + const entryDataString = JSON.stringify(item).replace(/'/g, '''); + + div.innerHTML = ` + + ${item.title || 'Entry'} + +
+
+ +

${item.title || 'Unknown Title'}

+
+
+ ${statusLabels[item.status] || item.status} + ${entryType} + ${item.source.toUpperCase()} + ${extraInfo.join('')} +
+
+ +
+
+
+
+
+ ${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel} ${score ? `⭐ ${score}` : ''} +
+
+
+ + + `; + + 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; +} \ No newline at end of file diff --git a/docker/src/scripts/marketplace.js b/docker/src/scripts/marketplace.js new file mode 100644 index 0000000..6180eb9 --- /dev/null +++ b/docker/src/scripts/marketplace.js @@ -0,0 +1,422 @@ +const GITEA_INSTANCE = 'https://git.waifuboard.app'; +const REPO_OWNER = 'ItsSkaiya'; +const REPO_NAME = 'WaifuBoard-Extensions'; +let DETECTED_BRANCH = 'main'; +const API_URL_BASE = `${GITEA_INSTANCE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents`; + +const INSTALLED_EXTENSIONS_API = '/api/extensions'; + +const extensionsGrid = document.getElementById('extensions-grid'); +const filterSelect = document.getElementById('extension-filter'); + +let allExtensionsData = []; + +const customModal = document.getElementById('customModal'); +const modalTitle = document.getElementById('modalTitle'); +const modalMessage = document.getElementById('modalMessage'); + +function getRawUrl(filename) { + + const targetUrl = `${GITEA_INSTANCE}/${REPO_OWNER}/${REPO_NAME}/raw/branch/main/${filename}`; + + const encodedUrl = encodeURIComponent(targetUrl); + + return `/api/proxy?url=${encodedUrl}`; +} + +function updateExtensionState(fileName, installed) { + const ext = allExtensionsData.find(e => e.fileName === fileName); + if (!ext) return; + + ext.isInstalled = installed; + ext.isLocal = installed && ext.isLocal; + + filterAndRenderExtensions(filterSelect?.value || 'All'); +} + +function formatExtensionName(fileName) { + return fileName.replace('.js', '') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/^[a-z]/, (char) => char.toUpperCase()); +} + +function getIconUrl(extensionDetails) { + return extensionDetails; +} + +async function getExtensionDetails(url) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); + const text = await res.text(); + + const regex = /(?:this\.|const\s+|let\s+|var\s+)?baseUrl\s*=\s*(["'`])(.*?)\1/i; + const match = text.match(regex); + let finalHostname = null; + if (match && match[2]) { + let rawUrl = match[2].trim(); + if (!rawUrl.startsWith('http')) rawUrl = 'https://' + rawUrl; + try { + const urlObj = new URL(rawUrl); + finalHostname = urlObj.hostname; + } catch(e) { + console.warn(`Could not parse baseUrl: ${rawUrl}`); + } + } + + const classMatch = text.match(/class\s+(\w+)/); + const name = classMatch ? classMatch[1] : null; + + let type = 'Image'; + if (text.includes('type = "book-board"') || text.includes("type = 'book-board'")) type = 'Book'; + else if (text.includes('type = "anime-board"') || text.includes("type = 'anime-board'")) type = 'Anime'; + + return { baseUrl: finalHostname, name, type }; + } catch (e) { + return { baseUrl: null, name: null, type: 'Unknown' }; + } +} + +function showCustomModal(title, message, isConfirm = false) { + return new Promise(resolve => { + + modalTitle.textContent = title; + modalMessage.textContent = message; + + const currentConfirmButton = document.getElementById('modalConfirmButton'); + const currentCloseButton = document.getElementById('modalCloseButton'); + + const newConfirmButton = currentConfirmButton.cloneNode(true); + currentConfirmButton.parentNode.replaceChild(newConfirmButton, currentConfirmButton); + + const newCloseButton = currentCloseButton.cloneNode(true); + currentCloseButton.parentNode.replaceChild(newCloseButton, currentCloseButton); + + if (isConfirm) { + + newConfirmButton.classList.remove('hidden'); + newConfirmButton.textContent = 'Confirm'; + newCloseButton.textContent = 'Cancel'; + } else { + + newConfirmButton.classList.add('hidden'); + newCloseButton.textContent = 'Close'; + } + + const closeModal = (confirmed) => { + customModal.classList.add('hidden'); + resolve(confirmed); + }; + + newConfirmButton.onclick = () => closeModal(true); + newCloseButton.onclick = () => closeModal(false); + + customModal.classList.remove('hidden'); + }); +} + +function renderExtensionCard(extension, isInstalled, isLocalOnly = false) { + + const extensionName = formatExtensionName(extension.fileName || extension.name); + const extensionType = extension.type || 'Unknown'; + + let iconUrl; + + if (extension.baseUrl && extension.baseUrl !== 'Local Install') { + + iconUrl = `https://www.google.com/s2/favicons?domain=${extension.baseUrl}&sz=128`; + } else { + + const displayName = extensionName.replace(/\s/g, '+'); + iconUrl = `https://ui-avatars.com/api/?name=${displayName}&background=1f2937&color=fff&length=1`; + } + + const card = document.createElement('div'); + card.className = `extension-card grid-item extension-type-${extensionType.toLowerCase()}`; + card.dataset.path = extension.fileName || extension.name; + card.dataset.type = extensionType; + + let buttonHtml; + let badgeHtml = ''; + + if (isInstalled) { + + if (isLocalOnly) { + badgeHtml = 'Local'; + } else { + badgeHtml = 'Installed'; + } + buttonHtml = ` + + `; + } else { + + buttonHtml = ` + + `; + } + + card.innerHTML = ` + ${extensionName} Icon +
+

${extensionName}

+ ${badgeHtml} +
+ ${buttonHtml} + `; + + const installButton = card.querySelector('[data-action="install"]'); + const uninstallButton = card.querySelector('[data-action="uninstall"]'); + + if (installButton) { + installButton.addEventListener('click', async () => { + try { + const response = await fetch('/api/extensions/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: extension.fileName }), + }); + + const result = await response.json(); + + if (response.ok) { + updateExtensionState(extension.fileName, true); + + await showCustomModal( + 'Installation Successful', + `${extensionName} has been successfully installed.`, + false + ); + } else { + + await showCustomModal( + 'Installation Failed', + `Installation failed: ${result.error || 'Unknown error.'}`, + false + ); + } + } catch (error) { + + await showCustomModal( + 'Installation Failed', + `Network error during installation.`, + false + ); + } + }); + } + + if (uninstallButton) { + uninstallButton.addEventListener('click', async () => { + + const confirmed = await showCustomModal( + 'Confirm Uninstallation', + `Are you sure you want to uninstall ${extensionName}?`, + true + ); + + if (!confirmed) return; + + try { + const response = await fetch('/api/extensions/uninstall', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: extension.fileName }), + }); + + const result = await response.json(); + + if (response.ok) { + updateExtensionState(extension.fileName, false); + + await showCustomModal( + 'Uninstallation Successful', + `${extensionName} has been successfully uninstalled.`, + false + ); + } else { + + await showCustomModal( + 'Uninstallation Failed', + `Uninstallation failed: ${result.error || 'Unknown error.'}`, + false + ); + } + } catch (error) { + + await showCustomModal( + 'Uninstallation Failed', + `Network error during uninstallation.`, + false + ); + } + }); + } + + extensionsGrid.appendChild(card); +} + +async function getInstalledExtensions() { + console.log(`Fetching installed extensions from: ${INSTALLED_EXTENSIONS_API}`); + + try { + const response = await fetch(INSTALLED_EXTENSIONS_API); + + if (!response.ok) { + console.error(`Error fetching installed extensions. Status: ${response.status}`); + return new Set(); + } + + const data = await response.json(); + + if (!data.extensions || !Array.isArray(data.extensions)) { + console.error("Invalid response format from /api/extensions: 'extensions' array missing or incorrect."); + return new Set(); + } + + const installedFileNames = data.extensions + .map(name => `${name.toLowerCase()}.js`); + + return new Set(installedFileNames); + + } catch (error) { + console.error('Network or JSON parsing error during fetch of installed extensions:', error); + return new Set(); + } +} + +function filterAndRenderExtensions(filterType) { + extensionsGrid.innerHTML = ''; + + if (!allExtensionsData || allExtensionsData.length === 0) { + console.log('No extension data to filter.'); + return; + } + + const filteredExtensions = allExtensionsData.filter(ext => + filterType === 'All' || ext.type === filterType || (ext.isLocal && filterType === 'Local') + ); + + filteredExtensions.forEach(ext => { + renderExtensionCard(ext, ext.isInstalled, ext.isLocal); + }); + + if (filteredExtensions.length === 0) { + extensionsGrid.innerHTML = `

No extensions found for the selected filter (${filterType}).

`; + } +} + +async function loadMarketplace() { + extensionsGrid.innerHTML = ''; + + for (let i = 0; i < 6; i++) { + extensionsGrid.innerHTML += ` +
+
+
+
+
+
+
+
`; + } + + try { + + const [availableExtensionsRaw, installedExtensionsSet] = await Promise.all([ + fetch(API_URL_BASE).then(res => { + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); + return res.json(); + }), + getInstalledExtensions() + ]); + + const availableExtensionsJs = availableExtensionsRaw.filter(ext => ext.type === 'file' && ext.name.endsWith('.js')); + const detailPromises = []; + + const marketplaceFileNames = new Set(availableExtensionsJs.map(ext => ext.name.toLowerCase())); + + for (const ext of availableExtensionsJs) { + + const downloadUrl = getRawUrl(ext.name); + + const detailsPromise = getExtensionDetails(downloadUrl).then(details => ({ + ...ext, + ...details, + fileName: ext.name, + + isInstalled: installedExtensionsSet.has(ext.name.toLowerCase()), + isLocal: false, + })); + detailPromises.push(detailsPromise); + } + + const extensionsWithDetails = await Promise.all(detailPromises); + + installedExtensionsSet.forEach(installedName => { + + if (!marketplaceFileNames.has(installedName)) { + + const localExt = { + name: formatExtensionName(installedName), + fileName: installedName, + type: 'Local', + isInstalled: true, + isLocal: true, + baseUrl: 'Local Install', + }; + extensionsWithDetails.push(localExt); + } + }); + + extensionsWithDetails.sort((a, b) => { + if (a.isInstalled !== b.isInstalled) { + return b.isInstalled - a.isInstalled; + + } + + const nameA = a.name || ''; + const nameB = b.name || ''; + + return nameA.localeCompare(nameB); + + }); + + allExtensionsData = extensionsWithDetails; + + if (filterSelect) { + filterSelect.addEventListener('change', (event) => { + filterAndRenderExtensions(event.target.value); + }); + } + + filterAndRenderExtensions('All'); + + } catch (error) { + console.error('Error loading the marketplace:', error); + extensionsGrid.innerHTML = ` +
+ 🚨 Error loading extensions. +

Could not connect to the extension repository or local endpoint. Detail: ${error.message}

+
+ `; + allExtensionsData = []; + } +} + +customModal.addEventListener('click', (e) => { + if (e.target === customModal || e.target.tagName === 'BUTTON') { + customModal.classList.add('hidden'); + } +}); + +document.addEventListener('DOMContentLoaded', loadMarketplace); + +window.addEventListener('scroll', () => { + const navbar = document.getElementById('navbar'); + if (window.scrollY > 0) { + navbar.classList.add('scrolled'); + } else { + navbar.classList.remove('scrolled'); + } +}); \ No newline at end of file diff --git a/docker/src/scripts/rpc-inapp.js b/docker/src/scripts/rpc-inapp.js new file mode 100644 index 0000000..943d2ba --- /dev/null +++ b/docker/src/scripts/rpc-inapp.js @@ -0,0 +1,9 @@ +fetch("/api/rpc", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + details: "Browsing", + state: `In App`, + mode: "idle" + }) +}); \ No newline at end of file diff --git a/docker/src/scripts/schedule/schedule.js b/docker/src/scripts/schedule/schedule.js new file mode 100644 index 0000000..3db7d8e --- /dev/null +++ b/docker/src/scripts/schedule/schedule.js @@ -0,0 +1,360 @@ +const ANILIST_API = 'https://graphql.anilist.co'; +const CACHE_NAME = 'waifuboard-schedule-v1'; +const CACHE_DURATION = 5 * 60 * 1000; + +const state = { + currentDate: new Date(), + viewType: 'MONTH', + mode: 'SUB', + loading: false, + abortController: null, + refreshInterval: null +}; + +document.addEventListener('DOMContentLoaded', () => { + renderHeader(); + fetchSchedule(); + + state.refreshInterval = setInterval(() => { + console.log("Auto-refreshing schedule..."); + fetchSchedule(true); + }, CACHE_DURATION); +}); + +async function getCache(key) { + try { + const cache = await caches.open(CACHE_NAME); + + const response = await cache.match(`/schedule-cache/${key}`); + + if (!response) return null; + + const cached = await response.json(); + const age = Date.now() - cached.timestamp; + + if (age < CACHE_DURATION) { + console.log(`[Cache Hit] Loaded ${key} (Age: ${Math.round(age / 1000)}s)`); + return cached.data; + } + + console.log(`[Cache Stale] ${key} expired.`); + + cache.delete(`/schedule-cache/${key}`); + return null; + } catch (e) { + console.error("Cache read failed", e); + return null; + } +} + +async function setCache(key, data) { + try { + const cache = await caches.open(CACHE_NAME); + const payload = JSON.stringify({ + timestamp: Date.now(), + data: data + }); + + const response = new Response(payload, { + headers: { 'Content-Type': 'application/json' } + }); + await cache.put(`/schedule-cache/${key}`, response); + } catch (e) { + console.warn("Cache write failed", e); + } +} + +function getCacheKey() { + if (state.viewType === 'MONTH') { + return `M_${state.currentDate.getFullYear()}_${state.currentDate.getMonth()}`; + } else { + const start = getWeekStart(state.currentDate); + return `W_${start.toISOString().split('T')[0]}`; + } +} + +function navigate(delta) { + if (state.abortController) state.abortController.abort(); + + if (state.viewType === 'MONTH') { + state.currentDate.setMonth(state.currentDate.getMonth() + delta); + } else { + state.currentDate.setDate(state.currentDate.getDate() + (delta * 7)); + } + + renderHeader(); + fetchSchedule(); +} + +function setViewType(type) { + if (state.viewType === type) return; + state.viewType = type; + + document.getElementById('btnViewMonth').classList.toggle('active', type === 'MONTH'); + document.getElementById('btnViewWeek').classList.toggle('active', type === 'WEEK'); + + if (state.abortController) state.abortController.abort(); + + renderHeader(); + fetchSchedule(); +} + +function setMode(mode) { + if (state.mode === mode) return; + state.mode = mode; + document.getElementById('btnSub').classList.toggle('active', mode === 'SUB'); + document.getElementById('btnDub').classList.toggle('active', mode === 'DUB'); + + fetchSchedule(); +} + +function renderHeader() { + const options = { month: 'long', year: 'numeric' }; + let title = state.currentDate.toLocaleDateString('en-US', options); + + if (state.viewType === 'WEEK') { + const start = getWeekStart(state.currentDate); + const end = new Date(start); + end.setDate(end.getDate() + 6); + + const startStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const endStr = end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + title = `Week of ${startStr} - ${endStr}`; + } + + document.getElementById('monthTitle').textContent = title; +} + +function getWeekStart(date) { + const d = new Date(date); + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + return new Date(d.setDate(diff)); +} + +async function fetchSchedule(forceRefresh = false) { + const key = getCacheKey(); + + if (!forceRefresh) { + const cachedData = await getCache(key); + if (cachedData) { + renderGrid(cachedData); + updateAmbient(cachedData); + return; + } + } + + if (state.abortController) state.abortController.abort(); + state.abortController = new AbortController(); + const signal = state.abortController.signal; + + if (!forceRefresh) setLoading(true); + + let startTs, endTs; + if (state.viewType === 'MONTH') { + const year = state.currentDate.getFullYear(); + const month = state.currentDate.getMonth(); + startTs = Math.floor(new Date(year, month, 1).getTime() / 1000); + endTs = Math.floor(new Date(year, month + 1, 0, 23, 59, 59).getTime() / 1000); + } else { + const start = getWeekStart(state.currentDate); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 7); + startTs = Math.floor(start.getTime() / 1000); + endTs = Math.floor(end.getTime() / 1000); + } + + const query = ` + query ($start: Int, $end: Int, $page: Int) { + Page(page: $page, perPage: 50) { + pageInfo { hasNextPage } + airingSchedules(airingAt_greater: $start, airingAt_lesser: $end, sort: TIME) { + airingAt + episode + media { + id + title { userPreferred english } + coverImage { large } + bannerImage + isAdult + countryOfOrigin + popularity + } + } + } + } + `; + + let allData = []; + let page = 1; + let hasNext = true; + let retries = 0; + + try { + while (hasNext && page <= 6) { + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); + + try { + const res = await fetch(ANILIST_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + query, + variables: { start: startTs, end: endTs, page } + }), + signal: signal + }); + + if (res.status === 429) { + if (retries > 2) throw new Error("Rate Limited"); + console.warn("429 Hit. Waiting..."); + await delay(4000); + retries++; + continue; + } + + const json = await res.json(); + if (json.errors) throw new Error("API Error"); + + const data = json.data.Page; + allData = [...allData, ...data.airingSchedules]; + hasNext = data.pageInfo.hasNextPage; + page++; + + await delay(600); + + } catch (e) { + if (e.name === 'AbortError') throw e; + console.error(e); + break; + } + } + + if (!signal.aborted) { + + await setCache(key, allData); + renderGrid(allData); + updateAmbient(allData); + } + + } catch (e) { + if (e.name !== 'AbortError') console.error("Fetch failed:", e); + } finally { + if (!signal.aborted) { + setLoading(false); + state.abortController = null; + } + } +} + +function renderGrid(data) { + const grid = document.getElementById('daysGrid'); + grid.innerHTML = ''; + + let items = data.filter(i => !i.media.isAdult && i.media.countryOfOrigin === 'JP'); + if (state.mode === 'DUB') { + items = items.filter(i => i.media.popularity > 20000); + } + + if (state.viewType === 'MONTH') { + const year = state.currentDate.getFullYear(); + const month = state.currentDate.getMonth(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + let firstDayIndex = new Date(year, month, 1).getDay() - 1; + if (firstDayIndex === -1) firstDayIndex = 6; + + for (let i = 0; i < firstDayIndex; i++) { + const empty = document.createElement('div'); + empty.className = 'day-cell empty'; + grid.appendChild(empty); + } + + for (let day = 1; day <= daysInMonth; day++) { + const dateObj = new Date(year, month, day); + renderDayCell(dateObj, items, grid); + } + } else { + const start = getWeekStart(state.currentDate); + for (let i = 0; i < 7; i++) { + const dateObj = new Date(start); + dateObj.setDate(start.getDate() + i); + renderDayCell(dateObj, items, grid); + } + } +} + +function renderDayCell(dateObj, items, grid) { + const cell = document.createElement('div'); + cell.className = 'day-cell'; + + if (state.viewType === 'WEEK') cell.style.minHeight = '300px'; + + const day = dateObj.getDate(); + const month = dateObj.getMonth(); + const year = dateObj.getFullYear(); + + const now = new Date(); + if (day === now.getDate() && month === now.getMonth() && year === now.getFullYear()) { + cell.classList.add('today'); + } + + const dayEvents = items.filter(i => { + const eventDate = new Date(i.airingAt * 1000); + return eventDate.getDate() === day && eventDate.getMonth() === month && eventDate.getFullYear() === year; + }); + + dayEvents.sort((a, b) => b.media.popularity - a.media.popularity); + + if (dayEvents.length > 0) { + const top = dayEvents[0].media; + const bg = document.createElement('div'); + bg.className = 'cell-backdrop'; + bg.style.backgroundImage = `url('${top.coverImage.large}')`; + cell.appendChild(bg); + } + + const header = document.createElement('div'); + header.className = 'day-header'; + header.innerHTML = ` + ${day} + Today + `; + cell.appendChild(header); + + const list = document.createElement('div'); + list.className = 'events-list'; + + dayEvents.forEach(evt => { + const title = evt.media.title.english || evt.media.title.userPreferred; + const link = `/anime/${evt.media.id}`; + + const chip = document.createElement('a'); + chip.className = 'anime-chip'; + chip.href = link; + chip.innerHTML = ` + ${title} + Ep ${evt.episode} + `; + list.appendChild(chip); + }); + + cell.appendChild(list); + grid.appendChild(cell); +} + +function setLoading(bool) { + state.loading = bool; + const loader = document.getElementById('loader'); + if (bool) loader.classList.add('active'); + else loader.classList.remove('active'); +} + +function delay(ms) { return new Promise(r => setTimeout(r, ms)); } + +function updateAmbient(data) { + if (!data || !data.length) return; + const top = data.reduce((prev, curr) => (prev.media.popularity > curr.media.popularity) ? prev : curr); + const img = top.media.bannerImage || top.media.coverImage.large; + if (img) document.getElementById('ambientBg').style.backgroundImage = `url('${img}')`; +} \ No newline at end of file diff --git a/docker/src/scripts/titlebar.js b/docker/src/scripts/titlebar.js new file mode 100644 index 0000000..d451bba --- /dev/null +++ b/docker/src/scripts/titlebar.js @@ -0,0 +1,18 @@ +if (window.electronAPI?.isElectron) { + document.documentElement.classList.add("electron"); +} + +document.addEventListener("DOMContentLoaded", () => { + document.documentElement.style.visibility = "visible"; + if (!window.electronAPI?.isElectron) return; + document.body.classList.add("electron"); + + const titlebar = document.getElementById("titlebar"); + if (!titlebar) return; + + titlebar.style.display = "flex"; + + titlebar.querySelector(".min").onclick = () => window.electronAPI.win.minimize(); + titlebar.querySelector(".max").onclick = () => window.electronAPI.win.maximize(); + titlebar.querySelector(".close").onclick = () => window.electronAPI.win.close(); +}); \ No newline at end of file diff --git a/docker/src/scripts/updateNotifier.js b/docker/src/scripts/updateNotifier.js new file mode 100644 index 0000000..b6a1517 --- /dev/null +++ b/docker/src/scripts/updateNotifier.js @@ -0,0 +1,102 @@ +const Gitea_OWNER = "ItsSkaiya"; +const Gitea_REPO = "WaifuBoard"; +const CURRENT_VERSION = "v2.0.0-rc.0"; +const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000; + +let currentVersionDisplay; +let latestVersionDisplay; +let updateToast; + +document.addEventListener("DOMContentLoaded", () => { + currentVersionDisplay = document.getElementById("currentVersionDisplay"); + latestVersionDisplay = document.getElementById("latestVersionDisplay"); + updateToast = document.getElementById("updateToast"); + + if (currentVersionDisplay) { + currentVersionDisplay.textContent = CURRENT_VERSION; + } + + checkForUpdates(); + + setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL); +}); + +function showToast(latestVersion) { + if (latestVersionDisplay && updateToast) { + latestVersionDisplay.textContent = latestVersion; + updateToast.classList.add("update-available"); + updateToast.classList.remove("hidden"); + } else { + console.error( + "Error: Cannot display toast because one or more DOM elements were not found.", + ); + } +} + +function hideToast() { + if (updateToast) { + updateToast.classList.add("hidden"); + updateToast.classList.remove("update-available"); + } +} + +function isVersionOutdated(versionA, versionB) { + const vA = versionA.replace(/^v/, "").split(".").map(Number); + const vB = versionB.replace(/^v/, "").split(".").map(Number); + + for (let i = 0; i < Math.max(vA.length, vB.length); i++) { + const numA = vA[i] || 0; + const numB = vB[i] || 0; + + if (numA < numB) return true; + if (numA > numB) return false; + } + + return false; +} + +async function checkForUpdates() { + console.log(`Checking for updates for ${Gitea_OWNER}/${Gitea_REPO}...`); + + const apiUrl = `https://git.waifuboard.app/api/v1/repos/${Gitea_OWNER}/${Gitea_REPO}/releases/latest`; + + try { + const response = await fetch(apiUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + if (response.status === 404) { + console.info("No releases found for this repository."); + return; + } + throw new Error( + `Gitea API error: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + + const latestVersion = data.tag_name; + + if (!latestVersion) { + console.warn("Release found but no tag_name present"); + return; + } + + console.log(`Latest Gitea Release: ${latestVersion}`); + + if (isVersionOutdated(CURRENT_VERSION, latestVersion)) { + console.warn("Update available!"); + showToast(latestVersion); + } else { + console.info("Package is up to date."); + hideToast(); + } + } catch (error) { + console.error("Failed to fetch Gitea release:", error); + } +} diff --git a/docker/src/scripts/users.js b/docker/src/scripts/users.js new file mode 100644 index 0000000..8a268e6 --- /dev/null +++ b/docker/src/scripts/users.js @@ -0,0 +1,977 @@ +const API_BASE = '/api'; + +let users = []; +let selectedFile = null; +let currentUserId = null; + +const usersGrid = document.getElementById('usersGrid'); +const btnAddUser = document.getElementById('btnAddUser'); + +const modalCreateUser = document.getElementById('modalCreateUser'); +const closeCreateModal = document.getElementById('closeCreateModal'); +const cancelCreate = document.getElementById('cancelCreate'); +const createUserForm = document.getElementById('createUserForm'); + +const modalUserActions = document.getElementById('modalUserActions'); +const closeActionsModal = document.getElementById('closeActionsModal'); +const actionsModalTitle = document.getElementById('actionsModalTitle'); + +const modalEditUser = document.getElementById('modalEditUser'); +const closeEditModal = document.getElementById('closeEditModal'); +const cancelEdit = document.getElementById('cancelEdit'); +const editUserForm = document.getElementById('editUserForm'); + +const modalAniList = document.getElementById('modalAniList'); +const closeAniListModal = document.getElementById('closeAniListModal'); +const aniListContent = document.getElementById('aniListContent'); + +const toastContainer = document.getElementById('userToastContainer'); + +const params = new URLSearchParams(window.location.search); +const anilistStatus = params.get("anilist"); + +if (anilistStatus === "success") { + showUserToast("βœ… AniList connected successfully!"); +} + +if (anilistStatus === "error") { + showUserToast("❌ Failed to connect AniList"); +} + +document.addEventListener('DOMContentLoaded', () => { + loadUsers(); + attachEventListeners(); +}); + +function showUserToast(message, type = 'info') { + if (!toastContainer) return; + + const toast = document.createElement('div'); + toast.className = `wb-toast ${type}`; + toast.textContent = message; + + toastContainer.prepend(toast); + + setTimeout(() => toast.classList.add('show'), 10); + + setTimeout(() => { + toast.classList.remove('show'); + toast.addEventListener('transitionend', () => toast.remove()); + }, 4000); +} + +function attachEventListeners() { + if (btnAddUser) btnAddUser.addEventListener('click', openCreateModal); + + if (closeCreateModal) closeCreateModal.addEventListener('click', closeModal); + if (cancelCreate) cancelCreate.addEventListener('click', closeModal); + if (closeAniListModal) closeAniListModal.addEventListener('click', closeModal); + if (closeActionsModal) closeActionsModal.addEventListener('click', closeModal); + if (closeEditModal) closeEditModal.addEventListener('click', closeModal); + if (cancelEdit) cancelEdit.addEventListener('click', closeModal); + + if (createUserForm) createUserForm.addEventListener('submit', handleCreateUser); + if (editUserForm) editUserForm.addEventListener('submit', handleEditUser); + + document.querySelectorAll('.modal-overlay').forEach(overlay => { + overlay.addEventListener('click', (e) => { + if (e.target.classList.contains('modal-overlay')) closeModal(); + }); + }); +} + +function initAvatarUpload(uploadAreaId, fileInputId, previewId) { + const uploadArea = document.getElementById(uploadAreaId); + const fileInput = document.getElementById(fileInputId); + const preview = document.getElementById(previewId); + + if (!uploadArea || !fileInput) return; + + uploadArea.addEventListener('click', () => fileInput.click()); + + fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) handleFileSelect(file, previewId); + }); + + uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.classList.add('dragover'); + }); + + uploadArea.addEventListener('dragleave', () => { + uploadArea.classList.remove('dragover'); + }); + + uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.classList.remove('dragover'); + const file = e.dataTransfer.files[0]; + if (file && file.type.startsWith('image/')) { + handleFileSelect(file, previewId); + } + }); +} + +function handleFileSelect(file, previewId) { + if (!file.type.startsWith('image/')) { + showUserToast('Please select an image file', 'error'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + showUserToast('Image size must be less than 5MB', 'error'); + return; + } + + selectedFile = file; + + const reader = new FileReader(); + reader.onload = (e) => { + const preview = document.getElementById(previewId); + if (preview) preview.innerHTML = `Avatar preview`; + }; + reader.readAsDataURL(file); +} + +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = (err) => reject(err); + reader.readAsDataURL(file); + }); +} + +function togglePasswordVisibility(inputId, buttonElement) { + const input = document.getElementById(inputId); + if (!input) return; + + if (input.type === 'password') { + input.type = 'text'; + buttonElement.innerHTML = ` + + + + + `; + } else { + input.type = 'password'; + buttonElement.innerHTML = ` + + + + + `; + } +} + +async function loadUsers() { + try { + const res = await fetch(`${API_BASE}/users`); + if (!res.ok) throw new Error('Failed to fetch users'); + const data = await res.json(); + users = data.users || []; + renderUsers(); + } catch (err) { + console.error('Error loading users:', err); + showEmptyState(); + } +} + +function renderUsers() { + if (!usersGrid) return; + + if (users.length === 0) { + showEmptyState(); + return; + } + + usersGrid.innerHTML = ''; + users.forEach(user => { + const userCard = createUserCard(user); + usersGrid.appendChild(userCard); + }); +} + +function createUserCard(user) { + const card = document.createElement('div'); + card.className = 'user-card'; + + if (user.has_password) { + card.classList.add('has-password'); + } + + card.addEventListener('click', (e) => { + if (!e.target.closest('.user-config-btn')) { + loginUser(user.id, user.has_password); + } + }); + + const avatarContent = user.profile_picture_url + ? `${user.username}` + : `
+ + + + +
`; + + card.innerHTML = ` +
${avatarContent}
+ + + `; + + const configBtn = card.querySelector('.user-config-btn'); + configBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openUserActionsModal(user.id); + }); + + return card; +} + +function showEmptyState() { + if (!usersGrid) return; + usersGrid.innerHTML = ` +
+ + + + + + +

No Users Yet

+

Create your first profile to get started

+
+ `; +} + +function openCreateModal() { + modalCreateUser.innerHTML = ` + + + `; + + modalCreateUser.classList.add('active'); + + initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview'); + + document.getElementById('createUserFormDynamic').addEventListener('submit', handleCreateUser); + document.getElementById('username').focus(); + + selectedFile = null; +} + +function closeModal() { + modalCreateUser.classList.remove('active'); + modalAniList.classList.remove('active'); + modalUserActions.classList.remove('active'); + modalEditUser.classList.remove('active'); + selectedFile = null; +} + +async function handleCreateUser(e) { + e.preventDefault(); + + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('createPassword').value.trim(); + + if (!username) { + showUserToast('Please enter a username', 'error'); + return; + } + + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Creating...'; + + try { + let profilePictureUrl = null; + if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile); + + const body = { username }; + if (profilePictureUrl) body.profilePictureUrl = profilePictureUrl; + if (password) body.password = password; + + const res = await fetch(`${API_BASE}/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error creating user'); + } + + closeModal(); + await loadUsers(); + showUserToast(`User ${username} created successfully!`, 'success'); + } catch (err) { + console.error(err); + showUserToast(err.message || 'Error creating user', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Create User'; + } +} + +function openUserActionsModal(userId) { + currentUserId = userId; + const user = users.find(u => u.id === userId); + if (!user) return; + + modalAniList.classList.remove('active'); + modalEditUser.classList.remove('active'); + + actionsModalTitle.textContent = `Manage ${user.username}`; + + const content = document.getElementById('actionsModalContent'); + if (!content) return; + + content.innerHTML = ` +
+ + + + + +
+ `; + + modalUserActions.classList.add('active'); +} + +window.openEditModal = function(userId) { + currentUserId = userId; + modalUserActions.classList.remove('active'); + const user = users.find(u => u.id === userId); + if (!user) return; + + modalEditUser.innerHTML = ` + + + `; + + modalEditUser.classList.add('active'); + + initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview'); + + selectedFile = null; + + document.getElementById('editUserFormDynamic').addEventListener('submit', handleEditUser); +}; + +async function handleEditUser(e) { + e.preventDefault(); + + const user = users.find(u => u.id === currentUserId); + if (!user) return; + + const username = document.getElementById('editUsername').value.trim(); + if (!username) { + showUserToast('Please enter a username', 'error'); + return; + } + + const submitBtn = e.target.querySelector('.btn-primary'); + submitBtn.disabled = true; + submitBtn.textContent = 'Saving...'; + + try { + let profilePictureUrl; + if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile); + + const updates = { username }; + if (profilePictureUrl !== undefined) updates.profilePictureUrl = profilePictureUrl; + + const res = await fetch(`${API_BASE}/users/${currentUserId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error updating user'); + } + + closeModal(); + await loadUsers(); + showUserToast('Profile updated successfully!', 'success'); + } catch (err) { + console.error(err); + showUserToast(err.message || 'Error updating user', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Save Changes'; + } +} + +window.openPasswordModal = function(userId) { + currentUserId = userId; + modalUserActions.classList.remove('active'); + const user = users.find(u => u.id === userId); + if (!user) return; + + modalAniList.innerHTML = ` + + + `; + + modalAniList.classList.add('active'); + + document.getElementById('passwordForm').addEventListener('submit', handlePasswordSubmit); +}; + +async function handlePasswordSubmit(e) { + e.preventDefault(); + + const user = users.find(u => u.id === currentUserId); + if (!user) return; + + const currentPassword = user.has_password ? document.getElementById('currentPassword').value : null; + const newPassword = document.getElementById('newPassword').value; + + if (!newPassword && !user.has_password) { + showUserToast('Please enter a password', 'error'); + return; + } + + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Updating...'; + + try { + const body = { newPassword: newPassword || null }; + if (currentPassword) body.currentPassword = currentPassword; + + const res = await fetch(`${API_BASE}/users/${currentUserId}/password`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error updating password'); + } + + closeModal(); + await loadUsers(); + showUserToast('Password updated successfully!', 'success'); + } catch (err) { + console.error(err); + showUserToast(err.message || 'Error updating password', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = user.has_password ? 'Update Password' : 'Set Password'; + } +} + +window.handleRemovePassword = async function() { + if (!confirm('Are you sure you want to remove the password protection from this profile?')) return; + + try { + const currentPassword = document.getElementById('currentPassword').value; + + if (!currentPassword) { + showUserToast('Please enter your current password', 'error'); + return; + } + + const res = await fetch(`${API_BASE}/users/${currentUserId}/password`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword, newPassword: null }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error removing password'); + } + + closeModal(); + await loadUsers(); + showUserToast('Password removed successfully!', 'success'); + } catch (err) { + console.error(err); + showUserToast(err.message || 'Error removing password', 'error'); + } +}; + +window.handleDeleteConfirmation = function(userId) { + const user = users.find(u => u.id === userId); + if (!user) return; + + closeModal(); + + showConfirmationModal( + 'Confirm Deletion', + `Are you absolutely sure you want to delete profile ${user.username}? This action cannot be undone.`, + `handleConfirmedDeleteUser(${userId})` + ); +}; + +window.handleConfirmedDeleteUser = async function(userId) { + closeModal(); + showUserToast('Deleting user...', 'info'); + + try { + const res = await fetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error deleting user'); + } + + await loadUsers(); + showUserToast('User deleted successfully!', 'success'); + } catch (err) { + console.error(err); + showUserToast('Error deleting user', 'error'); + } +}; + +function showConfirmationModal(title, message, confirmAction) { + closeModal(); + + modalAniList.innerHTML = ` + + + `; + + modalAniList.classList.add('active'); +} + +function openAniListModal(userId) { + currentUserId = userId; + + modalUserActions.classList.remove('active'); + modalEditUser.classList.remove('active'); + + aniListContent.innerHTML = `
Loading integration status...
`; + + modalAniList.innerHTML = ` + + + `; + + modalAniList.classList.add('active'); + + getIntegrationStatus(userId).then(integration => { + const content = document.getElementById('aniListContent'); + + content.innerHTML = ` +
+ ${integration.connected ? ` +
+
+ AniList +
+
+

Connected to AniList

+

User ID: ${integration.anilistUserId}

+

Expires: ${new Date(integration.expiresAt).toLocaleDateString()}

+
+
+ + ` : ` +
+

Connect with AniList

+

+ Sync your anime list by logging in with AniList. +

+
+ +
+

+ You will be redirected and then returned here. +

+
+ `} +
+ `; + }).catch(err => { + console.error(err); + const content = document.getElementById('aniListContent'); + content.innerHTML = `
Error loading integration status.
`; + }); +} + +async function redirectToAniListLogin() { + try { + const res = await fetch(`/api/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: currentUserId }) + }); + + if (!res.ok) throw new Error('Login failed before AniList redirect'); + + const data = await res.json(); + localStorage.setItem('token', data.token); + + const clientId = 32898; + const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist'); + const state = encodeURIComponent(currentUserId); + + window.location.href = + `https://anilist.co/api/v2/oauth/authorize` + + `?client_id=${clientId}` + + `&response_type=code` + + `&redirect_uri=${redirectUri}` + + `&state=${state}`; + + } catch (err) { + console.error(err); + showUserToast('Error starting AniList login', 'error'); + } +} + +async function getIntegrationStatus(userId) { + try { + const res = await fetch(`${API_BASE}/users/${userId}/integration`); + if (!res.ok) { + return { connected: false }; + } + return await res.json(); + } catch (err) { + console.error('getIntegrationStatus error', err); + return { connected: false }; + } +} + +window.handleDisconnectAniList = async function() { + if (!confirm('Are you sure you want to disconnect AniList?')) return; + + try { + const res = await fetch(`${API_BASE}/users/${currentUserId}/integration`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error disconnecting AniList'); + } + + showUserToast('AniList disconnected successfully', 'success'); + openAniListModal(currentUserId); + } catch (err) { + console.error(err); + showUserToast('Error disconnecting AniList', 'error'); + } +}; + +async function loginUser(userId, hasPassword) { + if (hasPassword) { + // Mostrar modal de contraseΓ±a + modalAniList.innerHTML = ` + + + `; + + modalAniList.classList.add('active'); + + document.getElementById('loginPasswordForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const password = document.getElementById('loginPassword').value; + await performLogin(userId, password); + }); + } else { + await performLogin(userId); + } +} + +async function performLogin(userId, password = null) { + try { + const body = { userId }; + if (password) body.password = password; + + const res = await fetch(`${API_BASE}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Login failed'); + } + + const data = await res.json(); + localStorage.setItem('token', data.token); + + window.location.href = '/anime'; + } catch (err) { + console.error('Login error', err); + showUserToast(err.message || 'Login failed', 'error'); + } +} + +window.openAniListModal = openAniListModal; +window.redirectToAniListLogin = redirectToAniListLogin; \ No newline at end of file diff --git a/docker/src/scripts/utils/auth-utils.js b/docker/src/scripts/utils/auth-utils.js new file mode 100644 index 0000000..e6d0623 --- /dev/null +++ b/docker/src/scripts/utils/auth-utils.js @@ -0,0 +1,26 @@ +const AuthUtils = { + getToken() { + return localStorage.getItem('token'); + }, + + getAuthHeaders() { + const token = this.getToken(); + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }; + }, + + getSimpleAuthHeaders() { + const token = this.getToken(); + return { + 'Authorization': `Bearer ${token}` + }; + }, + + isAuthenticated() { + return !!this.getToken(); + } +}; + +window.AuthUtils = AuthUtils; \ No newline at end of file diff --git a/docker/src/scripts/utils/continue-watching-manager.js b/docker/src/scripts/utils/continue-watching-manager.js new file mode 100644 index 0000000..dbdea0c --- /dev/null +++ b/docker/src/scripts/utils/continue-watching-manager.js @@ -0,0 +1,86 @@ +const ContinueWatchingManager = { + API_BASE: '/api', + + async load(containerId, status = 'watching', entryType = 'ANIME') { + if (!AuthUtils.isAuthenticated()) return; + + const container = document.getElementById(containerId); + if (!container) return; + + try { + const res = await fetch(`${this.API_BASE}/list/filter?status=${status}&entry_type=${entryType}`, { + headers: AuthUtils.getAuthHeaders() + }); + + if (!res.ok) return; + + const data = await res.json(); + const list = data.results || []; + + this.render(containerId, list, entryType); + } catch (err) { + console.error(`Continue ${entryType === 'ANIME' ? 'Watching' : 'Reading'} Error:`, err); + } + }, + + render(containerId, list, entryType = 'ANIME') { + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = ''; + + if (list.length === 0) { + const label = entryType === 'ANIME' ? 'watching anime' : 'reading manga'; + container.innerHTML = `
No ${label}
`; + return; + } + + list.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)); + + list.forEach(item => { + const card = this.createCard(item, entryType); + container.appendChild(card); + }); + }, + + createCard(item, entryType) { + const el = document.createElement('div'); + el.className = 'card'; + + const nextProgress = (item.progress || 0) + 1; + let url; + + if (entryType === 'ANIME') { + url = item.source === 'anilist' + ? `/watch/${item.entry_id}/${nextProgress}` + : `/watch/${item.entry_id}/${nextProgress}?${item.source}`; + } else { + + url = item.source === 'anilist' + ? `/book/${item.entry_id}?chapter=${nextProgress}` + : `/read/${item.source}/${nextProgress}/${item.entry_id}?source=${item.source}`; + } + + el.onclick = () => window.location.href = url; + + const progressText = item.total_episodes || item.total_chapters + ? `${item.progress || 0}/${item.total_episodes || item.total_chapters}` + : `${item.progress || 0}`; + + const unitLabel = entryType === 'ANIME' ? 'Ep' : 'Ch'; + + el.innerHTML = ` +
+ ${item.title} +
+
+

${item.title}

+

${unitLabel} ${progressText} - ${item.source}

+
+ `; + + return el; + } +}; + +window.ContinueWatchingManager = ContinueWatchingManager; \ No newline at end of file diff --git a/docker/src/scripts/utils/list-modal-manager.js b/docker/src/scripts/utils/list-modal-manager.js new file mode 100644 index 0000000..be33a4a --- /dev/null +++ b/docker/src/scripts/utils/list-modal-manager.js @@ -0,0 +1,226 @@ +const ListModalManager = { + API_BASE: '/api', + currentData: null, + isInList: false, + currentEntry: null, + + STATUS_MAP: { + CURRENT: 'CURRENT', + COMPLETED: 'COMPLETED', + PLANNING: 'PLANNING', + PAUSED: 'PAUSED', + DROPPED: 'DROPPED', + REPEATING: 'REPEATING' + }, + + getEntryType(data) { + if (!data) return 'ANIME'; + if (data.entry_type) return data.entry_type.toUpperCase(); + return 'ANIME'; + }, + + async checkIfInList(entryId, source = 'anilist', entryType) { + if (!AuthUtils.isAuthenticated()) return false; + + const url = `${this.API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`; + + try { + const response = await fetch(url, { + headers: AuthUtils.getSimpleAuthHeaders() + }); + + if (response.ok) { + const data = await response.json(); + this.isInList = data.found && !!data.entry; + this.currentEntry = data.entry || null; + } else { + this.isInList = false; + this.currentEntry = null; + } + + return this.isInList; + } catch (error) { + console.error('Error checking list entry:', error); + return false; + } + }, + + updateButton(buttonSelector = '.hero-buttons .btn-blur') { + const btn = document.querySelector(buttonSelector); + if (!btn) return; + + if (this.isInList) { + btn.innerHTML = ` + + + + In Your ${this.currentData?.format ? 'Library' : '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 ${this.currentData?.format ? 'Library' : 'List'}`; + btn.style.background = null; + btn.style.color = null; + btn.style.borderColor = null; + } + }, + + open(data, source = 'anilist') { + if (!AuthUtils.isAuthenticated()) { + NotificationUtils.error('Please log in to manage your list.'); + return; + } + + this.currentData = data; + const entryType = this.getEntryType(data); + const totalUnits = data.episodes || data.chapters || data.volumes || 999; + + const modalTitle = document.getElementById('modal-title'); + const deleteBtn = document.getElementById('modal-delete-btn'); + const progressLabel = document.querySelector('label[for="entry-progress"]') || + document.getElementById('progress-label'); + + if (this.isInList && this.currentEntry) { + document.getElementById('entry-status').value = this.currentEntry.status || 'PLANNING'; + 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] || ''; + document.getElementById('entry-end-date').value = this.currentEntry.end_date?.split('T')[0] || ''; + document.getElementById('entry-repeat-count').value = this.currentEntry.repeat_count || 0; + document.getElementById('entry-notes').value = this.currentEntry.notes || ''; + document.getElementById('entry-is-private').checked = this.currentEntry.is_private === true || this.currentEntry.is_private === 1; + + modalTitle.textContent = `Edit ${entryType === 'ANIME' ? 'List' : 'Library'} Entry`; + deleteBtn.style.display = 'block'; + } else { + document.getElementById('entry-status').value = 'PLANNING'; + document.getElementById('entry-progress').value = 0; + document.getElementById('entry-score').value = ''; + document.getElementById('entry-start-date').value = ''; + document.getElementById('entry-end-date').value = ''; + document.getElementById('entry-repeat-count').value = 0; + document.getElementById('entry-notes').value = ''; + document.getElementById('entry-is-private').checked = false; + + modalTitle.textContent = `Add to ${entryType === 'ANIME' ? 'List' : 'Library'}`; + deleteBtn.style.display = 'none'; + } + + const statusSelect = document.getElementById('entry-status'); + + [...statusSelect.options].forEach(opt => { + if (opt.value === 'CURRENT') { + opt.textContent = entryType === 'ANIME' ? 'Watching' : 'Reading'; + } + }); + + + if (progressLabel) { + if (entryType === 'ANIME') { + progressLabel.textContent = 'Episodes Watched'; + } else if (entryType === 'MANGA') { + progressLabel.textContent = 'Chapters Read'; + } else { + progressLabel.textContent = 'Volumes/Parts Read'; + } + } + + document.getElementById('entry-progress').max = totalUnits; + document.getElementById('add-list-modal').classList.add('active'); + }, + + close() { + document.getElementById('add-list-modal').classList.remove('active'); + }, + + async save(entryId, source = 'anilist') { + const uiStatus = document.getElementById('entry-status').value; + const status = this.STATUS_MAP[uiStatus] || uiStatus; + const progress = parseInt(document.getElementById('entry-progress').value) || 0; + const scoreValue = document.getElementById('entry-score').value; + const score = scoreValue ? parseFloat(scoreValue) : null; + const start_date = document.getElementById('entry-start-date').value || null; + const end_date = document.getElementById('entry-end-date').value || null; + const repeat_count = parseInt(document.getElementById('entry-repeat-count').value) || 0; + const notes = document.getElementById('entry-notes').value || null; + const is_private = document.getElementById('entry-is-private').checked; + + const entryType = this.getEntryType(this.currentData); + + try { + const response = await fetch(`${this.API_BASE}/list/entry`, { + method: 'POST', + headers: AuthUtils.getAuthHeaders(), + body: JSON.stringify({ + entry_id: entryId, + source, + entry_type: entryType, + status, + progress, + score, + start_date, + end_date, + repeat_count, + notes, + is_private + }) + }); + + if (!response.ok) throw new Error('Failed to save entry'); + + const data = await response.json(); + this.isInList = true; + this.currentEntry = data.entry; + this.updateButton(); + this.close(); + NotificationUtils.success(this.isInList ? 'Updated successfully!' : 'Added to your list!'); + } catch (error) { + console.error('Error saving to list:', error); + NotificationUtils.error('Failed to save. Please try again.'); + } + }, + + async delete(entryId, source = 'anilist') { + if (!confirm(`Remove this ${this.getEntryType(this.currentData).toLowerCase()} from your list?`)) { + return; + } + + const entryType = this.getEntryType(this.currentData); + + try { + const response = await fetch( + `${this.API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`, + { + method: 'DELETE', + headers: AuthUtils.getSimpleAuthHeaders() + } + ); + + if (!response.ok) throw new Error('Failed to delete entry'); + + this.isInList = false; + this.currentEntry = null; + this.updateButton(); + this.close(); + NotificationUtils.success('Removed from your list'); + } catch (error) { + console.error('Error deleting from list:', error); + NotificationUtils.error('Failed to remove. Please try again.'); + } + } +}; + +document.addEventListener('DOMContentLoaded', () => { + const modal = document.getElementById('add-list-modal'); + if (modal) { + modal.addEventListener('click', (e) => { + if (e.target.id === 'add-list-modal') { + ListModalManager.close(); + } + }); + } +}); + +window.ListModalManager = ListModalManager; \ No newline at end of file diff --git a/docker/src/scripts/utils/media-metadata-utils.js b/docker/src/scripts/utils/media-metadata-utils.js new file mode 100644 index 0000000..dc00179 --- /dev/null +++ b/docker/src/scripts/utils/media-metadata-utils.js @@ -0,0 +1,192 @@ +const MediaMetadataUtils = { + + getTitle(data) { + if (!data) return "Unknown Title"; + + if (data.title) { + if (typeof data.title === 'string') return data.title; + return data.title.english || data.title.romaji || data.title.native || "Unknown Title"; + } + + return data.name || "Unknown Title"; + }, + + getDescription(data) { + const rawDesc = data.description || data.summary || "No description available."; + + const tmp = document.createElement("DIV"); + tmp.innerHTML = rawDesc; + return tmp.textContent || tmp.innerText || rawDesc; + }, + + getPosterUrl(data, isExtension = false) { + if (isExtension) { + return data.image || ''; + } + + if (data.coverImage) { + return data.coverImage.extraLarge || data.coverImage.large || data.coverImage.medium || ''; + } + + return data.image || ''; + }, + + getBannerUrl(data, isExtension = false) { + if (isExtension) { + return data.image || ''; + } + + return data.bannerImage || this.getPosterUrl(data, isExtension); + }, + + getScore(data, isExtension = false) { + if (isExtension) { + return data.score ? Math.round(data.score * 10) : '?'; + } + + return data.averageScore || '?'; + }, + + getYear(data, isExtension = false) { + if (isExtension) { + return data.year || data.published || '????'; + } + + if (data.seasonYear) return data.seasonYear; + if (data.startDate?.year) return data.startDate.year; + + return '????'; + }, + + getGenres(data, maxGenres = 3) { + if (!data.genres || !Array.isArray(data.genres)) return ''; + return data.genres.slice(0, maxGenres).join(' β€’ '); + }, + + getSeason(data, isExtension = false) { + if (isExtension) { + return data.season || 'Unknown'; + } + + if (data.season && data.seasonYear) { + return `${data.season} ${data.seasonYear}`; + } + + if (data.startDate?.year && data.startDate?.month) { + const months = ['', 'Winter', 'Winter', 'Spring', 'Spring', 'Spring', + 'Summer', 'Summer', 'Summer', 'Fall', 'Fall', 'Fall', 'Winter']; + const season = months[data.startDate.month] || ''; + return season ? `${season} ${data.startDate.year}` : `${data.startDate.year}`; + } + + return 'Unknown'; + }, + + getStudio(data, isExtension = false) { + if (isExtension) { + return data.studio || "Unknown"; + } + + if (data.studios?.nodes?.[0]?.name) { + return data.studios.nodes[0].name; + } + + if (data.studios?.edges?.[0]?.node?.name) { + return data.studios.edges[0].node.name; + } + + return 'Unknown Studio'; + }, + + getCharacters(data, isExtension = false, maxChars = 5) { + let characters = []; + + if (isExtension) { + characters = data.characters || []; + } else { + if (data.characters?.nodes?.length > 0) { + characters = data.characters.nodes; + } else if (data.characters?.edges?.length > 0) { + characters = data.characters.edges + .filter(edge => edge?.node?.name?.full) + .map(edge => edge.node); + } + } + + return characters.slice(0, maxChars).map(char => ({ + name: char?.name?.full || char?.name || "Unknown", + image: char?.image?.large || char?.image?.medium || null + })); + }, + + getTotalEpisodes(data, isExtension = false) { + if (isExtension) { + return data.episodes || 1; + } + + if (data.nextAiringEpisode?.episode) { + return data.nextAiringEpisode.episode - 1; + } + + return data.episodes || 12; + }, + + truncateDescription(text, maxSentences = 4) { + const tmp = document.createElement("DIV"); + tmp.innerHTML = text; + const cleanText = tmp.textContent || tmp.innerText || ""; + + const sentences = cleanText.match(/[^\.!\?]+[\.!\?]+/g) || [cleanText]; + + if (sentences.length > maxSentences) { + return { + short: sentences.slice(0, maxSentences).join(' ') + '...', + full: text, + isTruncated: true + }; + } + + return { + short: text, + full: text, + isTruncated: false + }; + }, + + formatBookData(data, isExtension = false) { + return { + title: this.getTitle(data), + description: this.getDescription(data), + score: this.getScore(data, isExtension), + year: this.getYear(data, isExtension), + status: data.status || 'Unknown', + format: data.format || (isExtension ? 'LN' : 'MANGA'), + chapters: data.chapters || '?', + volumes: data.volumes || '?', + poster: this.getPosterUrl(data, isExtension), + banner: this.getBannerUrl(data, isExtension), + genres: this.getGenres(data) + }; + }, + + formatAnimeData(data, isExtension = false) { + return { + title: this.getTitle(data), + description: this.getDescription(data), + score: this.getScore(data, isExtension), + year: this.getYear(data, isExtension), + season: this.getSeason(data, isExtension), + status: data.status || 'Unknown', + format: data.format || 'TV', + episodes: this.getTotalEpisodes(data, isExtension), + poster: this.getPosterUrl(data, isExtension), + banner: this.getBannerUrl(data, isExtension), + genres: this.getGenres(data), + studio: this.getStudio(data, isExtension), + characters: this.getCharacters(data, isExtension), + trailer: data.trailer || null + }; + } +}; + +window.MediaMetadataUtils = MediaMetadataUtils; \ No newline at end of file diff --git a/docker/src/scripts/utils/notification-utils.js b/docker/src/scripts/utils/notification-utils.js new file mode 100644 index 0000000..fef13d1 --- /dev/null +++ b/docker/src/scripts/utils/notification-utils.js @@ -0,0 +1,52 @@ +const NotificationUtils = { + show(message, type = 'info') { + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 100px; + right: 20px; + background: ${type === 'success' ? '#22c55e' : type === 'error' ? '#ef4444' : '#8b5cf6'}; + color: white; + padding: 1rem 1.5rem; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + z-index: 9999; + font-weight: 600; + animation: slideInRight 0.3s ease; + `; + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.animation = 'slideOutRight 0.3s ease'; + setTimeout(() => notification.remove(), 300); + }, 3000); + }, + + success(message) { + this.show(message, 'success'); + }, + + error(message) { + this.show(message, 'error'); + }, + + info(message) { + this.show(message, 'info'); + } +}; + +const style = document.createElement('style'); +style.textContent = ` + @keyframes slideInRight { + from { transform: translateX(400px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes slideOutRight { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(400px); opacity: 0; } + } +`; +document.head.appendChild(style); + +window.NotificationUtils = NotificationUtils; \ No newline at end of file diff --git a/docker/src/scripts/utils/pagination-manager.js b/docker/src/scripts/utils/pagination-manager.js new file mode 100644 index 0000000..d16483d --- /dev/null +++ b/docker/src/scripts/utils/pagination-manager.js @@ -0,0 +1,91 @@ +const PaginationManager = { + currentPage: 1, + itemsPerPage: 12, + totalItems: 0, + onPageChange: null, + + init(itemsPerPage = 12, onPageChange = null) { + this.itemsPerPage = itemsPerPage; + this.onPageChange = onPageChange; + this.currentPage = 1; + }, + + setTotalItems(total) { + this.totalItems = total; + }, + + getTotalPages() { + return Math.ceil(this.totalItems / this.itemsPerPage); + }, + + getCurrentPageItems(items) { + const start = (this.currentPage - 1) * this.itemsPerPage; + const end = start + this.itemsPerPage; + return items.slice(start, end); + }, + + getPageRange() { + const start = (this.currentPage - 1) * this.itemsPerPage; + const end = Math.min(start + this.itemsPerPage, this.totalItems); + return { start, end }; + }, + + nextPage() { + if (this.currentPage < this.getTotalPages()) { + this.currentPage++; + if (this.onPageChange) this.onPageChange(); + return true; + } + return false; + }, + + prevPage() { + if (this.currentPage > 1) { + this.currentPage--; + if (this.onPageChange) this.onPageChange(); + return true; + } + return false; + }, + + goToPage(page) { + const totalPages = this.getTotalPages(); + if (page >= 1 && page <= totalPages) { + this.currentPage = page; + if (this.onPageChange) this.onPageChange(); + return true; + } + return false; + }, + + reset() { + this.currentPage = 1; + }, + + renderControls(containerId, pageInfoId, prevBtnId, nextBtnId) { + const container = document.getElementById(containerId); + const pageInfo = document.getElementById(pageInfoId); + const prevBtn = document.getElementById(prevBtnId); + const nextBtn = document.getElementById(nextBtnId); + + if (!container || !pageInfo || !prevBtn || !nextBtn) return; + + const totalPages = this.getTotalPages(); + + if (totalPages <= 1) { + container.style.display = 'none'; + return; + } + + container.style.display = 'flex'; + pageInfo.innerText = `Page ${this.currentPage} of ${totalPages}`; + + prevBtn.disabled = this.currentPage === 1; + nextBtn.disabled = this.currentPage >= totalPages; + + prevBtn.onclick = () => this.prevPage(); + nextBtn.onclick = () => this.nextPage(); + } +}; + +window.PaginationManager = PaginationManager; \ No newline at end of file diff --git a/docker/src/scripts/utils/search-manager.js b/docker/src/scripts/utils/search-manager.js new file mode 100644 index 0000000..5414754 --- /dev/null +++ b/docker/src/scripts/utils/search-manager.js @@ -0,0 +1,176 @@ +const SearchManager = { + availableExtensions: [], + searchTimeout: null, + + init(inputSelector, resultsSelector, type = 'anime') { + const searchInput = document.querySelector(inputSelector); + const searchResults = document.querySelector(resultsSelector); + + if (!searchInput || !searchResults) { + console.error('Search elements not found'); + return; + } + + this.loadExtensions(type); + + searchInput.addEventListener('input', (e) => { + const query = e.target.value; + clearTimeout(this.searchTimeout); + + if (query.length < 2) { + searchResults.classList.remove('active'); + searchResults.innerHTML = ''; + searchInput.style.borderRadius = '99px'; + return; + } + + this.searchTimeout = setTimeout(() => { + this.search(query, type, searchResults); + }, 300); + }); + + document.addEventListener('click', (e) => { + if (!e.target.closest('.search-wrapper')) { + searchResults.classList.remove('active'); + searchInput.style.borderRadius = '99px'; + } + }); + }, + + async loadExtensions(type) { + try { + const endpoint = type === 'book' ? '/api/extensions/book' : '/api/extensions/anime'; + const res = await fetch(endpoint); + const data = await res.json(); + this.availableExtensions = data.extensions || []; + console.log(`${type} extensions loaded:`, this.availableExtensions); + } catch (err) { + console.error('Error loading extensions:', err); + } + }, + + async search(query, type, resultsContainer) { + try { + let apiUrl, extensionName = null, finalQuery = query; + + const parts = query.split(':'); + if (parts.length >= 2) { + const potentialExtension = parts[0].trim().toLowerCase(); + const foundExtension = this.availableExtensions.find( + ext => ext.toLowerCase() === potentialExtension + ); + + if (foundExtension) { + extensionName = foundExtension; + finalQuery = parts.slice(1).join(':').trim(); + + if (finalQuery.length === 0) { + this.renderResults([], resultsContainer, type); + return; + } + } + } + + if (extensionName) { + const endpoint = type === 'book' ? 'books' : ''; + apiUrl = `/api/search/${endpoint ? endpoint + '/' : ''}${extensionName}?q=${encodeURIComponent(finalQuery)}`; + } else { + const endpoint = type === 'book' ? '/api/search/books' : '/api/search'; + apiUrl = `${endpoint}?q=${encodeURIComponent(query)}`; + } + + const res = await fetch(apiUrl); + const data = await res.json(); + + const results = (data.results || []).map(item => ({ + ...item, + isExtensionResult: !!extensionName, + extensionName + })); + + this.renderResults(results, resultsContainer, type); + } catch (err) { + console.error("Search Error:", err); + this.renderResults([], resultsContainer, type); + } + }, + + renderResults(results, container, type) { + container.innerHTML = ''; + + if (!results || results.length === 0) { + container.innerHTML = '
No results found
'; + } else { + results.forEach(item => { + const resultElement = this.createResultElement(item, type); + container.appendChild(resultElement); + }); + } + + container.classList.add('active'); + const searchInput = container.previousElementSibling || document.querySelector('.search-input'); + if (searchInput) { + searchInput.style.borderRadius = '12px 12px 0 0'; + } + }, + + createResultElement(item, type) { + const element = document.createElement('a'); + element.className = 'search-item'; + + if (type === 'book') { + const title = item.title?.english || item.title?.romaji || "Unknown"; + const img = item.coverImage?.medium || item.coverImage?.large || ''; + const rating = Number.isInteger(item.averageScore) ? `${item.averageScore}%` : item.averageScore || 'N/A'; + const year = item.seasonYear || item.startDate?.year || '????'; + const format = item.format || 'MANGA'; + + element.href = item.isExtensionResult + ? `/book/${item.extensionName}/${item.id}` + : `/book/${item.id}`; + + element.innerHTML = ` + ${title} +
+
${title}
+
+ ${rating} + β€’ ${year} + β€’ ${format} +
+
+ `; + } else { + + const title = item.title?.english || item.title?.romaji || "Unknown Title"; + const img = item.coverImage?.medium || item.coverImage?.large || ''; + const rating = item.averageScore ? `${item.averageScore}%` : 'N/A'; + const year = item.seasonYear || ''; + const format = item.format || 'TV'; + + element.href = item.isExtensionResult + ? `/anime/${item.extensionName}/${item.id}` + : `/anime/${item.id}`; + + element.innerHTML = ` + ${title} +
+
${title}
+
+ ${rating} + β€’ ${year} + β€’ ${format} +
+
+ `; + } + + return element; + }, + + getTitle(item) { + return item.title?.english || item.title?.romaji || "Unknown Title"; + } +}; + +window.SearchManager = SearchManager; \ No newline at end of file diff --git a/docker/src/scripts/utils/url-utils.js b/docker/src/scripts/utils/url-utils.js new file mode 100644 index 0000000..57479a8 --- /dev/null +++ b/docker/src/scripts/utils/url-utils.js @@ -0,0 +1,51 @@ +const URLUtils = { + + parseEntityPath(basePath = 'anime') { + const path = window.location.pathname; + const parts = path.split("/").filter(Boolean); + + if (parts[0] !== basePath) { + return null; + } + + if (parts.length === 3) { + + return { + extensionName: parts[1], + entityId: parts[2], + slug: parts[2] + }; + } else if (parts.length === 2) { + + return { + extensionName: null, + entityId: parts[1], + slug: null + }; + } + + return null; + }, + + buildWatchUrl(animeId, episode, extensionName = null) { + const base = `/watch/${animeId}/${episode}`; + return extensionName ? `${base}?${extensionName}` : base; + }, + + buildReadUrl(bookId, chapterId, provider, extensionName = null) { + const c = encodeURIComponent(chapterId); + const p = encodeURIComponent(provider); + const extension = extensionName ? `?source=${extensionName}` : "?source=anilist"; + return `/read/${p}/${c}/${bookId}${extension}`; + }, + + getQueryParams() { + return new URLSearchParams(window.location.search); + }, + + getQueryParam(key) { + return this.getQueryParams().get(key); + } +}; + +window.URLUtils = URLUtils; \ No newline at end of file diff --git a/docker/src/scripts/utils/youtube-player-utils.js b/docker/src/scripts/utils/youtube-player-utils.js new file mode 100644 index 0000000..d4c2169 --- /dev/null +++ b/docker/src/scripts/utils/youtube-player-utils.js @@ -0,0 +1,111 @@ +const YouTubePlayerUtils = { + player: null, + isAPIReady: false, + pendingVideoId: null, + + init(containerId = 'player') { + if (this.isAPIReady) return; + + const tag = document.createElement('script'); + tag.src = "https://www.youtube.com/iframe_api"; + const firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); + + window.onYouTubeIframeAPIReady = () => { + this.isAPIReady = true; + this.createPlayer(containerId); + }; + }, + + createPlayer(containerId, videoId = null) { + if (!this.isAPIReady) { + this.pendingVideoId = videoId; + return; + } + + const config = { + height: '100%', + width: '100%', + playerVars: { + autoplay: 1, + controls: 0, + mute: 1, + loop: 1, + showinfo: 0, + modestbranding: 1, + disablekb: 1 + }, + events: { + onReady: (event) => { + event.target.mute(); + if (this.pendingVideoId) { + this.loadVideo(this.pendingVideoId); + this.pendingVideoId = null; + } else { + event.target.playVideo(); + } + } + } + }; + + if (videoId) { + config.videoId = videoId; + config.playerVars.playlist = videoId; + } + + this.player = new YT.Player(containerId, config); + }, + + loadVideo(videoId) { + if (!this.player || !this.player.loadVideoById) { + this.pendingVideoId = videoId; + return; + } + + this.player.loadVideoById(videoId); + this.player.mute(); + }, + + playTrailer(trailerData, containerId = 'player', fallbackImage = null) { + if (!trailerData || trailerData.site !== 'youtube' || !trailerData.id) { + + if (fallbackImage) { + this.showFallbackImage(containerId, fallbackImage); + } + return false; + } + + if (!this.isAPIReady) { + this.init(containerId); + this.pendingVideoId = trailerData.id; + } else if (this.player) { + this.loadVideo(trailerData.id); + } else { + this.createPlayer(containerId, trailerData.id); + } + + return true; + }, + + showFallbackImage(containerId, imageUrl) { + const container = document.querySelector(`#${containerId}`)?.parentElement; + if (!container) return; + + container.innerHTML = ``; + }, + + stop() { + if (this.player && this.player.stopVideo) { + this.player.stopVideo(); + } + }, + + destroy() { + if (this.player && this.player.destroy) { + this.player.destroy(); + this.player = null; + } + } +}; + +window.YouTubePlayerUtils = YouTubePlayerUtils; \ No newline at end of file diff --git a/docker/src/shared/database.js b/docker/src/shared/database.js new file mode 100644 index 0000000..bfcc1ce --- /dev/null +++ b/docker/src/shared/database.js @@ -0,0 +1,125 @@ +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 databases = new Map(); + +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") +}; + +function initDatabase(name = 'anilist', dbPath = null, readOnly = false) { + if (databases.has(name)) { + return databases.get(name); + } + + const finalPath = dbPath || DEFAULT_PATHS[name] || DEFAULT_PATHS.anilist; + + if (name === "favorites") { + ensureFavoritesDB(finalPath) + .catch(err => console.error("Error creando favorites:", err)); + } + + if (name === "cache") { + const dir = path.dirname(finalPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + + if (name === "userdata") { + ensureUserDataDB(finalPath) + .catch(err => console.error("Error creando userdata:", err)); + } + + const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE); + + const db = new sqlite3.Database(finalPath, mode, (err) => { + if (err) { + console.error(`Database Error (${name}):`, err.message); + } else { + console.log(`Connected to ${name} database at ${finalPath}`); + } + }); + + databases.set(name, db); + + if (name === "anilist") { + ensureAnilistSchema(db) + .then(() => ensureExtensionsTable(db)) + .catch(err => console.error("Error creating anilist schema:", err)); + } + + if (name === "cache") { + ensureCacheTable(db) + .catch(err => console.error("Error creating cache table:", err)); + } + + return db; +} + +function getDatabase(name = 'anilist') { + if (!databases.has(name)) { + const readOnly = (name === 'anilist'); + return initDatabase(name, null, readOnly); + } + return databases.get(name); +} + +function queryOne(sql, params = [], dbName = 'anilist') { + return new Promise((resolve, reject) => { + getDatabase(dbName).get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +} + +function queryAll(sql, params = [], dbName = 'anilist') { + return new Promise((resolve, reject) => { + getDatabase(dbName).all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); +} + +function run(sql, params = [], dbName = 'anilist') { + return new Promise((resolve, reject) => { + getDatabase(dbName).run(sql, params, function(err) { + if (err) reject(err); + else resolve({ changes: this.changes, lastID: this.lastID }); + }); + }); +} + +function closeDatabase(name = null) { + if (name) { + const db = databases.get(name); + if (db) { + db.close(); + databases.delete(name); + console.log(`Closed ${name} database`); + } + } else { + for (const [dbName, db] of databases) { + db.close(); + console.log(`Closed ${dbName} database`); + } + databases.clear(); + } +} + +module.exports = { + initDatabase, + getDatabase, + queryOne, + queryAll, + run, + closeDatabase +}; \ No newline at end of file diff --git a/docker/src/shared/extensions.js b/docker/src/shared/extensions.js new file mode 100644 index 0000000..d59b4a6 --- /dev/null +++ b/docker/src/shared/extensions.js @@ -0,0 +1,209 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const cheerio = require("cheerio"); +const { queryAll, run } = require('./database'); +const { scrape } = require("./headless"); + +const extensions = new Map(); + +async function loadExtensions() { + const homeDir = os.homedir(); + const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions'); + + if (!fs.existsSync(extensionsDir)) { + console.log("πŸ“ Extensions directory not found, creating..."); + fs.mkdirSync(extensionsDir, { recursive: true }); + } + + try { + const files = await fs.promises.readdir(extensionsDir); + + for (const file of files) { + if (file.endsWith('.js')) { + await loadExtension(file); + } + } + + console.log(`βœ… Loaded ${extensions.size} extensions`); + + try { + const loaded = Array.from(extensions.keys()); + const rows = await queryAll("SELECT DISTINCT ext_name FROM extension"); + + for (const row of rows) { + if (!loaded.includes(row.ext_name)) { + console.log(`🧹 Cleaning cached metadata for removed extension: ${row.ext_name}`); + await run("DELETE FROM extension WHERE ext_name = ?", [row.ext_name]); + } + } + } catch (err) { + console.error("❌ Error cleaning extension cache:", err); + } + + } catch (err) { + console.error("❌ Extension Scan Error:", err); + } +} + + +async function loadExtension(fileName) { + const homeDir = os.homedir(); + const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions'); + const filePath = path.join(extensionsDir, fileName); + + if (!fs.existsSync(filePath)) { + console.warn(`⚠️ Extension not found: ${fileName}`); + return; + } + + try { + delete require.cache[require.resolve(filePath)]; + + const ExtensionClass = require(filePath); + + const instance = typeof ExtensionClass === 'function' + ? new ExtensionClass() + : (ExtensionClass.default ? new ExtensionClass.default() : null); + + if (!instance) { + console.warn(`⚠️ Invalid extension: ${fileName}`); + return; + } + + if (!["anime-board", "book-board", "image-board"].includes(instance.type)) { + console.warn(`⚠️ Invalid extension (${instance.type}): ${fileName}`); + return; + } + + const name = instance.constructor.name; + instance.scrape = scrape; + instance.cheerio = cheerio; + extensions.set(name, instance); + + console.log(`πŸ“¦ Installed & loaded: ${name}`); + return name; + + } catch (err) { + console.warn(`⚠️ Error loading ${fileName}: ${err.message}`); + } +} + +const https = require('https'); + +async function saveExtensionFile(fileName, downloadUrl) { + const homeDir = os.homedir(); + const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions'); + const filePath = path.join(extensionsDir, fileName); + const fullUrl = downloadUrl; + + if (!fs.existsSync(extensionsDir)) { + fs.mkdirSync(extensionsDir, { recursive: true }); + } + + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(filePath); + + https.get(fullUrl, async (response) => { + if (response.statusCode !== 200) { + return reject(new Error(`Download failed: ${response.statusCode}`)); + } + + response.pipe(file); + + file.on('finish', async () => { + file.close(async () => { + try { + await loadExtension(fileName); + resolve(); + } catch (err) { + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath); + } + reject(new Error(`Load failed, file rolled back: ${err.message}`)); + } + }); + }); + }).on('error', async (err) => { + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath); + } + reject(err); + }); + }); +} + +async function deleteExtensionFile(fileName) { + const homeDir = os.homedir(); + const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions'); + const filePath = path.join(extensionsDir, fileName); + + const extName = fileName.replace(".js", ""); + + for (const key of extensions.keys()) { + if (key.toLowerCase() === extName) { + extensions.delete(key); + console.log(`πŸ—‘οΈ Removed from memory: ${key}`); + break; + } + } + + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath); + console.log(`πŸ—‘οΈ Deleted file: ${fileName}`); + } +} + +function getExtension(name) { + return extensions.get(name); +} + +function getAllExtensions() { + return extensions; +} + +function getExtensionsList() { + return Array.from(extensions.keys()); +} + +function getAnimeExtensionsMap() { + const animeExts = new Map(); + for (const [name, ext] of extensions) { + if (ext.type === 'anime-board') { + animeExts.set(name, ext); + } + } + return animeExts; +} + +function getBookExtensionsMap() { + const bookExts = new Map(); + for (const [name, ext] of extensions) { + if (ext.type === 'book-board' || ext.type === 'manga-board') { + bookExts.set(name, ext); + } + } + return bookExts; +} + +function getGalleryExtensionsMap() { + const galleryExts = new Map(); + for (const [name, ext] of extensions) { + if (ext.type === 'image-board') { + galleryExts.set(name, ext); + } + } + return galleryExts; +} + +module.exports = { + loadExtensions, + getExtension, + getAllExtensions, + getExtensionsList, + getAnimeExtensionsMap, + getBookExtensionsMap, + getGalleryExtensionsMap, + saveExtensionFile, + deleteExtensionFile +}; \ No newline at end of file diff --git a/docker/src/shared/headless.js b/docker/src/shared/headless.js new file mode 100644 index 0000000..8dee9d5 --- /dev/null +++ b/docker/src/shared/headless.js @@ -0,0 +1,117 @@ +const { chromium } = require("playwright-chromium"); +let browser; +let context; +const BLOCK_LIST = [ + "google-analytics", "doubleclick", "facebook", "twitter", + "adsystem", "analytics", "tracker", "pixel", "quantserve", "newrelic" +]; +async function initHeadless() { + if (browser) return; + browser = await chromium.launch({ + headless: true, + 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 +}; \ No newline at end of file diff --git a/docker/src/shared/queries.js b/docker/src/shared/queries.js new file mode 100644 index 0000000..7fbcd07 --- /dev/null +++ b/docker/src/shared/queries.js @@ -0,0 +1,62 @@ +const { queryOne, run } = require('./database'); + +async function getCachedExtension(extName, id) { + return queryOne( + "SELECT metadata FROM extension WHERE ext_name = ? AND id = ?", + [extName, id] + ); +} + +async function cacheExtension(extName, id, title, metadata) { + return run( + ` + INSERT INTO extension (ext_name, id, title, metadata, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(ext_name, id) + DO UPDATE SET + title = excluded.title, + metadata = excluded.metadata, + updated_at = ? + `, + [extName, id, title, JSON.stringify(metadata), Date.now(), Date.now()] + ); +} + +async function getExtensionTitle(extName, id) { + const sql = "SELECT title FROM extension WHERE ext_name = ? AND id = ?"; + const row = await queryOne(sql, [extName, id], 'anilist'); + return row ? row.title : null; +} + +async function deleteExtension(extName) { + return run( + "DELETE FROM extension WHERE ext_name = ?", + [extName] + ); +} + +async function getCache(key) { + return queryOne("SELECT result, created_at, ttl_ms FROM cache WHERE key = ?", [key], "cache"); +} + +async function setCache(key, result, ttl_ms) { + return run( + ` + INSERT INTO cache (key, result, created_at, ttl_ms) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) + DO UPDATE SET result = excluded.result, created_at = excluded.created_at, ttl_ms = excluded.ttl_ms + `, + [key, JSON.stringify(result), Date.now(), ttl_ms], + "cache" + ); +} + +module.exports = { + getCachedExtension, + cacheExtension, + getExtensionTitle, + deleteExtension, + getCache, + setCache +}; \ No newline at end of file diff --git a/docker/src/shared/schemas.js b/docker/src/shared/schemas.js new file mode 100644 index 0000000..30676fa --- /dev/null +++ b/docker/src/shared/schemas.js @@ -0,0 +1,234 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require("path"); +const fs = require("fs"); + +async function ensureUserDataDB(dbPath) { + const dir = path.dirname(dbPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const db = new sqlite3.Database( + dbPath, + sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE + ); + + return new Promise((resolve, reject) => { + const schema = ` + CREATE TABLE IF NOT EXISTS User ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + profile_picture_url TEXT, + email TEXT UNIQUE, + password_hash TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS UserIntegration ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + platform TEXT NOT NULL DEFAULT 'AniList', + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + token_type TEXT NOT NULL, + anilist_user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS ListEntry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + entry_id INTEGER NOT NULL, + source TEXT NOT NULL, + entry_type TEXT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + score INTEGER, + + start_date DATE, + end_date DATE, + repeat_count INTEGER NOT NULL DEFAULT 0, + notes TEXT, + is_private BOOLEAN NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, entry_id), + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE + ); + `; + + db.exec(schema, (err) => { + if (err) reject(err); + else resolve(true); + }); + }); +} + +async function ensureAnilistSchema(db) { + return new Promise((resolve, reject) => { + const schema = ` + CREATE TABLE IF NOT EXISTS anime ( + id INTEGER PRIMARY KEY, + title TEXT, + updatedAt INTEGER, + cache_created_at INTEGER DEFAULT 0, + cache_ttl_ms INTEGER DEFAULT 0, + full_data JSON + ); + + CREATE TABLE IF NOT EXISTS trending ( + rank INTEGER PRIMARY KEY, + id INTEGER, + full_data JSON, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS top_airing ( + rank INTEGER PRIMARY KEY, + id INTEGER, + full_data JSON, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS books ( + id INTEGER PRIMARY KEY, + title TEXT, + updatedAt INTEGER, + full_data JSON + ); + + CREATE TABLE IF NOT EXISTS trending_books ( + rank INTEGER PRIMARY KEY, + id INTEGER, + full_data JSON, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS popular_books ( + rank INTEGER PRIMARY KEY, + id INTEGER, + full_data JSON, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + `; + + db.exec(schema, (err) => { + if (err) reject(err); + else resolve(true); + }); + }); +} + +async function ensureExtensionsTable(db) { + return new Promise((resolve, reject) => { + db.exec(` + CREATE TABLE IF NOT EXISTS extension ( + ext_name TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL, + metadata TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(ext_name, id) + ); + `, (err) => { + if (err) reject(err); + else resolve(true); + }); + }); +} + +async function ensureCacheTable(db) { + return new Promise((resolve, reject) => { + db.exec(` + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + result TEXT NOT NULL, + created_at INTEGER NOT NULL, + ttl_ms INTEGER NOT NULL + ); + `, (err) => { + if (err) reject(err); + else resolve(true); + }); + }); +} + +function ensureFavoritesDB(dbPath) { + const dir = path.dirname(dbPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const exists = fs.existsSync(dbPath); + + const db = new sqlite3.Database( + dbPath, + sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE + ); + + return new Promise((resolve, reject) => { + if (!exists) { + const schema = ` + CREATE TABLE IF NOT EXISTS favorites ( + id TEXT NOT NULL, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + image_url TEXT NOT NULL, + thumbnail_url TEXT NOT NULL DEFAULT "", + tags TEXT NOT NULL DEFAULT "", + headers TEXT NOT NULL DEFAULT "", + provider TEXT NOT NULL DEFAULT "", + PRIMARY KEY (id, user_id) + ); + `; + + db.exec(schema, (err) => { + if (err) reject(err); + else resolve(true); + }); + + return; + } + + db.all(`PRAGMA table_info(favorites)`, (err, cols) => { + if (err) return reject(err); + + const hasHeaders = cols.some(c => c.name === "headers"); + const hasProvider = cols.some(c => c.name === "provider"); + const hasUserId = cols.some(c => c.name === "user_id"); + + const queries = []; + + if (!hasHeaders) { + queries.push(`ALTER TABLE favorites ADD COLUMN headers TEXT NOT NULL DEFAULT ""`); + } + + if (!hasProvider) { + queries.push(`ALTER TABLE favorites ADD COLUMN provider TEXT NOT NULL DEFAULT ""`); + } + + if (!hasUserId) { + queries.push(`ALTER TABLE favorites ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1`); + } + + if (queries.length === 0) { + return resolve(false); + } + + db.exec(queries.join(";"), (err) => { + if (err) reject(err); + else resolve(true); + }); + }); + }); +} + +module.exports = { + ensureUserDataDB, + ensureAnilistSchema, + ensureExtensionsTable, + ensureCacheTable, + ensureFavoritesDB +}; \ No newline at end of file diff --git a/docker/src/views/views.routes.ts b/docker/src/views/views.routes.ts new file mode 100644 index 0000000..5548636 --- /dev/null +++ b/docker/src/views/views.routes.ts @@ -0,0 +1,82 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import * as fs from 'fs'; +import * as path from 'path'; + +async function viewsRoutes(fastify: FastifyInstance) { + fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'users.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/my-list', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'list.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/books', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'books.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/schedule', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'schedule.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/gallery', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'gallery.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/marketplace', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'marketplace.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/gallery/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/gallery/favorites/*', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/anime/:id', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/anime/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/book/:extension/*', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html')); + reply.type('text/html').send(stream); + }); + + fastify.get('/read/:provider/:chapter/*', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'read.html')); + reply.type('text/html').send(stream); + }); +} + +export default viewsRoutes; \ No newline at end of file diff --git a/docker/tsconfig.json b/docker/tsconfig.json new file mode 100644 index 0000000..d1bc8c1 --- /dev/null +++ b/docker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "CommonJS", + "allowJs": true, + "checkJs": false, + "strict": true, + "outDir": "electron", + "rootDir": "src", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/docker/views/anime/anime.html b/docker/views/anime/anime.html new file mode 100644 index 0000000..4d61863 --- /dev/null +++ b/docker/views/anime/anime.html @@ -0,0 +1,224 @@ + + + + + + + WaifuBoard + + + + + + + + + +
+
+ + WaifuBoard +
+
+ + + +
+
+ + + + + + + + Back to Home + + +
+
+
+
+
+
+ +
+ + +
+
+

Loading...

+ +
+ +
--% Score
+
----
+
Action
+
+ +
+ + +
+
+ +
+
+ +
+ +
+
+
+

Episodes

+
+
+ +
+
+ +
+ +
+ + Page 1 of 1 + +
+
+
+
+ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/views/anime/animes.html b/docker/views/anime/animes.html new file mode 100644 index 0000000..0a0d16d --- /dev/null +++ b/docker/views/anime/animes.html @@ -0,0 +1,265 @@ + + + + + + WaifuBoard + + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ + + +
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ + +
+
+
+ + +
+
+
+
Continue watching
+
+ +
+ + +
+
Trending This Season
+ +
+ + +
+
Top Airing Now
+ +
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/views/anime/watch.html b/docker/views/anime/watch.html new file mode 100644 index 0000000..77d87cc --- /dev/null +++ b/docker/views/anime/watch.html @@ -0,0 +1,197 @@ + + + + + + WaifuBoard Watch + + + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ + +
+ + + + + Back to Series + +
+ +
+
+ +
+
+
+ Anime Cover +
+
+

Loading...

+
+ -- + -- + -- +
+
+

Loading description...

+
+
+
+ +
+ +
+
+
+
+
Sub
+
Dub
+
+
+
+ + +
+
+ +
+ +
+
+

Select a source...

+
+
+ +
+
+

Loading...

+

Episode --

+
+ +
+ + +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/docker/views/books/book.html b/docker/views/books/book.html new file mode 100644 index 0000000..e75e682 --- /dev/null +++ b/docker/views/books/book.html @@ -0,0 +1,218 @@ + + + + + + + WaifuBoard Book + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ + + + + Back to Books + + + +
+
+ +
+
+
+ +
+ + + + + +
+
+

Loading...

+ +
+ +
--% Score
+
Action
+
+ +
+ + +
+
+ +
+
+

Chapters

+
+ + +
+
+ +
+ + + + + + + + + + + + + +
#TitleProviderAction
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/views/books/books.html b/docker/views/books/books.html new file mode 100644 index 0000000..6496492 --- /dev/null +++ b/docker/views/books/books.html @@ -0,0 +1,234 @@ + + + + + + WaifuBoard Books + + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+

Loading...

+
+ + + +
+

+
+ + +
+
+
+
+ + +
+
+
+
Continue Reading
+
+ +
+ +
+
Trending Books
+ +
+ +
+
All Time Popular
+ +
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/views/books/read.html b/docker/views/books/read.html new file mode 100644 index 0000000..878408f --- /dev/null +++ b/docker/views/books/read.html @@ -0,0 +1,208 @@ + + + + + + Reader + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ +
+ + +
+ + Loading... + +
+ + +
+ + + +
+ +
+
+
+ Loading chapter... +
+
+ + + + + + + \ No newline at end of file diff --git a/docker/views/css/anime/anime.css b/docker/views/css/anime/anime.css new file mode 100644 index 0000000..8fa4f50 --- /dev/null +++ b/docker/views/css/anime/anime.css @@ -0,0 +1,297 @@ +.video-background { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(1.35); + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; + opacity: 0.6; +} + +.content-container { + position: relative; + z-index: 10; + max-width: 1600px; + margin: -350px auto 0 auto; + padding: 0 3rem 4rem 3rem; + display: grid; + grid-template-columns: 280px 1fr; + gap: 3rem; + animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1); +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.poster-card { + width: 100%; + aspect-ratio: 2/3; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8); + border: 1px solid rgba(255,255,255,0.1); +} + +.poster-card img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.info-grid { + background: var(--color-bg-elevated); + border: 1px solid rgba(255,255,255,0.05); + border-radius: var(--radius-md); + padding: 1.5rem; + display: flex; + flex-direction: column; + 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); } + +.character-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.character-item { display: flex; align-items: center; gap: 0.75rem; font-size: 0.95rem; } +.char-dot { width: 6px; height: 6px; background: var(--color-primary); border-radius: 50%; } + +.main-content { + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.anime-header { + margin-bottom: 2rem; +} + +.anime-title { + font-size: 4rem; + font-weight: 900; + line-height: 1; + margin: 0 0 1.5rem 0; + text-shadow: 0 4px 30px rgba(0,0,0,0.8); +} + +.meta-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.pill { + padding: 0.5rem 1.25rem; + background: rgba(255,255,255,0.1); + backdrop-filter: blur(10px); + border: 1px solid rgba(255,255,255,0.1); + border-radius: var(--radius-full); + font-weight: 600; + font-size: 0.95rem; +} +.pill.score { background: rgba(34, 197, 94, 0.2); color: #4ade80; border-color: rgba(34, 197, 94, 0.2); } + +.action-row { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +.btn-watch { + padding: 1rem 3rem; + background: var(--color-text-primary); + color: var(--color-bg-base); + border-radius: var(--radius-full); + font-weight: 800; + font-size: 1.1rem; + border: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.75rem; + transition: transform 0.2s, box-shadow 0.2s; +} + +.btn-watch:hover { + transform: scale(1.05); + box-shadow: 0 0 30px rgba(255, 255, 255, 0.25); +} + +.btn-secondary { + padding: 1rem 2rem; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + color: white; + border-radius: var(--radius-full); + font-weight: 700; + font-size: 1rem; + border: 1px solid rgba(255,255,255,0.2); + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; +} +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.05); +} + +.description-box { + margin-top: 3rem; + font-size: 1.15rem; + line-height: 1.8; + color: #e4e4e7; + max-width: 900px; + background: rgba(255,255,255,0.03); + padding: 2rem; + border-radius: var(--radius-md); + border: 1px solid rgba(255,255,255,0.05); +} + +.episodes-section { + margin-top: 4rem; +} +.section-title { font-size: 1.8rem; font-weight: 800; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.8rem; } +.section-title::before { content: ''; width: 4px; height: 28px; background: var(--color-primary); border-radius: 2px; } + +.episodes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; +} + +.episode-btn { + background: var(--color-bg-elevated); + border: 1px solid rgba(255,255,255,0.1); + padding: 1.25rem 1rem; + border-radius: var(--radius-md); + cursor: pointer; + transition: 0.2s; + text-align: center; + font-weight: 600; + color: var(--text-secondary); +} + +.episode-btn:hover { + background: var(--color-bg-elevated-hover); + color: white; + transform: translateY(-3px); + border-color: var(--color-primary); +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(60px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (max-width: 1024px) { + .content-container { + grid-template-columns: 1fr; + margin-top: -100px; + padding: 0 1.5rem 4rem 1.5rem; + } + .poster-card { width: 220px; margin: 0 auto; box-shadow: 0 10px 30px rgba(0,0,0,0.5); } + .main-content { text-align: center; align-items: center; } + .anime-title { font-size: 2.5rem; } + .meta-row { justify-content: center; } + .sidebar { display: none; } +} + +.read-more-btn { + background: none; + border: none; + color: #8b5cf6; + cursor: pointer; + font-weight: 600; + padding: 0; + margin-top: 0.5rem; + font-size: 0.95rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.read-more-btn:hover { text-decoration: underline; } + +.episodes-header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} +.episodes-header-row h2 { + margin: 0; + font-size: 1.8rem; + border-left: 4px solid #8b5cf6; + padding-left: 1rem; +} +.episode-search-wrapper { + position: relative; + display: flex; + align-items: center; +} +.episode-search-input { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 99px; + padding: 0.6rem 1rem; + color: white; + width: 140px; + text-align: center; + font-family: inherit; + transition: 0.2s; + -moz-appearance: textfield; +} +.episode-search-input:focus { + border-color: #8b5cf6; + background: rgba(255, 255, 255, 0.1); + outline: none; +} + +.episode-search-input::-webkit-outer-spin-button, +.episode-search-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } + +.pagination-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} +.page-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: white; + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + transition: 0.2s; + font-weight: 500; +} +.page-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + border-color: #8b5cf6; +} +.page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.page-info { + color: #a1a1aa; + font-size: 0.9rem; + font-weight: 500; +} \ No newline at end of file diff --git a/docker/views/css/anime/watch.css b/docker/views/css/anime/watch.css new file mode 100644 index 0000000..27bddfd --- /dev/null +++ b/docker/views/css/anime/watch.css @@ -0,0 +1,603 @@ +.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: 4.5rem 1rem; } + + .episode-carousel-compact-list { + padding: var(--spacing-sm) var(--spacing-md); + } + .carousel-header { + padding: 0 var(--spacing-md); + } + .carousel-item { + flex: 0 0 180px; + height: 100px; + } + .carousel-item-img-container { height: 60px; } + .carousel-item-info p { font-size: 0.95rem; } + .carousel-item.no-thumbnail { + flex: 0 0 140px; + height: 80px; + } + + .details-container { flex-direction: column; text-align: center; } + + .details-cover { + align-items: center; + width: 100%; + } + .details-cover h1 { + font-size: 2rem; + text-align: center; + } + .cover-image { width: 180px; margin: 0 auto; } + + .episode-controls { flex-direction: column; gap: var(--spacing-md); } + .navigation-buttons { width: 100%; justify-content: center; } + .nav-btn { flex: 1; justify-content: center; } +} + +@media (max-width: 480px) { + .episode-info h1, .details-content h1 { font-size: 1.5rem; } + + .carousel-item { + flex: 0 0 150px; + height: 90px; + } + .carousel-item-img-container { height: 50px; } + .carousel-item-info p { font-size: 0.9rem; } + .carousel-item.no-thumbnail { + flex: 0 0 120px; + height: 70px; + } + + .details-cover h1 { + font-size: 1.5rem; + } + + .nav-btn span { display: none; } + + .character-card { + + flex: 1 1 100%; + } +} \ No newline at end of file diff --git a/docker/views/css/books/book.css b/docker/views/css/books/book.css new file mode 100644 index 0000000..3e3c042 --- /dev/null +++ b/docker/views/css/books/book.css @@ -0,0 +1,194 @@ +.back-btn { + position: fixed; + top: 2rem; left: 2rem; z-index: 100; + display: flex; align-items: center; gap: 0.5rem; + padding: 0.8rem 1.5rem; + background: var(--color-glass-bg); backdrop-filter: blur(12px); + border: var(--border-subtle); border-radius: var(--radius-full); + color: white; text-decoration: none; font-weight: 600; + transition: all 0.2s ease; +} +.back-btn:hover { background: rgba(255, 255, 255, 0.15); transform: translateX(-5px); } + +.hero-wrapper { + position: relative; width: 100%; height: 60vh; overflow: hidden; +} +.hero-background { position: absolute; inset: 0; z-index: 0; } +.hero-background img { width: 100%; height: 100%; object-fit: cover; opacity: 0.4; filter: blur(8px); transform: scale(1.1); } +.hero-overlay { + position: absolute; inset: 0; z-index: 1; + background: linear-gradient(to bottom, transparent 0%, var(--color-bg-base) 100%); +} + +.content-container { + position: relative; z-index: 10; + max-width: 1600px; margin: -350px auto 0 auto; + padding: 0 3rem 4rem 3rem; + display: grid; + grid-template-columns: 260px 1fr; + gap: 3rem; + align-items: flex-start; + animation: slideUp 0.8s ease; +} + +.hero-content { display: none; } + +.sidebar { + display: flex; + flex-direction: column; + gap: 1.5rem; + position: sticky; + top: calc(var(--nav-height) + 2rem); + align-self: flex-start; + z-index: 20; +} + +.poster-card { + width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-lg); + overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.8); + border: 1px solid rgba(255,255,255,0.1); + background: #1a1a1a; +} +.poster-card img { width: 100%; height: 100%; object-fit: cover; } + +.info-grid { + background: var(--color-bg-elevated); border: var(--border-subtle); + border-radius: var(--radius-md); padding: 1.25rem; + display: flex; flex-direction: column; gap: 1rem; +} +.info-item h4 { margin: 0 0 0.25rem 0; font-size: 0.8rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; } +.info-item span { font-weight: 600; font-size: 0.95rem; } + +.main-content { + display: flex; flex-direction: column; + padding-top: 4rem; + justify-content: flex-start; +} + +.book-header { margin-bottom: 1.5rem; } +.book-title { font-size: 3.5rem; font-weight: 900; line-height: 1.1; margin: 0 0 1rem 0; text-shadow: 0 4px 30px rgba(0,0,0,0.8); } + +.meta-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; } +.pill { padding: 0.4rem 1rem; background: rgba(255,255,255,0.1); border-radius: 99px; font-size: 0.9rem; font-weight: 600; border: var(--border-subtle); backdrop-filter: blur(10px); } +.pill.score { background: rgba(34, 197, 94, 0.2); color: #4ade80; border-color: rgba(34, 197, 94, 0.2); } + +#description { display: none; } +#year { display: none; } + +.action-row { display: flex; gap: 1rem; } +.btn-primary { + padding: 0.8rem 2rem; background: white; color: black; border: none; border-radius: 99px; + font-weight: 800; cursor: pointer; transition: transform 0.2s; +} +.btn-primary:hover { transform: scale(1.05); } + +.btn-secondary { + padding: 0.8rem 2rem; + background: rgba(255,255,255,0.1); + color: white; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 99px; + font-weight: 700; + cursor: pointer; + transition: 0.2s; + backdrop-filter: blur(10px); +} +.btn-secondary:hover { + background: rgba(255,255,255,0.2); +} + +.btn-blur { + padding: 0.8rem 2rem; + background: rgba(255,255,255,0.1); + color: white; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 99px; + font-weight: 700; + cursor: pointer; + transition: 0.2s; + backdrop-filter: blur(10px); +} +.btn-blur:hover { background: rgba(255,255,255,0.2); } + +.chapters-section { margin-top: 1rem; } +.section-title { display: flex; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 0.8rem; margin-bottom: 1.5rem; } +.section-title h2 { font-size: 1.5rem; margin: 0; border-left: 4px solid var(--color-primary); padding-left: 1rem; } + +.chapters-table-wrapper { + background: var(--color-bg-elevated); border-radius: var(--radius-md); + border: 1px solid rgba(255,255,255,0.05); overflow: hidden; +} +.chapters-table { width: 100%; border-collapse: collapse; text-align: left; } +.chapters-table th { + padding: 0.8rem 1.2rem; background: rgba(255,255,255,0.03); + color: var(--color-text-secondary); font-weight: 600; font-size: 0.85rem; + text-transform: uppercase; letter-spacing: 0.5px; +} +.chapters-table td { + padding: 1rem 1.2rem; border-bottom: 1px solid rgba(255,255,255,0.05); + color: var(--color-text-primary); font-size: 0.95rem; +} +.chapters-table tr:last-child td { border-bottom: none; } +.chapters-table tr:hover { background: var(--color-bg-elevated-hover); } + +.filter-select { + appearance: none; + -webkit-appearance: none; + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); + border: 1px solid rgba(255,255,255,0.1); + padding: 0.5rem 2rem 0.5rem 1rem; + border-radius: 99px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + outline: none; + background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; +} + +.filter-select:hover { + border-color: var(--color-primary); + background-color: var(--color-bg-elevated-hover); +} + +.filter-select option { + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); +} + +.read-btn-small { + background: var(--color-primary); color: white; border: none; + padding: 0.4rem 0.9rem; border-radius: 6px; font-weight: 600; cursor: pointer; + font-size: 0.8rem; transition: 0.2s; +} +.read-btn-small:hover { background: #7c3aed; } + +.pagination-controls { + display: flex; justify-content: center; gap: 1rem; margin-top: 1.5rem; align-items: center; +} +.page-btn { + background: var(--color-bg-elevated); border: 1px solid rgba(255,255,255,0.1); + color: white; padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem; +} +.page-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.page-btn:hover:not(:disabled) { border-color: var(--color-primary); } + +@keyframes slideUp { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } } + +@media (max-width: 1024px) { + .hero-wrapper { height: 40vh; } + .content-container { grid-template-columns: 1fr; margin-top: -80px; padding: 0 1.5rem 4rem 1.5rem; } + .poster-card { display: none; } + + .main-content { padding-top: 0; align-items: center; text-align: center; } + .book-title { font-size: 2.2rem; } + .meta-row { justify-content: center; } + .action-row { justify-content: center; width: 100%; } + .btn-primary, .btn-blur { flex: 1; justify-content: center; } + + .sidebar { display: none; } + .chapters-table th:nth-child(3), .chapters-table td:nth-child(3) { display: none; } + .chapters-table th:nth-child(4), .chapters-table td:nth-child(4) { display: none; } +} \ No newline at end of file diff --git a/docker/views/css/books/reader.css b/docker/views/css/books/reader.css new file mode 100644 index 0000000..a616a21 --- /dev/null +++ b/docker/views/css/books/reader.css @@ -0,0 +1,547 @@ +:root { + --bg-surface: #14141b; + --bg-elevated: #1c1c26; + --bg-hover: #252530; +} + +.hidden { display: none !important; } + +.top-bar { + position: fixed; + top: 0; left: 0; right: 0; + height: 64px; + background: rgba(10, 10, 15, 0.85); + backdrop-filter: blur(20px) saturate(180%); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1.5rem; + z-index: 1000; + box-shadow: var(--shadow-sm); +} + +.glass-btn { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + color: var(--color-text-primary); + padding: 0.625rem 1.25rem; + border-radius: var(--radius-full); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.glass-btn:hover { + background: var(--bg-hover); + border-color: var(--color-primary); + transform: translateY(-1px); +} + +.glass-btn:active { + transform: translateY(0); +} + +.chapter-info { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.nav-arrow { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 1.25rem; + cursor: pointer; + transition: all 0.2s; + color: var(--color-text-primary); + display: flex; + align-items: center; + justify-content: center; +} + +.nav-arrow:hover { + background: var(--color-primary); + border-color: var(--color-primary); + transform: scale(1.05); +} + +.nav-arrow:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +#reader { + margin-top: 64px; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + min-height: calc(100vh - 64px); + width: 100%; +} + +.manga-container { + width: 100%; + max-width: var(--manga-max-width, 1200px); + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--page-spacing, 16px); +} + +.page-img { + width: 100%; + max-width: var(--page-max-width, 900px); + height: auto; + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; + display: block; +} + +.page-img:hover { + box-shadow: 0 24px 56px rgba(0, 0, 0, 0.6); +} + +.page-img.zoomed { + position: fixed; + top: 64px; + left: 0; + right: 0; + bottom: 0; + max-width: 100vw; + max-height: calc(100vh - 64px); + width: auto; + height: auto; + margin: auto; + z-index: 999; + cursor: zoom-out; + border-radius: 0; + object-fit: contain; +} + +.zoom-overlay { + position: fixed; + top: 64px; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.95); + z-index: 998; + cursor: zoom-out; +} + +.double-container { + display: flex; + gap: var(--page-spacing, 16px); + width: 100%; + max-width: var(--manga-max-width, 1400px); + justify-content: center; +} + +.double-container img { + width: 48%; + max-width: 700px; + height: auto; + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + object-fit: contain; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.double-container img:hover { + box-shadow: 0 24px 56px rgba(0, 0, 0, 0.6); +} + +.ln-content { + max-width: var(--ln-max-width, 750px); + width: 100%; + margin: 0 auto; + padding: 3rem 2.5rem; + line-height: var(--ln-line-height, 1.8); + font-size: var(--ln-font-size, 18px); + font-family: var(--ln-font-family, 'Georgia', serif); + color: var(--ln-text-color, #e5e7eb); + text-align: var(--ln-text-align, left); +} + +.ln-content p { + margin-bottom: 1.5em; +} + +.ln-content h1, .ln-content h2, .ln-content h3 { + margin-top: 2em; + margin-bottom: 1em; + font-weight: 700; +} + +.settings-panel { + position: fixed; + right: 0; + top: 0; + bottom: 0; + width: 400px; + padding: 0; + z-index: 1001; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow-y: auto; + display: flex; + flex-direction: column; + background: var(--bg-surface); + border-left: 1px solid var(--border-subtle); + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.6); +} + +.settings-panel.open { + transform: translateX(0); +} + +.panel-header { + position: sticky; + top: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + z-index: 10; + background: #0a0a0f; + border-bottom: 1px solid var(--border-subtle); +} + +.panel-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; +} + +.close-btn { + width: 32px; + height: 32px; + border-radius: 50%; + font-size: 1.25rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated); + border: none; + color: var(--color-text-secondary); +} + +.close-btn:hover { + background: var(--color-primary); + color: var(--color-text-primary); + transform: rotate(90deg); +} + +.panel-content { + flex: 1; + padding: 1.5rem; + overflow-y: auto; +} + +.settings-section { + margin-bottom: 2rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border-subtle); +} + +.settings-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.settings-section h4 { + margin: 0 0 1.25rem 0; + color: var(--color-text-primary); + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.control { + margin-bottom: 1.25rem; +} + +.control:last-child { + margin-bottom: 0; +} + +.control label { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.625rem; + font-size: 0.875rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +.control label span { + color: var(--color-text-primary); + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + font-size: 0.8125rem; +} + +input[type="range"] { + width: 100%; + border-radius: var(--radius-full); + outline: none; + -webkit-appearance: none; + cursor: pointer; + height: 8px; + background: var(--bg-hover); +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4); + width: 20px; + height: 20px; + background: var(--color-primary); +} + +input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.6); +} + +input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--color-primary); + border-radius: 50%; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +select, input[type="color"], input[type="number"] { + width: 100%; + padding: 0.625rem 0.875rem; + border-radius: var(--radius-md); + color: var(--color-text-primary); + font-size: 0.875rem; + transition: all 0.2s; + cursor: pointer; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); +} + +select:hover, input[type="color"]:hover, input[type="number"]:hover { + border-color: var(--color-primary); +} + +select:focus, input[type="color"]:focus, input[type="number"]:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-glow); + transform: translateY(-1px); +} + +input[type="color"] { + height: 44px; + padding: 0.25rem; + cursor: pointer; +} + +.presets { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; +} + +.presets button { + background: var(--bg-elevated); + border-radius: var(--radius-md); + color: var(--color-text-primary); + cursor: pointer; + transition: all 0.2s; + font-size: 0.875rem; + padding: 1rem; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + font-weight: 700; + letter-spacing: 0.02em; +} + +.presets button:hover { + background: var(--color-primary); + background: var(--color-primary); + border-color: var(--color-primary); + transform: translateY(-2px); + box-shadow: 0 4px 15px var(--color-primary-glow); +} + +.toggle-group { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.toggle-btn { + flex: 1; + background: var(--bg-elevated); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s; + font-size: 0.8125rem; + text-align: center; + padding: 0.75rem 1rem; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + color: var(--color-text-secondary); + font-weight: 600; +} + +.toggle-btn:hover { + border-color: var(--color-primary); + color: var(--color-text-primary); +} + +.toggle-btn.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-text-primary); + box-shadow: 0 2px 10px var(--color-primary-glow); +} + +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; +} + +.overlay.active { + opacity: 1; + pointer-events: all; +} + +.loading-spinner { + display: inline-block; + width: 40px; + height: 40px; + border: 3px solid var(--bg-elevated); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + gap: 1rem; + color: var(--color-text-secondary); +} + +.settings-panel::-webkit-scrollbar { + width: 8px; +} + +.settings-panel::-webkit-scrollbar-track { + background: var(--bg-surface); +} + +.settings-panel::-webkit-scrollbar-thumb { + background: var(--bg-elevated); + border-radius: 4px; +} + +.settings-panel::-webkit-scrollbar-thumb:hover { + background: var(--bg-hover); +} + +@media (max-width: 768px) { + .settings-panel { + width: 100%; + max-width: 100%; + } + + .top-bar { + padding: 0 1rem; + } + + .glass-btn { + padding: 0.5rem 1rem; + font-size: 0.8125rem; + } + + .chapter-info { + font-size: 0.875rem; + gap: 0.75rem; + } + + .double-container { + flex-direction: column; + } + + .double-container img { + width: 100%; + max-width: 100%; + } + + .ln-content { + padding: 2rem 1.5rem; + font-size: var(--ln-font-size, 16px); + } +} + +.fit-width { + width: 100% !important; + height: auto !important; + max-width: 100% !important; +} + +.fit-height { + height: var(--viewport-height, 85vh) !important; + width: auto !important; + max-width: 100% !important; +} + +.fit-screen { + max-height: var(--viewport-height, 85vh) !important; + max-width: 100% !important; + width: auto !important; + height: auto !important; + object-fit: contain !important; +} + +.page-img.longstrip-fit { + width: 50%; + max-width: 50%; + margin: 0 auto; + display: block; +} \ No newline at end of file diff --git a/docker/views/css/components/anilist-modal.css b/docker/views/css/components/anilist-modal.css new file mode 100644 index 0000000..5312c7b --- /dev/null +++ b/docker/views/css/components/anilist-modal.css @@ -0,0 +1,268 @@ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.9); + backdrop-filter: blur(10px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-content { + background: var(--color-bg-amoled); + border: 1px solid rgba(255,255,255,0.1); + border-radius: var(--radius-lg); + max-width: 900px; + width: 95%; + padding: 0; + position: relative; + animation: modalSlideUp 0.3s ease; + box-shadow: 0 20px 50px rgba(0,0,0,0.8); + max-height: 90vh; + display: flex; + flex-direction: column; +} + +@keyframes modalSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-close { + position: absolute; + top: 1rem; + right: 1rem; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + color: white; + width: 36px; + height: 36px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + transition: 0.2s; + z-index: 2001; +} + +.modal-close:hover { + background: var(--color-danger); + border-color: var(--color-danger); +} + +.modal-title { + font-size: 1.8rem; + font-weight: 800; + padding: 1.5rem 2rem 0.5rem; + margin-bottom: 0; + color: var(--color-text-primary); + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.modal-body { + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 0 2rem; + flex-grow: 1; +} + +.modal-fields-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem 2rem; + padding: 1.5rem 0; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group.notes-group { + grid-column: 1 / span 2; +} +.form-group.checkbox-group { + grid-column: 3 / 4; + align-self: flex-end; + margin-bottom: 0.5rem; +} +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-group label { + font-size: 0.8rem; + font-weight: 700; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-input { + background: var(--color-bg-field); + border: 1px solid rgba(255,255,255,0.1); + color: var(--color-text-primary); + padding: 0.8rem 1rem; + border-radius: 8px; + font-family: inherit; + font-size: 1rem; + transition: 0.2s; +} + +.form-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 10px var(--color-primary-glow); +} + +.notes-textarea { + resize: vertical; + min-height: 100px; +} + +.date-group { + display: flex; + gap: 1rem; +} + +.date-input-pair { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.checkbox-group { + flex-direction: row; + align-items: center; + gap: 1rem; +} + +.form-checkbox { + width: 18px; + height: 18px; + border: 1px solid rgba(255,255,255,0.2); + background: var(--color-bg-base); + border-radius: 4px; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + position: relative; + transition: all 0.2s; + flex-shrink: 0; +} + +.form-checkbox:checked { + background: var(--color-primary); + border-color: var(--color-primary); +} + +.form-checkbox:checked::after { + content: 'βœ“'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 14px; +} + +.modal-actions { + display: flex; + gap: 1rem; + margin-top: 0; + justify-content: flex-end; + flex-shrink: 0; + padding: 1rem 2rem; + border-top: 1px solid rgba(255,255,255,0.05); + background: var(--color-bg-amoled); + position: sticky; + bottom: 0; + z-index: 10; +} + +.btn-primary, .btn-secondary, .btn-danger { + padding: 0.8rem 1.5rem; + border-radius: 999px; + font-weight: 700; + font-size: 0.95rem; + border: none; + cursor: pointer; + transition: transform 0.2s, background 0.2s; + flex: none; +} + +.btn-primary { + background: var(--color-primary); + color: white; +} + +.btn-primary:hover { + transform: scale(1.05); +} + +.btn-secondary { + background: rgba(255,255,255,0.1); + color: white; + border: 1px solid rgba(255,255,255,0.2); +} + +.btn-secondary:hover { + background: rgba(255,255,255,0.15); +} + +.btn-danger { + background: var(--color-danger); + color: white; + margin-right: auto; +} + +.btn-danger:hover { + opacity: 0.9; +} + +.modal-overlay { + display: none; + opacity: 0; +} +.modal-overlay.active { + display: flex; + opacity: 1; +} + +@media (max-width: 900px) { + .modal-content { + max-width: 95%; + } + .modal-fields-grid { + grid-template-columns: repeat(2, 1fr); + } + .form-group.notes-group { + grid-column: 1 / -1; + } + .form-group.checkbox-group { + grid-column: 1 / -1; + align-self: auto; + } + .modal-actions { + padding: 1rem 1.5rem; + } + .modal-title, .modal-body { + padding-left: 1.5rem; + padding-right: 1.5rem; + } +} + diff --git a/docker/views/css/components/hero.css b/docker/views/css/components/hero.css new file mode 100644 index 0000000..ea120a4 --- /dev/null +++ b/docker/views/css/components/hero.css @@ -0,0 +1,120 @@ +.hero-wrapper { + position: relative; + height: 85vh; + width: 100%; + overflow: hidden; +} + +.hero-background { + position: absolute; + inset: 0; + z-index: 0; +} + +#hero-bg-media { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.6; + transform: scale(1.1); + transition: opacity 1s ease; +} + +.hero-vignette { + position: absolute; + inset: 0; + background: radial-gradient(circle at center, transparent 0%, var(--color-bg-base) 120%), + linear-gradient(to top, var(--color-bg-base) 10%, transparent 60%); + z-index: 1; +} + +.hero-content { + position: relative; + z-index: 10; + height: 100%; + max-width: 1600px; + margin: 0 auto; + padding: 0 3rem; + display: flex; + align-items: flex-end; + padding-bottom: 6rem; + gap: 3rem; +} + +.hero-poster-card { + width: 260px; + height: 380px; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; + background: #1a1a1a; +} +.hero-poster-card img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.hero-text { + flex: 1; + max-width: 800px; + margin-bottom: 1rem; + animation: fadeInUp 0.8s ease; +} + +.hero-title { + font-size: 4rem; + font-weight: 900; + line-height: 1.1; + margin: 0 0 1rem 0; + text-shadow: 0 4px 20px rgba(0, 0, 0, 0.8); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.hero-meta { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 1.5rem; + font-size: 1.1rem; + font-weight: 600; +} + +.hero-desc { + font-size: 1.1rem; + line-height: 1.6; + color: #e4e4e7; + margin-bottom: 2rem; + max-width: 650px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.hero-buttons { + display: flex; + gap: 1rem; +} + +.hero-overlay { + position: absolute; + inset: 0; + background: radial-gradient(circle at center, transparent 0%, var(--color-bg-base) 120%), + linear-gradient(to top, var(--color-bg-base) 10%, rgba(9,9,11,0.8) 25%, transparent 60%); + z-index: 1; +} + +.score-badge { + color: #22c55e; + background: rgba(34, 197, 94, 0.1); + padding: 0.2rem 0.8rem; + border-radius: 6px; + border: 1px solid rgba(34, 197, 94, 0.2); +} \ No newline at end of file diff --git a/docker/views/css/components/navbar.css b/docker/views/css/components/navbar.css new file mode 100644 index 0000000..048eb64 --- /dev/null +++ b/docker/views/css/components/navbar.css @@ -0,0 +1,418 @@ +.navbar { + width: 100%; + height: var(--nav-height); + position: fixed; + top: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 3rem; + background: linear-gradient(to bottom, rgba(9, 9, 11, 0.9) 0%, rgba(9, 9, 11, 0) 100%); + transition: background 0.3s; +} + +.navbar.scrolled { + background: rgba(9, 9, 11, 0.85); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.nav-brand { + font-weight: 900; + font-size: 1.5rem; + display: flex; + align-items: center; + gap: 0.8rem; + letter-spacing: -0.5px; + min-width: 200px; + color: white; + text-decoration: none; + cursor: pointer; +} + +.brand-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.brand-icon img { + width: 70%; + height: 70%; + object-fit: contain; +} + +.nav-center { + display: flex; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.03); + padding: 0.4rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.nav-button { + background: transparent; + border: none; + color: var(--color-text-secondary); + padding: 0.6rem 1.5rem; + border-radius: 999px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.nav-button:hover { + color: white; +} +.nav-button.active { + background: rgba(255, 255, 255, 0.1); + color: white; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.search-wrapper { + position: relative; + width: 300px; + z-index: 2000; +} +.search-input { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.7rem 1rem 0.7rem 2.5rem; + border-radius: 99px; + color: white; + font-family: inherit; + transition: 0.2s; +} + +.search-input:focus { + background: rgba(255, 255, 255, 0.1); + border-color: var(--color-primary); + box-shadow: 0 0 15px var(--color-primary-glow); +} +.search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--color-text-secondary); +} + +.search-input { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.7rem 1rem 0.7rem 2.5rem; + border-radius: 99px; + color: white; + font-family: inherit; + transition: 0.2s; +} + +.search-input:focus { + background: rgba(0, 0, 0, 0.8); + border-color: var(--color-primary); + box-shadow: 0 0 15px var(--color-primary-glow); + border-radius: 12px 12px 0 0; +} + +.search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--color-text-secondary); +} + +.search-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: rgba(15, 15, 18, 0.95); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-top: none; + border-radius: 0 0 12px 12px; + padding: 0.5rem; + display: none; + flex-direction: column; + gap: 0.25rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + max-height: 400px; + overflow-y: auto; +} + +.nav-user { + position: relative; + display: flex; + align-items: center; +} + +.user-avatar-btn { + position: relative; + cursor: pointer; + transition: transform 0.2s ease; +} + +.user-avatar-btn:hover { + transform: scale(1.05); +} + +#nav-avatar { + width: 44px; + height: 44px; + object-fit: cover; + border-radius: 50%; + border: 2px solid var(--color-primary); + background: var(--color-bg-elevated); + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 0 0 0 var(--color-primary-glow); +} + +.user-avatar-btn:hover #nav-avatar { + box-shadow: 0 0 0 4px var(--color-primary-glow); + border-color: #a78bfa; +} + +.online-indicator { + position: absolute; + bottom: 2px; + right: 2px; + width: 12px; + height: 12px; + background: #22c55e; + border: 2px solid var(--color-bg-base); + border-radius: 50%; + animation: pulse 2s infinite; +} + +.nav-dropdown { + position: absolute; + top: calc(100% + 12px); + right: 0; + background: rgba(18, 18, 21, 0.98); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 0; + min-width: 260px; + display: none; + flex-direction: column; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(139, 92, 246, 0.1); + z-index: 9999; + overflow: hidden; + animation: dropdownSlide 0.2s ease-out; +} + +@keyframes dropdownSlide { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.nav-dropdown.active { + display: flex; +} + +.dropdown-header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: rgba(139, 92, 246, 0.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.dropdown-avatar { + width: 48px; + height: 48px; + border-radius: 12px; + object-fit: cover; + border: 2px solid var(--color-primary); + background: var(--color-bg-elevated); +} + +.dropdown-user-info { + flex: 1; + overflow: hidden; +} + +.dropdown-username { + font-weight: 700; + font-size: 1rem; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.dropdown-status { + font-size: 0.75rem; + color: #22c55e; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} + +.dropdown-status::before { + content: ""; + width: 6px; + height: 6px; + background: #22c55e; + border-radius: 50%; + display: inline-block; +} + +.dropdown-divider { + height: 1px; + background: rgba(255, 255, 255, 0.06); + margin: 4px 0; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + color: var(--color-text-primary); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; + background: transparent; + width: 100%; + text-align: left; + font-family: inherit; +} + +.dropdown-item svg { + flex-shrink: 0; + color: var(--color-text-secondary); + transition: all 0.2s ease; +} + +.dropdown-item:hover { + background: rgba(255, 255, 255, 0.08); + color: white; + padding-left: 20px; +} + +.dropdown-item:hover svg { + color: var(--color-primary); + transform: translateX(2px); +} + +.dropdown-item:active { + transform: scale(0.98); +} + +.logout-item { + color: #ef4444; + margin-top: 4px; +} + +.logout-item svg { + color: #ef4444; +} + +.logout-item:hover { + background: rgba(239, 68, 68, 0.15); + color: #f87171; +} + +.logout-item:hover svg { + color: #f87171; + transform: translateX(2px); +} + +.nav-right { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.search-results.active { + display: flex; +} + +.search-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; + text-decoration: none; + color: inherit; +} + +.search-item:hover { + background: rgba(255, 255, 255, 0.1); +} + +.search-poster { + width: 40px; + height: 56px; + border-radius: 4px; + object-fit: cover; + background: #222; +} + +.search-info { + flex: 1; + overflow: hidden; +} + +.search-title { + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-primary); +} + +.search-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--color-text-secondary); + margin-top: 2px; +} + +.rating-pill { + color: #4ade80; + font-weight: 700; +} + +.search-results::-webkit-scrollbar { + width: 6px; +} +.search-results::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} \ No newline at end of file diff --git a/docker/views/css/components/titlebar.css b/docker/views/css/components/titlebar.css new file mode 100644 index 0000000..e4e48f9 --- /dev/null +++ b/docker/views/css/components/titlebar.css @@ -0,0 +1,195 @@ +:root { + --titlebar-height: 40px; +} + +* { + box-sizing: border-box; +} + +html { + background: #09090b; + visibility: hidden; + scrollbar-gutter: stable; +} + +html.electron { + margin: 0; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; +} + +html.electron .navbar, +html.electron .top-bar, +html.electron .panel-header { + top: var(--titlebar-height) !important; +} + +html.electron .panel-content { + margin-top: 2rem; +} + +html.electron .calendar-wrapper{ + margin-top: 4rem; +} + +html.electron .back-btn { + top: 55px !important; +} + +#back-link { + margin-top: 55px !important; +} + +#titlebar { + display: none; + height: var(--titlebar-height); + background: rgba(9, 9, 11, 0.95); + color: white; + align-items: center; + justify-content: space-between; + padding: 0 12px; + -webkit-app-region: drag; + user-select: none; + font-family: "Inter", system-ui, sans-serif; + border-bottom: 1px solid rgba(139, 92, 246, 0.2); + position: fixed; + top: 0; + left: 0; + width: 100vw; + z-index: 999999; + backdrop-filter: blur(12px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.title-left { + display: flex; + align-items: center !important; + gap: 10px; +} + +#titlebar .app-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background: rgba(139, 92, 246, 0.15); + border: 1px solid rgba(139, 92, 246, 0.3); + padding: 3px; +} + +#titlebar .app-icon img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.app-title { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + letter-spacing: -0.2px; +} + +.title-right { + display: flex; + height: 100%; + gap: 1px; +} + +.title-right button { + -webkit-app-region: no-drag; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.7); + width: 46px; + height: 100%; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.title-right button svg { + width: 16px; + height: 16px; + transition: transform 0.2s; +} + +.title-right button:hover { + color: white; +} + +.title-right button:active { + transform: scale(0.95); +} + +.title-right .min:hover { + background: rgba(139, 92, 246, 0.2); +} + +.title-right .max:hover { + background: rgba(34, 197, 94, 0.2); +} + +.title-right .close:hover { + background: #e81123; + color: white; +} + +.title-right button:hover svg { + transform: scale(1.1); +} + +html.electron::-webkit-scrollbar { + width: 12px; + position: absolute; +} + +html.electron::-webkit-scrollbar-track { + background: #09090b; + margin-top: var(--titlebar-height); +} + +html.electron::-webkit-scrollbar-thumb { + background: rgba(139, 92, 246, 0.3); + border-radius: 6px; + border: 2px solid #09090b; +} + +html.electron::-webkit-scrollbar-thumb:hover { + background: rgba(139, 92, 246, 0.5); +} + +body { + margin: 0; + padding: 0; + overflow-x: hidden; +} + +.user-box { + display: flex; + align-items: center; + gap: 8px; + margin-right: 12px; +} + +.user-box img { + width: 26px; + height: 26px; + border-radius: 50%; + object-fit: cover; +} + +.user-box span { + font-size: 13px; + opacity: 0.9; +} + +.hidden { + display: none; +} diff --git a/docker/views/css/components/updateNotifier.css b/docker/views/css/components/updateNotifier.css new file mode 100644 index 0000000..232721f --- /dev/null +++ b/docker/views/css/components/updateNotifier.css @@ -0,0 +1,83 @@ +#updateToast { + position: fixed; + bottom: 20px; + right: 30px; + z-index: 5000; + opacity: 0; + transform: translateY(100px); + pointer-events: none; + transition: opacity 0.4s ease-out, transform 0.4s ease-out; + + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + + padding: 0.8rem 1.25rem; + border-radius: var(--radius-md); + max-width: 300px; + + background: rgba(18, 18, 21, 0.8); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.6); + + color: var(--color-text-primary); + font-size: 0.9rem; +} + +#updateToast.hidden { + opacity: 0; + transform: translateY(100px); + pointer-events: none; +} + +#updateToast.update-available { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +#updateToast p { + margin: 0; + font-weight: 500; + + width: 100%; + padding-bottom: 0.3rem; +} + +#latestVersionDisplay { + font-weight: 700; + font-size: 0.9rem; + padding: 0.2rem 0.6rem; + border-radius: 6px; + background: var(--color-primary); + color: var(--color-bg-base); + display: inline-block; + margin-left: 0.5rem; + box-shadow: 0 0 12px var(--color-primary-glow); + transition: transform 0.2s; +} + +#downloadButton { + width: 100%; + padding: 0.6rem 1rem; + border-radius: 999px; + font-weight: 700; + font-size: 0.9rem; + cursor: pointer; + text-decoration: none; + text-align: center; + + background: var(--color-primary); + color: white; + border: none; + box-shadow: 0 4px 10px var(--color-primary-glow); + transition: transform 0.2s, background 0.2s; +} + +#downloadButton:hover { + transform: translateY(-2px); + background: #7c4dff; + box-shadow: 0 6px 15px var(--color-primary-glow); +} \ No newline at end of file diff --git a/docker/views/css/gallery/gallery.css b/docker/views/css/gallery/gallery.css new file mode 100644 index 0000000..bafc394 --- /dev/null +++ b/docker/views/css/gallery/gallery.css @@ -0,0 +1,293 @@ +.gallery-hero-placeholder { + height: var(--nav-height); + width: 100%; +} + +.gallery-controls { + display: flex; + gap: 1.5rem; + align-items: center; + margin-bottom: 2rem; + padding-top: 1rem; +} + +.provider-selector { + appearance: none; + width: 100%; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + padding-right: 2.5rem; + border-radius: 99px; + color: var(--color-text-primary); + font-family: inherit; + font-size: 1rem; + cursor: pointer; + transition: 0.2s; +} + +.provider-selector:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 15px var(--color-primary-glow); +} + +.provider-icon { + position: absolute; + right: 15px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--color-text-secondary); + font-size: 0.8rem; +} + +.gallery-results { + position: relative; + padding-bottom: 3rem; + margin: 0 -0.75rem; +} + +.gallery-card { + width: calc(25% - 1.5rem); + margin: 0.75rem; + background: var(--bg-surface); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255,255,255,0.05); + position: relative; + + opacity: 0; + transform: translateY(20px) scale(0.98); +} + +.gallery-card.is-loaded { + opacity: 1; + transform: translateY(0) scale(1); +} + +.gallery-card:hover { + transform: translateY(-8px); + z-index: 10; +} + +.gallery-card-img { + width: 100%; + height: auto; + object-fit: cover; + display: block; + transition: transform 0.4s ease; +} + +.gallery-card:hover .gallery-card-img { + transform: scale(1.05); +} + +.fav-btn { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0,0,0,0.6); + color: white; + border: none; + border-radius: 50%; + width: 38px; + height: 38px; + font-size: 1.2rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(6px); + transition: all 0.25s ease; + z-index: 3; +} + +.fav-btn:hover { + background: rgba(0,0,0,0.85); + transform: scale(1.15); +} + +.fav-btn.favorited { + color: #ff6b6b; + background: rgba(255, 107, 107, 0.25); + box-shadow: 0 0 12px rgba(255, 107, 107, 0.4); +} + +.provider-badge { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0,0,0,0.7); + color: white; + font-size: 0.75rem; + font-weight: 500; + padding: 5px 9px; + border-radius: 6px; + backdrop-filter: blur(6px); + z-index: 2; +} + +@media (max-width: 1200px) { + .gallery-card { width: calc(33.333% - 1.5rem); } +} +@media (max-width: 900px) { + .gallery-card { width: calc(50% - 1.5rem); } +} +@media (max-width: 600px) { + .gallery-card { width: calc(100% - 1.5rem); } + .fav-btn { width: 42px; height: 42px; font-size: 1.4rem; } +} + +.gallery-card.skeleton { + min-height: 250px; + aspect-ratio: 1/1.4; + display: flex; + align-items: center; + justify-content: center; +} + +.load-more-container { + display: flex; + justify-content: center; + padding: 2rem 0 4rem 0; +} + +.provider-and-fav { + display: flex; + gap: 12px; + align-items: center; + position: relative; +} + +.fav-toggle-btn { + display: flex; + align-items: center; + gap: 8px; + background: rgba(255,255,255,0.07); + color: var(--color-text-primary); + border: 1px solid rgba(255,255,255,0.15); + padding: 0.7rem 1rem; + border-radius: 99px; + font-size: 0.95rem; + font-family: inherit; + cursor: pointer; + transition: all 0.25s ease; + white-space: nowrap; +} + +.fav-toggle-btn:hover { + background: rgba(255,255,255,0.12); + border-color: var(--color-primary); +} + +.fav-toggle-btn.active { + background: rgba(255,107,107,0.2); + border-color: #ff6b6b; + color: #ff6b6b; + box-shadow: 0 0 15px rgba(255,107,107,0.3); +} + +.fav-toggle-btn.active i { + color: #ff6b6b; + animation: beat 1.4s ease infinite; +} + +.fav-toggle-btn i { + font-size: 1.1rem; + transition: color 0.25s; +} + +@keyframes beat { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.15); } +} + +@media (max-width: 720px) { + .fav-text { display: none; } + .fav-toggle-btn { padding: 0.7rem 0.9rem; } + .provider-and-fav { gap: 8px; } +} + +.gallery-controls { + display: flex; + gap: 1.5rem; /* Reduced gap since there are fewer items */ + align-items: center; + margin-bottom: 2rem; + padding-top: 1rem; + + /* Center the provider selector since it's the only one */ + justify-content: flex-start; +} + +.provider-selector { + appearance: none; + width: auto; /* Allow it to shrink */ + max-width: 250px; /* Adjusted for better fit */ + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.15); + color: var(--color-text-primary); + padding: 0.75rem 2.8rem 0.75rem 1rem; + border-radius: 99px; + font-size: 0.95rem; + cursor: pointer; + min-width: 170px; + flex-shrink: 0; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888888' viewBox='0 0 16 16'%3E%3Cpath d='M4.646 6.646a.5.5 0 0 1 .708 0L8 9.293l2.646-2.647a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + transition: all 0.25s ease; +} + +.provider-selector:hover { background-color: rgba(255,255,255,0.12); border-color: var(--color-primary); } +.provider-selector:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(255,107,107,0.2); } + +.provider-selector option { + background: #111; + color: white; + padding: 12px; +} + +.search-gallery-wrapper { + position: relative; + width: 300px; + z-index: 2000; +} + +.fav-toggle-btn { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + background: rgba(255,255,255,0.08); + color: var(--color-text-primary); + border: 1px solid rgba(255,255,255,0.15); + padding: 0.75rem 1.1rem; + border-radius: 99px; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; +} + +.fav-toggle-btn:hover { background: rgba(255,255,255,0.15); border-color: #ff6b6b; } +.fav-toggle-btn.active { + background: rgba(255,107,107,0.25); + border-color: #ff6b6b; + color: #ff6b6b; + box-shadow: 0 0 15px rgba(255,107,107,0.4); +} +.fav-toggle-btn.active i { color: #ff6b6b; animation: heartbeat 1.5s ease infinite; } +@keyframes heartbeat { 0%,100%{transform:scale(1)} 50%{transform:scale(1.2)} } + +@media (max-width: 900px) { + .gallery-controls { + flex-wrap: wrap; + gap: 0.8rem; + } + .provider-selector { max-width: none; width: 48%; } + .search-gallery-wrapper { order: -1; width: 100%; } + .fav-toggle-btn { width: 48%; justify-content: center; } + .fav-text { display: none; } +} \ No newline at end of file diff --git a/docker/views/css/gallery/image.css b/docker/views/css/gallery/image.css new file mode 100644 index 0000000..6746405 --- /dev/null +++ b/docker/views/css/gallery/image.css @@ -0,0 +1,260 @@ +.gallery-hero-placeholder { height: var(--nav-height); width: 100%; } + +.item-content-flex-wrapper { + display: flex; + gap: 3rem; + padding: 2rem 0 4rem; + min-height: 80vh; + max-width: 1400px; + margin: 0 auto; +} + +.image-col { + flex: 2; + display: flex; + justify-content: center; + align-items: flex-start; + position: relative; +} + +.item-image { + max-width: 100%; + max-height: 85vh; + border-radius: var(--radius-lg, 16px); + box-shadow: 0 20px 50px rgba(0,0,0,0.6); + object-fit: contain; + transition: all 0.4s ease; + cursor: zoom-in; +} + +.item-image:hover { + transform: scale(1.02); + box-shadow: 0 30px 70px rgba(0,0,0,0.7); +} + +.info-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 2rem; + min-width: 300px; +} + +.info-header h1 { + font-size: 2.4rem; + margin: 0 0 0.8rem 0; + line-height: 1.2; + word-break: break-word; +} + +.provider-name { + color: var(--color-text-secondary); + font-size: 1rem; + opacity: 0.9; +} + +.actions-row { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 1rem 1.5rem; + border-radius: 99px; + font-size: 1.05rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + border: none; +} + +.fav-action { + background: rgba(255,255,255,0.08); + border: 2px solid rgba(255,255,255,0.2); + color: white; +} + +.fav-action:hover { background: rgba(255,255,255,0.15); } +.fav-action.favorited { + background: linear-gradient(135deg, #ff6b6b, #ff8e8e); + border-color: #ff6b6b; + color: white; + animation: pulse 2s infinite; +} + +.fav-action i { font-size: 1.4rem; } +.fav-action.favorited i { animation: heartbeat 1.4s ease infinite; } + +@keyframes heartbeat { + 0%,100% { transform: scale(1); } + 50% { transform: scale(1.25); } +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(255,107,107,0.4); } + 70% { box-shadow: 0 0 0 12px rgba(255,107,107,0); } + 100% { box-shadow: 0 0 0 0 rgba(255,107,107,0); } +} + +.download-btn { + background: var(--color-primary); + color: white; +} + +.download-btn:hover { + background: #7c4dff; + transform: translateY(-2px); + box-shadow: 0 10px 30px rgba(139,92,246,0.4); +} + +.copy-link-btn { + background: rgba(255,255,255,0.1); + border: 2px solid rgba(255,255,255,0.2); + color: white; +} + +.copy-link-btn:hover { background: rgba(255,255,255,0.18); } + +.tags-section h3 { + margin: 0 0 1rem 0; + color: var(--color-text-secondary); + font-weight: 500; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.tag-item { + background: rgba(139,92,246,0.2); + color: var(--color-primary); + padding: 0.5rem 1rem; + border-radius: 99px; + font-size: 0.95rem; + text-decoration: none; + transition: all 0.2s; + backdrop-filter: blur(4px); +} + +.tag-item:hover { + background: rgba(139,92,246,0.4); + transform: translateY(-2px); +} + +@media (max-width: 900px) { + .item-content-flex-wrapper { flex-direction: column; align-items: center; gap: 2rem; } + .info-col { width: 100%; max-width: 600px; } + .item-image { max-height: 70vh; } + .actions-row { flex-direction: row; } + .action-btn { flex: 1; } +} + +@media (max-width: 500px) { + .actions-row { flex-direction: column; } + .action-btn { padding: 0.9rem; font-size: 1rem; } +} + +.similar-section { + margin-top: 4rem; + padding: 2rem 0; + border-top: 1px solid rgba(255,255,255,0.1); + max-width: 1400px; + margin-left: auto; + margin-right: auto; +} + +.similar-section h3 { + margin: 0 0 1.5rem 0; + font-size: 1.6rem; + color: var(--color-text-primary); + text-align: center; +} + +.similar-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + padding: 1rem 0; +} + +.similar-card { + height: 180px; + border-radius: var(--radius-lg, 16px); + overflow: hidden; + position: relative; + cursor: pointer; + transition: transform 0.3s ease; + box-shadow: 0 8px 25px rgba(0,0,0,0.4); +} + +.similar-card:hover { + transform: scale(1.03); +} + +.similar-card img { + width: 100%; + height: 100%; + object-fit: cover; + transition: opacity 0.3s; +} + +.similar-card:hover img { + opacity: 0.9; +} + +.similar-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(transparent 60%, rgba(0,0,0,0.7)); + pointer-events: none; +} + +.similar-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 1rem; + color: white; + font-size: 0.9rem; + font-weight: 500; + text-shadow: 0 1px 3px rgba(0,0,0,0.8); +} + +.back-btn { + position: fixed; + top: 5rem; left: 2rem; z-index: 100; + display: flex; align-items: center; gap: 0.5rem; + padding: 0.8rem 1.5rem; + background: var(--color-glass-bg); backdrop-filter: blur(12px); + border: var(--glass-border); border-radius: var(--radius-full); + color: white; text-decoration: none; font-weight: 600; + transition: all 0.2s ease; +} +body.electron .back-btn { + top: 115px !important; +} +.back-btn:hover { background: rgba(255, 255, 255, 0.15); transform: translateX(-5px); } + +@media (max-width: 768px) { + .similar-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + padding: 1rem; + } + .similar-card { height: 140px; } +} + +@media (max-width: 500px) { + .similar-grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/docker/views/css/globals.css b/docker/views/css/globals.css new file mode 100644 index 0000000..1daa0e9 --- /dev/null +++ b/docker/views/css/globals.css @@ -0,0 +1,301 @@ +:root { + --color-bg-base: #09090b; + --color-bg-elevated: #121215; + --color-bg-elevated-hover: #1e1e22; + --color-bg-card: #121214; + --color-primary: #8b5cf6; + --color-primary-hover: #7c3aed; + --color-primary-glow: rgba(139, 92, 246, 0.4); + --color-danger: #ef4444; + --color-success: #22c55e; + --color-bg-amoled: #0a0a0a; + --color-bg-field: #0e0e0f; + --color-glass-bg: rgba(20, 20, 23, 0.7); + + --color-text-primary: #ffffff; + --color-text-secondary: #a1a1aa; + --color-text-muted: #71717a; + + --border-subtle: 1px solid rgba(255, 255, 255, 0.08); + --border-medium: 1px solid rgba(255, 255, 255, 0.12); + --glass-bg: rgba(18, 18, 20, 0.8); + --glass-border: 1px solid rgba(255, 255, 255, 0.1); + + --spacing-xs: 0.5rem; + --spacing-sm: 0.75rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + + --radius-sm: 0.5rem; + --radius-md: 0.75rem; + --radius-lg: 1.5rem; + --radius-xl: 1.5rem; + --radius-full: 9999px; + + --nav-height: 80px; + + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 8px 32px var(--color-primary-glow); + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-smooth: 350ms cubic-bezier(0.4, 0, 0.2, 1); + + --plyr-color-main: var(--color-primary); +} + +* { + box-sizing: border-box; + outline: none; +} + +body { + margin: 0; + padding: 0; + background-color: var(--color-bg-base); + color: var(--color-text-primary); + font-family: 'Inter', system-ui, sans-serif; + overflow-x: hidden; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +.section { + max-width: 1700px; + margin: 0 auto; + padding: 2rem 3rem; +} +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} +.section-title { + font-size: 1.8rem; + font-weight: 800; +} + +.btn-primary, +.btn-blur { + padding: 1rem 2.5rem; + border-radius: 999px; + font-weight: 700; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: transform 0.2s; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.btn-primary { + background: white; + color: black; +} + +.btn-primary:hover { + transform: scale(1.05); +} + +.btn-blur:hover { + background: rgba(255, 255, 255, 0.2); +} + +.card { + min-width: 220px; + width: 220px; + flex: 0 0 220px; + cursor: pointer; + transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.card:hover { + transform: translateY(-8px); +} +.card-img-wrap { + width: 100%; + aspect-ratio: 2 / 3; + border-radius: var(--radius-md); + overflow: hidden; + position: relative; + margin-bottom: 0.8rem; + background: #222; +} + +.card-img-wrap img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s; +} + +.card:hover .card-img-wrap img { + transform: scale(1.05); +} + +.card-content h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.card-content p { + margin: 4px 0 0 0; + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.skeleton { + background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 6px; + display: inline-block; +} +.text-skeleton { + height: 1em; + width: 70%; + margin-bottom: 0.5rem; +} +.title-skeleton { + height: 3em; + width: 80%; + margin-bottom: 1rem; +} +.poster-skeleton { + width: 100%; + height: 100%; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +.carousel-wrapper { + position: relative; +} +.carousel { + display: flex; + gap: 1.25rem; + overflow-x: auto; + padding: 1rem 0; + scroll-behavior: smooth; + scrollbar-width: none; +} +.carousel::-webkit-scrollbar { + display: none; +} + +.carousel-wrapper:hover .scroll-btn { + opacity: 1; +} + +.scroll-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 50px; + height: 50px; + border-radius: 50%; + background: rgba(20, 20, 23, 0.9); + border: 1px solid rgba(255, 255, 255, 0.1); + color: white; + z-index: 20; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: 0.3s; +} + +.scroll-btn:hover { + background: var(--color-primary); +} +.scroll-btn.left { + left: -25px; +} +.scroll-btn.right { + right: -25px; +} + +.back-btn { + position: fixed; + top: 2rem; + left: 2rem; + z-index: 100; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.8rem 1.5rem; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-full); + color: white; + text-decoration: none; + font-weight: 600; + transition: all 0.2s ease; + cursor: pointer; +} + +.back-btn:hover { + background: rgba(255, 255, 255, 0.1); + transform: translateX(-5px); +} + +.btn-secondary { + padding: 1rem 2rem; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + color: white; + border-radius: var(--radius-full); + font-weight: 700; + font-size: 1rem; + border: 1px solid rgba(255,255,255,0.2); + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; +} +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.05); +} \ No newline at end of file diff --git a/docker/views/css/list.css b/docker/views/css/list.css new file mode 100644 index 0000000..9ba9228 --- /dev/null +++ b/docker/views/css/list.css @@ -0,0 +1,485 @@ +.container { + max-width: 1600px; + margin: 0 auto; + padding: 3rem; +} + +.header-section { + margin-bottom: 3rem; + margin-top: 3rem; +} + +.page-title { + font-size: 3rem; + font-weight: 900; + margin-bottom: 2rem; + background: linear-gradient(135deg, var(--color-primary), #a78bfa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; +} + +.stat-card { + background: var(--color-bg-elevated); + border: 1px solid rgba(255,255,255,0.1); + border-radius: var(--radius-lg); + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + transition: transform 0.3s, box-shadow 0.3s; + box-shadow: 0 5px 20px rgba(0,0,0,0.2); +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 15px 35px var(--color-primary-glow); +} + +.stat-value { + font-size: 2.5rem; + font-weight: 900; + color: var(--color-primary); +} + +.stat-label { + font-size: 0.9rem; + color: var(--color-text-secondary); + font-weight: 600; +} + +/* --- Filtros mejorados --- */ +.filters-section { + display: flex; + gap: 1.5rem; + margin-bottom: 2rem; + padding: 1.5rem; + background: var(--color-bg-elevated); + border-radius: var(--radius-md); + border: 1px solid rgba(255,255,255,0.05); + flex-wrap: wrap; + box-shadow: 0 4px 15px rgba(0,0,0,0.3); +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; + min-width: 150px; +} + +.filter-group label { + font-size: 0.8rem; + font-weight: 700; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 1px; +} + +.filter-select { + background: var(--color-bg-base); + border: 1px solid rgba(255,255,255,0.1); + color: var(--color-text-primary); + padding: 0.7rem 1rem; + border-radius: 8px; + font-family: inherit; + cursor: pointer; + transition: 0.2s; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a1a1aa'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z' clip-rule='evenodd'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.7rem center; + background-size: 1.2em; + padding-right: 2.5rem; +} + +.filter-select:hover { + border-color: var(--color-primary); +} + +.filter-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 10px var(--color-primary-glow); +} + +.view-toggle { + display: flex; + gap: 0.5rem; +} + +.view-btn { + background: var(--color-bg-base); + border: 1px solid rgba(255,255,255,0.1); + color: var(--color-text-secondary); + padding: 0.7rem; + border-radius: 8px; + cursor: pointer; + transition: 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.view-btn:hover { + border-color: var(--color-primary); + color: white; +} + +.view-btn.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 5rem 0; + gap: 1.5rem; +} + +.spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(139, 92, 246, 0.1); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 5rem 0; + gap: 1.5rem; + color: var(--color-text-secondary); +} + +.empty-state svg { + opacity: 0.3; +} + +.empty-state h2 { + font-size: 1.8rem; + color: var(--color-text-primary); +} + +.list-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 2rem; +} + +.list-grid.list-view { + grid-template-columns: 1fr; + gap: 1rem; +} + +.list-item { + background: var(--color-bg-elevated-hover); + border: 1px solid rgba(255,255,255,0.08); + border-radius: var(--radius-md); + overflow: hidden; + transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); + display: flex; + flex-direction: column; + position: relative; + box-shadow: 0 4px 15px rgba(0,0,0,0.3); +} + +.list-item:hover { + transform: translateY(-8px); + border-color: var(--color-primary); + box-shadow: 0 15px 30px var(--color-primary-glow); +} + +.list-grid.list-view .list-item { + flex-direction: row; + align-items: center; + padding-right: 1rem; + transition: all 0.3s ease; +} + +.list-grid.list-view .list-item:hover { + transform: none; + box-shadow: 0 4px 20px var(--color-primary-glow); +} + +.item-poster-link { + display: block; + cursor: pointer; + flex-shrink: 0; +} + +.item-poster { + width: 100%; + aspect-ratio: 2/3; + object-fit: cover; + background: #222; +} + +.list-grid.list-view .item-poster { + /* Cambiar el ancho y alto */ + width: 120px; /* Antes: 100px */ + height: 180px; /* Antes: 150px */ + aspect-ratio: auto; + border-radius: 8px; + margin: 1rem; +} + +.item-content { + padding: 1rem; /* Antes: 1.2rem */ + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: space-between; +} + +.list-grid.list-view .item-content { + padding: 1rem 0; + flex-direction: row; + align-items: center; +} +.list-grid.list-view .item-content > div:first-child { + flex-basis: 75%; +} + +.item-title { + font-size: 1rem; /* Antes: 1.1rem */ + font-weight: 800; + margin-bottom: 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: white; +} + +.list-grid.list-view .item-title { + font-size: 1.3rem; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + max-width: 400px; +} + +.item-meta { + display: flex; + gap: 0.3rem; /* Antes: 0.75rem. Espacio entre los pills */ + margin-bottom: 0.5rem; /* Antes: 0.8rem */ + flex-wrap: wrap; + /* AΓ±adir: Asegura que si se envuelven, lo hagan con poco margen vertical */ + line-height: 1.4; +} + +.meta-pill { + font-size: 0.65rem; /* Antes: 0.7rem */ + padding: 0.15rem 0.4rem; /* Antes: 0.25rem 0.6rem. Reduce el padding interno */ + border-radius: 999px; + font-weight: 700; + white-space: nowrap; + text-transform: uppercase; +} + +.status-pill { + background: rgba(34, 197, 94, 0.2); + color: var(--color-success); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.type-pill { + background: rgba(139, 92, 246, 0.15); + color: var(--color-primary); + border: 1px solid rgba(139, 92, 246, 0.3); +} + +.source-pill { + background: rgba(255, 255, 255, 0.1); + color: var(--color-text-primary); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.repeat-pill { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.3); + text-transform: none; +} +.private-pill { + background: rgba(251, 191, 36, 0.15); + color: #facc15; + border: 1px solid rgba(251, 191, 36, 0.3); + text-transform: none; +} + +.progress-bar-container { + background: rgba(255,255,255,0.08); + border-radius: 999px; + height: 10px; + overflow: hidden; + margin-bottom: 0.5rem; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.5); +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--color-primary), #a78bfa); + border-radius: 999px; + transition: width 0.3s; +} + +.progress-text { + font-size: 0.9rem; + color: var(--color-text-secondary); + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 500; +} + +.score-badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-weight: 700; + color: #facc15; + background: rgba(250, 204, 21, 0.1); + padding: 0.1rem 0.5rem; + border-radius: 4px; +} + +/* --- BotΓ³n de ediciΓ³n flotante --- */ +.edit-icon-btn { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 50; + background: rgba(18, 18, 21, 0.9); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s, transform 0.2s, background 0.2s; +} + +.list-item:hover .edit-icon-btn { + opacity: 1; + transform: scale(1.05); +} + +.edit-icon-btn:hover { + background: var(--color-primary); + border-color: var(--color-primary); +} + +.list-grid.list-view .edit-icon-btn { + position: relative; + top: auto; + right: auto; + margin-left: auto; + opacity: 1; + transform: none; + background: var(--color-bg-elevated); + border: 1px solid rgba(255, 255, 255, 0.1); +} +.list-grid.list-view .list-item:hover .edit-icon-btn { + opacity: 1; + background: var(--color-primary); + border-color: var(--color-primary); + transform: none; +} + +/* --- Modal de EdiciΓ³n Mejorado (Estilo Anilist + AMOLED) --- */ + +@media (max-width: 550px) { + /* Layout de lista (card view) */ + .list-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } + .list-grid.list-view .list-item { + flex-direction: column; + align-items: flex-start; + padding-right: 0; + } + .list-grid.list-view .item-poster { + width: 100%; + height: auto; + margin: 0; + border-radius: 0; + aspect-ratio: 16/9; + } + .list-grid.list-view .item-content { + flex-direction: column; + padding: 1rem; + } + .list-grid.list-view .item-content > div:first-child { + flex-basis: auto; + } + .list-grid.list-view .edit-icon-btn { + position: absolute; + top: 1rem; + right: 1rem; + opacity: 1; + background: rgba(18, 18, 21, 0.8); + } + + /* Modal en mΓ³vil */ + .modal-content { + margin: 0.5rem; + width: auto; + } + .modal-fields-grid { + grid-template-columns: 1fr; + gap: 1rem; + padding-bottom: 0; + } + .form-group.notes-group, + .form-group.checkbox-group { + grid-column: auto; + } + .modal-actions { + flex-direction: column; + align-items: stretch; + } + .btn-danger { + margin-right: 0; + order: 3; + } + .btn-secondary { + order: 2; + } + .btn-primary { + order: 1; + } +} + +.edit-btn-card { + display: none; +} + +.item-poster-link { + z-index: 1; +} \ No newline at end of file diff --git a/docker/views/css/marketplace.css b/docker/views/css/marketplace.css new file mode 100644 index 0000000..02d5f7f --- /dev/null +++ b/docker/views/css/marketplace.css @@ -0,0 +1,295 @@ +.hero-spacer { + height: var(--nav-height); + width: 100%; +} + +.marketplace-subtitle { + font-size: 1.1rem; + color: var(--color-text-secondary); +} + +.filter-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.filter-label { + font-size: 0.9rem; + color: var(--color-text-secondary); + font-weight: 600; +} + +.filter-select { + padding: 0.6rem 2rem 0.6rem 1.25rem; + border-radius: 999px; + background: var(--color-bg-elevated-hover); + color: var(--color-text-primary); + border: 1px solid rgba(255,255,255,0.1); + appearance: none; + font-weight: 600; + + background-image: url('data:image/svg+xml;utf8,'); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 1em; + cursor: pointer; + transition: all 0.2s; +} + +.filter-select:hover { + border-color: var(--color-primary); + box-shadow: 0 0 8px var(--color-primary-glow); +} + +.marketplace-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; + padding-top: 1rem; +} + +.extension-card { + background: var(--bg-surface); + border: 1px solid rgba(255,255,255,0.05); + border-radius: var(--radius-md); + padding: 1rem; + display: flex; + flex-direction: column; + align-items: flex-start; + transition: all 0.2s; + box-shadow: 0 4px 15px rgba(0,0,0,0.3); + min-height: 140px; + position: relative; + overflow: hidden; +} + +.extension-card:hover { + background: var(--color-bg-elevated-hover); + transform: translateY(-4px); + box-shadow: 0 8px 25px rgba(0,0,0,0.5); +} + +.card-content-wrapper { + flex-grow: 1; + margin-bottom: 0.5rem; +} + +.extension-icon { + width: 50px; + height: 50px; + border-radius: 8px; + object-fit: contain; + margin-bottom: 0.75rem; + border: 2px solid var(--color-primary); + background-color: var(--color-bg-base); + flex-shrink: 0; + box-shadow: 0 0 10px var(--color-primary-glow); +} + +.extension-name { + font-size: 1.1rem; + font-weight: 700; + margin: 0; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +.extension-status-badge { + font-size: 0.75rem; + font-weight: 600; + padding: 0.15rem 0.5rem; + border-radius: 999px; + margin-top: 0.4rem; + display: inline-block; + letter-spacing: 0.5px; +} + +.badge-installed { + background: rgba(34, 197, 94, 0.2); + color: #4ade80; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.badge-local { + background: rgba(251, 191, 36, 0.2); + color: #fcd34d; + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.extension-action-button { + width: 100%; + padding: 0.6rem 1rem; + border-radius: 999px; + font-weight: 700; + font-size: 0.9rem; + border: none; + cursor: pointer; + transition: background 0.2s, transform 0.2s, box-shadow 0.2s; + margin-top: auto; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.btn-install { + background: var(--color-primary); + color: white; +} +.btn-install:hover { + background: #a78bfa; + transform: scale(1.02); + box-shadow: 0 0 15px var(--color-primary-glow); +} + +.btn-uninstall { + background: #dc2626; + color: white; + margin-top: auto; +} + +.btn-uninstall:hover { + background: #ef4444; + transform: scale(1.02); + box-shadow: 0 0 15px rgba(220, 38, 38, 0.4); +} + +.extension-card.skeleton { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + + box-shadow: none; + transform: none; +} +.extension-card.skeleton:hover { + background: var(--bg-surface); + box-shadow: none; + transform: none; +} +.skeleton-icon { + width: 50px; + height: 50px; + border-radius: 8px; + margin-bottom: 0.75rem; + + border: 2px solid rgba(255,255,255,0.05); +} +.skeleton-text.title-skeleton { + height: 1.1em; + margin-bottom: 0.25rem; +} +.skeleton-text.text-skeleton { + height: 0.7em; + margin-bottom: 0; +} +.skeleton-button { + width: 100%; + height: 32px; + border-radius: 999px; + margin-top: auto; +} + +.section-title { + font-size: 1.8rem; + font-weight: 800; + color: var(--color-text-primary); +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(5px); + display: flex; + justify-content: center; + align-items: center; + z-index: 5000; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease-in-out; +} + +.modal-overlay:not(.hidden) { + opacity: 1; + pointer-events: auto; +} + +.modal-content { + background: var(--bg-surface); + color: var(--color-text-primary); + padding: 2.5rem; + border-radius: var(--radius-lg); + width: 90%; + max-width: 450px; + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.8), 0 0 20px var(--color-primary-glow); + border: 1px solid rgba(255,255,255,0.1); + transform: translateY(-50px); + transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.3s ease-in-out; + opacity: 0; +} + +.modal-overlay:not(.hidden) .modal-content { + transform: translateY(0); + opacity: 1; +} + +#modalTitle { + font-size: 1.6rem; + font-weight: 800; + margin-top: 0; + color: var(--color-primary); + border-bottom: 2px solid rgba(255,255,255,0.05); + padding-bottom: 0.75rem; + margin-bottom: 1.5rem; +} + +#modalMessage { + font-size: 1rem; + line-height: 1.6; + color: var(--color-text-secondary); + margin-bottom: 2rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; +} + +.modal-button { + + padding: 0.6rem 1.5rem; + border-radius: 999px; + font-weight: 700; + font-size: 0.9rem; + border: none; + cursor: pointer; + transition: background 0.2s, transform 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.modal-button.btn-install { + background: var(--color-primary); + color: white; +} +.modal-button.btn-install:hover { + background: #a78bfa; + transform: scale(1.02); +} + +.modal-button.btn-uninstall { + background: #dc2626; + color: white; +} +.modal-button.btn-uninstall:hover { + background: #ef4444; + transform: scale(1.02); +} \ No newline at end of file diff --git a/docker/views/css/schedule/schedule.css b/docker/views/css/schedule/schedule.css new file mode 100644 index 0000000..23b010f --- /dev/null +++ b/docker/views/css/schedule/schedule.css @@ -0,0 +1,363 @@ +:root { + --bg-glass: rgba(18, 18, 21, 0.8); + --bg-cell: #0c0c0e; + --color-primary-glow: rgba(139, 92, 246, 0.3); +} + +body { + margin: 0; + background-color: var(--color-bg-base); + color: var(--color-text-primary); + overflow: hidden; + height: 100vh; + display: flex; + flex-direction: column; +} + +html.electron body { + padding-top: 0; +} + +.ambient-bg { + position: absolute; + inset: 0; + z-index: -1; + background-size: cover; + background-position: center; + opacity: 0.06; + filter: blur(120px) saturate(1.2); + transition: background-image 1s ease-in-out; + pointer-events: none; +} + +.calendar-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 3rem; + max-width: 1920px; + width: 100%; + margin: 0 auto; +} + +.calendar-controls { + padding: 1.5rem 0; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.month-selector { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.month-title { + font-size: 2.2rem; + font-weight: 800; + letter-spacing: -0.03em; + background: linear-gradient(to right, #fff, #a1a1aa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + min-width: 350px; +} + +.icon-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-subtle); + width: 44px; + height: 44px; + border-radius: 12px; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: 0.2s; +} + +.icon-btn:hover { + background: var(--color-primary); + border-color: var(--color-primary); + transform: translateY(-2px); +} + +.controls-right { + display: flex; + gap: 1rem; +} + +.view-toggles { + display: flex; + background: #0f0f12; + padding: 4px; + border-radius: 99px; + border: 1px solid var(--border-subtle); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.toggle-item { + padding: 10px 24px; + border-radius: 99px; + border: none; + background: transparent; + color: var(--color-text-secondary); + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.toggle-item.active { + background: var(--color-primary); + color: white; + box-shadow: 0 2px 10px var(--color-primary-glow); +} + +.calendar-board { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + background: var(--color-bg-elevated); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} + +.weekdays-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + border-bottom: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.02); + flex-shrink: 0; +} + +.weekday-header { + padding: 16px; + text-align: center; + text-transform: uppercase; + font-size: 0.75rem; + font-weight: 800; + color: var(--color-text-secondary); + letter-spacing: 0.1em; + border-right: 1px solid var(--border-subtle); +} + +.weekday-header:last-child { + border-right: none; +} + +.days-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + width: 100%; + overflow-y: auto; + flex: 1; + + grid-auto-rows: minmax(180px, 1fr); + background: var(--color-bg-base); +} + +.day-cell { + position: relative; + background: var(--bg-cell); + border-right: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + padding: 12px; + transition: background 0.2s; + overflow: hidden; +} + +.day-cell:nth-child(7n) { + border-right: none; +} + +.day-cell.empty { + background: rgba(0, 0, 0, 0.2); + pointer-events: none; +} + +.day-cell:hover { + background: #16161a; +} + +.day-cell.today { + background: rgba(139, 92, 246, 0.03); + box-shadow: inset 0 0 0 1px var(--color-primary); +} + +.day-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + z-index: 2; + pointer-events: none; +} + +.day-number { + font-size: 1.1rem; + font-weight: 700; + color: var(--color-text-secondary); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.day-cell.today .day-number { + background: var(--color-primary); + color: white; + box-shadow: 0 0 15px var(--color-primary-glow); +} + +.today-label { + font-size: 0.65rem; + font-weight: 800; + color: var(--color-primary); + letter-spacing: 0.05em; + text-transform: uppercase; + display: none; +} + +.day-cell.today .today-label { + display: block; +} + +.events-list { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + z-index: 2; + padding-right: 4px; +} + +.events-list::-webkit-scrollbar { + width: 4px; +} + +.events-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.anime-chip { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + padding: 8px 10px; + border-radius: 8px; + font-size: 0.8rem; + color: #d4d4d8; + text-decoration: none; + transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1); + cursor: pointer; + position: relative; + overflow: hidden; +} + +.anime-chip::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--color-primary); + opacity: 0; + transition: opacity 0.2s; +} + +.anime-chip:hover { + background: rgba(255, 255, 255, 0.1); + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + padding-left: 14px; +} + +.anime-chip:hover::before { + opacity: 1; +} + +.chip-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + margin-right: 8px; +} + +.chip-ep { + font-size: 0.7rem; + font-weight: 700; + color: var(--color-text-secondary); + background: rgba(0, 0, 0, 0.4); + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.cell-backdrop { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + opacity: 0; + transition: opacity 0.4s ease; + filter: grayscale(100%) brightness(0.25); + z-index: 1; + pointer-events: none; +} + +.day-cell:hover .cell-backdrop { + opacity: 1; +} + +.loader { + position: fixed; + bottom: 30px; + right: 30px; + background: #18181b; + border: 1px solid var(--border-subtle); + padding: 12px 24px; + border-radius: 99px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + transform: translateY(100px); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 1000; +} + +.loader.active { + transform: translateY(0); +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s infinite linear; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/docker/views/css/users.css b/docker/views/css/users.css new file mode 100644 index 0000000..ed90504 --- /dev/null +++ b/docker/views/css/users.css @@ -0,0 +1,741 @@ +.page-wrapper { + position: relative; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.background-gradient { + position: fixed; + inset: 0; + background: radial-gradient(ellipse at top, rgba(139, 92, 246, 0.15) 0%, transparent 60%), + radial-gradient(ellipse at bottom right, rgba(59, 130, 246, 0.1) 0%, transparent 50%); + z-index: 0; + animation: gradientShift 10s ease infinite; +} + +@keyframes gradientShift { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.content-container { + position: relative; + z-index: 10; + max-width: 1400px; + width: 100%; + margin: 0 auto; +} + +.header-section { + text-align: center; + margin-bottom: 4rem; + animation: fadeInDown 0.6s ease; +} + +.page-title { + font-size: 3.5rem; + font-weight: 900; + margin-bottom: 1rem; + background: linear-gradient(135deg, #ffffff 0%, #a78bfa 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.page-subtitle { + font-size: 1.2rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +.users-grid { + display: grid; + grid-template-columns: repeat(auto-fit, 260px); + gap: 2rem; + margin-bottom: 3rem; + animation: fadeInUp 0.8s ease; + justify-content: center; + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +.user-card { + position: relative; + aspect-ratio: 1; + cursor: pointer; + border-radius: var(--radius-lg); + overflow: hidden; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + background: var(--color-bg-elevated); + border: 2px solid rgba(255, 255, 255, 0.05); +} + +.user-card:hover { + transform: translateY(-12px) scale(1.02); + border-color: var(--color-primary); + box-shadow: 0 20px 40px rgba(139, 92, 246, 0.3); +} + +/* Badge de contraseΓ±a protegida */ +.user-card.has-password::after { + content: 'πŸ”’'; + position: absolute; + top: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.7); + padding: 0.4rem 0.7rem; + border-radius: var(--radius-md); + font-size: 0.9rem; + z-index: 10; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.user-avatar { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1e1e22 0%, #2a2a2f 100%); + position: relative; +} + +.user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%); +} + +.user-avatar-placeholder svg { + width: 50%; + height: 50%; + opacity: 0.3; +} + +.user-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 1rem 1.5rem; + background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 50%, transparent 100%); + transform: translateY(100%); + transition: transform 0.3s ease; +} + +.user-card:hover .user-info { + transform: translateY(0); +} + +.user-name { + font-size: 1.2rem; + font-weight: 700; + margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-status { + display: none; +} + +.user-config-btn { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.5); + border: none; + color: white; + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.3s, background 0.2s; + z-index: 20; +} + +.user-card:hover .user-config-btn { + opacity: 1; +} + +.user-config-btn:hover { + background: var(--color-primary); +} + +.btn-add-user { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + width: 100%; + max-width: 400px; + margin: 0 auto 2rem; + padding: 1.2rem 2rem; + background: rgba(139, 92, 246, 0.1); + border: 2px dashed var(--color-primary); + border-radius: 999px; + color: var(--color-primary); + font-weight: 700; + font-size: 1.1rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-add-user:hover { + background: rgba(139, 92, 246, 0.2); + transform: scale(1.05); + box-shadow: 0 10px 30px var(--color-primary-glow); +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + inset: 0; + z-index: 1000; + align-items: center; + justify-content: center; +} + +.modal.active { + display: flex; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(8px); + animation: fadeIn 0.3s ease; +} + +.modal-content { + position: relative; + z-index: 10; + background: var(--color-bg-elevated); + border-radius: var(--radius-lg); + padding: 2rem; + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + animation: scaleIn 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.modal-large { + max-width: 700px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.modal-header h2 { + font-size: 1.8rem; + font-weight: 800; +} + +.modal-close { + background: transparent; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0.5rem; + border-radius: 8px; + transition: all 0.2s; +} + +.modal-close:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--color-text-secondary); +} + +.form-group input[type="text"], +.form-group input[type="password"] { + width: 100%; + padding: 1rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + color: white; + font-family: inherit; + font-size: 1rem; + transition: all 0.2s; +} + +.form-group input[type="text"]:focus, +.form-group input[type="password"]:focus { + background: rgba(255, 255, 255, 0.08); + border-color: var(--color-primary); + box-shadow: 0 0 15px var(--color-primary-glow); + outline: none; +} + +.password-toggle-wrapper { + position: relative; +} + +.password-toggle-btn { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; +} + +.password-toggle-btn:hover { + color: white; +} + +.optional-label { + font-size: 0.85rem; + color: var(--color-text-muted); + font-weight: 400; +} + +.avatar-upload-area { + border: 2px dashed rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s; + background: rgba(255, 255, 255, 0.02); +} + +.avatar-upload-area:hover { + border-color: var(--color-primary); + background: rgba(139, 92, 246, 0.05); +} + +.avatar-upload-area.dragover { + border-color: var(--color-primary); + background: rgba(139, 92, 246, 0.1); +} + +.avatar-preview { + width: 120px; + height: 120px; + margin: 0 auto 1rem; + border-radius: var(--radius-md); + overflow: hidden; + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; +} + +.avatar-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar-preview-placeholder { + width: 60%; + height: 60%; + opacity: 0.3; +} + +.avatar-upload-text { + font-size: 0.9rem; + color: var(--color-text-secondary); + margin-bottom: 0.5rem; +} + +.avatar-upload-hint { + font-size: 0.75rem; + color: var(--color-text-secondary); + opacity: 0.7; +} + +input[type="file"] { + display: none; +} + +.modal-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; +} + +.btn-primary, +.btn-secondary { + flex: 1; + padding: 1rem 2rem; + border-radius: 999px; + font-weight: 700; + font-size: 1rem; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background: #7c3aed; + transform: scale(1.02); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.05); + color: var(--color-text-secondary); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +/* Estilos para modal de password */ +.password-modal-content { + padding: 1.5rem; +} + +.password-info { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: var(--radius-md); + padding: 1rem; + margin-bottom: 1.5rem; + color: #3b82f6; + font-size: 0.9rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.password-info svg { + flex-shrink: 0; + margin-top: 0.1rem; +} + +/* ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */ +.manage-actions-modal { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.manage-actions-modal .btn-action { + width: 100%; + padding: 1rem; + border-radius: var(--radius-md); + font-weight: 700; + border: 1px solid rgba(255, 255, 255, 0.1); + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + font-size: 1rem; +} + +.btn-action.edit { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.3); +} + +.btn-action.edit:hover { + background: rgba(59, 130, 246, 0.2); +} + +.btn-action.password { + background: rgba(245, 158, 11, 0.1); + color: #f59e0b; + border-color: rgba(245, 158, 11, 0.3); +} + +.btn-action.password:hover { + background: rgba(245, 158, 11, 0.2); +} + +.btn-action.delete { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.3); +} + +.btn-action.delete:hover { + background: rgba(239, 68, 68, 0.2); +} + +.btn-action.anilist { + background: rgba(2, 169, 255, 0.1); + color: #02a9ff; + border-color: rgba(2, 169, 255, 0.3); +} + +.btn-action.anilist:hover { + background: rgba(2, 169, 255, 0.2); +} + +.btn-action.cancel { + background: rgba(255, 255, 255, 0.05); + color: var(--color-text-secondary); +} + +.btn-action.cancel:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +.anilist-status { + padding: 1.5rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: var(--radius-md); + margin-bottom: 1.5rem; +} + +.anilist-connected { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.anilist-icon { + width: 50px; + height: 50px; + border-radius: var(--radius-md); + background: linear-gradient(135deg, #02a9ff 0%, #0170d9 100%); + display: flex; + align-items: center; + justify-content: center; + font-weight: 900; + color: white; +} + +.anilist-info h3 { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.anilist-info p { + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.btn-disconnect { + width: 100%; + padding: 1rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + border-radius: var(--radius-md); + font-weight: 700; + cursor: pointer; + transition: all 0.2s; +} + +.btn-disconnect:hover { + background: rgba(239, 68, 68, 0.2); +} + +.btn-connect { + width: 100%; + padding: 1rem; + background: linear-gradient(135deg, #02a9ff 0%, #0170d9 100%); + border: none; + color: white; + border-radius: var(--radius-md); + font-weight: 700; + cursor: pointer; + transition: all 0.2s; +} + +.btn-connect:hover { + transform: scale(1.02); + box-shadow: 0 10px 30px rgba(2, 169, 255, 0.3); +} + +.empty-state { + text-align: center; + padding: 4rem 2rem; + animation: fadeInUp 0.8s ease; +} + +.empty-icon { + width: 80px; + height: 80px; + margin: 0 auto 1.5rem; + opacity: 0.3; +} + +.empty-title { + font-size: 1.8rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.empty-text { + font-size: 1.1rem; + color: var(--color-text-secondary); + margin-bottom: 2rem; +} + +#userToastContainer { + position: fixed; + top: 20px; + right: 20px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.wb-toast { + padding: 1rem 1.5rem; + border-radius: var(--radius-md); + font-weight: 600; + color: white; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + opacity: 0; + transform: translateX(100%); + transition: all 0.5s ease-out; + pointer-events: all; + min-width: 250px; +} + +.wb-toast.show { + opacity: 1; + transform: translateX(0); +} + +.wb-toast.success { + background: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.4); +} + +.wb-toast.error { + background: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.4); +} + +.wb-toast.info { + background: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.4); +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: translateY(-30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.skeleton { + background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-md); +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Responsive */ +@media (max-width: 768px) { + .page-title { + font-size: 2.5rem; + } + + .users-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1.5rem; + } + + .modal-content { + padding: 1.5rem; + } +} \ No newline at end of file diff --git a/docker/views/gallery/gallery.html b/docker/views/gallery/gallery.html new file mode 100644 index 0000000..00f710e --- /dev/null +++ b/docker/views/gallery/gallery.html @@ -0,0 +1,137 @@ + + + + + + WaifuBoard - Gallery + + + + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ + + +
+ + +
+ + + + +
+ +
+ +
+
+ + + + + + + + + \ No newline at end of file diff --git a/docker/views/gallery/image.html b/docker/views/gallery/image.html new file mode 100644 index 0000000..9789c90 --- /dev/null +++ b/docker/views/gallery/image.html @@ -0,0 +1,122 @@ + + + + + + WaifuBoard - Gallery Item + + + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ + + + + Back to Gallery + + +
+ + +
+
+ +
+

Loading similar images...

+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/docker/views/list.html b/docker/views/list.html new file mode 100644 index 0000000..2871037 --- /dev/null +++ b/docker/views/list.html @@ -0,0 +1,278 @@ + + + + + + + My Lists - WaifuBoard + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ + + +
+
+

My List

+
+
+ 0 + Total Entries +
+
+ 0 + Watching +
+
+ 0 + Completed +
+
+ 0 + Planning +
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+ +
+
+

Loading your list...

+
+ + + +
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/views/marketplace.html b/docker/views/marketplace.html new file mode 100644 index 0000000..69933c6 --- /dev/null +++ b/docker/views/marketplace.html @@ -0,0 +1,164 @@ + + + + + + WaifuBoard - Marketplace + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ + + +
+ +
+
+ +
+

Explore, install, and manage all available data source extensions for WaifuBoard.

+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/docker/views/schedule.html b/docker/views/schedule.html new file mode 100644 index 0000000..3582fe7 --- /dev/null +++ b/docker/views/schedule.html @@ -0,0 +1,158 @@ + + + + + + WaifuBoard - Schedule + + + + + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ +
+ + + +
+
+
+ +
Loading...
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
Sun
+
+
+ +
+
+
+ +
+
+ Syncing Schedule... +
+ + + + + + + + + \ No newline at end of file diff --git a/docker/views/users.html b/docker/views/users.html new file mode 100644 index 0000000..da13032 --- /dev/null +++ b/docker/views/users.html @@ -0,0 +1,174 @@ + + + + + WaifuBoard - Users + + + + + + + + +
+ + WaifuBoard +
+
+ + + +
+
+ +
+
+ +
+
+

Who's exploring?

+

Select your profile to continue

+
+ +
+
+ + +
+
+ + + + + + + + + +
+ + + + + + \ No newline at end of file