diff --git a/README.md b/README.md index cc17357..bfc45f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,24 @@ # Chytanka -**Chytanka** is a versatile and user-friendly PWA for reading manga, comics, and other visual stories. Whether you prefer to read from popular online platforms, your own server, or local files, Chytanka is here to enhance your reading experience. +**Chytanka** is a lightweight, privacy-friendly PWA for reading manga, comics, and visual stories โ€” from online sources, your own server, or local files. + +No accounts. No tracking. Just reading. + +## ๐Ÿš€ Get Started + +๐Ÿ‘‰ [https://chytanka.ink](https://chytanka.ink) + +## โšก Why Chytanka? + +- ๐ŸŒ Open anything โ€” links, APIs, or local files +- ๐Ÿ“‚ No uploads โ€” your files stay on your device +- ๐Ÿง  Smart behavior โ€” auto-detect reading mode via tags +- ๐ŸŽฎ Gamepad ready โ€” full control without mouse +- ๐ŸŒ“ Comfort first โ€” night filter, fullscreen, responsive + +## ๐Ÿ–ผ๏ธ Preview + +![Chytanka Screenshot](./src/assets/screenshots/12fps-drop-file.webp) ## Features @@ -22,7 +40,7 @@ Chytanka supports opening episodes from the following platforms: - [ ] [Bluesky](https://bsky.app) -### ๐ŸŒ **Custom JSON API** +#### ๐ŸŒ **Custom JSON API** Chytanka can open episodes from any custom JSON API returning the following format: @@ -44,7 +62,7 @@ Chytanka can open episodes from any custom JSON API returning the following form } ``` -### ๐Ÿ“š **Create and Share Readlists** +#### ๐Ÿ“š **Create and Share Readlists** Compile a readlist using [Chytanka Readlist Creator](https://chytanka.ink/list): @@ -85,7 +103,7 @@ Immerse yourself in reading with a fullscreen option. ### ๐Ÿ•’ **Viewing History** - [x] Tracks history of supported links. -- [ ] File history support is planned. +- [x] File history support is planned. ### โŒจ๏ธ **Keyboard Shortcuts** @@ -138,32 +156,137 @@ Chytanka includes built-in support for gamepads (tested with PlayStation-style c If supported by the API, Chytanka warns users about NSFW content. +### ๐Ÿท๏ธ Tags in Titles + +Chytanka supports special tags inside file names or episode titles to automatically control viewer behavior. + +Tags are parsed from the title and applied on load. + +#### ๐Ÿ“Œ Supported Tags + +You can include any of the following words in the title: + +``` +rtl, ltr, ver, long, scroll, nsfw, sfw, color, bw, demo, extra +``` + +Example: + +``` +My Manga Vol.1 [rtl][nsfw] +Chapter 5 - [long][scroll] +Demo Episode [ltr] +``` + +#### ๐ŸŽฏ What Tags Do + +##### ๐Ÿ“– View Mode + +These tags control how pages are displayed: + +| Tag | Description | Mode | +| ----------- | ----------------------- | ---- | +| `[rtl]` | Right-to-left reading | 1 | +| `[ltr]` | Left-to-right reading | 2 | +| `[ver]` | Vertical / webtoon mode | 3 | +| `[long]` | Vertical / long strip | 3 | +| `[scroll]` | Vertical scrolling | 3 | + +> If multiple tags are present, the first matched tag wins. + +##### ๐Ÿ”ž Content Flags + +| Tag | Effect | +| -------- | --------------------- | +| `[nsfw]` | Marks episode as NSFW | +| `[sfw]` | (Reserved / optional) | + +##### ๐ŸŽจ Additional Tags (for future use) + +These tags are parsed but may be used later: + +- `[color]` โ€“ colored pages +- `[bw]` โ€“ black & white +- `[demo]` โ€“ demo content +- `[extra]` โ€“ bonus materials + +#### โš™๏ธ How It Works + +- Tags are case-insensitive +- Tags are detected as standalone words (e.g. rtl, not ultra) +- Parsed automatically from: + - file name + - episode title + +#### ๐Ÿ’ก Example + +``` +Attack on Titan - Chapter 1 [rtl][nsfw].cbz +``` + +Result: + +- View mode โ†’ Right-to-left +- NSFW flag โ†’ enabled + +#### ๐Ÿง  Notes + +- *Tags are processed on episode load* +- *They override default viewer settings* +- *Designed for compatibility with local files (.cbz, .zip) and remote sources* + ### ๐Ÿ–‡๏ธ **Embed Chytanka on Your Website** -Embed Chytanka using an iframe and interact with it via `postMessage`. Learn more in the [Embedding Guide](https://github.com/chytanka/chytanka.github.io/wiki/Embedding-Chytanka-on-Your-Website). +You can embed Chytanka into your website and control it via `postMessage`. + +Perfect for: + +- manga sites +- blogs +- personal collections + + Learn more in the [Embedding Guide](https://github.com/chytanka/chytanka.github.io/wiki/Embedding-Chytanka-on-Your-Website). --- -## Development server +## ๐Ÿ› ๏ธ Development -Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. +Chytanka requires a proxy server for handling some external sources. +Proxy repository: [https://github.com/chytanka/proxy](https://github.com/chytanka/proxy) -## Code scaffolding +> โš ๏ธ *Important*: Both Chytanka and the proxy must run on the *same host.* -Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. +### โ–ถ๏ธ Run locally on Windows -## Build +```bash +# Start proxy on the same host +set NODE_ENV=dev&& set HOST=192.168.0.0 && node index.js +``` + +```bash +# Start Chytanka app +ng serve --host 192.168.0.0 +``` -Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. +### ๐Ÿ’ก Notes -## Running unit tests +- *The proxy handles CORS and headers for external image sources.* +- *Make sure both services use the same host IP (e.g., 192.168.0.0).* +- *Adjust the host if your network requires a different local IP.* -Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). +### ๐Ÿง  Why a Proxy? -## Running end-to-end tests +- Bypasses CORS restrictions from image hosts +- Normalizes headers +- Ensures safe and consistent fetching of external content -Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. +## ๐Ÿ”— Other Chytanka Projects -## Further help +Chytanka is more than just a reader โ€” itโ€™s an ecosystem. Check out these companion projects: -To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. +| Project | What it does | Repository | +| ------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| **Meta Image Generator** | Generates social preview images (meta tags) for episodes | [chytanka-meta-image](https://github.com/chytanka/chytanka-meta-image) | +| **Chytanka Helper** | Browser extension: adds a button to open supported site links directly in Chytanka | [chytanka-helper](https://github.com/chytanka/chytanka-hepler) | +| **Opera GX Theme** | Custom theme for Opera GX tailored to Chytanka | [chytanka-gx-mod](https://github.com/chytanka/chytanka-gx-mod) | +| **Chytanka Make** | Create and edit CBZ files from images, reorder pages, add metadata | [chytanka-make](https://github.com/chytanka/chytanka-make) | diff --git a/package.json b/package.json index b9032dd..6ad740b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chytanka", - "version": "0.13.51", + "version": "0.13.54", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/src/app/@site-modules/@common-read/ui/common-read/common-read.component.html b/src/app/@site-modules/@common-read/ui/common-read/common-read.component.html index 4dcaff7..0200d3d 100644 --- a/src/app/@site-modules/@common-read/ui/common-read/common-read.component.html +++ b/src/app/@site-modules/@common-read/ui/common-read/common-read.component.html @@ -1,7 +1,7 @@ @if(episode$() | async; as episode){ @if (!(loading$() | async)) { -
diff --git a/src/app/@site-modules/@common-read/ui/common-read/common-read.component.ts b/src/app/@site-modules/@common-read/ui/common-read/common-read.component.ts index e91602f..1902aa3 100644 --- a/src/app/@site-modules/@common-read/ui/common-read/common-read.component.ts +++ b/src/app/@site-modules/@common-read/ui/common-read/common-read.component.ts @@ -22,6 +22,7 @@ export class CommonReadComponent { currentPlaylistItem = input(); refreshData = output(); + pageChange = output<{ total: number, current: number[] }>(); onRefreshData() { this.refreshData.emit(); diff --git a/src/app/@site-modules/@common-read/utils/composition.ts b/src/app/@site-modules/@common-read/utils/composition.ts index 259dfc6..fcf66b4 100644 --- a/src/app/@site-modules/@common-read/utils/composition.ts +++ b/src/app/@site-modules/@common-read/utils/composition.ts @@ -11,10 +11,10 @@ export interface CompositionImage { export interface CompositionPublisher { id: string; site: string; - avatar: CompositionImage; + avatar?: CompositionImage; description: string name: string; - links: Array<{ + links?: Array<{ link: string, title: string }> diff --git a/src/app/@site-modules/@common-read/utils/read-base-component.ts b/src/app/@site-modules/@common-read/utils/read-base-component.ts index 0b07723..52c1b3e 100644 --- a/src/app/@site-modules/@common-read/utils/read-base-component.ts +++ b/src/app/@site-modules/@common-read/utils/read-base-component.ts @@ -2,12 +2,12 @@ import { BehaviorSubject, MonoTypeOperatorFunction, Observable, OperatorFunction import { CompositionEpisode } from "./composition"; import { ActivatedRoute, ParamMap } from "@angular/router"; import { Title } from "@angular/platform-browser"; -import { ChangeDetectorRef, OnDestroy, WritableSignal, inject, signal } from "@angular/core"; +import { ChangeDetectorRef, OnDestroy, WritableSignal, inject, output, signal } from "@angular/core"; import { LangService } from "../../../shared/data-access/lang.service"; import { HistoryService } from "../../../history/data-access/history.service"; -import { ViewerService } from "../../../shared/data-access"; import { PlaylistItem, PlaylistService, isPlaylist } from "../../../playlist/data-access/playlist.service"; import { MetaTagsService } from "../../../shared/data-access/meta-tags.service"; +import { ViewerService } from "../../../viewer/services"; export abstract class ReadBaseComponent { protected refresh$: BehaviorSubject = new BehaviorSubject(null); @@ -109,21 +109,25 @@ export abstract class ReadBaseComponent { }) } - + site = ''; + post_id = ''; protected tapSaveToHistory(site: string, post_id: string): MonoTypeOperatorFunction { return tap(async (episode: CompositionEpisode) => { if (episode) { + this.site = site; + this.post_id = post_id; let e = structuredClone(episode); + const pages = e.images?.length ?? 0; e.images = []; - await this.saveToHistory(site, post_id, episode.title, episode.images[0]?.src, e); + await this.saveToHistory(site, post_id, episode.title, episode.images[0]?.src, e, pages, 1); } }) } public history: HistoryService = inject(HistoryService); - async saveToHistory(site: string, post_id: string, title: string, cover: string, episode: any) { - await this.history.addHistory(site, post_id, title, cover, episode); + async saveToHistory(site: string, post_id: string, title: string, cover: string, episode: any, pages: number = 0, page: number = 1) { + await this.history.addHistory(site, post_id, title, cover, episode, pages, page); } cdr = inject(ChangeDetectorRef) @@ -143,4 +147,10 @@ export abstract class ReadBaseComponent { }) } + + protected onPageChange(event: { total: number, current: number[] }) { + const lastPageIndex = event.current[event.current.length - 1] - 1; + this.history.updatePage(this.site, this.post_id, lastPageIndex + 1); + + } } \ No newline at end of file diff --git a/src/app/@site-modules/imgchest/imgchest-shell.component.ts b/src/app/@site-modules/imgchest/imgchest-shell.component.ts index ea26d7c..38655a8 100644 --- a/src/app/@site-modules/imgchest/imgchest-shell.component.ts +++ b/src/app/@site-modules/imgchest/imgchest-shell.component.ts @@ -8,7 +8,7 @@ import { IMGCHEST_PATH } from '../../app-routing.module'; @Component({ imports: [CommonReadModule], selector: 'app-imgchest-shell', - template: ` + template: ` ` diff --git a/src/app/@site-modules/imgur/imgur-shell.component.ts b/src/app/@site-modules/imgur/imgur-shell.component.ts index 8bc3b17..1d1b65b 100644 --- a/src/app/@site-modules/imgur/imgur-shell.component.ts +++ b/src/app/@site-modules/imgur/imgur-shell.component.ts @@ -8,7 +8,7 @@ import { IMGUR_PATH } from '../../app-routing.module'; @Component({ imports: [CommonReadModule], selector: 'app-imgur-shell', - template: ` + template: ` ` diff --git a/src/app/@site-modules/mangadex/mangadex-shell.component.ts b/src/app/@site-modules/mangadex/mangadex-shell.component.ts index 3a8ea02..ba0b1ba 100644 --- a/src/app/@site-modules/mangadex/mangadex-shell.component.ts +++ b/src/app/@site-modules/mangadex/mangadex-shell.component.ts @@ -8,7 +8,7 @@ import { MANGADEX_PATH } from '../../app-routing.module'; @Component({ imports: [CommonReadModule], selector: 'app-mangadex-shell', - template: ` + template: ` ` @@ -33,11 +33,13 @@ export default class MangadexShellComponent extends ReadBaseComponent { switchMap(ch => { const imgs$ = this.mangadex.getChapterImages(id); const manga$ = (ch.mangaId) ? this.mangadex.getManga(ch.mangaId) : of(null); + const group$ = (ch.publisher?.id !== null) ? this.mangadex.getScanlationGroup(ch.publisher?.id!) : of(null); - return forkJoin([imgs$, manga$]).pipe( - map(([imgs, manga]) => { + return forkJoin([imgs$, manga$, group$]).pipe( + map(([imgs, manga, group]) => { ch.images = imgs; ch.nsfw = manga?.nsfw ?? undefined; + ch.publisher = group ?? undefined; return ch; }), this.catchError() diff --git a/src/app/@site-modules/mangadex/mangadex.service.ts b/src/app/@site-modules/mangadex/mangadex.service.ts index f10c466..29b29e8 100644 --- a/src/app/@site-modules/mangadex/mangadex.service.ts +++ b/src/app/@site-modules/mangadex/mangadex.service.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { environment } from '../../../environments/environment'; import { Observable, catchError, map, throwError } from 'rxjs'; -import { CompositionEpisode, CompositionImage } from '../@common-read'; +import { CompositionEpisode, CompositionImage, CompositionPublisher } from '../@common-read'; import { ProxyService } from '../../shared/data-access/proxy.service'; import { isPlatformServer } from '@angular/common'; @@ -60,6 +60,32 @@ interface MdMangaResp { response: string, data: MdMangaData } +interface MdScanlationGroup { + id: string; + type: string; + attributes: { + name: string, + altNames: any[], + website: string, + ircServer: string, + ircChannel: string, + discord: string, + contactEmail: string, + description: string, + twitter: string, + mangaUpdates: string, + focusedLanguage: string[], + locked: boolean, + official: boolean, + verified: boolean, + inactive: boolean, + exLicensed: boolean, + publishDelay: string, + version: number, + createdAt: string, + updatedAt: string + } +} @Injectable({ providedIn: 'root' }) @@ -93,8 +119,14 @@ export class MangadexService { return this.http.get(url) .pipe( map((data: MdChapterResp) => { + const mangaId = data.data.relationships.filter(r => r.type == "manga")[0].id ?? null; + const publisherId = data.data.relationships.filter(r => r.type == "scanlation_group")[0].id ?? null; + return { - mangaId: data.data.relationships.filter(r => r.type == "manga")[0].id ?? null, + mangaId: mangaId, + publisher: { + id: publisherId, + }, title: data.data.attributes.title, episode: data.data.attributes.chapter, volume: data.data.attributes.volume, @@ -124,24 +156,23 @@ export class MangadexService { }) } + getScanlationGroup(id: string): Observable { + const endpoint = environment.mangadexScanlationGroup + id; + const url = isPlatformServer(this.platformId) ? endpoint : this.proxy.proxyUrl(endpoint); -} + return this.http.get<{ result: string, response: string, data: MdScanlationGroup }>(url) + .pipe(map(res => { + const data = res.data; + return { + id: data.id, + name: data.attributes.name, + description: data.attributes.description, + site: data.attributes.website ? data.attributes.website : `https://mangadex.org/group/${data.id}`, + }; + })); -/* - -{ - "result": "ok", - "baseUrl": "https://uploads.mangadex.org", - "chapter": { - "hash": "3303dd03ac8d27452cce3f2a882e94b2", - "data": [ - "1-f7a76de10d346de7ba01786762ebbedc666b412ad0d4b73baa330a2a392dbcdd.png" - ], - "dataSaver": [ - "1-27e7476475e60ad4cc4cefdb9b2dce29d84f490e145211f6b2e14b13bdb57f33.jpg" - ] } -} -*/ \ No newline at end of file + +} diff --git a/src/app/@site-modules/nhentai/nhentai-shell.component.ts b/src/app/@site-modules/nhentai/nhentai-shell.component.ts index e234e8b..2f0ffbf 100644 --- a/src/app/@site-modules/nhentai/nhentai-shell.component.ts +++ b/src/app/@site-modules/nhentai/nhentai-shell.component.ts @@ -8,7 +8,7 @@ import { CommonReadModule, ReadBaseComponent } from '../@common-read'; @Component({ imports: [CommonReadModule], selector: 'app-nhentai-shell', - template: `

{{lang.ph().imagesVia}}Nhentai diff --git a/src/app/@site-modules/nhentai/nhentai.service.ts b/src/app/@site-modules/nhentai/nhentai.service.ts index fccd67f..8db307f 100644 --- a/src/app/@site-modules/nhentai/nhentai.service.ts +++ b/src/app/@site-modules/nhentai/nhentai.service.ts @@ -23,18 +23,16 @@ export class NhentaiService { .pipe(map((data) => { return this.map(data) })) } - imageType = new Map().set('p', 'png').set('j', 'jpg').set('g', 'gif').set('w', 'webp') - map(data: any): CompositionEpisode { const mediaId = data.media_id; const mappedResponse = { title: data.title.pretty, nsfw: true, - images: (data.images.pages.map((item: any, index: number) => { + images: (data.pages.map((item: any, index: number) => { return { - src: `https://i1.nhentai.net/galleries/${mediaId}/${index + 1}.${this.imageType.get(item.t)}`, - height: item.h, - width: item.w + src: `https://i1.nhentai.net/${item.path}`, + height: item.height, + width: item.width }; })).filter((i: any) => i.src) .map((img: any) => { diff --git a/src/app/@site-modules/pixiv/pixiv-shell.component.ts b/src/app/@site-modules/pixiv/pixiv-shell.component.ts index 625e3e3..3e6388a 100644 --- a/src/app/@site-modules/pixiv/pixiv-shell.component.ts +++ b/src/app/@site-modules/pixiv/pixiv-shell.component.ts @@ -8,7 +8,7 @@ import { Base64 } from '../../shared/utils'; @Component({ selector: 'app-pixiv-shell', imports: [CommonReadModule], - template: `

{{lang.ph().imagesVia}}Pixiv diff --git a/src/app/@site-modules/read/read-shell.component.ts b/src/app/@site-modules/read/read-shell.component.ts index d1cc404..fb30708 100644 --- a/src/app/@site-modules/read/read-shell.component.ts +++ b/src/app/@site-modules/read/read-shell.component.ts @@ -8,7 +8,7 @@ import { READ_PATH } from '../../app-routing.module'; @Component({ selector: 'app-read-shell', imports: [CommonReadModule], - template: ` + template: `

{{lang.ph().imagesVia}}{{url()?.hostname}}. {{lang.ph().thanks}}
{{lang.ph().detalisCopy}}

diff --git a/src/app/@site-modules/reddit/reddit-shell.component.ts b/src/app/@site-modules/reddit/reddit-shell.component.ts index 6934f09..21d46c0 100644 --- a/src/app/@site-modules/reddit/reddit-shell.component.ts +++ b/src/app/@site-modules/reddit/reddit-shell.component.ts @@ -8,7 +8,7 @@ import { REDDIT_PATH } from '../../app-routing.module'; @Component({ imports: [CommonReadModule], selector: 'app-reddit-shell', - template: ` + template: ` ` diff --git a/src/app/@site-modules/telegraph/telegraph-shell.component.ts b/src/app/@site-modules/telegraph/telegraph-shell.component.ts index 3dc0ed8..1208a1c 100644 --- a/src/app/@site-modules/telegraph/telegraph-shell.component.ts +++ b/src/app/@site-modules/telegraph/telegraph-shell.component.ts @@ -9,7 +9,7 @@ import { CommonReadModule } from '../@common-read'; @Component({ selector: 'app-telegraph-shell', imports: [CommonReadModule], - template: ` diff --git a/src/app/@site-modules/yandere/yandere-shell.component.ts b/src/app/@site-modules/yandere/yandere-shell.component.ts index 40ce01e..97779a9 100644 --- a/src/app/@site-modules/yandere/yandere-shell.component.ts +++ b/src/app/@site-modules/yandere/yandere-shell.component.ts @@ -8,7 +8,7 @@ import { YANDERE_PATH } from '../../app-routing.module'; @Component({ selector: 'app-yandere-shell', imports: [CommonReadModule], - template: ` diff --git a/src/app/@site-modules/zenko/zenko-shell.component.ts b/src/app/@site-modules/zenko/zenko-shell.component.ts index a1b82a4..bff6dde 100644 --- a/src/app/@site-modules/zenko/zenko-shell.component.ts +++ b/src/app/@site-modules/zenko/zenko-shell.component.ts @@ -8,7 +8,7 @@ import { ZenkoService } from './zenko.service'; @Component({ selector: 'app-zenko-shell', imports: [CommonReadModule], - template: ` diff --git a/src/app/file/data-access/file-history.service.ts b/src/app/file/data-access/file-history.service.ts index 35bce3b..d22f445 100644 --- a/src/app/file/data-access/file-history.service.ts +++ b/src/app/file/data-access/file-history.service.ts @@ -33,9 +33,9 @@ export class FileHistoryService { const existingEntry = await this.db.table(HISTORY_TABLE_NAME).where({ sha256 }).first(); if (existingEntry) { - // Entry already exists, update the 'updated' field + // Entry already exists, update the 'updated' field and other relevant fields const now = new Date().toISOString(); - await this.db.table(HISTORY_TABLE_NAME).update(existingEntry.id, { arrayBuffer, updated: now }); + await this.db.table(HISTORY_TABLE_NAME).update(existingEntry.id, { arrayBuffer, updated: now, page }); } else { // Entry doesn't exist, add a new one const now = new Date().toISOString(); @@ -55,12 +55,12 @@ export class FileHistoryService { async getItemBySha256(sha256: string) { return await this.db.table(HISTORY_TABLE_NAME).where('sha256').equals(sha256).first(); -} + } async getAllHistoryWithoutBufferArray() { const records = await this.db.table(HISTORY_TABLE_NAME).orderBy('updated').reverse().toArray(); - + return records.map(({ arrayBuffer, ...rest }) => rest); } diff --git a/src/app/file/data-access/zip.worker.ts b/src/app/file/data-access/zip.worker.ts index d8a5923..b6b81d0 100644 --- a/src/app/file/data-access/zip.worker.ts +++ b/src/app/file/data-access/zip.worker.ts @@ -6,34 +6,87 @@ import JSZip from 'jszip'; import { filterImages, getAcbfFile, getComicInfoFile, processFile, processImagesInBatches } from '../utils'; +import { ZipWorkerMessageType } from '../models'; const metadataFiles = [ - { getter: getComicInfoFile, type: 'comicinfo' }, - { getter: getAcbfFile, type: 'acbf' }, + { getter: getComicInfoFile, type: ZipWorkerMessageType.ComicInfo }, + { getter: getAcbfFile, type: ZipWorkerMessageType.Acbf }, ]; -addEventListener('message', ({ data }) => { - const arrayBuffer = data.arrayBuffer; +// addEventListener('message', ({ data }) => { +// const arrayBuffer = data.arrayBuffer; - const zip = new JSZip(); +// const zip = new JSZip(); - zip.loadAsync(arrayBuffer) - .then(async zip => { - const filesName: string[] = Object.keys(zip.files); +// zip.loadAsync(arrayBuffer) +// .then(async zip => { +// const filesName: string[] = Object.keys(zip.files); - console.log(filesName); - +// // metadata +// for (const { getter, type } of metadataFiles) { +// await processFile(getter(filesName), zip, type); +// } - // metadata - for (const { getter, type } of metadataFiles) { - await processFile(getter(filesName), zip, type); - } +// // images +// const images = filterImages(filesName).sort(); +// postMessage({ type: ZipWorkerMessageType.ZipOpen, data: { count: images.length } }); - // images - const images = filterImages(filesName).sort(); - postMessage({ type: 'zipopen', data: { count: images.length } }); +// await processImagesInBatches(zip, images, 30); - await processImagesInBatches(zip, images, 30); +// }); +// }); +let zipRef: JSZip; +let imagesRef: string[] = []; + +addEventListener('message', async ({ data }) => { + if (data.type === 'init') { + zipRef = await new JSZip().loadAsync(data.arrayBuffer); + + const filesName = Object.keys(zipRef.files); + + // metadata + for (const { getter, type } of metadataFiles) { + await processFile(getter(filesName), zipRef, type); + } + + imagesRef = filterImages(filesName).sort(); + + postMessage({ + type: ZipWorkerMessageType.ZipOpen, + data: { count: imagesRef.length }, }); + } +}); + +addEventListener('message', async ({ data }) => { + if (data.type === 'loadBatch') { + const { start, count } = data; + + const batch = imagesRef.slice(start, start + count); + + await Promise.all( + batch.map(async (filename, index) => { + const file = zipRef.files[filename]; + if (!file) return; + + let blob: Blob; + + if (filename.endsWith('.svg') || filename.endsWith('.svgz')) { + const text = await file.async('string'); + blob = new Blob([text], { type: 'image/svg+xml' }); + } else { + blob = await file.async('blob'); + } + + const url = URL.createObjectURL(blob); + + postMessage({ + type: ZipWorkerMessageType.ImageLoad, + url, + index: start + index, + }); + }) + ); + } }); \ No newline at end of file diff --git a/src/app/file/models/index.ts b/src/app/file/models/index.ts new file mode 100644 index 0000000..63c59b6 --- /dev/null +++ b/src/app/file/models/index.ts @@ -0,0 +1 @@ +export * from './zip-worker-message-type.enum' \ No newline at end of file diff --git a/src/app/file/models/zip-worker-message-type.enum.ts b/src/app/file/models/zip-worker-message-type.enum.ts new file mode 100644 index 0000000..0ec2522 --- /dev/null +++ b/src/app/file/models/zip-worker-message-type.enum.ts @@ -0,0 +1,11 @@ +export enum ZipWorkerMessageType { + ComicInfo = 'comicinfo', + ZipOpen = 'zipopen', + ImageLoad = 'imageload', + Acbf = 'acbf' +} + +export enum ZipWorkerCommandType { + LoadBatch = 'loadBatch', + Init = 'init' +} \ No newline at end of file diff --git a/src/app/file/utils/process-images-in-batches.ts b/src/app/file/utils/process-images-in-batches.ts index 6402379..e7595fd 100644 --- a/src/app/file/utils/process-images-in-batches.ts +++ b/src/app/file/utils/process-images-in-batches.ts @@ -1,4 +1,5 @@ import JSZip from "jszip"; +import { ZipWorkerMessageType } from "../models"; export async function processImagesInBatches( zip: JSZip, @@ -26,7 +27,7 @@ export async function processImagesInBatches( const url = URL.createObjectURL(blob); postMessage({ - type: 'file', + type: ZipWorkerMessageType.ImageLoad, url, index: i + index, }); diff --git a/src/app/file/zip/facades/index.ts b/src/app/file/zip/facades/index.ts new file mode 100644 index 0000000..d5ad3ce --- /dev/null +++ b/src/app/file/zip/facades/index.ts @@ -0,0 +1,2 @@ +export * from './zip-worker.facade' +export * from './zip-history.facade' \ No newline at end of file diff --git a/src/app/file/zip/facades/zip-history.facade.ts b/src/app/file/zip/facades/zip-history.facade.ts new file mode 100644 index 0000000..31d2c03 --- /dev/null +++ b/src/app/file/zip/facades/zip-history.facade.ts @@ -0,0 +1,49 @@ +import { inject, Injectable, WritableSignal } from "@angular/core"; +import { FileSettingsService } from "../../data-access/file-settings.service"; +import { FileHistoryService } from "../../data-access/file-history.service"; +import { ZipWorkerFacade } from "./zip-worker.facade"; +import { CompositionEpisode } from "../../../@site-modules/@common-read"; + +@Injectable() +export class ZipHistoryFacade { + workerFacade = inject(ZipWorkerFacade) + fileHistory = inject(FileHistoryService) + fileSetts = inject(FileSettingsService) + + + async loadFromHistory(sha256: string, episode: WritableSignal) { + const { arrayBuffer, title } = await this.fileHistory.getItemBySha256(sha256) + if (!arrayBuffer) return; + + this.workerFacade.openArrayBuffer(arrayBuffer, title, sha256, episode) + } + + saveToHistory(sha256: string, title: string, arrayBuffer: ArrayBuffer, pages: number, size: number) { + // save to history only if enabled in settings, otherwise just save metadata, if retention time or storage limit is enabled, otherwise just save metadata + const sfth = this.fileSetts.saveFileToHistory() + // copy file to history only if retention time or storage limit is enabled, otherwise just save metadata + const ctfth = this.fileSetts.copyFileToHistory() + + const obj = { + arrayBuffer: (ctfth) ? arrayBuffer : null, + sha256: sha256, + pages: pages, + size: (ctfth) ? size : 0, + page: 1, + cover: '', + title: title, + format: 'zip' + } + + if (sfth) this.fileHistory.addHistory(obj) + } + + updateHistory(sha256: string, page: number) { + this.fileHistory.getItemBySha256(sha256).then(item => { + + if (!item) return; + item.page = page; + this.fileHistory.addHistory(item) + }) + } +} \ No newline at end of file diff --git a/src/app/file/zip/facades/zip-worker.facade.ts b/src/app/file/zip/facades/zip-worker.facade.ts new file mode 100644 index 0000000..bf26f55 --- /dev/null +++ b/src/app/file/zip/facades/zip-worker.facade.ts @@ -0,0 +1,47 @@ +import { Injectable, WritableSignal } from "@angular/core"; +import { CompositionEpisode } from "../../../@site-modules/@common-read"; +import { ZipWorkerCommandType } from "../../models"; + +@Injectable() +export class ZipWorkerFacade { + private worker!: Worker; + readonly batchSize = 4; + + initZipWorker(workerHandlers: Map) { + this.terminateWorker() + + if (typeof Worker !== 'undefined') { + this.worker = new Worker(new URL('../../data-access/zip.worker', import.meta.url)); + this.worker.onmessage = ({ data }) => { + const fn = workerHandlers.get(data.type) + if (fn) fn(data) + }; + } else { + console.error('Web Workers are not supported in this environment.'); + } + } + + terminateWorker() { + if (this.worker) + this.worker.terminate(); + } + + // worker + async openArrayBuffer(ab: ArrayBuffer, filename: string, sha256: string = '', episode: WritableSignal) { + // if (sha256 == '') this.sha256 = await this.fileHash.sha256(this.fs.file() as File) + + episode.set({ title: filename, images: [] }); + this.worker.postMessage({ arrayBuffer: ab, type: ZipWorkerCommandType.Init }); + } + + loadNextBatch(startIndex: number) { + const midBatchSize = this.batchSize / 2; + const calculatedStartIndex = startIndex - midBatchSize; + const start = calculatedStartIndex < 0 ? 0 : calculatedStartIndex; + this.worker.postMessage({ + type: ZipWorkerCommandType.LoadBatch, + start: start, + count: this.batchSize, + }); + } +} \ No newline at end of file diff --git a/src/app/file/zip/zip.component.html b/src/app/file/zip/zip.component.html index 0ec8be5..5b6c78f 100644 --- a/src/app/file/zip/zip.component.html +++ b/src/app/file/zip/zip.component.html @@ -1,5 +1,6 @@ -@if(episode && episode.images && episode.images.length > 0){ - +@if(!loading()) { + } @else { +๐Ÿ  } \ No newline at end of file diff --git a/src/app/file/zip/zip.component.ts b/src/app/file/zip/zip.component.ts index ba32907..4135aa8 100644 --- a/src/app/file/zip/zip.component.ts +++ b/src/app/file/zip/zip.component.ts @@ -1,154 +1,132 @@ import { Component, effect, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { FileService } from '../data-access/file.service'; import { SharedModule } from '../../shared/shared.module'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { CompositionEpisode, CompositionImage } from '../../@site-modules/@common-read'; import { DomManipulationService } from '../../shared/data-access'; import { ComicInfo } from '../../shared/utils/comic-info'; import { Acbf } from '../../shared/utils/acbf'; import { FileHashService } from '../data-access/file-hash.service'; -import { FileHistoryService } from '../data-access/file-history.service'; -import { FileSettingsService } from '../data-access/file-settings.service'; import { ViewerModule } from '../../viewer/viewer.module'; +import { ZipHistoryFacade, ZipWorkerFacade } from './facades'; +import { ZipWorkerMessageType } from '../models'; @Component({ selector: 'app-zip', - imports: [SharedModule, ViewerModule], + imports: [SharedModule, ViewerModule, RouterModule], templateUrl: './zip.component.html', - styleUrl: './zip.component.scss' + styleUrl: './zip.component.scss', + providers: [ZipWorkerFacade, ZipHistoryFacade] }) export class ZipComponent implements OnInit, OnDestroy { - private worker!: Worker; + workerFacade = inject(ZipWorkerFacade) + historyFacade = inject(ZipHistoryFacade) + + episode = signal({ title: '', images: [] }); + fileHash = inject(FileHashService) - fileHistory = inject(FileHistoryService) - fileSetts = inject(FileSettingsService) - sha256: string | undefined; arrayBuffer: ArrayBuffer | undefined - episode: CompositionEpisode | undefined; + loading = signal(true); router = inject(Router) private activatedRoute = inject(ActivatedRoute); dm = inject(DomManipulationService) fs = inject(FileService) - status = signal('') constructor() { - this.initZipWorker() + this.workerFacade.initZipWorker(this.workerHandlers) effect(() => { this.fileChange(); }); } + sha256Params: string = ''; ngOnInit() { this.sha256Params = this.activatedRoute.snapshot.params['sha256'] - if (this.sha256Params && this.sha256Params != '') - this.loadFromHistory(this.sha256Params) - } - - async loadFromHistory(sha256: string) { - const { arrayBuffer, title } = await this.fileHistory.getItemBySha256(sha256) - if (!arrayBuffer) return; - - this.sha256 = sha256 - this.arrayBuffer = arrayBuffer - this.openArrayBuffer(arrayBuffer, title, sha256) + if (this.sha256Params && this.sha256Params != '') { + this.historyFacade.loadFromHistory(this.sha256Params, this.episode) + this.loading.set(true); + } } ngOnDestroy() { - this.terminateWorker() - } - - terminateWorker() { - if (this.worker) - this.worker.terminate(); + this.workerFacade.terminateWorker(); } private workerHandlers = new Map() - .set('comicinfo', this.comicinfoHandler.bind(this)) - .set('zipopen', this.zipopenHandler.bind(this)) - .set('file', this.fileHandler.bind(this)) - .set('acbf', this.acbfHandler.bind(this)) + .set(ZipWorkerMessageType.ComicInfo, this.comicinfoHandler.bind(this)) + .set(ZipWorkerMessageType.ZipOpen, this.zipopenHandler.bind(this)) + .set(ZipWorkerMessageType.ImageLoad, this.imageloadHandler.bind(this)) + .set(ZipWorkerMessageType.Acbf, this.acbfHandler.bind(this)) private acbfHandler(msg: any) { const acbf = new Acbf(msg.data) - } private comicinfoHandler(msg: any) { const comicInfo = new ComicInfo(msg.data) - if (this.episode && comicInfo.title) { - this.episode.title = comicInfo.title - this.episode.volume = parseInt(comicInfo.volume ?? '') + if (this.episode() && comicInfo.title) { + this.episode().title = comicInfo.title + this.episode().volume = parseInt(comicInfo.volume ?? '') } } - private zipopenHandler(msg: any) { - const imgs: CompositionImage[] = [...Array(msg.data.count)].map((item: CompositionImage, index) => { return { src: `?id=${index}` } }); + private async zipopenHandler(msg: any) { + this.loading.set(false); + if (msg.data.count == 0) return; + + const imgs: CompositionImage[] = [...Array(msg.data.count)].map((item: CompositionImage, index) => { return { src: `/assets/no-image.svg?id=${index}` } }); + + this.workerFacade.loadNextBatch(0); if (this.episode) { - this.episode.images = imgs + this.episode().images = imgs + + const inputFile = this.fs.file() as File; - const obj = { - arrayBuffer: (this.fileSetts.copyFileToHistory()) ? this.arrayBuffer : null, - sha256: this.sha256, - pages: this.episode.images.length, - size: this.fs.file()?.size, - page: 1, - cover: '', - title: this.fs.file()?.name, - format: 'zip' - } + if (!inputFile) return; - if (this.fileSetts.saveFileToHistory()) this.fileHistory.addHistory(obj) + const sha256 = await this.fileHash.sha256(inputFile); + this.historyFacade.saveToHistory(sha256, this.episode().title, this.arrayBuffer!, this.episode().images.length, inputFile.size) } } - private fileHandler(msg: any) { + + private imageloadHandler(msg: any) { const { index, url } = msg; - if (this.episode) - this.episode.images[index].src = url - } - - initZipWorker() { - this.terminateWorker() - - if (typeof Worker !== 'undefined') { - this.worker = new Worker(new URL('../data-access/zip.worker', import.meta.url)); - this.worker.onmessage = ({ data }) => { - const fn = this.workerHandlers.get(data.type) - if (fn) fn(data) - }; - } else { - console.error('Web Workers are not supported in this environment.'); - } + if (this.episode() && this.episode().images[index]) + this.episode().images[index].src = url } fileChange() { - this.status.set(`Opening file: ${this.fs.file()?.name}`) const file = this.fs.file(); - if (file && this.worker) { + if (file && this.workerFacade) { const reader = new FileReader(); reader.onload = (e) => { this.arrayBuffer = reader.result as ArrayBuffer; - this.openArrayBuffer(this.arrayBuffer, file.name) + this.workerFacade.openArrayBuffer(this.arrayBuffer, file.name, '', this.episode) + this.loading.set(true); }; reader.readAsArrayBuffer(file); - } else { - // this.router.navigateByUrl('/') + } else if (!this.sha256Params) { + this.router.navigateByUrl('/') } } - async openArrayBuffer(ab: ArrayBuffer, filename: string, sha256: string = '') { - if (sha256 == '') this.sha256 = await this.fileHash.sha256(this.fs.file() as File) - - this.episode = { title: filename, images: [] } - this.worker.postMessage({ arrayBuffer: ab }); + onPageChange(event: { total: number, current: number[] }) { + const lastPageIndex = event.current[event.current.length - 1] - 1; + this.workerFacade.loadNextBatch(lastPageIndex); + const sha256 = this.sha256Params; + if (sha256) { + this.historyFacade.updateHistory(sha256, lastPageIndex + 1); + } } + } diff --git a/src/app/history/data-access/history.service.ts b/src/app/history/data-access/history.service.ts index df49b7d..959e596 100644 --- a/src/app/history/data-access/history.service.ts +++ b/src/app/history/data-access/history.service.ts @@ -21,28 +21,28 @@ export class HistoryService { this.db = new Dexie(HISTORY_DB_NAME); this.db.version(1).stores({ - history: '++id,site,post_id,title,cover,episode,created,updated' + history: '++id,site,post_id,title,cover,episode,pages,page,created,updated' }); } - async addHistory(site: string, post_id: string, title: string, cover: string, episode: any) { - if(!isPlatformBrowser(this.platformId)) return; + async addHistory(site: string, post_id: string, title: string, cover: string, episode: any, pages: number, page: number = 1) { + if (!isPlatformBrowser(this.platformId)) return; // await this.db.table(HISTORY_TABLE_NAME).add({ site, post_id, title, cover }); const existingEntry = await this.db.table(HISTORY_TABLE_NAME).where({ site, post_id: post_id }).first(); if (existingEntry) { // Entry already exists, update the 'updated' field const now = new Date().toISOString(); - await this.db.table(HISTORY_TABLE_NAME).update(existingEntry.id, { updated: now }); + await this.db.table(HISTORY_TABLE_NAME).update(existingEntry.id, { updated: now, page }); } else { // Entry doesn't exist, add a new one const now = new Date().toISOString(); - await this.db.table(HISTORY_TABLE_NAME).add({ site, title, cover, episode, post_id: post_id, created: now, updated: now }); + await this.db.table(HISTORY_TABLE_NAME).add({ site, title, cover, episode, post_id: post_id, pages, page, created: now, updated: now }); } } async getAllHistory() { - return (await this.db.table(HISTORY_TABLE_NAME).orderBy('updated').reverse().toArray()).map(v=>{v.site = [v.site]; return v}); + return (await this.db.table(HISTORY_TABLE_NAME).orderBy('updated').reverse().toArray()).map(v => { v.site = [v.site]; return v }); } async clearHistory() { @@ -52,4 +52,15 @@ export class HistoryService { async deleteHistoryItem(itemId: number) { await this.db.table(HISTORY_TABLE_NAME).delete(itemId); } + + async getItem(site: string, post_id: string) { + return await this.db.table(HISTORY_TABLE_NAME).where({ post_id, site }).first(); + } + + updatePage(site: string, post_id: string, page: number) { + this.db.table(HISTORY_TABLE_NAME).where({ post_id, site }).first().then(async item => { + if (!item) return; + await this.db.table(HISTORY_TABLE_NAME).update(item.id, { page }); + }) + } } diff --git a/src/app/shared/data-access/index.ts b/src/app/shared/data-access/index.ts index 15f6b01..1d71a97 100644 --- a/src/app/shared/data-access/index.ts +++ b/src/app/shared/data-access/index.ts @@ -1,5 +1,4 @@ export * from './dom-manipulation.service' -export * from './viewer.service' export * from './gamepad.service' export * from './browser.service' export * from './lang.service' diff --git a/src/app/shared/data-access/lang.service.ts b/src/app/shared/data-access/lang.service.ts index 763eb0e..34ecd48 100644 --- a/src/app/shared/data-access/lang.service.ts +++ b/src/app/shared/data-access/lang.service.ts @@ -2,7 +2,7 @@ import { ChangeDetectorRef, Injectable, PLATFORM_ID, WritableSignal, inject, sig import { Phrases } from '../utils/phrases'; import { Observable, Subject, map, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -import { ViewModeOption } from './viewer.service'; +import { ViewModeOption } from '../../viewer/services/viewer.service'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { VibrationService } from './vibration.service'; diff --git a/src/app/shared/ui/lang-toggle/lang-toggle.component.ts b/src/app/shared/ui/lang-toggle/lang-toggle.component.ts index 377455e..7a34556 100644 --- a/src/app/shared/ui/lang-toggle/lang-toggle.component.ts +++ b/src/app/shared/ui/lang-toggle/lang-toggle.component.ts @@ -1,15 +1,14 @@ import { ChangeDetectionStrategy, Component, WritableSignal, inject, signal } from '@angular/core'; import { LangService } from '../../data-access/lang.service'; -import { ViewModeOption } from '../../data-access'; @Component({ - selector: 'app-lang-toggle', - templateUrl: './lang-toggle.component.html', - styleUrl: './lang-toggle.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false + selector: 'app-lang-toggle', + templateUrl: './lang-toggle.component.html', + styleUrl: './lang-toggle.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class LangToggleComponent { lang = inject(LangService) diff --git a/src/app/shared/ui/pages-indicator/pages-indicator.component.ts b/src/app/shared/ui/pages-indicator/pages-indicator.component.ts index afc6eb0..02c261f 100644 --- a/src/app/shared/ui/pages-indicator/pages-indicator.component.ts +++ b/src/app/shared/ui/pages-indicator/pages-indicator.component.ts @@ -30,7 +30,6 @@ export class PagesIndicatorComponent { if (this.activeIndexs().length == 0) return [] const totalPages = this.images().length const currentPage = Math.max(...this.activeIndexs().map(v => v + 1)) - // console.log(currentPage); return pairPagination({ totalPages, currentPage }) as number[] }) diff --git a/src/app/shared/ui/title-card/title-card.component.html b/src/app/shared/ui/title-card/title-card.component.html index 50518bb..639cbc8 100644 --- a/src/app/shared/ui/title-card/title-card.component.html +++ b/src/app/shared/ui/title-card/title-card.component.html @@ -1,17 +1,32 @@ @let isLink = (value().site && value().post_id); +@let nsfw = value().episode?.nsfw == true; @if (isLink) {
- +
- - {{value().title || lang.ph().untitled}}
- {{siteTag()}}  - @if(value().size){ {{value().size | filesize}}  } - @if(value().page && value().pages){ {{value().page}}/{{value().pages}}  } +
+ + {{value().title || lang.ph().untitled}} + + @if(value().page && value().pages){ +
+
    +
  • {{value().page}}/{{value().pages}}
  • +
+ +
+ } +
    + @if(nsfw){
  • NSFW
  • } +
  • {{siteTag()}}
  • + @if(value().size){
  • {{value().size | filesize}}
  • } + +
+
- } @else {
@@ -21,6 +36,10 @@ {{value().title || lang.ph().untitled}} @if(value().page && value().pages){ {{value().page}}/{{value().pages}} }
} -{{value().updated | date : 'short'}} + + @let date = value().updated; + {{date | date : 'shortDate'}}
+ {{date | date : 'shortTime'}} +
\ No newline at end of file diff --git a/src/app/shared/ui/title-card/title-card.component.scss b/src/app/shared/ui/title-card/title-card.component.scss index 5c813cb..54d9b40 100644 --- a/src/app/shared/ui/title-card/title-card.component.scss +++ b/src/app/shared/ui/title-card/title-card.component.scss @@ -15,6 +15,7 @@ .cover-wrapper { position: relative; + overflow: hidden; .site-logo { position: absolute; @@ -32,26 +33,71 @@ .cover { border-radius: 4px; width: 100%; - aspect-ratio: 1; + aspect-ratio: 2/3; object-fit: cover; display: block; + cursor: pointer; + + &.nsfw { + filter: blur(8px); + } } .title { word-break: break-word; display: -webkit-box; -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + -webkit-line-clamp: 1; overflow: hidden; + line-height: 1; + +} + +.tags { + display: inline-flex; + gap: 1ch; + list-style: none; + padding: 0; + margin: 0; + overflow-x: auto; + scrollbar-width: none; } .site-tag { + font-weight: bold; line-height: 1; - padding: .25ch; + padding: .5ch 1ch; border-radius: .25ch; background-color: #166496; color: white; - filter: grayscale(1); text-transform: uppercase; - font-size: .6rem; + font-size: .64rem; + display: inline-block; + + &.nsfw-tag { + background-color: #961616; + } +} + +progress { + width: 100%; + height: 4px; + border: none; + border-radius: 2px; + background-color: var(--border-color); + overflow: hidden; + --value-color: #caa902; + + &.done { + --value-color: #169664; + } + + &::-webkit-progress-bar { + background-color: transparent; + } + + &::-webkit-progress-value { + background-color: var(--value-color); + border-radius: 2px; + } } \ No newline at end of file diff --git a/src/app/shared/ui/title-card/title-card.component.ts b/src/app/shared/ui/title-card/title-card.component.ts index 088b3fd..9e3af47 100644 --- a/src/app/shared/ui/title-card/title-card.component.ts +++ b/src/app/shared/ui/title-card/title-card.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, InputSignal, output, computed, inject, input } from '@angular/core'; import { LangService } from '../../data-access/lang.service'; +import { CompositionEpisode } from '../../../@site-modules/@common-read'; export interface TitleCartItem { site: string[]; @@ -7,6 +8,7 @@ export interface TitleCartItem { cover: string; title: string; updated: string; + episode?: CompositionEpisode; id: number; size: number; page: number; @@ -34,6 +36,7 @@ export class TitleCardComponent { }); routeLink = computed(() => ['', ...this.value().site, this.value().post_id]) + queryParams = computed(() => ({ page: this.value().page || 1 })); siteTag = computed(() => { const arr = [...this.value().site] return arr.pop(); diff --git a/src/app/viewer/facades/index.ts b/src/app/viewer/facades/index.ts index dab12cc..defd743 100644 --- a/src/app/viewer/facades/index.ts +++ b/src/app/viewer/facades/index.ts @@ -6,4 +6,5 @@ export * from './view-mode.facade'; export * from './page-tracking.facade'; export * from './viewer-keyboard.facade'; export * from './viewer-scroll.facade'; -export * from './readlist.facade'; \ No newline at end of file +export * from './readlist.facade'; +export * from './viewer-title-tag.facade'; \ No newline at end of file diff --git a/src/app/viewer/facades/page-tracking.facade.ts b/src/app/viewer/facades/page-tracking.facade.ts index a01aa7f..b2a537e 100644 --- a/src/app/viewer/facades/page-tracking.facade.ts +++ b/src/app/viewer/facades/page-tracking.facade.ts @@ -3,6 +3,7 @@ import { Injectable, signal, computed, PLATFORM_ID, inject, Signal, effect } fro import { ViewModeFacade } from "./view-mode.facade"; import { EmbedFacade } from "./viewer-embed.facade"; import { CompositionEpisode } from "../../@site-modules/@common-read"; +import { ActivatedRoute, Router } from "@angular/router"; @Injectable() export class PageTrackingFacade { @@ -17,6 +18,8 @@ export class PageTrackingFacade { activeIndexes = signal([]); pagesCount = signal(0); + pageChangeFunction: ((total: number, current: number[]) => void) | null = null; + setActive(indexes: number[]) { this.activeIndexes.set(indexes); } @@ -53,6 +56,7 @@ export class PageTrackingFacade { } private _update() { + const prewActive = this.activeIndexes(); if (this._pagesElement() == null || this._longStripElement() == null || this._figuresElement() == null) return; const isPageMode = this.viewMode.mode() == 'pages'; @@ -79,11 +83,30 @@ export class PageTrackingFacade { this.activeIndexes.set(activeIndxs); + if (JSON.stringify(prewActive) === JSON.stringify(activeIndxs)) return; const total = this.pagesCount() || this._figuresElement()!.length; const current = activeIndxs.map(i => i + 1) + if(!current.length) return; + this.embedFacade.postPageChange(total, current); + this.setRoutePage(current[current.length - 1] - 1); + + if (this.pageChangeFunction) this.pageChangeFunction(total, current); + } + private router = inject(Router); + + setRoutePage(index: number) { + if (!index || index < 0) return; + + const queryParams = { page: index + 1 }; + + this.router.navigate([], { + queryParams: { ...queryParams }, + queryParamsHandling: 'merge', + }); } + } \ No newline at end of file diff --git a/src/app/viewer/facades/view-mode.facade.ts b/src/app/viewer/facades/view-mode.facade.ts index 5d04622..2218642 100644 --- a/src/app/viewer/facades/view-mode.facade.ts +++ b/src/app/viewer/facades/view-mode.facade.ts @@ -1,5 +1,5 @@ import { Injectable, computed, inject } from "@angular/core"; -import { ViewerService } from "../../shared/data-access"; +import { ViewerService } from "../services"; @Injectable() export class ViewModeFacade { diff --git a/src/app/viewer/facades/viewer-scroll.facade.ts b/src/app/viewer/facades/viewer-scroll.facade.ts index 0adf4c5..c36c0b5 100644 --- a/src/app/viewer/facades/viewer-scroll.facade.ts +++ b/src/app/viewer/facades/viewer-scroll.facade.ts @@ -1,9 +1,13 @@ import { inject, Injectable, signal } from "@angular/core"; import { DomManipulationService } from "../../shared/data-access"; import { VibrationService } from "../../shared/data-access/vibration.service"; +import { ActivatedRoute } from "@angular/router"; +import { PageTrackingFacade } from "./page-tracking.facade"; @Injectable() export class ViewerScrollFacade { + pageTracking = inject(PageTrackingFacade); + private _isScrollStart: boolean = false; private readonly _verAmount = 256; @@ -55,12 +59,27 @@ export class ViewerScrollFacade { } } - scrollToPage(index: number) { - const el = this._pagesElement()!.querySelector(`#page_${index + 1}`); + scrollToPage(index: number, behavior: ScrollBehavior = 'smooth') { + // const el = this._pagesElement()!.querySelector(`#page_${index + 1}`); + const el = this._pagesElement()!.querySelector(`[data-page-index="${index}"]`); + el?.scrollIntoView({ - behavior: 'smooth', + behavior: behavior, block: 'start', inline: 'center' }); } + + private route = inject(ActivatedRoute); + + toRoutePage() { + const routePage = this.route.snapshot.queryParamMap.get('page'); + if (routePage) { + const pageIndex = parseInt(routePage, 10) - 1; + + if (!isNaN(pageIndex) && pageIndex >= 0) { + this.scrollToPage(pageIndex, 'instant'); + } + } + } } \ No newline at end of file diff --git a/src/app/viewer/facades/viewer-title-tag.facade.ts b/src/app/viewer/facades/viewer-title-tag.facade.ts new file mode 100644 index 0000000..8d8339d --- /dev/null +++ b/src/app/viewer/facades/viewer-title-tag.facade.ts @@ -0,0 +1,34 @@ +import { inject, Injectable, Signal } from "@angular/core"; +import { parseTags, resolveViewMode } from "../../shared/utils"; +import { ViewModeFacade } from "./view-mode.facade"; +import { CompositionEpisode } from "../../@site-modules/@common-read"; + +/** + * Facade for the viewer title tag. + * This is used to set view mode by tags in title + * and set nsfw warning y tags in title. + */ + +@Injectable() +export class ViewerTitleTagFacade { + viewMode = inject(ViewModeFacade); + + private _episode!: Signal; + + initialize(ep: Signal) { + this._episode = ep; + + const tags = parseTags(this._episode().title); + this.applyEpisodeTitleTags(tags); + } + + private applyEpisodeTitleTags(tags: Set): void { + const code = resolveViewMode(tags); + + if (code) this.viewMode.setModeByCode(code); + + if (tags.has('nsfw')) { + this._episode().nsfw = true; + } + } +} \ No newline at end of file diff --git a/src/app/viewer/facades/viewer-ui.facade.ts b/src/app/viewer/facades/viewer-ui.facade.ts index ed6972c..ed1ad33 100644 --- a/src/app/viewer/facades/viewer-ui.facade.ts +++ b/src/app/viewer/facades/viewer-ui.facade.ts @@ -1,10 +1,14 @@ import { DOCUMENT, isPlatformServer } from "@angular/common"; -import { inject, Injectable, PLATFORM_ID, signal } from "@angular/core"; +import { effect, inject, Injectable, PLATFORM_ID, signal } from "@angular/core"; import { DomManipulationService } from "../../shared/data-access"; +import { ViewerScrollFacade } from "./viewer-scroll.facade"; +import { PageTrackingFacade } from "./page-tracking.facade"; @Injectable() export class ViewerUiFacade { private platformId = inject(PLATFORM_ID); + private scroll = inject(ViewerScrollFacade); + private pageTracking = inject(PageTrackingFacade); private _viewElement = signal(null); private document = inject(DOCUMENT); @@ -18,17 +22,12 @@ export class ViewerUiFacade { this.showOverlay.update(v => !v); } - // TODO: Fix scroll position reset after exiting fullscreen in pages mode toggleFullScreen = () => { if (this._viewElement() == null) return; - // const activeIndexs = this.activeIndexs(); - // const page = (activeIndexs.length == 1) ? activeIndexs[0] : activeIndexs.filter(v => v+1 % 2 != 0)[0]; - // console.log(activeIndexs, page); + const page = this.getCurentPage(); this.dm.toggleFullScreen(this._viewElement()!); - - // if (page != undefined) - // setTimeout(() => {this.onActive(page)}, 100); + this.scrollToCurrentPage(page); } setDialog(open: boolean) { @@ -46,4 +45,18 @@ export class ViewerUiFacade { this.isFullScreen.set(this.document.fullscreenElement === this._viewElement()); }); } + + private getCurentPage() { + const activeIndexs = this.pageTracking.activeIndexes(); + const page = (activeIndexs.length == 1) ? activeIndexs[0] : activeIndexs.filter(v => v + 1 % 2 != 0)[0]; + + return page; + } + + private scrollToCurrentPage(page: number) { + if (page != undefined) + setTimeout(() => { + this.scroll.scrollToPage(page, 'instant'); + }, 100); + } } \ No newline at end of file diff --git a/src/app/viewer/services/index.ts b/src/app/viewer/services/index.ts new file mode 100644 index 0000000..a153f43 --- /dev/null +++ b/src/app/viewer/services/index.ts @@ -0,0 +1 @@ +export * from './viewer.service'; \ No newline at end of file diff --git a/src/app/shared/data-access/viewer.service.ts b/src/app/viewer/services/viewer.service.ts similarity index 98% rename from src/app/shared/data-access/viewer.service.ts rename to src/app/viewer/services/viewer.service.ts index 9b635db..33769db 100644 --- a/src/app/shared/data-access/viewer.service.ts +++ b/src/app/viewer/services/viewer.service.ts @@ -17,9 +17,7 @@ export const VIEV_MODE_OPTIONS: ViewModeOption[] = [ const VIEW_MODE_OPT_NAME = `viewModeOption`; -@Injectable({ - providedIn: 'root' -}) +@Injectable() export class ViewerService { public viewModeOptions = VIEV_MODE_OPTIONS; platformId = inject(PLATFORM_ID) diff --git a/src/app/viewer/viewer.module.ts b/src/app/viewer/viewer.module.ts index 370f1cd..75409a1 100644 --- a/src/app/viewer/viewer.module.ts +++ b/src/app/viewer/viewer.module.ts @@ -3,7 +3,8 @@ import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { VIEWER_DECLARABLES } from './viewer.declarables'; import { SharedModule } from '../shared/shared.module'; -import { EmbedFacade, GamepadFacade, KeyboardFacade, NsfwFacade, PageTrackingFacade, ReadlistFacade, ViewerScrollFacade, ViewerUiFacade, ViewModeFacade } from './facades'; +import { EmbedFacade, GamepadFacade, KeyboardFacade, NsfwFacade, PageTrackingFacade, ReadlistFacade, ViewerScrollFacade, ViewerTitleTagFacade, ViewerUiFacade, ViewModeFacade } from './facades'; +import { ViewerService } from './services'; @NgModule({ imports: [ @@ -12,6 +13,7 @@ import { EmbedFacade, GamepadFacade, KeyboardFacade, NsfwFacade, PageTrackingFac SharedModule ], providers: [ + ViewerService, NsfwFacade, GamepadFacade, EmbedFacade, @@ -20,7 +22,8 @@ import { EmbedFacade, GamepadFacade, KeyboardFacade, NsfwFacade, PageTrackingFac PageTrackingFacade, KeyboardFacade, ViewerScrollFacade, - ReadlistFacade + ReadlistFacade, + ViewerTitleTagFacade ], declarations: [...VIEWER_DECLARABLES], exports: [...VIEWER_DECLARABLES] diff --git a/src/app/viewer/viewer/components/episode-share-form/episode-share-form.component.ts b/src/app/viewer/viewer/components/episode-share-form/episode-share-form.component.ts index 103cce1..9920a7b 100644 --- a/src/app/viewer/viewer/components/episode-share-form/episode-share-form.component.ts +++ b/src/app/viewer/viewer/components/episode-share-form/episode-share-form.component.ts @@ -1,9 +1,10 @@ import { Component, computed, inject, input, output, PLATFORM_ID, Signal } from '@angular/core'; -import { DomManipulationService, ViewerService } from '../../../../shared/data-access'; +import { DomManipulationService } from '../../../../shared/data-access'; import { CompositionEpisode } from '../../../../@site-modules/@common-read'; import { isPlatformBrowser } from '@angular/common'; import { LangService } from '../../../../shared/data-access/lang.service'; import { DomSanitizer } from '@angular/platform-browser'; +import { ViewerService } from '../../../services'; @Component({ selector: 'episode-share-form', diff --git a/src/app/viewer/viewer/components/hint-page/hint-page.component.ts b/src/app/viewer/viewer/components/hint-page/hint-page.component.ts index 67e803c..55f2092 100644 --- a/src/app/viewer/viewer/components/hint-page/hint-page.component.ts +++ b/src/app/viewer/viewer/components/hint-page/hint-page.component.ts @@ -1,5 +1,5 @@ import { Component, inject, input } from '@angular/core'; -import { ViewerService } from '../../../../shared/data-access'; +import { ViewerService } from '../../../services'; import { LangService } from '../../../../shared/data-access/lang.service'; import { Playlist, PlaylistItem } from '../../../../playlist/data-access/playlist.service'; diff --git a/src/app/viewer/viewer/components/manga-page/manga-page-even.component.ts b/src/app/viewer/viewer/components/manga-page/manga-page-even.component.ts index fd5a331..44a12fc 100644 --- a/src/app/viewer/viewer/components/manga-page/manga-page-even.component.ts +++ b/src/app/viewer/viewer/components/manga-page/manga-page-even.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ViewerService } from '../../../../shared/data-access'; +import { ViewerService } from '../../../services'; @Component({ selector: 'app-manga-page-even', diff --git a/src/app/viewer/viewer/components/manga-page/manga-page.component.ts b/src/app/viewer/viewer/components/manga-page/manga-page.component.ts index 548011c..bb42b86 100644 --- a/src/app/viewer/viewer/components/manga-page/manga-page.component.ts +++ b/src/app/viewer/viewer/components/manga-page/manga-page.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ViewerService } from '../../../../shared/data-access'; +import { ViewerService } from '../../../services'; @Component({ selector: 'manga-page', diff --git a/src/app/viewer/viewer/components/page/page.component.ts b/src/app/viewer/viewer/components/page/page.component.ts index a95519f..3bc78dd 100644 --- a/src/app/viewer/viewer/components/page/page.component.ts +++ b/src/app/viewer/viewer/components/page/page.component.ts @@ -60,7 +60,6 @@ export class PageComponent { const parser = new DOMParser(); const svgDoc = parser.parseFromString(text, 'image/svg+xml'); const svgElement: SVGSVGElement = svgDoc.documentElement as unknown as SVGSVGElement; - console.log(svgElement.viewBox.baseVal.width); this.naturalWidth.set(svgElement.viewBox.baseVal.width); this.naturalHeight.set(svgElement.viewBox.baseVal.height); diff --git a/src/app/viewer/viewer/components/thanks-page/thanks-page.component.ts b/src/app/viewer/viewer/components/thanks-page/thanks-page.component.ts index 3725311..a1266f4 100644 --- a/src/app/viewer/viewer/components/thanks-page/thanks-page.component.ts +++ b/src/app/viewer/viewer/components/thanks-page/thanks-page.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { Playlist, PlaylistItem } from '../../../../playlist/data-access/playlist.service'; -import { ViewerService } from '../../../../shared/data-access'; +import { ViewerService } from '../../../services'; import { LangService } from '../../../../shared/data-access/lang.service'; import { CompositionEpisode } from '../../../../@site-modules/@common-read'; diff --git a/src/app/viewer/viewer/components/viewer-footer/viewer-footer.component.ts b/src/app/viewer/viewer/components/viewer-footer/viewer-footer.component.ts index 93c0151..78236ad 100644 --- a/src/app/viewer/viewer/components/viewer-footer/viewer-footer.component.ts +++ b/src/app/viewer/viewer/components/viewer-footer/viewer-footer.component.ts @@ -1,9 +1,10 @@ import { Component, EventEmitter, HostListener, inject, input, InputSignal, output, signal, ViewChild } from '@angular/core'; -import { BrowserService, DomManipulationService, ViewerService } from '../../../../shared/data-access'; +import { BrowserService, DomManipulationService } from '../../../../shared/data-access'; import { LangService } from '../../../../shared/data-access/lang.service'; import { Playlist, PlaylistItem } from '../../../../playlist/data-access/playlist.service'; import { CompositionEpisode } from '../../../../@site-modules/@common-read'; import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component'; +import { ViewerService } from '../../../services'; @Component({ selector: 'app-viewer-footer', diff --git a/src/app/viewer/viewer/components/viewer-header/viewer-header.component.ts b/src/app/viewer/viewer/components/viewer-header/viewer-header.component.ts index ff89a21..0037c08 100644 --- a/src/app/viewer/viewer/components/viewer-header/viewer-header.component.ts +++ b/src/app/viewer/viewer/components/viewer-header/viewer-header.component.ts @@ -1,5 +1,5 @@ import { Component, computed, effect, HostListener, inject, input, output, PLATFORM_ID, Signal, signal, ViewChild } from '@angular/core'; -import { DomManipulationService, ViewerService } from '../../../../shared/data-access'; +import { DomManipulationService } from '../../../../shared/data-access'; import { CompositionEpisode } from '../../../../@site-modules/@common-read'; import { PlaylistItem } from '../../../../playlist/data-access/playlist.service'; import { LangService } from '../../../../shared/data-access/lang.service'; @@ -10,6 +10,7 @@ import { isPlatformBrowser } from '@angular/common'; import { GamepadService } from '../../../../shared/data-access/gamepad.service'; import { GamepadButton } from '../../../../shared/models'; import { FileService } from '../../../../file/data-access/file.service'; +import { ViewerService } from '../../../services'; @Component({ selector: 'app-viewer-header', @@ -75,10 +76,10 @@ export class ViewerHeaderComponent { // #region โš™๏ธ Lifecycle / Effects constructor() { effect(() => { - const episode = this.episode(); - if (!episode) return; - const tags = this.parseTagsFromTitle(episode.title); - this.applyEpisodeTitleTags(tags); + // const episode = this.episode(); + // if (!episode) return; + // const tags = this.parseTagsFromTitle(episode.title); + // this.applyEpisodeTitleTags(tags); if (this.gamepad.buttons()[GamepadButton.Share]?.pressed) this.showShare(); }) @@ -86,18 +87,18 @@ export class ViewerHeaderComponent { //#endregion // #region ๐Ÿท๏ธ Episode Tags Logic - parseTagsFromTitle = (title: string): Set => parseTags(title); + // parseTagsFromTitle = (title: string): Set => parseTags(title); - applyEpisodeTitleTags(tags: Set): void { - const code = resolveViewMode(tags); + // applyEpisodeTitleTags(tags: Set): void { + // const code = resolveViewMode(tags); - if (code) - this.viewer.setViewModeOptionByCode(code); + // if (code) + // this.viewer.setViewModeOptionByCode(code); - if (tags.has('nsfw')) { - this.episode().nsfw = true; - } - } + // if (tags.has('nsfw')) { + // this.episode().nsfw = true; + // } + // } //#endregion // #region ๐Ÿงญ Actions (UI) diff --git a/src/app/viewer/viewer/viewer.component.html b/src/app/viewer/viewer/viewer.component.html index a61824b..4162542 100644 --- a/src/app/viewer/viewer/viewer.component.html +++ b/src/app/viewer/viewer/viewer.component.html @@ -15,7 +15,7 @@ @for(img of episode().images; track img.src; let i = $index) { - diff --git a/src/app/viewer/viewer/viewer.component.ts b/src/app/viewer/viewer/viewer.component.ts index afd0169..618c4f1 100644 --- a/src/app/viewer/viewer/viewer.component.ts +++ b/src/app/viewer/viewer/viewer.component.ts @@ -1,9 +1,9 @@ -import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, HostListener, input, PLATFORM_ID, Signal, ViewChild, WritableSignal, computed, inject, signal } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, HostListener, input, Signal, ViewChild, WritableSignal, computed, inject, signal, output } from '@angular/core'; import { CompositionEpisode } from '../../@site-modules/@common-read'; import { LangService } from '../../shared/data-access/lang.service'; import { Playlist, PlaylistItem } from '../../playlist/data-access/playlist.service'; import { DOCUMENT } from '@angular/common'; -import { EmbedFacade, GamepadFacade, KeyboardFacade, NsfwFacade, PageTrackingFacade, ReadlistFacade, ViewerScrollFacade, ViewerUiFacade, ViewModeFacade } from '../facades'; +import { EmbedFacade, GamepadFacade, KeyboardFacade, NsfwFacade, PageTrackingFacade, ReadlistFacade, ViewerScrollFacade, ViewerTitleTagFacade, ViewerUiFacade, ViewModeFacade } from '../facades'; import { DomManipulationService } from '../../shared/data-access'; @Component({ @@ -27,6 +27,7 @@ export class ViewerComponent implements AfterViewInit { scroll = inject(ViewerScrollFacade); embedFacade = inject(EmbedFacade); readlist = inject(ReadlistFacade); + tagTitle = inject(ViewerTitleTagFacade); private readonly dom = inject(DomManipulationService); private readonly document = inject(DOCUMENT); @@ -36,6 +37,8 @@ export class ViewerComponent implements AfterViewInit { currentPlaylistItem = input(); playlistInput = input([]); + pageChange = output<{ total: number, current: number[] }>(); + @ViewChild('viewRef', { static: true }) viewRef!: ElementRef; constructor(private el: ElementRef, public lang: LangService) { @@ -53,9 +56,17 @@ export class ViewerComponent implements AfterViewInit { this.viewerUi.initFullscreenListener(); this.viewElement.set(this.viewRef.nativeElement); this.pageTracking.initZone(this.viewElement(), this.el.nativeElement, this.imageElements()); + this.pageTracking.pageChangeFunction = this.onPageChange.bind(this); this.pageTracking.updateActiveIndexes(); this.scroll.initZone(this.viewElement(), this.el.nativeElement); this.embedFacade.loadCurrentPlaylistItem(); + this.tagTitle.initialize(this.episode); + + setTimeout(() => { this.scroll.toRoutePage() }, 100); + } + + onPageChange(total: number, current: number[]) { + this.pageChange.emit({ total, current }); } @HostListener('scroll') diff --git a/src/assets/logos/nhentai-logo.svg b/src/assets/logos/nhentai-logo.svg new file mode 100644 index 0000000..f7be540 --- /dev/null +++ b/src/assets/logos/nhentai-logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/assets/screenshots/12fps-drop-file.webp b/src/assets/screenshots/12fps-drop-file.webp new file mode 100644 index 0000000..a9d5b87 Binary files /dev/null and b/src/assets/screenshots/12fps-drop-file.webp differ diff --git a/src/assets/screenshots/Screenshot_2026-04-07_220509.png b/src/assets/screenshots/Screenshot_2026-04-07_220509.png new file mode 100644 index 0000000..ad493df Binary files /dev/null and b/src/assets/screenshots/Screenshot_2026-04-07_220509.png differ diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 94f98fd..bd8427b 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -1,7 +1,7 @@ const PROXY = `http://192.168.10.107:3003/api?url=` export const environment = { - version: "0.13.51-2026.3.28", + version: "0.13.54-2026.4.8", prod: false, proxy: PROXY, blankaryoHost: `https://blankary.com/page/`, @@ -10,10 +10,11 @@ export const environment = { mangadexHost: `https://api.mangadex.org/at-home/server/`, mangadexChapter: `https://api.mangadex.org/chapter/`, mangadexManga: `https://api.mangadex.org/manga/`, + mangadexScanlationGroup: `https://api.mangadex.org/group/`, telegraphHost: `https://api.telegra.ph/getPage/`, redditHost: `https://www.reddit.com/r/all/comments/`, zenkoHost: `https:///api.zenko.online/chapters/`, - nhentaiHost: `https://nhentai.net/api/gallery/`, + nhentaiHost: `https://nhentai.net/api/v2/galleries/`, comickHost: `https://api.comick.fun/chapter/`, yanderePoolsHost: `https://yande.re/pool/show.json/`, pixivHost: `https://www.pixiv.net/touch/ajax/illust/details?illust_id=` diff --git a/src/environments/environment.ts b/src/environments/environment.ts index dd56892..f439285 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,7 +1,7 @@ const PROXY = `https://proxy.chytanka.ink/api?url=` export const environment = { - version: "0.13.51-2026.3.28", + version: "0.13.54-2026.4.8", prod: true, proxy: PROXY, blankaryoHost: `https://blankary.com/page/`, @@ -10,10 +10,11 @@ export const environment = { mangadexHost: `https://api.mangadex.org/at-home/server/`, mangadexChapter: `https://api.mangadex.org/chapter/`, mangadexManga: `https://api.mangadex.org/manga/`, + mangadexScanlationGroup: `https://api.mangadex.org/group/`, telegraphHost: `https://api.telegra.ph/getPage/`, redditHost: `https://www.reddit.com/r/all/comments/`, zenkoHost: `https:///api.zenko.online/chapters/`, - nhentaiHost: `https://nhentai.net/api/gallery/`, + nhentaiHost: `https://nhentai.net/api/v2/galleries/`, comickHost: `https://api.comick.fun/chapter/`, yanderePoolsHost: `https://yande.re/pool/show.json/`, pixivHost: `https://www.pixiv.net/touch/ajax/illust/details?illust_id=`