From 28ff6ccc68dfe38fa4cea797e0d1fc6056800edf Mon Sep 17 00:00:00 2001 From: itsskaiya Date: Tue, 16 Dec 2025 21:50:22 -0500 Subject: [PATCH] Organized the differences between server and docker versions. We are launching a docker version (server version) today so we want to just organize the repo so its easier to navigate. --- .gitignore | 16 +- desktop/.dockerignore | 8 + desktop/.gitignore | 5 + desktop/Dockerfile | 15 + main.js => desktop/main.js | 0 .../package-lock.json | 0 package.json => desktop/package.json | 0 preload.js => desktop/preload.js | 0 {public => desktop/public}/assets/avatar.png | Bin .../public}/assets/placeholder.svg | 0 .../public}/assets/waifuboards.ico | Bin server.js => desktop/server.js | 0 .../src}/api/anilist/anilist.service.ts | 0 {src => desktop/src}/api/anilist/anilist.ts | 0 .../src}/api/anime/anime.controller.ts | 0 .../src}/api/anime/anime.routes.ts | 0 .../src}/api/anime/anime.service.ts | 0 .../src}/api/books/books.controller.ts | 0 .../src}/api/books/books.routes.ts | 0 .../src}/api/books/books.service.ts | 0 .../api/extensions/extensions.controller.ts | 0 .../src}/api/extensions/extensions.routes.ts | 0 .../src}/api/gallery/gallery.controller.ts | 0 .../src}/api/gallery/gallery.routes.ts | 0 .../src}/api/gallery/gallery.service.ts | 0 .../src}/api/list/list.controller.ts | 0 {src => desktop/src}/api/list/list.routes.ts | 0 {src => desktop/src}/api/list/list.service.ts | 0 .../src}/api/proxy/proxy.controller.ts | 0 .../src}/api/proxy/proxy.routes.ts | 0 .../src}/api/proxy/proxy.service.ts | 0 {src => desktop/src}/api/rpc/rp.service.ts | 0 .../src}/api/rpc/rpc.controller.ts | 0 {src => desktop/src}/api/rpc/rpc.routes.ts | 0 {src => desktop/src}/api/types.ts | 0 .../src}/api/user/user.controller.ts | 0 {src => desktop/src}/api/user/user.routes.ts | 0 {src => desktop/src}/api/user/user.service.ts | 0 {src => desktop/src}/scripts/anime/anime.js | 0 {src => desktop/src}/scripts/anime/animes.js | 0 {src => desktop/src}/scripts/anime/player.js | 0 {src => desktop/src}/scripts/auth-guard.js | 0 {src => desktop/src}/scripts/books/book.js | 0 {src => desktop/src}/scripts/books/books.js | 0 {src => desktop/src}/scripts/books/reader.js | 0 .../src}/scripts/gallery/gallery.js | 0 {src => desktop/src}/scripts/gallery/image.js | 0 {src => desktop/src}/scripts/list.js | 0 {src => desktop/src}/scripts/marketplace.js | 0 {src => desktop/src}/scripts/rpc-inapp.js | 0 .../src}/scripts/schedule/schedule.js | 0 {src => desktop/src}/scripts/titlebar.js | 0 .../src}/scripts/updateNotifier.js | 0 {src => desktop/src}/scripts/users.js | 0 .../src}/scripts/utils/auth-utils.js | 0 .../utils/continue-watching-manager.js | 0 .../src}/scripts/utils/list-modal-manager.js | 0 .../scripts/utils/media-metadata-utils.js | 0 .../src}/scripts/utils/notification-utils.js | 0 .../src}/scripts/utils/pagination-manager.js | 0 .../src}/scripts/utils/search-manager.js | 0 .../src}/scripts/utils/url-utils.js | 0 .../scripts/utils/youtube-player-utils.js | 0 {src => desktop/src}/shared/database.js | 0 {src => desktop/src}/shared/extensions.js | 0 {src => desktop/src}/shared/headless.js | 0 {src => desktop/src}/shared/queries.js | 0 {src => desktop/src}/shared/schemas.js | 0 {src => desktop/src}/views/views.routes.ts | 0 tsconfig.json => desktop/tsconfig.json | 0 {views => desktop/views}/anime/anime.html | 0 {views => desktop/views}/anime/animes.html | 0 {views => desktop/views}/anime/watch.html | 0 {views => desktop/views}/books/book.html | 0 {views => desktop/views}/books/books.html | 0 {views => desktop/views}/books/read.html | 0 {views => desktop/views}/css/anime/anime.css | 0 {views => desktop/views}/css/anime/watch.css | 0 {views => desktop/views}/css/books/book.css | 0 {views => desktop/views}/css/books/reader.css | 0 .../views}/css/components/anilist-modal.css | 0 .../views}/css/components/hero.css | 0 .../views}/css/components/navbar.css | 0 .../views}/css/components/titlebar.css | 0 .../views}/css/components/updateNotifier.css | 0 .../views}/css/gallery/gallery.css | 0 .../views}/css/gallery/image.css | 0 {views => desktop/views}/css/globals.css | 0 {views => desktop/views}/css/list.css | 0 {views => desktop/views}/css/marketplace.css | 0 .../views}/css/schedule/schedule.css | 0 {views => desktop/views}/css/users.css | 0 {views => desktop/views}/gallery/gallery.html | 0 {views => desktop/views}/gallery/image.html | 0 {views => desktop/views}/list.html | 0 {views => desktop/views}/marketplace.html | 0 {views => desktop/views}/schedule.html | 0 {views => desktop/views}/users.html | 0 docker/.dockerignore | 8 + docker/.gitignore | 5 + docker/Dockerfile | 15 + docker/README.md | 53 + docker/main.js | 41 + docker/package-lock.json | 3975 +++++++++++++++++ docker/package.json | 34 + docker/preload.js | 10 + docker/public/assets/avatar.png | Bin 0 -> 2011 bytes docker/public/assets/placeholder.svg | 1 + docker/public/assets/waifuboards.ico | Bin 0 -> 125811 bytes docker/server.js | 87 + docker/src/api/anilist/anilist.service.ts | 564 +++ docker/src/api/anilist/anilist.ts | 91 + docker/src/api/anime/anime.controller.ts | 107 + docker/src/api/anime/anime.routes.ts | 14 + docker/src/api/anime/anime.service.ts | 450 ++ docker/src/api/books/books.controller.ts | 116 + docker/src/api/books/books.routes.ts | 14 + docker/src/api/books/books.service.ts | 572 +++ .../api/extensions/extensions.controller.ts | 85 + .../src/api/extensions/extensions.routes.ts | 14 + docker/src/api/gallery/gallery.controller.ts | 126 + docker/src/api/gallery/gallery.routes.ts | 13 + docker/src/api/gallery/gallery.service.ts | 178 + docker/src/api/list/list.controller.ts | 166 + docker/src/api/list/list.routes.ts | 12 + docker/src/api/list/list.service.ts | 584 +++ docker/src/api/proxy/proxy.controller.ts | 60 + docker/src/api/proxy/proxy.routes.ts | 8 + docker/src/api/proxy/proxy.service.ts | 138 + docker/src/api/types.ts | 294 ++ docker/src/api/user/user.controller.ts | 269 ++ docker/src/api/user/user.routes.ts | 17 + docker/src/api/user/user.service.ts | 186 + docker/src/scripts/anime/anime.js | 301 ++ docker/src/scripts/anime/animes.js | 194 + docker/src/scripts/anime/player.js | 431 ++ docker/src/scripts/auth-guard.js | 81 + docker/src/scripts/books/book.js | 342 ++ docker/src/scripts/books/books.js | 134 + docker/src/scripts/books/reader.js | 695 +++ docker/src/scripts/gallery/gallery.js | 391 ++ docker/src/scripts/gallery/image.js | 320 ++ docker/src/scripts/list.js | 368 ++ docker/src/scripts/marketplace.js | 422 ++ docker/src/scripts/rpc-inapp.js | 9 + docker/src/scripts/schedule/schedule.js | 360 ++ docker/src/scripts/titlebar.js | 18 + docker/src/scripts/updateNotifier.js | 102 + docker/src/scripts/users.js | 977 ++++ docker/src/scripts/utils/auth-utils.js | 26 + .../utils/continue-watching-manager.js | 86 + .../src/scripts/utils/list-modal-manager.js | 226 + .../src/scripts/utils/media-metadata-utils.js | 192 + .../src/scripts/utils/notification-utils.js | 52 + .../src/scripts/utils/pagination-manager.js | 91 + docker/src/scripts/utils/search-manager.js | 176 + docker/src/scripts/utils/url-utils.js | 51 + .../src/scripts/utils/youtube-player-utils.js | 111 + docker/src/shared/database.js | 125 + docker/src/shared/extensions.js | 209 + docker/src/shared/headless.js | 117 + docker/src/shared/queries.js | 62 + docker/src/shared/schemas.js | 234 + docker/src/views/views.routes.ts | 82 + docker/tsconfig.json | 15 + docker/views/anime/anime.html | 224 + docker/views/anime/animes.html | 265 ++ docker/views/anime/watch.html | 197 + docker/views/books/book.html | 218 + docker/views/books/books.html | 234 + docker/views/books/read.html | 208 + docker/views/css/anime/anime.css | 297 ++ docker/views/css/anime/watch.css | 603 +++ docker/views/css/books/book.css | 194 + docker/views/css/books/reader.css | 547 +++ docker/views/css/components/anilist-modal.css | 268 ++ docker/views/css/components/hero.css | 120 + docker/views/css/components/navbar.css | 418 ++ docker/views/css/components/titlebar.css | 195 + .../views/css/components/updateNotifier.css | 83 + docker/views/css/gallery/gallery.css | 293 ++ docker/views/css/gallery/image.css | 260 ++ docker/views/css/globals.css | 301 ++ docker/views/css/list.css | 485 ++ docker/views/css/marketplace.css | 295 ++ docker/views/css/schedule/schedule.css | 363 ++ docker/views/css/users.css | 741 +++ docker/views/gallery/gallery.html | 137 + docker/views/gallery/image.html | 122 + docker/views/list.html | 278 ++ docker/views/marketplace.html | 164 + docker/views/schedule.html | 158 + docker/views/users.html | 174 + 193 files changed, 23188 insertions(+), 5 deletions(-) create mode 100644 desktop/.dockerignore create mode 100644 desktop/.gitignore create mode 100644 desktop/Dockerfile rename main.js => desktop/main.js (100%) rename package-lock.json => desktop/package-lock.json (100%) rename package.json => desktop/package.json (100%) rename preload.js => desktop/preload.js (100%) rename {public => desktop/public}/assets/avatar.png (100%) rename {public => desktop/public}/assets/placeholder.svg (100%) rename {public => desktop/public}/assets/waifuboards.ico (100%) rename server.js => desktop/server.js (100%) rename {src => desktop/src}/api/anilist/anilist.service.ts (100%) rename {src => desktop/src}/api/anilist/anilist.ts (100%) rename {src => desktop/src}/api/anime/anime.controller.ts (100%) rename {src => desktop/src}/api/anime/anime.routes.ts (100%) rename {src => desktop/src}/api/anime/anime.service.ts (100%) rename {src => desktop/src}/api/books/books.controller.ts (100%) rename {src => desktop/src}/api/books/books.routes.ts (100%) rename {src => desktop/src}/api/books/books.service.ts (100%) rename {src => desktop/src}/api/extensions/extensions.controller.ts (100%) rename {src => desktop/src}/api/extensions/extensions.routes.ts (100%) rename {src => desktop/src}/api/gallery/gallery.controller.ts (100%) rename {src => desktop/src}/api/gallery/gallery.routes.ts (100%) rename {src => desktop/src}/api/gallery/gallery.service.ts (100%) rename {src => desktop/src}/api/list/list.controller.ts (100%) rename {src => desktop/src}/api/list/list.routes.ts (100%) rename {src => desktop/src}/api/list/list.service.ts (100%) rename {src => desktop/src}/api/proxy/proxy.controller.ts (100%) rename {src => desktop/src}/api/proxy/proxy.routes.ts (100%) rename {src => desktop/src}/api/proxy/proxy.service.ts (100%) rename {src => desktop/src}/api/rpc/rp.service.ts (100%) rename {src => desktop/src}/api/rpc/rpc.controller.ts (100%) rename {src => desktop/src}/api/rpc/rpc.routes.ts (100%) rename {src => desktop/src}/api/types.ts (100%) rename {src => desktop/src}/api/user/user.controller.ts (100%) rename {src => desktop/src}/api/user/user.routes.ts (100%) rename {src => desktop/src}/api/user/user.service.ts (100%) rename {src => desktop/src}/scripts/anime/anime.js (100%) rename {src => desktop/src}/scripts/anime/animes.js (100%) rename {src => desktop/src}/scripts/anime/player.js (100%) rename {src => desktop/src}/scripts/auth-guard.js (100%) rename {src => desktop/src}/scripts/books/book.js (100%) rename {src => desktop/src}/scripts/books/books.js (100%) rename {src => desktop/src}/scripts/books/reader.js (100%) rename {src => desktop/src}/scripts/gallery/gallery.js (100%) rename {src => desktop/src}/scripts/gallery/image.js (100%) rename {src => desktop/src}/scripts/list.js (100%) rename {src => desktop/src}/scripts/marketplace.js (100%) rename {src => desktop/src}/scripts/rpc-inapp.js (100%) rename {src => desktop/src}/scripts/schedule/schedule.js (100%) rename {src => desktop/src}/scripts/titlebar.js (100%) rename {src => desktop/src}/scripts/updateNotifier.js (100%) rename {src => desktop/src}/scripts/users.js (100%) rename {src => desktop/src}/scripts/utils/auth-utils.js (100%) rename {src => desktop/src}/scripts/utils/continue-watching-manager.js (100%) rename {src => desktop/src}/scripts/utils/list-modal-manager.js (100%) rename {src => desktop/src}/scripts/utils/media-metadata-utils.js (100%) rename {src => desktop/src}/scripts/utils/notification-utils.js (100%) rename {src => desktop/src}/scripts/utils/pagination-manager.js (100%) rename {src => desktop/src}/scripts/utils/search-manager.js (100%) rename {src => desktop/src}/scripts/utils/url-utils.js (100%) rename {src => desktop/src}/scripts/utils/youtube-player-utils.js (100%) rename {src => desktop/src}/shared/database.js (100%) rename {src => desktop/src}/shared/extensions.js (100%) rename {src => desktop/src}/shared/headless.js (100%) rename {src => desktop/src}/shared/queries.js (100%) rename {src => desktop/src}/shared/schemas.js (100%) rename {src => desktop/src}/views/views.routes.ts (100%) rename tsconfig.json => desktop/tsconfig.json (100%) rename {views => desktop/views}/anime/anime.html (100%) rename {views => desktop/views}/anime/animes.html (100%) rename {views => desktop/views}/anime/watch.html (100%) rename {views => desktop/views}/books/book.html (100%) rename {views => desktop/views}/books/books.html (100%) rename {views => desktop/views}/books/read.html (100%) rename {views => desktop/views}/css/anime/anime.css (100%) rename {views => desktop/views}/css/anime/watch.css (100%) rename {views => desktop/views}/css/books/book.css (100%) rename {views => desktop/views}/css/books/reader.css (100%) rename {views => desktop/views}/css/components/anilist-modal.css (100%) rename {views => desktop/views}/css/components/hero.css (100%) rename {views => desktop/views}/css/components/navbar.css (100%) rename {views => desktop/views}/css/components/titlebar.css (100%) rename {views => desktop/views}/css/components/updateNotifier.css (100%) rename {views => desktop/views}/css/gallery/gallery.css (100%) rename {views => desktop/views}/css/gallery/image.css (100%) rename {views => desktop/views}/css/globals.css (100%) rename {views => desktop/views}/css/list.css (100%) rename {views => desktop/views}/css/marketplace.css (100%) rename {views => desktop/views}/css/schedule/schedule.css (100%) rename {views => desktop/views}/css/users.css (100%) rename {views => desktop/views}/gallery/gallery.html (100%) rename {views => desktop/views}/gallery/image.html (100%) rename {views => desktop/views}/list.html (100%) rename {views => desktop/views}/marketplace.html (100%) rename {views => desktop/views}/schedule.html (100%) rename {views => desktop/views}/users.html (100%) create mode 100644 docker/.dockerignore create mode 100644 docker/.gitignore create mode 100644 docker/Dockerfile create mode 100644 docker/README.md create mode 100644 docker/main.js create mode 100644 docker/package-lock.json create mode 100644 docker/package.json create mode 100644 docker/preload.js create mode 100644 docker/public/assets/avatar.png create mode 100644 docker/public/assets/placeholder.svg create mode 100644 docker/public/assets/waifuboards.ico create mode 100644 docker/server.js create mode 100644 docker/src/api/anilist/anilist.service.ts create mode 100644 docker/src/api/anilist/anilist.ts create mode 100644 docker/src/api/anime/anime.controller.ts create mode 100644 docker/src/api/anime/anime.routes.ts create mode 100644 docker/src/api/anime/anime.service.ts create mode 100644 docker/src/api/books/books.controller.ts create mode 100644 docker/src/api/books/books.routes.ts create mode 100644 docker/src/api/books/books.service.ts create mode 100644 docker/src/api/extensions/extensions.controller.ts create mode 100644 docker/src/api/extensions/extensions.routes.ts create mode 100644 docker/src/api/gallery/gallery.controller.ts create mode 100644 docker/src/api/gallery/gallery.routes.ts create mode 100644 docker/src/api/gallery/gallery.service.ts create mode 100644 docker/src/api/list/list.controller.ts create mode 100644 docker/src/api/list/list.routes.ts create mode 100644 docker/src/api/list/list.service.ts create mode 100644 docker/src/api/proxy/proxy.controller.ts create mode 100644 docker/src/api/proxy/proxy.routes.ts create mode 100644 docker/src/api/proxy/proxy.service.ts create mode 100644 docker/src/api/types.ts create mode 100644 docker/src/api/user/user.controller.ts create mode 100644 docker/src/api/user/user.routes.ts create mode 100644 docker/src/api/user/user.service.ts create mode 100644 docker/src/scripts/anime/anime.js create mode 100644 docker/src/scripts/anime/animes.js create mode 100644 docker/src/scripts/anime/player.js create mode 100644 docker/src/scripts/auth-guard.js create mode 100644 docker/src/scripts/books/book.js create mode 100644 docker/src/scripts/books/books.js create mode 100644 docker/src/scripts/books/reader.js create mode 100644 docker/src/scripts/gallery/gallery.js create mode 100644 docker/src/scripts/gallery/image.js create mode 100644 docker/src/scripts/list.js create mode 100644 docker/src/scripts/marketplace.js create mode 100644 docker/src/scripts/rpc-inapp.js create mode 100644 docker/src/scripts/schedule/schedule.js create mode 100644 docker/src/scripts/titlebar.js create mode 100644 docker/src/scripts/updateNotifier.js create mode 100644 docker/src/scripts/users.js create mode 100644 docker/src/scripts/utils/auth-utils.js create mode 100644 docker/src/scripts/utils/continue-watching-manager.js create mode 100644 docker/src/scripts/utils/list-modal-manager.js create mode 100644 docker/src/scripts/utils/media-metadata-utils.js create mode 100644 docker/src/scripts/utils/notification-utils.js create mode 100644 docker/src/scripts/utils/pagination-manager.js create mode 100644 docker/src/scripts/utils/search-manager.js create mode 100644 docker/src/scripts/utils/url-utils.js create mode 100644 docker/src/scripts/utils/youtube-player-utils.js create mode 100644 docker/src/shared/database.js create mode 100644 docker/src/shared/extensions.js create mode 100644 docker/src/shared/headless.js create mode 100644 docker/src/shared/queries.js create mode 100644 docker/src/shared/schemas.js create mode 100644 docker/src/views/views.routes.ts create mode 100644 docker/tsconfig.json create mode 100644 docker/views/anime/anime.html create mode 100644 docker/views/anime/animes.html create mode 100644 docker/views/anime/watch.html create mode 100644 docker/views/books/book.html create mode 100644 docker/views/books/books.html create mode 100644 docker/views/books/read.html create mode 100644 docker/views/css/anime/anime.css create mode 100644 docker/views/css/anime/watch.css create mode 100644 docker/views/css/books/book.css create mode 100644 docker/views/css/books/reader.css create mode 100644 docker/views/css/components/anilist-modal.css create mode 100644 docker/views/css/components/hero.css create mode 100644 docker/views/css/components/navbar.css create mode 100644 docker/views/css/components/titlebar.css create mode 100644 docker/views/css/components/updateNotifier.css create mode 100644 docker/views/css/gallery/gallery.css create mode 100644 docker/views/css/gallery/image.css create mode 100644 docker/views/css/globals.css create mode 100644 docker/views/css/list.css create mode 100644 docker/views/css/marketplace.css create mode 100644 docker/views/css/schedule/schedule.css create mode 100644 docker/views/css/users.css create mode 100644 docker/views/gallery/gallery.html create mode 100644 docker/views/gallery/image.html create mode 100644 docker/views/list.html create mode 100644 docker/views/marketplace.html create mode 100644 docker/views/schedule.html create mode 100644 docker/views/users.html 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 0000000000000000000000000000000000000000..09892098aa943af5fe95e7dd434a07fe03e7ccd1 GIT binary patch literal 2011 zcmV<12PF83P)D!y%g4vZ*x1<2%*?Q`u)x5;+S=OH)zz}HvdGBD zw6wIs!oteR%FWHqrlzLJ$;r^r(7(UGt*x!9s;a}o!>FjJ#>U3Iy}i%R&$YF+x3{;h zuCCtR-nO>3udlDMv9Z$9(zv*|ySuyA*4EqG+ZlWNivR!y*-1n}RCr$O+*gj>NDPM2 zpNco<96RTn9rwSi8F&oCZFlz#Qq{xgJ4+x4LZrx&UjP6A00000000000000000000 z0000000000h)13Iv~gIg9X6)(&Ma2$kko6>k^LchuGJIe1?9VT%ih&$-<6da;(GtY zRygU`V`YKSGPJdX%aOK)>O)}bd#Gv~*uA$6-S28s*y-CA`a9YL;;WKvrF0c*ZKyul z)*h=`7wH^W2Elqsjqd}?Xz*Ptp%PkVVMQxo5?Bs`i9Upe<)onxU}?Eo>Ro7Cj@nv* ze0H1D=vvOYdIRQ`ySe^?YRPg}s_G9&F3o|+E;KJf72K|Qva4ddGB4O%8ki>o6?HI`i*6#+w#5|Y?<y|LwN+|wJ-DOs*c9lZ(lz;YDS^$r~880p|x z??Sy~IVsik0el`?F2Y>=KFtcZ1R zaGELyc&axa?H!NJ`jhg5v~%-y@6vvAx%c&^lgbGsy=v!Q)n20Y000000000000000 z0000001(G=0_9$3(l{U9f4Ydqf&CDSql-`X!}G?Z(<>_@q?M!EmqlnRgo`h=qe`mH zp^F!L#dKMP=0-j66q)X!m2_UKU< z?=gcl`PrjD-Z_UQ!Oo*l-Y$R@ac4on7M&O-j9L_~HG+QG8x@#l(9NwQst~Om6uTU# z#^D0B?jkA@-9e`x4^-yi2NWuOs^gbXXX{ZRZwp;qmr8Y=vc4+;?DuSiV=6Wdp+vB! zYWn~xgnKHt51~MOqTGjPD@PoaW3S(TV1qAdpLGPU;{}n+CNZ&z1&l zAk)yBhI<>K$Szko#25&!NK0f~qZEphQ8{ z3aIf)#a^Mvnrf|~$}^REhB95M)P*|TX3iCPq&kn#$aR|+5*6uFl|Ix;s7M0Ewp3#a z)kYpw@J3LsOsPOxLcOd<@p>5y2p=fiLkJUGpQ800tayqjR`dizEyTFw96Gb|` z!YX_?aV~Q~zz%VD}8+~-=U|TkA>Od+oZ}G}nbJRjS zi+DxREJj@nPrRDb5Owl07n$q)ia(q8)f$NXg*KhY?6EB-U9U5WqIxO65S z|C%@HT<{BxaK1b6FYI^o5WlAp7RhA6Kem`8MTkGt$mX+;7JpOgV>ZtqfL8IK*!Dm6 zzQ>>P{QXCNTMXj=+Nnb$0D%|)f;hy>mx(1hlm3f?BA8dZ4V=5bdfd6DDtbSk-BC3w tPksUb00000000000000000000fEW70#Mag`cL)Fg002ovPDHLkV1gNB=am2e literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..12deec3081e29eaa42b450f5f50b88ef1b896fca GIT binary patch literal 125811 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 B3mE_a literal 0 HcmV?d00001 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}" +

+
+ `; + + } 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