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 { seriesState } from '$lib/state/series.svelte'
|
||||
import { readerState } from '$lib/state/reader.svelte'
|
||||
import {getAdapter} from '$lib/request-manager';
|
||||
import {seriesState} from '$lib/state/series.svelte';
|
||||
import {readerState} from '$lib/state/reader.svelte';
|
||||
|
||||
export async function loadChapters(mangaId: string) {
|
||||
seriesState.chaptersLoading = true
|
||||
seriesState.chaptersError = null
|
||||
seriesState.chaptersLoading = true;
|
||||
seriesState.chaptersError = null;
|
||||
try {
|
||||
seriesState.chapters = await getAdapter().getChapters(mangaId)
|
||||
seriesState.chapters = await getAdapter().getChapters(mangaId);
|
||||
} catch (e) {
|
||||
seriesState.chaptersError = String(e)
|
||||
seriesState.chaptersError = String(e);
|
||||
} finally {
|
||||
seriesState.chaptersLoading = false
|
||||
seriesState.chaptersLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadChapterPages(chapterId: string) {
|
||||
readerState.pagesLoading = true
|
||||
readerState.pagesError = null
|
||||
readerState.pagesLoading = true;
|
||||
readerState.pagesError = null;
|
||||
try {
|
||||
readerState.pages = await getAdapter().getChapterPages(chapterId)
|
||||
readerState.pages = await getAdapter().getChapterPages(chapterId);
|
||||
} catch (e) {
|
||||
readerState.pagesError = String(e)
|
||||
readerState.pagesError = String(e);
|
||||
} 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) {
|
||||
await getAdapter().markChapterRead(id, read)
|
||||
const chapter = seriesState.chapters.find(c => c.id === id)
|
||||
if (chapter) chapter.read = read
|
||||
await getAdapter().markChapterRead(id, read);
|
||||
const chapter = seriesState.chapters.find(c => c.id === id);
|
||||
if (chapter) chapter.read = read;
|
||||
}
|
||||
|
||||
export async function markManyRead(ids: string[], read: boolean) {
|
||||
await getAdapter().markChaptersRead(ids, read)
|
||||
await getAdapter().markChaptersRead(ids, read);
|
||||
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,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
} from '$lib/server-adapters/types';
|
||||
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
|
||||
|
||||
function notImplemented(): never {
|
||||
throw new Error('MokuAdapter: not implemented')
|
||||
throw new Error('MokuAdapter: not implemented');
|
||||
}
|
||||
|
||||
export class MokuAdapter implements ServerAdapter {
|
||||
async connect(_config: ServerConfig): Promise<void> { notImplemented() }
|
||||
async getStatus(): Promise<ServerStatus> { return notImplemented() }
|
||||
async connect(_config: ServerConfig): Promise<void> {notImplemented();}
|
||||
async getStatus(): Promise<ServerStatus> {return notImplemented();}
|
||||
|
||||
async getManga(_id: string): Promise<Manga> { return notImplemented() }
|
||||
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> { return notImplemented() }
|
||||
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> { return notImplemented() }
|
||||
async addToLibrary(_mangaId: string): Promise<void> { notImplemented() }
|
||||
async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() }
|
||||
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() }
|
||||
async getManga(_id: string): Promise<Manga> {return notImplemented();}
|
||||
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> {return notImplemented();}
|
||||
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> {return notImplemented();}
|
||||
async addToLibrary(_mangaId: string): Promise<void> {notImplemented();}
|
||||
async removeFromLibrary(_mangaId: string): Promise<void> {notImplemented();}
|
||||
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> {notImplemented();}
|
||||
|
||||
async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
|
||||
async getChapter(_id: string): Promise<Chapter> { return notImplemented() }
|
||||
async getChapterPages(_id: string): Promise<Page[]> { return notImplemented() }
|
||||
async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() }
|
||||
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() }
|
||||
async getChapters(_mangaId: string): Promise<Chapter[]> {return notImplemented();}
|
||||
async getChapter(_id: string): Promise<Chapter> {return notImplemented();}
|
||||
async getChapterPages(_id: string): Promise<Page[]> {return notImplemented();}
|
||||
async markChapterRead(_id: 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 enqueueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
||||
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
||||
async clearDownloads(): Promise<void> { notImplemented() }
|
||||
async getDownloads(): Promise<DownloadItem[]> {return notImplemented();}
|
||||
async enqueueDownload(_chapterId: string): Promise<void> {notImplemented();}
|
||||
async dequeueDownload(_chapterId: string): Promise<void> {notImplemented();}
|
||||
async clearDownloads(): Promise<void> {notImplemented();}
|
||||
|
||||
async getExtensions(): Promise<Extension[]> { return notImplemented() }
|
||||
async installExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async uninstallExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async updateExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async getExtensions(): Promise<Extension[]> {return notImplemented();}
|
||||
async installExtension(_id: string): Promise<void> {notImplemented();}
|
||||
async uninstallExtension(_id: string): Promise<void> {notImplemented();}
|
||||
async updateExtension(_id: string): Promise<void> {notImplemented();}
|
||||
|
||||
async getSources(): Promise<Source[]> { return notImplemented() }
|
||||
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() }
|
||||
async getSources(): Promise<Source[]> {return notImplemented();}
|
||||
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> {return notImplemented();}
|
||||
|
||||
async getTrackers(): Promise<Tracker[]> { return notImplemented() }
|
||||
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() }
|
||||
async syncTracking(_mangaId: string): Promise<void> { notImplemented() }
|
||||
async getTrackers(): Promise<Tracker[]> {return notImplemented();}
|
||||
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: 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,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
} from '$lib/server-adapters/types';
|
||||
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
|
||||
import {
|
||||
GET_LIBRARY,
|
||||
GET_MANGA,
|
||||
@@ -19,38 +19,39 @@ import {
|
||||
SET_MANGA_META,
|
||||
UPDATE_LIBRARY,
|
||||
FETCH_SOURCE_MANGA,
|
||||
} from './manga'
|
||||
} from './manga';
|
||||
import {
|
||||
GET_CHAPTERS,
|
||||
FETCH_CHAPTERS,
|
||||
FETCH_CHAPTER_PAGES,
|
||||
MARK_CHAPTER_READ,
|
||||
MARK_CHAPTERS_READ,
|
||||
} from './chapters'
|
||||
UPDATE_CHAPTERS_PROGRESS,
|
||||
} from './chapters';
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS,
|
||||
ENQUEUE_DOWNLOAD,
|
||||
DEQUEUE_DOWNLOAD,
|
||||
CLEAR_DOWNLOADER,
|
||||
} from './downloads'
|
||||
} from './downloads';
|
||||
import {
|
||||
GET_EXTENSIONS,
|
||||
GET_SOURCES,
|
||||
FETCH_EXTENSIONS,
|
||||
UPDATE_EXTENSION,
|
||||
} from './extensions'
|
||||
} from './extensions';
|
||||
import {
|
||||
GET_TRACKERS,
|
||||
BIND_TRACK,
|
||||
TRACK_PROGRESS,
|
||||
} from './tracking'
|
||||
} from './tracking';
|
||||
import {
|
||||
GQLResponse,
|
||||
mapManga,
|
||||
mapChapter,
|
||||
mapExtension,
|
||||
mapDownloadItem,
|
||||
} from './types'
|
||||
} from './types';
|
||||
|
||||
const GET_CHAPTER = `
|
||||
query GetChapter($id: Int!) {
|
||||
@@ -59,17 +60,17 @@ const GET_CHAPTER = `
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export class SuwayomiAdapter implements ServerAdapter {
|
||||
private baseUrl = 'http://127.0.0.1:4567'
|
||||
private authHeader: string | null = null
|
||||
private baseUrl = 'http://127.0.0.1:4567';
|
||||
private authHeader: string | null = null;
|
||||
|
||||
async connect(config: ServerConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
if (config.credentials) {
|
||||
const { username, password } = config.credentials
|
||||
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
|
||||
const {username, password} = config.credentials;
|
||||
this.authHeader = 'Basic ' + btoa(`${username}:${password}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,154 +79,162 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
||||
})
|
||||
return res.ok ? 'connected' : 'error'
|
||||
body: JSON.stringify({query: '{ aboutServer { name } }'}),
|
||||
});
|
||||
return res.ok ? 'connected' : 'error';
|
||||
} catch {
|
||||
return 'disconnected'
|
||||
return 'disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (this.authHeader) h['Authorization'] = this.authHeader
|
||||
return h
|
||||
const h: Record<string, string> = {'Content-Type': 'application/json'};
|
||||
if (this.authHeader) h['Authorization'] = this.authHeader;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||
const json: GQLResponse<T> = await res.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
return json.data
|
||||
body: JSON.stringify({query, variables}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async getManga(id: string): Promise<Manga> {
|
||||
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
|
||||
return mapManga(data.manga)
|
||||
const data = await this.gql<{manga: Record<string, unknown>;}>(GET_MANGA, {id: Number(id)});
|
||||
return mapManga(data.manga);
|
||||
}
|
||||
|
||||
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
let items = data.mangas.nodes.map(mapManga)
|
||||
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.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0)
|
||||
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId)
|
||||
return { items, hasNextPage: false }
|
||||
const data = await this.gql<{mangas: {nodes: Record<string, unknown>[];};}>(GET_LIBRARY);
|
||||
let items = data.mangas.nodes.map(mapManga);
|
||||
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.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId);
|
||||
return {items, hasNextPage: false};
|
||||
}
|
||||
|
||||
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
||||
if (!sourceId) return []
|
||||
if (!sourceId) return [];
|
||||
const data = await this.gql<{
|
||||
fetchSourceManga: { mangas: Record<string, unknown>[] }
|
||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query })
|
||||
return data.fetchSourceManga.mangas.map(mapManga)
|
||||
fetchSourceManga: {mangas: Record<string, unknown>[];};
|
||||
}>(FETCH_SOURCE_MANGA, {source: sourceId, type: 'SEARCH', page: 1, query});
|
||||
return data.fetchSourceManga.mangas.map(mapManga);
|
||||
}
|
||||
|
||||
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) {
|
||||
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>) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue
|
||||
await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value) })
|
||||
if (value === undefined) continue;
|
||||
await this.gql(SET_MANGA_META, {mangaId: Number(id), key, value: String(value)});
|
||||
}
|
||||
}
|
||||
|
||||
async getChapters(mangaId: string): Promise<Chapter[]> {
|
||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_CHAPTERS, { mangaId: Number(mangaId) }
|
||||
)
|
||||
return data.chapters.nodes.map(mapChapter)
|
||||
const data = await this.gql<{chapters: {nodes: Record<string, unknown>[];};}>(
|
||||
GET_CHAPTERS, {mangaId: Number(mangaId)}
|
||||
);
|
||||
return data.chapters.nodes.map(mapChapter);
|
||||
}
|
||||
|
||||
async getChapter(id: string): Promise<Chapter> {
|
||||
const data = await this.gql<{ chapter: Record<string, unknown> }>(
|
||||
GET_CHAPTER, { id: Number(id) }
|
||||
)
|
||||
return mapChapter(data.chapter)
|
||||
const data = await this.gql<{chapter: Record<string, unknown>;}>(
|
||||
GET_CHAPTER, {id: Number(id)}
|
||||
);
|
||||
return mapChapter(data.chapter);
|
||||
}
|
||||
|
||||
async getChapterPages(id: string): Promise<Page[]> {
|
||||
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>(
|
||||
FETCH_CHAPTER_PAGES, { chapterId: Number(id) }
|
||||
)
|
||||
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
|
||||
const data = await this.gql<{fetchChapterPages: {pages: string[];};}>(
|
||||
FETCH_CHAPTER_PAGES, {chapterId: Number(id)}
|
||||
);
|
||||
return data.fetchChapterPages.pages.map((url, index) => ({index, url}));
|
||||
}
|
||||
|
||||
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) {
|
||||
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[]> {
|
||||
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
|
||||
const data = await this.gql<{downloadStatus: {queue: Record<string, unknown>[];};}>(
|
||||
GET_DOWNLOAD_STATUS
|
||||
)
|
||||
return data.downloadStatus.queue.map(mapDownloadItem)
|
||||
);
|
||||
return data.downloadStatus.queue.map(mapDownloadItem);
|
||||
}
|
||||
|
||||
async enqueueDownload(chapterId: string) {
|
||||
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
||||
await this.gql(ENQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
|
||||
}
|
||||
|
||||
async dequeueDownload(chapterId: string) {
|
||||
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
||||
await this.gql(DEQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
|
||||
}
|
||||
|
||||
async clearDownloads() {
|
||||
await this.gql(CLEAR_DOWNLOADER)
|
||||
await this.gql(CLEAR_DOWNLOADER);
|
||||
}
|
||||
|
||||
async getExtensions(): Promise<Extension[]> {
|
||||
await this.gql(FETCH_EXTENSIONS)
|
||||
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
|
||||
return data.extensions.nodes.map(mapExtension)
|
||||
await this.gql(FETCH_EXTENSIONS);
|
||||
const data = await this.gql<{extensions: {nodes: Record<string, unknown>[];};}>(GET_EXTENSIONS);
|
||||
return data.extensions.nodes.map(mapExtension);
|
||||
}
|
||||
|
||||
async installExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, install: true })
|
||||
await this.gql(UPDATE_EXTENSION, {id, install: true});
|
||||
}
|
||||
|
||||
async uninstallExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
|
||||
await this.gql(UPDATE_EXTENSION, {id, uninstall: true});
|
||||
}
|
||||
|
||||
async updateExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, update: true })
|
||||
await this.gql(UPDATE_EXTENSION, {id, update: true});
|
||||
}
|
||||
|
||||
async getSources(): Promise<Source[]> {
|
||||
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
return data.sources.nodes
|
||||
const data = await this.gql<{sources: {nodes: Source[];};}>(GET_SOURCES);
|
||||
return data.sources.nodes;
|
||||
}
|
||||
|
||||
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
|
||||
const data = await this.gql<{
|
||||
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
|
||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page })
|
||||
fetchSourceManga: {mangas: Record<string, unknown>[]; hasNextPage: boolean;};
|
||||
}>(FETCH_SOURCE_MANGA, {source: sourceId, type: 'LATEST', page});
|
||||
return {
|
||||
items: data.fetchSourceManga.mangas.map(mapManga),
|
||||
hasNextPage: data.fetchSourceManga.hasNextPage,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getTrackers(): Promise<Tracker[]> {
|
||||
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||
return data.trackers.nodes
|
||||
const data = await this.gql<{trackers: {nodes: Tracker[];};}>(GET_TRACKERS);
|
||||
return data.trackers.nodes;
|
||||
}
|
||||
|
||||
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
||||
@@ -233,25 +242,25 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
mangaId: Number(mangaId),
|
||||
trackerId: Number(trackerId),
|
||||
remoteId,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
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[]> {
|
||||
if (mangaIds?.length) {
|
||||
const results: UpdateResult[] = []
|
||||
const results: UpdateResult[] = [];
|
||||
for (const id of mangaIds) {
|
||||
const before = await this.getChapters(id)
|
||||
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) })
|
||||
const after = await this.getChapters(id)
|
||||
results.push({ mangaId: id, newChapters: after.length - before.length })
|
||||
const before = await this.getChapters(id);
|
||||
await this.gql(FETCH_CHAPTERS, {mangaId: Number(id)});
|
||||
const after = await this.getChapters(id);
|
||||
results.push({mangaId: id, newChapters: after.length - before.length});
|
||||
}
|
||||
return results
|
||||
return results;
|
||||
}
|
||||
await this.gql(UPDATE_LIBRARY)
|
||||
return []
|
||||
await this.gql(UPDATE_LIBRARY);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -4,91 +4,92 @@ import type {
|
||||
Extension,
|
||||
Source,
|
||||
Tracker,
|
||||
} from '$lib/types'
|
||||
} from '$lib/types';
|
||||
|
||||
export interface ServerConfig {
|
||||
baseUrl: string
|
||||
credentials?: { username: string; password: string }
|
||||
baseUrl: string;
|
||||
credentials?: {username: string; password: string;};
|
||||
}
|
||||
|
||||
export type ServerStatus = 'connected' | 'disconnected' | 'error'
|
||||
export type ServerStatus = 'connected' | 'disconnected' | 'error';
|
||||
|
||||
export interface MangaFilters {
|
||||
inLibrary?: boolean
|
||||
status?: MangaStatus
|
||||
tags?: string[]
|
||||
unread?: boolean
|
||||
sourceId?: string
|
||||
inLibrary?: boolean;
|
||||
status?: MangaStatus;
|
||||
tags?: string[];
|
||||
unread?: boolean;
|
||||
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> {
|
||||
items: T[]
|
||||
hasNextPage: boolean
|
||||
total?: number
|
||||
items: T[];
|
||||
hasNextPage: boolean;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface MangaMeta {
|
||||
customTitle?: string
|
||||
customCover?: string
|
||||
notes?: string
|
||||
[key: string]: unknown
|
||||
customTitle?: string;
|
||||
customCover?: string;
|
||||
notes?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
index: number
|
||||
url: string
|
||||
imageData?: string
|
||||
index: number;
|
||||
url: string;
|
||||
imageData?: string;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
chapterId: string
|
||||
mangaId: string
|
||||
chapterName: string
|
||||
mangaTitle: string
|
||||
progress: number
|
||||
state: 'queued' | 'downloading' | 'finished' | 'error'
|
||||
chapterId: string;
|
||||
mangaId: string;
|
||||
chapterName: string;
|
||||
mangaTitle: string;
|
||||
progress: number;
|
||||
state: 'queued' | 'downloading' | 'finished' | 'error';
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
mangaId: string
|
||||
newChapters: number
|
||||
mangaId: string;
|
||||
newChapters: number;
|
||||
}
|
||||
|
||||
export interface ServerAdapter {
|
||||
connect(config: ServerConfig): Promise<void>
|
||||
getStatus(): Promise<ServerStatus>
|
||||
connect(config: ServerConfig): Promise<void>;
|
||||
getStatus(): Promise<ServerStatus>;
|
||||
|
||||
getManga(id: string): Promise<Manga>
|
||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
||||
addToLibrary(mangaId: string): Promise<void>
|
||||
removeFromLibrary(mangaId: string): Promise<void>
|
||||
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
|
||||
getManga(id: string): Promise<Manga>;
|
||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>;
|
||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>;
|
||||
addToLibrary(mangaId: string): Promise<void>;
|
||||
removeFromLibrary(mangaId: string): Promise<void>;
|
||||
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>;
|
||||
|
||||
getChapters(mangaId: string): Promise<Chapter[]>
|
||||
getChapter(id: string): Promise<Chapter>
|
||||
getChapterPages(id: string): Promise<Page[]>
|
||||
markChapterRead(id: string, read: boolean): Promise<void>
|
||||
markChaptersRead(ids: string[], read: boolean): Promise<void>
|
||||
getChapters(mangaId: string): Promise<Chapter[]>;
|
||||
getChapter(id: string): Promise<Chapter>;
|
||||
getChapterPages(id: string): Promise<Page[]>;
|
||||
markChapterRead(id: string, read: boolean): Promise<void>;
|
||||
updateChapterProgress(id: string, lastPageRead: number, read?: boolean): Promise<void>;
|
||||
markChaptersRead(ids: string[], read: boolean): Promise<void>;
|
||||
|
||||
getDownloads(): Promise<DownloadItem[]>
|
||||
enqueueDownload(chapterId: string): Promise<void>
|
||||
dequeueDownload(chapterId: string): Promise<void>
|
||||
clearDownloads(): Promise<void>
|
||||
getDownloads(): Promise<DownloadItem[]>;
|
||||
enqueueDownload(chapterId: string): Promise<void>;
|
||||
dequeueDownload(chapterId: string): Promise<void>;
|
||||
clearDownloads(): Promise<void>;
|
||||
|
||||
getExtensions(): Promise<Extension[]>
|
||||
installExtension(id: string): Promise<void>
|
||||
uninstallExtension(id: string): Promise<void>
|
||||
updateExtension(id: string): Promise<void>
|
||||
getExtensions(): Promise<Extension[]>;
|
||||
installExtension(id: string): Promise<void>;
|
||||
uninstallExtension(id: string): Promise<void>;
|
||||
updateExtension(id: string): Promise<void>;
|
||||
|
||||
getSources(): Promise<Source[]>
|
||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
||||
getSources(): Promise<Source[]>;
|
||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>;
|
||||
|
||||
getTrackers(): Promise<Tracker[]>
|
||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
||||
syncTracking(mangaId: string): Promise<void>
|
||||
getTrackers(): Promise<Tracker[]>;
|
||||
linkTracker(mangaId: string, trackerId: string, remoteId: 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 type { PageLoad } from './$types'
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
import { readerState } from '$lib/state/reader.svelte'
|
||||
import {error} from '@sveltejs/kit';
|
||||
import type {PageLoad} from './$types';
|
||||
import {getAdapter} from '$lib/request-manager';
|
||||
import {seriesState} from '$lib/state/series.svelte';
|
||||
import {readerState} from '$lib/state/reader.svelte';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const mangaId = params.mangaId
|
||||
export const load: PageLoad = async ({params}) => {
|
||||
const mangaId = params.mangaId;
|
||||
|
||||
if (!mangaId) {
|
||||
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,
|
||||
if (!mangaId) {
|
||||
throw error(400, 'Missing manga id');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
seriesState.error = message
|
||||
seriesState.chaptersError = message
|
||||
try {
|
||||
seriesState.loading = true;
|
||||
seriesState.error = null;
|
||||
seriesState.chaptersLoading = true;
|
||||
seriesState.chaptersError = null;
|
||||
|
||||
throw error(500, message)
|
||||
} finally {
|
||||
seriesState.loading = false
|
||||
seriesState.chaptersLoading = false
|
||||
}
|
||||
}
|
||||
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;
|
||||
seriesState.chaptersError = message;
|
||||
|
||||
throw error(500, message);
|
||||
} finally {
|
||||
seriesState.loading = false;
|
||||
seriesState.chaptersLoading = false;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user