mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Reader route migration
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
import {getAdapter} from '$lib/request-manager';
|
||||||
|
import {loadChapterPages, updateProgress} from '$lib/request-manager/chapters';
|
||||||
|
import {readerState} from '$lib/state/reader.svelte';
|
||||||
|
import type {Chapter} from '$lib/types';
|
||||||
|
|
||||||
|
function sortChapters(chapters: Chapter[]): Chapter[] {
|
||||||
|
return [...chapters].sort((left, right) => left.sourceOrder - right.sourceOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentChapterIndex(): number {
|
||||||
|
if (!readerState.chapter) return -1;
|
||||||
|
|
||||||
|
return sortChapters(readerState.chapters).findIndex(
|
||||||
|
(chapter) => String(chapter.id) === String(readerState.chapter?.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPageIndex(index: number): number {
|
||||||
|
if (readerState.pages.length === 0) return 0;
|
||||||
|
return Math.min(Math.max(index, 0), readerState.pages.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdjacentChapters() {
|
||||||
|
const chapters = sortChapters(readerState.chapters);
|
||||||
|
const index = currentChapterIndex();
|
||||||
|
|
||||||
|
return {
|
||||||
|
previous: index > 0 ? chapters[index - 1] : null,
|
||||||
|
next: index >= 0 && index < chapters.length - 1 ? chapters[index + 1] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureReaderSession(mangaId: string, chapterId: string) {
|
||||||
|
const adapter = getAdapter();
|
||||||
|
|
||||||
|
const mangaPromise =
|
||||||
|
readerState.manga && String(readerState.manga.id) === mangaId
|
||||||
|
? Promise.resolve(readerState.manga)
|
||||||
|
: adapter.getManga(mangaId);
|
||||||
|
|
||||||
|
const chaptersPromise =
|
||||||
|
readerState.chapters.length > 0 && String(readerState.chapters[0]?.mangaId) === mangaId
|
||||||
|
? Promise.resolve(readerState.chapters)
|
||||||
|
: adapter.getChapters(mangaId);
|
||||||
|
|
||||||
|
const [manga, chapters] = await Promise.all([mangaPromise, chaptersPromise]);
|
||||||
|
const chapter =
|
||||||
|
chapters.find((entry) => String(entry.id) === chapterId) ??
|
||||||
|
(String(readerState.chapter?.id) === chapterId ? readerState.chapter : null) ??
|
||||||
|
(await adapter.getChapter(chapterId));
|
||||||
|
|
||||||
|
readerState.manga = manga;
|
||||||
|
readerState.chapters = chapters;
|
||||||
|
readerState.chapter = chapter;
|
||||||
|
readerState.pages = [];
|
||||||
|
readerState.currentPage = 0;
|
||||||
|
readerState.pagesError = null;
|
||||||
|
|
||||||
|
await loadChapterPages(chapterId);
|
||||||
|
|
||||||
|
if (readerState.pages.length > 0) {
|
||||||
|
readerState.currentPage = clampPageIndex((chapter.lastPageRead ?? 1) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCurrentReaderPage(index: number) {
|
||||||
|
const nextIndex = clampPageIndex(index);
|
||||||
|
readerState.currentPage = nextIndex;
|
||||||
|
|
||||||
|
if (!readerState.chapter || readerState.pages.length === 0) return;
|
||||||
|
|
||||||
|
const lastPageRead = nextIndex + 1;
|
||||||
|
const completed = lastPageRead >= readerState.pages.length;
|
||||||
|
|
||||||
|
if (
|
||||||
|
readerState.chapter.lastPageRead === lastPageRead &&
|
||||||
|
readerState.chapter.read === completed
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProgress(String(readerState.chapter.id), lastPageRead, completed);
|
||||||
|
} catch (error) {
|
||||||
|
readerState.pagesError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function goToNextReaderPage(): Promise<boolean> {
|
||||||
|
if (readerState.currentPage >= readerState.pages.length - 1) return false;
|
||||||
|
|
||||||
|
await setCurrentReaderPage(readerState.currentPage + 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function goToPreviousReaderPage(): Promise<boolean> {
|
||||||
|
if (readerState.currentPage <= 0) return false;
|
||||||
|
|
||||||
|
await setCurrentReaderPage(readerState.currentPage - 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,40 +1,66 @@
|
|||||||
import { getAdapter } from '$lib/request-manager'
|
import {getAdapter} from '$lib/request-manager';
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import {seriesState} from '$lib/state/series.svelte';
|
||||||
import { readerState } from '$lib/state/reader.svelte'
|
import {readerState} from '$lib/state/reader.svelte';
|
||||||
|
|
||||||
export async function loadChapters(mangaId: string) {
|
export async function loadChapters(mangaId: string) {
|
||||||
seriesState.chaptersLoading = true
|
seriesState.chaptersLoading = true;
|
||||||
seriesState.chaptersError = null
|
seriesState.chaptersError = null;
|
||||||
try {
|
try {
|
||||||
seriesState.chapters = await getAdapter().getChapters(mangaId)
|
seriesState.chapters = await getAdapter().getChapters(mangaId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
seriesState.chaptersError = String(e)
|
seriesState.chaptersError = String(e);
|
||||||
} finally {
|
} finally {
|
||||||
seriesState.chaptersLoading = false
|
seriesState.chaptersLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadChapterPages(chapterId: string) {
|
export async function loadChapterPages(chapterId: string) {
|
||||||
readerState.pagesLoading = true
|
readerState.pagesLoading = true;
|
||||||
readerState.pagesError = null
|
readerState.pagesError = null;
|
||||||
try {
|
try {
|
||||||
readerState.pages = await getAdapter().getChapterPages(chapterId)
|
readerState.pages = await getAdapter().getChapterPages(chapterId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
readerState.pagesError = String(e)
|
readerState.pagesError = String(e);
|
||||||
} finally {
|
} finally {
|
||||||
readerState.pagesLoading = false
|
readerState.pagesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProgress(chapterId: string, lastPageRead: number, read = false) {
|
||||||
|
await getAdapter().updateChapterProgress(chapterId, lastPageRead, read);
|
||||||
|
|
||||||
|
const chapterIds = new Set<string>([chapterId]);
|
||||||
|
const nextRead = read || false;
|
||||||
|
|
||||||
|
for (const chapter of seriesState.chapters) {
|
||||||
|
if (chapterIds.has(String(chapter.id))) {
|
||||||
|
chapter.lastPageRead = lastPageRead;
|
||||||
|
chapter.read = nextRead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chapter of readerState.chapters) {
|
||||||
|
if (chapterIds.has(String(chapter.id))) {
|
||||||
|
chapter.lastPageRead = lastPageRead;
|
||||||
|
chapter.read = nextRead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readerState.chapter && String(readerState.chapter.id) === chapterId) {
|
||||||
|
readerState.chapter.lastPageRead = lastPageRead;
|
||||||
|
readerState.chapter.read = nextRead;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markRead(id: string, read: boolean) {
|
export async function markRead(id: string, read: boolean) {
|
||||||
await getAdapter().markChapterRead(id, read)
|
await getAdapter().markChapterRead(id, read);
|
||||||
const chapter = seriesState.chapters.find(c => c.id === id)
|
const chapter = seriesState.chapters.find(c => c.id === id);
|
||||||
if (chapter) chapter.read = read
|
if (chapter) chapter.read = read;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markManyRead(ids: string[], read: boolean) {
|
export async function markManyRead(ids: string[], read: boolean) {
|
||||||
await getAdapter().markChaptersRead(ids, read)
|
await getAdapter().markChaptersRead(ids, read);
|
||||||
for (const c of seriesState.chapters) {
|
for (const c of seriesState.chapters) {
|
||||||
if (ids.includes(c.id)) c.read = read
|
if (ids.includes(c.id)) c.read = read;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,46 +8,47 @@ import type {
|
|||||||
Page,
|
Page,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
UpdateResult,
|
UpdateResult,
|
||||||
} from '$lib/server-adapters/types'
|
} from '$lib/server-adapters/types';
|
||||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
|
||||||
|
|
||||||
function notImplemented(): never {
|
function notImplemented(): never {
|
||||||
throw new Error('MokuAdapter: not implemented')
|
throw new Error('MokuAdapter: not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MokuAdapter implements ServerAdapter {
|
export class MokuAdapter implements ServerAdapter {
|
||||||
async connect(_config: ServerConfig): Promise<void> { notImplemented() }
|
async connect(_config: ServerConfig): Promise<void> {notImplemented();}
|
||||||
async getStatus(): Promise<ServerStatus> { return notImplemented() }
|
async getStatus(): Promise<ServerStatus> {return notImplemented();}
|
||||||
|
|
||||||
async getManga(_id: string): Promise<Manga> { return notImplemented() }
|
async getManga(_id: string): Promise<Manga> {return notImplemented();}
|
||||||
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> { return notImplemented() }
|
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> {return notImplemented();}
|
||||||
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> { return notImplemented() }
|
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> {return notImplemented();}
|
||||||
async addToLibrary(_mangaId: string): Promise<void> { notImplemented() }
|
async addToLibrary(_mangaId: string): Promise<void> {notImplemented();}
|
||||||
async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() }
|
async removeFromLibrary(_mangaId: string): Promise<void> {notImplemented();}
|
||||||
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() }
|
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> {notImplemented();}
|
||||||
|
|
||||||
async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
|
async getChapters(_mangaId: string): Promise<Chapter[]> {return notImplemented();}
|
||||||
async getChapter(_id: string): Promise<Chapter> { return notImplemented() }
|
async getChapter(_id: string): Promise<Chapter> {return notImplemented();}
|
||||||
async getChapterPages(_id: string): Promise<Page[]> { return notImplemented() }
|
async getChapterPages(_id: string): Promise<Page[]> {return notImplemented();}
|
||||||
async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() }
|
async markChapterRead(_id: string, _read: boolean): Promise<void> {notImplemented();}
|
||||||
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() }
|
async updateChapterProgress(_id: string, _lastPageRead: number, _read?: boolean): Promise<void> {notImplemented();}
|
||||||
|
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> {notImplemented();}
|
||||||
|
|
||||||
async getDownloads(): Promise<DownloadItem[]> { return notImplemented() }
|
async getDownloads(): Promise<DownloadItem[]> {return notImplemented();}
|
||||||
async enqueueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
async enqueueDownload(_chapterId: string): Promise<void> {notImplemented();}
|
||||||
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
async dequeueDownload(_chapterId: string): Promise<void> {notImplemented();}
|
||||||
async clearDownloads(): Promise<void> { notImplemented() }
|
async clearDownloads(): Promise<void> {notImplemented();}
|
||||||
|
|
||||||
async getExtensions(): Promise<Extension[]> { return notImplemented() }
|
async getExtensions(): Promise<Extension[]> {return notImplemented();}
|
||||||
async installExtension(_id: string): Promise<void> { notImplemented() }
|
async installExtension(_id: string): Promise<void> {notImplemented();}
|
||||||
async uninstallExtension(_id: string): Promise<void> { notImplemented() }
|
async uninstallExtension(_id: string): Promise<void> {notImplemented();}
|
||||||
async updateExtension(_id: string): Promise<void> { notImplemented() }
|
async updateExtension(_id: string): Promise<void> {notImplemented();}
|
||||||
|
|
||||||
async getSources(): Promise<Source[]> { return notImplemented() }
|
async getSources(): Promise<Source[]> {return notImplemented();}
|
||||||
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() }
|
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> {return notImplemented();}
|
||||||
|
|
||||||
async getTrackers(): Promise<Tracker[]> { return notImplemented() }
|
async getTrackers(): Promise<Tracker[]> {return notImplemented();}
|
||||||
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() }
|
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> {notImplemented();}
|
||||||
async syncTracking(_mangaId: string): Promise<void> { notImplemented() }
|
async syncTracking(_mangaId: string): Promise<void> {notImplemented();}
|
||||||
|
|
||||||
async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> { return notImplemented() }
|
async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> {return notImplemented();}
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,8 @@ import type {
|
|||||||
Page,
|
Page,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
UpdateResult,
|
UpdateResult,
|
||||||
} from '$lib/server-adapters/types'
|
} from '$lib/server-adapters/types';
|
||||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
|
||||||
import {
|
import {
|
||||||
GET_LIBRARY,
|
GET_LIBRARY,
|
||||||
GET_MANGA,
|
GET_MANGA,
|
||||||
@@ -19,38 +19,39 @@ import {
|
|||||||
SET_MANGA_META,
|
SET_MANGA_META,
|
||||||
UPDATE_LIBRARY,
|
UPDATE_LIBRARY,
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA,
|
||||||
} from './manga'
|
} from './manga';
|
||||||
import {
|
import {
|
||||||
GET_CHAPTERS,
|
GET_CHAPTERS,
|
||||||
FETCH_CHAPTERS,
|
FETCH_CHAPTERS,
|
||||||
FETCH_CHAPTER_PAGES,
|
FETCH_CHAPTER_PAGES,
|
||||||
MARK_CHAPTER_READ,
|
MARK_CHAPTER_READ,
|
||||||
MARK_CHAPTERS_READ,
|
MARK_CHAPTERS_READ,
|
||||||
} from './chapters'
|
UPDATE_CHAPTERS_PROGRESS,
|
||||||
|
} from './chapters';
|
||||||
import {
|
import {
|
||||||
GET_DOWNLOAD_STATUS,
|
GET_DOWNLOAD_STATUS,
|
||||||
ENQUEUE_DOWNLOAD,
|
ENQUEUE_DOWNLOAD,
|
||||||
DEQUEUE_DOWNLOAD,
|
DEQUEUE_DOWNLOAD,
|
||||||
CLEAR_DOWNLOADER,
|
CLEAR_DOWNLOADER,
|
||||||
} from './downloads'
|
} from './downloads';
|
||||||
import {
|
import {
|
||||||
GET_EXTENSIONS,
|
GET_EXTENSIONS,
|
||||||
GET_SOURCES,
|
GET_SOURCES,
|
||||||
FETCH_EXTENSIONS,
|
FETCH_EXTENSIONS,
|
||||||
UPDATE_EXTENSION,
|
UPDATE_EXTENSION,
|
||||||
} from './extensions'
|
} from './extensions';
|
||||||
import {
|
import {
|
||||||
GET_TRACKERS,
|
GET_TRACKERS,
|
||||||
BIND_TRACK,
|
BIND_TRACK,
|
||||||
TRACK_PROGRESS,
|
TRACK_PROGRESS,
|
||||||
} from './tracking'
|
} from './tracking';
|
||||||
import {
|
import {
|
||||||
GQLResponse,
|
GQLResponse,
|
||||||
mapManga,
|
mapManga,
|
||||||
mapChapter,
|
mapChapter,
|
||||||
mapExtension,
|
mapExtension,
|
||||||
mapDownloadItem,
|
mapDownloadItem,
|
||||||
} from './types'
|
} from './types';
|
||||||
|
|
||||||
const GET_CHAPTER = `
|
const GET_CHAPTER = `
|
||||||
query GetChapter($id: Int!) {
|
query GetChapter($id: Int!) {
|
||||||
@@ -59,17 +60,17 @@ const GET_CHAPTER = `
|
|||||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
export class SuwayomiAdapter implements ServerAdapter {
|
export class SuwayomiAdapter implements ServerAdapter {
|
||||||
private baseUrl = 'http://127.0.0.1:4567'
|
private baseUrl = 'http://127.0.0.1:4567';
|
||||||
private authHeader: string | null = null
|
private authHeader: string | null = null;
|
||||||
|
|
||||||
async connect(config: ServerConfig) {
|
async connect(config: ServerConfig) {
|
||||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||||
if (config.credentials) {
|
if (config.credentials) {
|
||||||
const { username, password } = config.credentials
|
const {username, password} = config.credentials;
|
||||||
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
|
this.authHeader = 'Basic ' + btoa(`${username}:${password}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,154 +79,162 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
body: JSON.stringify({query: '{ aboutServer { name } }'}),
|
||||||
})
|
});
|
||||||
return res.ok ? 'connected' : 'error'
|
return res.ok ? 'connected' : 'error';
|
||||||
} catch {
|
} catch {
|
||||||
return 'disconnected'
|
return 'disconnected';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private headers(): Record<string, string> {
|
private headers(): Record<string, string> {
|
||||||
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
const h: Record<string, string> = {'Content-Type': 'application/json'};
|
||||||
if (this.authHeader) h['Authorization'] = this.authHeader
|
if (this.authHeader) h['Authorization'] = this.authHeader;
|
||||||
return h
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({query, variables}),
|
||||||
})
|
});
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
const json: GQLResponse<T> = await res.json()
|
const json: GQLResponse<T> = await res.json();
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
return json.data
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getManga(id: string): Promise<Manga> {
|
async getManga(id: string): Promise<Manga> {
|
||||||
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
|
const data = await this.gql<{manga: Record<string, unknown>;}>(GET_MANGA, {id: Number(id)});
|
||||||
return mapManga(data.manga)
|
return mapManga(data.manga);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
|
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
|
||||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
const data = await this.gql<{mangas: {nodes: Record<string, unknown>[];};}>(GET_LIBRARY);
|
||||||
let items = data.mangas.nodes.map(mapManga)
|
let items = data.mangas.nodes.map(mapManga);
|
||||||
if (filters.status) items = items.filter(m => m.status === filters.status)
|
if (filters.status) items = items.filter(m => m.status === filters.status);
|
||||||
if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t)))
|
if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t)));
|
||||||
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0)
|
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||||
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId)
|
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId);
|
||||||
return { items, hasNextPage: false }
|
return {items, hasNextPage: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
||||||
if (!sourceId) return []
|
if (!sourceId) return [];
|
||||||
const data = await this.gql<{
|
const data = await this.gql<{
|
||||||
fetchSourceManga: { mangas: Record<string, unknown>[] }
|
fetchSourceManga: {mangas: Record<string, unknown>[];};
|
||||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query })
|
}>(FETCH_SOURCE_MANGA, {source: sourceId, type: 'SEARCH', page: 1, query});
|
||||||
return data.fetchSourceManga.mangas.map(mapManga)
|
return data.fetchSourceManga.mangas.map(mapManga);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addToLibrary(mangaId: string) {
|
async addToLibrary(mangaId: string) {
|
||||||
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
|
await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeFromLibrary(mangaId: string) {
|
async removeFromLibrary(mangaId: string) {
|
||||||
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
|
await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: false});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
||||||
for (const [key, value] of Object.entries(meta)) {
|
for (const [key, value] of Object.entries(meta)) {
|
||||||
if (value === undefined) continue
|
if (value === undefined) continue;
|
||||||
await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value) })
|
await this.gql(SET_MANGA_META, {mangaId: Number(id), key, value: String(value)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChapters(mangaId: string): Promise<Chapter[]> {
|
async getChapters(mangaId: string): Promise<Chapter[]> {
|
||||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
const data = await this.gql<{chapters: {nodes: Record<string, unknown>[];};}>(
|
||||||
GET_CHAPTERS, { mangaId: Number(mangaId) }
|
GET_CHAPTERS, {mangaId: Number(mangaId)}
|
||||||
)
|
);
|
||||||
return data.chapters.nodes.map(mapChapter)
|
return data.chapters.nodes.map(mapChapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChapter(id: string): Promise<Chapter> {
|
async getChapter(id: string): Promise<Chapter> {
|
||||||
const data = await this.gql<{ chapter: Record<string, unknown> }>(
|
const data = await this.gql<{chapter: Record<string, unknown>;}>(
|
||||||
GET_CHAPTER, { id: Number(id) }
|
GET_CHAPTER, {id: Number(id)}
|
||||||
)
|
);
|
||||||
return mapChapter(data.chapter)
|
return mapChapter(data.chapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChapterPages(id: string): Promise<Page[]> {
|
async getChapterPages(id: string): Promise<Page[]> {
|
||||||
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>(
|
const data = await this.gql<{fetchChapterPages: {pages: string[];};}>(
|
||||||
FETCH_CHAPTER_PAGES, { chapterId: Number(id) }
|
FETCH_CHAPTER_PAGES, {chapterId: Number(id)}
|
||||||
)
|
);
|
||||||
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
|
return data.fetchChapterPages.pages.map((url, index) => ({index, url}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async markChapterRead(id: string, read: boolean) {
|
async markChapterRead(id: string, read: boolean) {
|
||||||
await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read })
|
await this.gql(MARK_CHAPTER_READ, {id: Number(id), isRead: read});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChapterProgress(id: string, lastPageRead: number, read?: boolean) {
|
||||||
|
await this.gql(UPDATE_CHAPTERS_PROGRESS, {
|
||||||
|
ids: [Number(id)],
|
||||||
|
lastPageRead,
|
||||||
|
isRead: read,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async markChaptersRead(ids: string[], read: boolean) {
|
async markChaptersRead(ids: string[], read: boolean) {
|
||||||
await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read })
|
await this.gql(MARK_CHAPTERS_READ, {ids: ids.map(Number), isRead: read});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDownloads(): Promise<DownloadItem[]> {
|
async getDownloads(): Promise<DownloadItem[]> {
|
||||||
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
|
const data = await this.gql<{downloadStatus: {queue: Record<string, unknown>[];};}>(
|
||||||
GET_DOWNLOAD_STATUS
|
GET_DOWNLOAD_STATUS
|
||||||
)
|
);
|
||||||
return data.downloadStatus.queue.map(mapDownloadItem)
|
return data.downloadStatus.queue.map(mapDownloadItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueueDownload(chapterId: string) {
|
async enqueueDownload(chapterId: string) {
|
||||||
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
await this.gql(ENQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
|
||||||
}
|
}
|
||||||
|
|
||||||
async dequeueDownload(chapterId: string) {
|
async dequeueDownload(chapterId: string) {
|
||||||
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
await this.gql(DEQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearDownloads() {
|
async clearDownloads() {
|
||||||
await this.gql(CLEAR_DOWNLOADER)
|
await this.gql(CLEAR_DOWNLOADER);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExtensions(): Promise<Extension[]> {
|
async getExtensions(): Promise<Extension[]> {
|
||||||
await this.gql(FETCH_EXTENSIONS)
|
await this.gql(FETCH_EXTENSIONS);
|
||||||
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
|
const data = await this.gql<{extensions: {nodes: Record<string, unknown>[];};}>(GET_EXTENSIONS);
|
||||||
return data.extensions.nodes.map(mapExtension)
|
return data.extensions.nodes.map(mapExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
async installExtension(id: string) {
|
async installExtension(id: string) {
|
||||||
await this.gql(UPDATE_EXTENSION, { id, install: true })
|
await this.gql(UPDATE_EXTENSION, {id, install: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
async uninstallExtension(id: string) {
|
async uninstallExtension(id: string) {
|
||||||
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
|
await this.gql(UPDATE_EXTENSION, {id, uninstall: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateExtension(id: string) {
|
async updateExtension(id: string) {
|
||||||
await this.gql(UPDATE_EXTENSION, { id, update: true })
|
await this.gql(UPDATE_EXTENSION, {id, update: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSources(): Promise<Source[]> {
|
async getSources(): Promise<Source[]> {
|
||||||
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
const data = await this.gql<{sources: {nodes: Source[];};}>(GET_SOURCES);
|
||||||
return data.sources.nodes
|
return data.sources.nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
|
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
|
||||||
const data = await this.gql<{
|
const data = await this.gql<{
|
||||||
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
|
fetchSourceManga: {mangas: Record<string, unknown>[]; hasNextPage: boolean;};
|
||||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page })
|
}>(FETCH_SOURCE_MANGA, {source: sourceId, type: 'LATEST', page});
|
||||||
return {
|
return {
|
||||||
items: data.fetchSourceManga.mangas.map(mapManga),
|
items: data.fetchSourceManga.mangas.map(mapManga),
|
||||||
hasNextPage: data.fetchSourceManga.hasNextPage,
|
hasNextPage: data.fetchSourceManga.hasNextPage,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrackers(): Promise<Tracker[]> {
|
async getTrackers(): Promise<Tracker[]> {
|
||||||
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
const data = await this.gql<{trackers: {nodes: Tracker[];};}>(GET_TRACKERS);
|
||||||
return data.trackers.nodes
|
return data.trackers.nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
||||||
@@ -233,25 +242,25 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
mangaId: Number(mangaId),
|
mangaId: Number(mangaId),
|
||||||
trackerId: Number(trackerId),
|
trackerId: Number(trackerId),
|
||||||
remoteId,
|
remoteId,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncTracking(mangaId: string) {
|
async syncTracking(mangaId: string) {
|
||||||
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
await this.gql(TRACK_PROGRESS, {mangaId: Number(mangaId)});
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
||||||
if (mangaIds?.length) {
|
if (mangaIds?.length) {
|
||||||
const results: UpdateResult[] = []
|
const results: UpdateResult[] = [];
|
||||||
for (const id of mangaIds) {
|
for (const id of mangaIds) {
|
||||||
const before = await this.getChapters(id)
|
const before = await this.getChapters(id);
|
||||||
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) })
|
await this.gql(FETCH_CHAPTERS, {mangaId: Number(id)});
|
||||||
const after = await this.getChapters(id)
|
const after = await this.getChapters(id);
|
||||||
results.push({ mangaId: id, newChapters: after.length - before.length })
|
results.push({mangaId: id, newChapters: after.length - before.length});
|
||||||
}
|
}
|
||||||
return results
|
return results;
|
||||||
}
|
}
|
||||||
await this.gql(UPDATE_LIBRARY)
|
await this.gql(UPDATE_LIBRARY);
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,91 +4,92 @@ import type {
|
|||||||
Extension,
|
Extension,
|
||||||
Source,
|
Source,
|
||||||
Tracker,
|
Tracker,
|
||||||
} from '$lib/types'
|
} from '$lib/types';
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
baseUrl: string
|
baseUrl: string;
|
||||||
credentials?: { username: string; password: string }
|
credentials?: {username: string; password: string;};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerStatus = 'connected' | 'disconnected' | 'error'
|
export type ServerStatus = 'connected' | 'disconnected' | 'error';
|
||||||
|
|
||||||
export interface MangaFilters {
|
export interface MangaFilters {
|
||||||
inLibrary?: boolean
|
inLibrary?: boolean;
|
||||||
status?: MangaStatus
|
status?: MangaStatus;
|
||||||
tags?: string[]
|
tags?: string[];
|
||||||
unread?: boolean
|
unread?: boolean;
|
||||||
sourceId?: string
|
sourceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MangaStatus = 'ONGOING' | 'COMPLETED' | 'LICENSED' | 'PUBLISHING_FINISHED' | 'CANCELLED' | 'ON_HIATUS'
|
export type MangaStatus = 'ONGOING' | 'COMPLETED' | 'LICENSED' | 'PUBLISHING_FINISHED' | 'CANCELLED' | 'ON_HIATUS';
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
items: T[]
|
items: T[];
|
||||||
hasNextPage: boolean
|
hasNextPage: boolean;
|
||||||
total?: number
|
total?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MangaMeta {
|
export interface MangaMeta {
|
||||||
customTitle?: string
|
customTitle?: string;
|
||||||
customCover?: string
|
customCover?: string;
|
||||||
notes?: string
|
notes?: string;
|
||||||
[key: string]: unknown
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Page {
|
export interface Page {
|
||||||
index: number
|
index: number;
|
||||||
url: string
|
url: string;
|
||||||
imageData?: string
|
imageData?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadItem {
|
export interface DownloadItem {
|
||||||
chapterId: string
|
chapterId: string;
|
||||||
mangaId: string
|
mangaId: string;
|
||||||
chapterName: string
|
chapterName: string;
|
||||||
mangaTitle: string
|
mangaTitle: string;
|
||||||
progress: number
|
progress: number;
|
||||||
state: 'queued' | 'downloading' | 'finished' | 'error'
|
state: 'queued' | 'downloading' | 'finished' | 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateResult {
|
export interface UpdateResult {
|
||||||
mangaId: string
|
mangaId: string;
|
||||||
newChapters: number
|
newChapters: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerAdapter {
|
export interface ServerAdapter {
|
||||||
connect(config: ServerConfig): Promise<void>
|
connect(config: ServerConfig): Promise<void>;
|
||||||
getStatus(): Promise<ServerStatus>
|
getStatus(): Promise<ServerStatus>;
|
||||||
|
|
||||||
getManga(id: string): Promise<Manga>
|
getManga(id: string): Promise<Manga>;
|
||||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>;
|
||||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
searchManga(query: string, sourceId?: string): Promise<Manga[]>;
|
||||||
addToLibrary(mangaId: string): Promise<void>
|
addToLibrary(mangaId: string): Promise<void>;
|
||||||
removeFromLibrary(mangaId: string): Promise<void>
|
removeFromLibrary(mangaId: string): Promise<void>;
|
||||||
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
|
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>;
|
||||||
|
|
||||||
getChapters(mangaId: string): Promise<Chapter[]>
|
getChapters(mangaId: string): Promise<Chapter[]>;
|
||||||
getChapter(id: string): Promise<Chapter>
|
getChapter(id: string): Promise<Chapter>;
|
||||||
getChapterPages(id: string): Promise<Page[]>
|
getChapterPages(id: string): Promise<Page[]>;
|
||||||
markChapterRead(id: string, read: boolean): Promise<void>
|
markChapterRead(id: string, read: boolean): Promise<void>;
|
||||||
markChaptersRead(ids: string[], read: boolean): Promise<void>
|
updateChapterProgress(id: string, lastPageRead: number, read?: boolean): Promise<void>;
|
||||||
|
markChaptersRead(ids: string[], read: boolean): Promise<void>;
|
||||||
|
|
||||||
getDownloads(): Promise<DownloadItem[]>
|
getDownloads(): Promise<DownloadItem[]>;
|
||||||
enqueueDownload(chapterId: string): Promise<void>
|
enqueueDownload(chapterId: string): Promise<void>;
|
||||||
dequeueDownload(chapterId: string): Promise<void>
|
dequeueDownload(chapterId: string): Promise<void>;
|
||||||
clearDownloads(): Promise<void>
|
clearDownloads(): Promise<void>;
|
||||||
|
|
||||||
getExtensions(): Promise<Extension[]>
|
getExtensions(): Promise<Extension[]>;
|
||||||
installExtension(id: string): Promise<void>
|
installExtension(id: string): Promise<void>;
|
||||||
uninstallExtension(id: string): Promise<void>
|
uninstallExtension(id: string): Promise<void>;
|
||||||
updateExtension(id: string): Promise<void>
|
updateExtension(id: string): Promise<void>;
|
||||||
|
|
||||||
getSources(): Promise<Source[]>
|
getSources(): Promise<Source[]>;
|
||||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>;
|
||||||
|
|
||||||
getTrackers(): Promise<Tracker[]>
|
getTrackers(): Promise<Tracker[]>;
|
||||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>;
|
||||||
syncTracking(mangaId: string): Promise<void>
|
syncTracking(mangaId: string): Promise<void>;
|
||||||
|
|
||||||
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
|
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { children } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="reader-shell">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reader-shell {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, color-mix(in srgb, var(--accent) 16%, transparent), transparent 42%),
|
||||||
|
var(--bg-base);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import { ArrowArcLeft, CaretLeft, CaretRight, Columns, List, SpinnerGap, TextAlignRight } from 'phosphor-svelte'
|
||||||
|
import { currentPageData, progress, readerState } from '$lib/state/reader.svelte'
|
||||||
|
import { ensureReaderSession, getAdjacentChapters, goToNextReaderPage, goToPreviousReaderPage, setCurrentReaderPage } from '$lib/core/reader/session'
|
||||||
|
import Button from '$lib/ui/primitives/Button.svelte'
|
||||||
|
|
||||||
|
let initializing = $state(true)
|
||||||
|
let routeError = $state<string | null>(null)
|
||||||
|
let requestVersion = 0
|
||||||
|
|
||||||
|
const mangaId = $derived($page.params.mangaId ?? '')
|
||||||
|
const chapterId = $derived($page.params.chapterId ?? '')
|
||||||
|
const chapterNeighbors = $derived.by(() => getAdjacentChapters())
|
||||||
|
const currentPageNumber = $derived(readerState.currentPage + 1)
|
||||||
|
const totalPages = $derived(readerState.pages.length)
|
||||||
|
const progressPercent = $derived(Math.round(progress * 100))
|
||||||
|
const pageLabel = $derived(totalPages > 0 ? `${currentPageNumber} / ${totalPages}` : '0 / 0')
|
||||||
|
const chapterLabel = $derived(
|
||||||
|
readerState.chapter
|
||||||
|
? `Ch. ${Number.isInteger(readerState.chapter.chapterNumber) ? readerState.chapter.chapterNumber : readerState.chapter.chapterNumber}`
|
||||||
|
: 'Chapter'
|
||||||
|
)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeMangaId = mangaId
|
||||||
|
const activeChapterId = chapterId
|
||||||
|
|
||||||
|
if (!activeMangaId || !activeChapterId) return
|
||||||
|
|
||||||
|
const version = ++requestVersion
|
||||||
|
initializing = true
|
||||||
|
routeError = null
|
||||||
|
|
||||||
|
void ensureReaderSession(activeMangaId, activeChapterId)
|
||||||
|
.catch((error) => {
|
||||||
|
if (version !== requestVersion) return
|
||||||
|
routeError = error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (version !== requestVersion) return
|
||||||
|
initializing = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function stepForward() {
|
||||||
|
const advanced = await goToNextReaderPage()
|
||||||
|
if (advanced) return
|
||||||
|
|
||||||
|
if (chapterNeighbors.next && readerState.manga) {
|
||||||
|
await goto(`/reader/${readerState.manga.id}/${chapterNeighbors.next.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stepBackward() {
|
||||||
|
const moved = await goToPreviousReaderPage()
|
||||||
|
if (moved) return
|
||||||
|
|
||||||
|
if (chapterNeighbors.previous && readerState.manga) {
|
||||||
|
await goto(`/reader/${readerState.manga.id}/${chapterNeighbors.previous.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRangeInput(event: Event) {
|
||||||
|
const target = event.currentTarget as HTMLInputElement
|
||||||
|
await setCurrentReaderPage(Number(target.value) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryLoad() {
|
||||||
|
requestVersion += 1
|
||||||
|
initializing = true
|
||||||
|
routeError = null
|
||||||
|
void ensureReaderSession(mangaId, chapterId)
|
||||||
|
.catch((error) => {
|
||||||
|
routeError = error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
initializing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function returnToSeries() {
|
||||||
|
if (!readerState.manga) return
|
||||||
|
await goto(`/series/${readerState.manga.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault()
|
||||||
|
void (readerState.direction === 'rtl' ? stepBackward() : stepForward())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
void (readerState.direction === 'rtl' ? stepForward() : stepBackward())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
void returnToSeries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<section class="reader-page">
|
||||||
|
<header class="reader-toolbar">
|
||||||
|
<div class="reader-meta">
|
||||||
|
<Button variant="ghost" size="sm" onclick={returnToSeries}>
|
||||||
|
<ArrowArcLeft size={16} weight="bold" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="reader-titles">
|
||||||
|
<p class="eyebrow">{readerState.manga?.title ?? 'Reader'}</p>
|
||||||
|
<h1>{readerState.chapter?.name ?? 'Loading chapter'}</h1>
|
||||||
|
<p class="subcopy">{chapterLabel} · {pageLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reader-actions">
|
||||||
|
<div class="toggle-group">
|
||||||
|
<button
|
||||||
|
class:active={readerState.mode === 'single'}
|
||||||
|
type="button"
|
||||||
|
onclick={() => (readerState.mode = 'single')}
|
||||||
|
aria-pressed={readerState.mode === 'single'}
|
||||||
|
>
|
||||||
|
<Columns size={16} weight="bold" />
|
||||||
|
Single
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class:active={readerState.mode === 'strip'}
|
||||||
|
type="button"
|
||||||
|
onclick={() => (readerState.mode = 'strip')}
|
||||||
|
aria-pressed={readerState.mode === 'strip'}
|
||||||
|
>
|
||||||
|
<List size={16} weight="bold" />
|
||||||
|
Strip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="direction-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={() => (readerState.direction = readerState.direction === 'ltr' ? 'rtl' : 'ltr')}
|
||||||
|
>
|
||||||
|
<TextAlignRight size={16} weight="bold" />
|
||||||
|
{readerState.direction.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="reader-progress">
|
||||||
|
<div class="progress-copy">
|
||||||
|
<span>{progressPercent}% read</span>
|
||||||
|
<span>{pageLabel}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max={Math.max(totalPages, 1)}
|
||||||
|
value={Math.min(Math.max(currentPageNumber, 1), Math.max(totalPages, 1))}
|
||||||
|
oninput={handleRangeInput}
|
||||||
|
disabled={totalPages === 0}
|
||||||
|
aria-label="Reader progress"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reader-stage">
|
||||||
|
{#if initializing && readerState.pages.length === 0}
|
||||||
|
<div class="reader-status">
|
||||||
|
<span class="spin"><SpinnerGap size={22} weight="bold" /></span>
|
||||||
|
<p>Loading chapter pages...</p>
|
||||||
|
</div>
|
||||||
|
{:else if routeError || readerState.pagesError}
|
||||||
|
<div class="reader-status error">
|
||||||
|
<p>{routeError ?? readerState.pagesError}</p>
|
||||||
|
<Button onclick={retryLoad}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
{:else if totalPages === 0}
|
||||||
|
<div class="reader-status">
|
||||||
|
<p>No pages were returned for this chapter.</p>
|
||||||
|
</div>
|
||||||
|
{:else if readerState.mode === 'strip'}
|
||||||
|
<div class="strip-view">
|
||||||
|
{#each readerState.pages as pageData, index (pageData.index)}
|
||||||
|
<button
|
||||||
|
class="strip-page"
|
||||||
|
class:current={index === readerState.currentPage}
|
||||||
|
type="button"
|
||||||
|
onclick={() => void setCurrentReaderPage(index)}
|
||||||
|
aria-label={`Open page ${index + 1}`}
|
||||||
|
>
|
||||||
|
<img src={pageData.imageData ?? pageData.url} alt={`Page ${index + 1}`} loading="lazy" />
|
||||||
|
<span>Page {index + 1}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="single-view">
|
||||||
|
<button class="edge-nav left" type="button" onclick={() => void stepBackward()} aria-label="Previous page">
|
||||||
|
<CaretLeft size={28} weight="bold" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if currentPageData}
|
||||||
|
<img
|
||||||
|
class="single-page"
|
||||||
|
src={currentPageData.imageData ?? currentPageData.url}
|
||||||
|
alt={`Page ${currentPageNumber}`}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="edge-nav right" type="button" onclick={() => void stepForward()} aria-label="Next page">
|
||||||
|
<CaretRight size={28} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="reader-footer">
|
||||||
|
<Button variant="ghost" onclick={() => void stepBackward()}>
|
||||||
|
<CaretLeft size={16} weight="bold" />
|
||||||
|
{chapterNeighbors.previous && readerState.currentPage === 0 ? 'Prev chapter' : 'Prev page'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onclick={() => void stepForward()}>
|
||||||
|
{readerState.currentPage >= totalPages - 1 && chapterNeighbors.next ? 'Next chapter' : 'Next page'}
|
||||||
|
<CaretRight size={16} weight="bold" />
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reader-page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr auto;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--sp-4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar,
|
||||||
|
.reader-progress,
|
||||||
|
.reader-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-meta,
|
||||||
|
.reader-actions,
|
||||||
|
.reader-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-titles {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-titles h1 {
|
||||||
|
margin: 2px 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.subcopy,
|
||||||
|
.progress-copy,
|
||||||
|
.strip-page span {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group,
|
||||||
|
.direction-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group button,
|
||||||
|
.direction-toggle {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group button.active,
|
||||||
|
.direction-toggle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-progress {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-copy {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-progress input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-stage {
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
background: color-mix(in srgb, var(--bg-base) 92%, black 8%);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-status,
|
||||||
|
.single-view,
|
||||||
|
.strip-view {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-status {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-6);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-status.error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(56px, 96px) 1fr minmax(56px, 96px);
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-page {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-nav {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-nav:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: color-mix(in srgb, var(--bg-overlay) 44%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-view {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-page {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
justify-items: center;
|
||||||
|
padding: var(--sp-3);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-page.current {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-page img {
|
||||||
|
width: min(100%, 1100px);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.reader-page {
|
||||||
|
padding: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar,
|
||||||
|
.reader-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-meta,
|
||||||
|
.reader-actions,
|
||||||
|
.reader-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-view {
|
||||||
|
grid-template-columns: 56px 1fr 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,48 +1,48 @@
|
|||||||
import { error } from '@sveltejs/kit'
|
import {error} from '@sveltejs/kit';
|
||||||
import type { PageLoad } from './$types'
|
import type {PageLoad} from './$types';
|
||||||
import { getAdapter } from '$lib/request-manager'
|
import {getAdapter} from '$lib/request-manager';
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import {seriesState} from '$lib/state/series.svelte';
|
||||||
import { readerState } from '$lib/state/reader.svelte'
|
import {readerState} from '$lib/state/reader.svelte';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({params}) => {
|
||||||
const mangaId = params.mangaId
|
const mangaId = params.mangaId;
|
||||||
|
|
||||||
if (!mangaId) {
|
if (!mangaId) {
|
||||||
throw error(400, 'Missing manga id')
|
throw error(400, 'Missing manga id');
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
seriesState.loading = true
|
|
||||||
seriesState.error = null
|
|
||||||
seriesState.chaptersLoading = true
|
|
||||||
seriesState.chaptersError = null
|
|
||||||
|
|
||||||
const adapter = getAdapter()
|
|
||||||
const [manga, chapters] = await Promise.all([
|
|
||||||
adapter.getManga(mangaId),
|
|
||||||
adapter.getChapters(mangaId),
|
|
||||||
])
|
|
||||||
|
|
||||||
seriesState.current = manga
|
|
||||||
seriesState.chapters = chapters
|
|
||||||
|
|
||||||
readerState.manga = manga
|
|
||||||
readerState.chapters = chapters
|
|
||||||
|
|
||||||
return {
|
|
||||||
manga,
|
|
||||||
chapters,
|
|
||||||
mangaId,
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
|
|
||||||
seriesState.error = message
|
try {
|
||||||
seriesState.chaptersError = message
|
seriesState.loading = true;
|
||||||
|
seriesState.error = null;
|
||||||
|
seriesState.chaptersLoading = true;
|
||||||
|
seriesState.chaptersError = null;
|
||||||
|
|
||||||
throw error(500, message)
|
const adapter = getAdapter();
|
||||||
} finally {
|
const [manga, chapters] = await Promise.all([
|
||||||
seriesState.loading = false
|
adapter.getManga(mangaId),
|
||||||
seriesState.chaptersLoading = false
|
adapter.getChapters(mangaId),
|
||||||
}
|
]);
|
||||||
}
|
|
||||||
|
seriesState.current = manga;
|
||||||
|
seriesState.chapters = chapters;
|
||||||
|
|
||||||
|
readerState.manga = manga;
|
||||||
|
readerState.chapters = chapters;
|
||||||
|
|
||||||
|
return {
|
||||||
|
manga,
|
||||||
|
chapters,
|
||||||
|
mangaId,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
seriesState.error = message;
|
||||||
|
seriesState.chaptersError = message;
|
||||||
|
|
||||||
|
throw error(500, message);
|
||||||
|
} finally {
|
||||||
|
seriesState.loading = false;
|
||||||
|
seriesState.chaptersLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user