Reader route migration

This commit is contained in:
Zerebos
2026-05-23 16:21:09 -04:00
parent 54307d4411
commit 3d6b6430ed
9 changed files with 846 additions and 233 deletions
+101
View File
@@ -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;
}
+44 -18
View File
@@ -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;
} }
} }
+31 -30
View File
@@ -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();}
} }
+96 -87
View File
@@ -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 [];
} }
} }
+56 -55
View File
@@ -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>
+43 -43
View File
@@ -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;
}
};