diff --git a/src/hooks.client.ts b/src/hooks.client.ts index f1b4f69..ce0f2c3 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -3,8 +3,8 @@ import { initPlatformService } from '$lib/platform-service' import { appState } from '$lib/state/app.svelte' import { configureAuth, probeServer } from '$lib/core/auth' -const SAVED_URL_KEY = 'moku_server_url' -const SAVED_AUTH_KEY = 'moku_auth_config' +const KEY_URL = 'moku_server_url' +const KEY_AUTH = 'moku_auth_config' interface SavedAuth { mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' @@ -12,24 +12,13 @@ interface SavedAuth { pass?: string } -function isTauri(): boolean { - return '__TAURI_INTERNALS__' in window -} +function isTauri(): boolean { return '__TAURI_INTERNALS__' in window } +function isCapacitor(): boolean { return 'Capacitor' in window } -function isCapacitor(): boolean { - return 'Capacitor' in window -} - -function loadSavedServerUrl(): string { - return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567' -} - -function loadSavedAuth(): SavedAuth { - try { - return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' } - } catch { - return { mode: 'NONE' } - } +function detectPlatform(): 'tauri' | 'capacitor' | 'web' { + if (isTauri()) return 'tauri' + if (isCapacitor()) return 'capacitor' + return 'web' } async function resolvePlatformAdapter() { @@ -60,30 +49,35 @@ async function boot() { initRequestManager(serverAdapter) initPlatformService(platformAdapter) - appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web' + appState.platform = detectPlatform() appState.version = await platformAdapter.getVersion() - const savedUrl = loadSavedServerUrl() - const savedAuth = loadSavedAuth() + const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567' + const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH) + const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' } appState.serverUrl = savedUrl appState.authMode = savedAuth.mode if (isTauri() && platformAdapter.isSupported('server-management')) { - await platformAdapter.launchServer({ url: savedUrl }).catch(() => {}) + // jarPath/port/dataPath come from persisted server config; omitted here + // until settings UI writes them — server auto-launch handled by Tauri side } configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass) - await serverAdapter.connect({ baseUrl: savedUrl }) + + await serverAdapter.connect({ + baseUrl: savedUrl, + credentials: + savedAuth.mode === 'BASIC_AUTH' && savedAuth.user && savedAuth.pass + ? { username: savedAuth.user, password: savedAuth.pass } + : undefined, + }) const probe = await probeServer() - if (probe === 'auth_required') { - appState.status = 'auth' - return - } - - if (probe === 'unreachable') { + if (probe === 'auth_required') { appState.status = 'auth'; return } + if (probe === 'unreachable') { appState.error = `Could not reach server at ${savedUrl}` appState.status = 'error' return diff --git a/src/lib/core/auth.ts b/src/lib/core/auth.ts index d486719..99043fc 100644 --- a/src/lib/core/auth.ts +++ b/src/lib/core/auth.ts @@ -1,388 +1,112 @@ -export type AuthMode = 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' +const DEFAULT_URL = 'http://127.0.0.1:4567' -export class AuthRequiredError extends Error { - constructor(msg = 'Authentication required') { - super(msg) - this.name = 'AuthRequiredError' +interface AuthConfig { + baseUrl: string + mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' + user?: string + pass?: string +} + +let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' } + +let accessToken: string | null = null +let refreshToken: string | null = null + +export function configureAuth( + baseUrl: string, + mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', + user?: string, + pass?: string, +): void { + config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass } + accessToken = null + refreshToken = null +} + +export function authHeaders(): Record { + if (config.mode === 'BASIC_AUTH' && config.user && config.pass) { + return { Authorization: 'Basic ' + btoa(`${config.user}:${config.pass}`) } } -} - -const TOKEN_KEY = 'moku_access_token' -const UI_SESSION_KEY = 'moku_ui_auth_session' -const REFRESH_SKEW_MS = 30_000 - -interface StoredToken { - base: string - token: string -} - -interface UiSession { - base: string - accessToken: string - refreshToken?: string - clientMutationId?: string - accessExpiresAt?: number | null - refreshExpiresAt?: number | null -} - -interface JwtSettings { - jwtAudience?: string | null - jwtRefreshExpiry?: string | null - jwtTokenExpiry?: string | null -} - -let _session: UiSession | null = null -let _accessToken: string | null = null -let _accessTokenBase: string | null = null -let _refreshPromise: Promise | null = null -let _jwtSettings: JwtSettings | null = null -let _jwtSettingsBase: string | null = null -let _jwtSettingsFetchedAt = 0 - -let _serverBase = 'http://127.0.0.1:4567' -let _authMode: AuthMode = 'NONE' -let _basicUser = '' -let _basicPass = '' - -export function configureAuth(base: string, mode: AuthMode, user = '', pass = '') { - _serverBase = base.replace(/\/$/, '') - _authMode = mode - _basicUser = user - _basicPass = pass -} - -export function getServerBase(): string { - return _serverBase -} - -export function getAuthMode(): AuthMode { - return _authMode -} - -function timeoutSignal(ms: number): AbortSignal { - return AbortSignal.timeout(ms) -} - -function gqlBody(query: string, variables?: Record): string { - return JSON.stringify({ query, variables }) -} - -function basicHeader(user: string, pass: string): Record { - return { Authorization: 'Basic ' + btoa(`${user}:${pass}`) } -} - -function bearerHeader(token: string): Record { - return { Authorization: `Bearer ${token}` } -} - -function parseIsoDuration(d: string): number | null { - const m = d.match(/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/) - if (!m) return null - let ms = 0 - if (m[1]) ms += +m[1] * 365.25 * 86400000 - if (m[2]) ms += +m[2] * 30.44 * 86400000 - if (m[3]) ms += +m[3] * 86400000 - if (m[4]) ms += +m[4] * 3600000 - if (m[5]) ms += +m[5] * 60000 - if (m[6]) ms += parseFloat(m[6]) * 1000 - return ms -} - -function decodeJwtExpiry(token: string): number | null { - try { - const part = token.split('.')[1] - if (!part) return null - const pad = part.replace(/-/g, '+').replace(/_/g, '/') - const json = JSON.parse(atob(pad.padEnd(pad.length + ((4 - pad.length % 4) % 4), '='))) as { exp?: number } - return typeof json.exp === 'number' ? json.exp * 1000 : null - } catch { return null } -} - -function isExpired(at?: number | null, skew = REFRESH_SKEW_MS): boolean { - if (!at || !Number.isFinite(at)) return false - return Date.now() >= at - skew -} - -function readStoredSession(): UiSession | null { - try { return JSON.parse(sessionStorage.getItem(UI_SESSION_KEY) ?? 'null') } catch { return null } -} - -function readStoredToken(): StoredToken | null { - try { return JSON.parse(sessionStorage.getItem(TOKEN_KEY) ?? 'null') } catch { return null } -} - -export const uiAuth = { - getSession(): UiSession | null { - if (_session?.base === _serverBase) return _session - const stored = readStoredSession() - if (!stored || stored.base !== _serverBase) { - sessionStorage.removeItem(UI_SESSION_KEY) - sessionStorage.removeItem(TOKEN_KEY) - _session = _accessToken = _accessTokenBase = null - return null - } - _session = stored - _accessToken = stored.accessToken - _accessTokenBase = stored.base - return _session - }, - - setSession(session: Omit) { - _session = { ...session, base: _serverBase } - _accessToken = session.accessToken - _accessTokenBase = _serverBase - sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_session)) - sessionStorage.removeItem(TOKEN_KEY) - }, - - getToken(): string | null { - const s = uiAuth.getSession() - if (!s || isExpired(s.accessExpiresAt, 0)) return null - if (_accessToken && _accessTokenBase === _serverBase) return _accessToken - const stored = readStoredToken() - if (!stored || stored.base !== _serverBase) { - sessionStorage.removeItem(TOKEN_KEY) - _accessToken = _accessTokenBase = null - return null - } - _accessToken = stored.token - _accessTokenBase = stored.base - return _accessToken - }, - - setToken(t: string) { - const existing = uiAuth.getSession() - if (existing?.refreshToken) { - uiAuth.setSession({ ...existing, accessToken: t, ...expiryFromJwt(t, _jwtSettings) }) - return - } - _accessToken = t - _accessTokenBase = _serverBase - sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base: _serverBase, token: t })) - }, - - setLoginSession( - payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, - jwt: JwtSettings | null, - ) { - uiAuth.setSession({ - accessToken: payload.accessToken, - refreshToken: payload.refreshToken, - clientMutationId: payload.clientMutationId, - ...expiryFromJwt(payload.accessToken, jwt), - }) - }, - - updateAccessToken( - payload: { accessToken: string; clientMutationId?: string }, - jwt: JwtSettings | null, - ) { - const s = uiAuth.getSession() - if (!s) return - uiAuth.setSession({ - ...s, - accessToken: payload.accessToken, - clientMutationId: payload.clientMutationId ?? s.clientMutationId, - ...expiryFromJwt(payload.accessToken, jwt), - }) - }, - - clearToken() { - _session = _accessToken = _accessTokenBase = null - sessionStorage.removeItem(UI_SESSION_KEY) - sessionStorage.removeItem(TOKEN_KEY) - }, -} - -function expiryFromJwt(token: string, jwt: JwtSettings | null) { - const now = Date.now() - return { - accessExpiresAt: decodeJwtExpiry(token) ?? (jwt?.jwtTokenExpiry ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null), - refreshExpiresAt: jwt?.jwtRefreshExpiry ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null, + if (config.mode === 'UI_LOGIN' && accessToken) { + return { Authorization: `Bearer ${accessToken}` } } + return {} } -async function fetchJwtSettings(): Promise { - try { - const res = await fetchAuthenticated(`${_serverBase}/api/graphql`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: gqlBody(`query { settings { jwtAudience jwtRefreshExpiry jwtTokenExpiry } }`), - }, timeoutSignal(5000)) - if (!res.ok) return null - const json = await res.json() - const s = json?.data?.settings - if (!s) return null - return { - jwtAudience: s.jwtAudience ?? null, - jwtRefreshExpiry: s.jwtRefreshExpiry ?? null, - jwtTokenExpiry: s.jwtTokenExpiry ?? null, - } - } catch { return null } -} - -async function getJwtSettings(force = false): Promise { - const fresh = Date.now() - _jwtSettingsFetchedAt < 60_000 - if (!force && _jwtSettingsBase === _serverBase && _jwtSettings && fresh) return _jwtSettings - _jwtSettings = await fetchJwtSettings() - _jwtSettingsBase = _serverBase - _jwtSettingsFetchedAt = Date.now() - return _jwtSettings -} - -export async function fetchAuthenticated( - url: string, - init: RequestInit = {}, - signal?: AbortSignal, -): Promise { - const baseHeaders = { ...(init.headers as Record ?? {}) } - - if (_authMode === 'BASIC_AUTH') { - return fetch(url, { - ...init, signal, credentials: 'omit', - headers: { ...baseHeaders, ...(_basicUser && _basicPass ? basicHeader(_basicUser, _basicPass) : {}) }, - }) - } - - if (_authMode === 'UI_LOGIN') { - const token = await getUIAccessToken() - if (!token) throw new AuthRequiredError() - - let res = await fetch(url, { - ...init, signal, credentials: 'omit', - headers: { ...baseHeaders, ...bearerHeader(token) }, - }) - - if (res.status !== 401) return res - - const refreshed = await refreshUiAccessToken(true) - if (!refreshed) return res - - return fetch(url, { - ...init, signal, credentials: 'omit', - headers: { ...baseHeaders, ...bearerHeader(refreshed) }, - }) - } - - return fetch(url, { ...init, signal, credentials: 'omit' }) -} - -export async function getUIAccessToken(forceRefresh = false): Promise { - const s = uiAuth.getSession() - if (!s) return null - if (forceRefresh || isExpired(s.accessExpiresAt)) return refreshUiAccessToken(true) - return s.accessToken -} - -export async function refreshUiAccessToken(force = false): Promise { - const s = uiAuth.getSession() - if (!s) return null - if (!s.refreshToken) { - if (force && isExpired(s.accessExpiresAt, 0)) return null - return s.accessToken - } - if (!force && !isExpired(s.accessExpiresAt)) return s.accessToken - if (isExpired(s.refreshExpiresAt)) { uiAuth.clearToken(); return null } - if (_refreshPromise) return _refreshPromise - - _refreshPromise = (async () => { - const jwt = await getJwtSettings().catch(() => null) - const res = await fetch(`${_serverBase}/api/graphql`, { - method: 'POST', credentials: 'omit', - headers: { 'Content-Type': 'application/json' }, - body: gqlBody( - `mutation RefreshToken($refreshToken: String!, $clientMutationId: String) { - refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) { - accessToken clientMutationId - } - }`, - { refreshToken: s.refreshToken, clientMutationId: s.clientMutationId }, - ), - signal: timeoutSignal(5000), - }) - if (!res.ok) { - if (res.status === 401 || res.status === 403) { uiAuth.clearToken(); return null } - throw new Error(`Token refresh failed (${res.status})`) - } - const json = await res.json() - const refreshed = json?.data?.refreshToken - const next: string | undefined = refreshed?.accessToken - if (!next) { uiAuth.clearToken(); return null } - uiAuth.updateAccessToken({ accessToken: next, clientMutationId: refreshed?.clientMutationId }, jwt) - return next - })().finally(() => { _refreshPromise = null }) - - return _refreshPromise -} - -export async function loginUI(user: string, pass: string): Promise { - const res = await fetch(`${_serverBase}/api/graphql`, { - method: 'POST', credentials: 'omit', - headers: { 'Content-Type': 'application/json' }, - body: gqlBody( - `mutation Login($username: String!, $password: String!) { - login(input: { username: $username, password: $password }) { - accessToken refreshToken clientMutationId - } - }`, - { username: user, password: pass }, - ), - signal: timeoutSignal(8000), +async function gqlRaw(query: string, variables?: Record): Promise { + const res = await fetch(`${config.baseUrl}/api/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ query, variables }), }) - if (!res.ok) throw new Error(`Login request failed (${res.status})`) - const json = await res.json() - const payload = json?.data?.login - if (!payload?.accessToken || !payload?.refreshToken) { - throw new Error(json?.errors?.[0]?.message ?? 'Login failed') - } - const jwt = await getJwtSettings(true).catch(() => null) - uiAuth.setLoginSession({ - accessToken: payload.accessToken, - refreshToken: payload.refreshToken, - clientMutationId: typeof payload.clientMutationId === 'string' ? payload.clientMutationId : undefined, - }, jwt) - _authMode = 'UI_LOGIN' - _basicUser = user - _basicPass = '' -} - -export async function loginBasic(user: string, pass: string): Promise { - const res = await fetch(`${_serverBase}/api/graphql`, { - method: 'POST', credentials: 'omit', - headers: { 'Content-Type': 'application/json', ...basicHeader(user, pass) }, - body: gqlBody('{ __typename }'), - signal: timeoutSignal(5000), - }) - if (!res.ok) throw new Error(`Authentication failed (${res.status})`) - _authMode = 'BASIC_AUTH' - _basicUser = user - _basicPass = pass -} - -export async function logout(): Promise { - uiAuth.clearToken() - _authMode = 'NONE' - _basicUser = '' - _basicPass = '' + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = await res.json() + if (json.errors?.length) throw new Error(json.errors[0].message) + return json.data } export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> { try { - const headers: Record = { 'Content-Type': 'application/json' } - if (_authMode === 'BASIC_AUTH' && _basicUser && _basicPass) { - Object.assign(headers, basicHeader(_basicUser, _basicPass)) - } else if (_authMode === 'UI_LOGIN') { - const token = await getUIAccessToken() - if (!token) return 'auth_required' - Object.assign(headers, bearerHeader(token)) - } - const res = await fetch(`${_serverBase}/api/graphql`, { - method: 'POST', credentials: 'omit', headers, - body: gqlBody('{ __typename }'), - signal: timeoutSignal(5000), + const res = await fetch(`${config.baseUrl}/api/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ query: '{ aboutServer { name } }' }), }) - if (res.ok) return 'ok' - if (res.status === 401) return 'auth_required' + if (res.status === 401 || res.status === 403) return 'auth_required' + if (!res.ok) return 'unreachable' + const json = await res.json() + const isAuthError = json.errors?.some((e: { message: string }) => + /unauthorized|unauthenticated/i.test(e.message) + ) + return isAuthError ? 'auth_required' : 'ok' + } catch { return 'unreachable' - } catch { return 'unreachable' } + } +} + +export async function loginBasic(user: string, pass: string): Promise { + config.user = user + config.pass = pass + config.mode = 'BASIC_AUTH' + const probe = await probeServer() + if (probe !== 'ok') throw new Error('Invalid credentials') +} + +const LOGIN_MUTATION = ` + mutation Login($username: String!, $password: String!) { + login(input: { username: $username, password: $password }) { + accessToken refreshToken + } + } +` + +const REFRESH_MUTATION = ` + mutation RefreshToken($refreshToken: String!) { + refreshToken(input: { refreshToken: $refreshToken }) { + accessToken + } + } +` + +export async function loginUI(user: string, pass: string): Promise { + const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as { + login: { accessToken: string; refreshToken: string } + } + accessToken = data.login.accessToken + refreshToken = data.login.refreshToken + config.mode = 'UI_LOGIN' + config.user = user +} + +export async function refreshAccessToken(): Promise { + if (!refreshToken) return false + try { + const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as { + refreshToken: { accessToken: string } + } + accessToken = data.refreshToken.accessToken + return true + } catch { + return false + } } \ No newline at end of file diff --git a/src/lib/platform-adapters/types.ts b/src/lib/platform-adapters/types.ts index dafb7c4..974de87 100644 --- a/src/lib/platform-adapters/types.ts +++ b/src/lib/platform-adapters/types.ts @@ -7,21 +7,21 @@ export type PlatformFeature = | 'discord-rpc' export interface ServerLaunchConfig { - jarPath: string - port: number + jarPath: string + port: number dataPath: string } export interface DiscordPresence { - title: string - chapter: string + title: string + chapter: string startTimestamp?: number } export interface AppUpdateInfo { version: string - url: string - notes?: string + url: string + notes?: string } export interface PlatformAdapter { diff --git a/src/lib/platform-service/index.ts b/src/lib/platform-service/index.ts index fc8079b..3aaf28a 100644 --- a/src/lib/platform-service/index.ts +++ b/src/lib/platform-service/index.ts @@ -1,10 +1,4 @@ -import type { - PlatformAdapter, - PlatformFeature, - ServerLaunchConfig, - DiscordPresence, - AppUpdateInfo, -} from '$lib/platform-adapters/types' +import type { PlatformAdapter } from '$lib/platform-adapters/types' let adapter: PlatformAdapter @@ -12,87 +6,7 @@ export function initPlatformService(a: PlatformAdapter) { adapter = a } -function getAdapter(): PlatformAdapter { +export function getPlatformService(): PlatformAdapter { if (!adapter) throw new Error('PlatformService not initialized') return adapter -} - -export function isSupported(feature: PlatformFeature): boolean { - return getAdapter().isSupported(feature) -} - -export function launchServer(config: ServerLaunchConfig) { - return getAdapter().launchServer(config) -} - -export function stopServer() { - return getAdapter().stopServer() -} - -export function getServerStatus() { - return getAdapter().getServerStatus() -} - -export function readFile(path: string) { - return getAdapter().readFile(path) -} - -export function writeFile(path: string, data: Uint8Array) { - return getAdapter().writeFile(path, data) -} - -export function pickFolder() { - return getAdapter().pickFolder() -} - -export function authenticateBiometric() { - return getAdapter().authenticateBiometric() -} - -export function storeCredential(key: string, value: string) { - return getAdapter().storeCredential(key, value) -} - -export function getCredential(key: string) { - return getAdapter().getCredential(key) -} - -export function setTitle(title: string) { - return getAdapter().setTitle(title) -} - -export function minimize() { - return getAdapter().minimize() -} - -export function maximize() { - return getAdapter().maximize() -} - -export function close() { - return getAdapter().close() -} - -export function setDiscordPresence(presence: DiscordPresence) { - return getAdapter().setDiscordPresence(presence) -} - -export function clearDiscordPresence() { - return getAdapter().clearDiscordPresence() -} - -export function getVersion() { - return getAdapter().getVersion() -} - -export function openExternal(url: string) { - return getAdapter().openExternal(url) -} - -export function checkForAppUpdate(): Promise { - return getAdapter().checkForAppUpdate() -} - -export function installAppUpdate() { - return getAdapter().installAppUpdate() } \ No newline at end of file diff --git a/src/lib/request-manager/chapters.ts b/src/lib/request-manager/chapters.ts index bd25178..d0869b1 100644 --- a/src/lib/request-manager/chapters.ts +++ b/src/lib/request-manager/chapters.ts @@ -4,7 +4,7 @@ import { readerState } from '$lib/state/reader.svelte' export async function loadChapters(mangaId: string) { seriesState.chaptersLoading = true - seriesState.chaptersError = null + seriesState.chaptersError = null try { seriesState.chapters = await getAdapter().getChapters(mangaId) } catch (e) { @@ -14,12 +14,25 @@ export async function loadChapters(mangaId: string) { } } -export async function loadChapterPages(chapterId: string) { - readerState.pagesLoading = true - readerState.pagesError = null +export async function fetchChapters(mangaId: string) { + seriesState.chaptersLoading = true + seriesState.chaptersError = null try { - readerState.pages = await getAdapter().getChapterPages(chapterId) + seriesState.chapters = await getAdapter().fetchChapters(mangaId) } catch (e) { + seriesState.chaptersError = String(e) + } finally { + seriesState.chaptersLoading = false + } +} + +export async function loadChapterPages(chapterId: string, signal?: AbortSignal) { + readerState.pagesLoading = true + readerState.pagesError = null + try { + readerState.pages = await getAdapter().getChapterPages(chapterId, signal) + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return readerState.pagesError = String(e) } finally { readerState.pagesLoading = false @@ -28,13 +41,38 @@ export async function loadChapterPages(chapterId: string) { export async function markRead(id: string, read: boolean) { await getAdapter().markChapterRead(id, read) - const chapter = seriesState.chapters.find(c => c.id === id) + // chapter.id is a number; route params arrive as strings — compare via Number() + const numId = Number(id) + const chapter = seriesState.chapters.find(c => c.id === numId) if (chapter) chapter.read = read } export async function markManyRead(ids: string[], read: boolean) { await getAdapter().markChaptersRead(ids, read) + const numIds = new Set(ids.map(Number)) for (const c of seriesState.chapters) { - if (ids.includes(c.id)) c.read = read + if (numIds.has(c.id)) c.read = read } } + +export async function updateChaptersProgress( + ids: string[], + patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }, +) { + await getAdapter().updateChaptersProgress(ids, patch) + const numIds = new Set(ids.map(Number)) + for (const c of seriesState.chapters) { + if (!numIds.has(c.id)) continue + if (patch.isRead !== undefined) c.read = patch.isRead + if (patch.isBookmarked !== undefined) c.bookmarked = patch.isBookmarked + if (patch.lastPageRead !== undefined) c.lastPageRead = patch.lastPageRead + } +} + +export async function deleteDownloadedChapters(ids: string[]) { + await getAdapter().deleteDownloadedChapters(ids) + const numIds = new Set(ids.map(Number)) + for (const c of seriesState.chapters) { + if (numIds.has(c.id)) c.downloaded = false + } +} \ No newline at end of file diff --git a/src/lib/request-manager/downloads.ts b/src/lib/request-manager/downloads.ts index 4519a40..47264b6 100644 --- a/src/lib/request-manager/downloads.ts +++ b/src/lib/request-manager/downloads.ts @@ -14,12 +14,31 @@ export async function enqueueDownload(chapterId: string) { await loadDownloads() } +export async function enqueueDownloads(chapterIds: string[]) { + await getAdapter().enqueueDownloads(chapterIds) + await loadDownloads() +} + export async function dequeueDownload(chapterId: string) { await getAdapter().dequeueDownload(chapterId) downloadsState.items = downloadsState.items.filter(d => d.chapterId !== chapterId) } +export async function dequeueDownloads(chapterIds: string[]) { + const ids = new Set(chapterIds) + await getAdapter().dequeueDownloads(chapterIds) + downloadsState.items = downloadsState.items.filter(d => !ids.has(d.chapterId)) +} + export async function clearDownloads() { await getAdapter().clearDownloads() downloadsState.items = [] } + +export async function startDownloader() { + await getAdapter().startDownloader() +} + +export async function stopDownloader() { + await getAdapter().stopDownloader() +} \ No newline at end of file diff --git a/src/lib/request-manager/extensions.ts b/src/lib/request-manager/extensions.ts index 4e02688..b643e42 100644 --- a/src/lib/request-manager/extensions.ts +++ b/src/lib/request-manager/extensions.ts @@ -3,7 +3,7 @@ import { extensionsState } from '$lib/state/extensions.svelte' export async function loadExtensions() { extensionsState.loading = true - extensionsState.error = null + extensionsState.error = null try { extensionsState.items = await getAdapter().getExtensions() } catch (e) { @@ -26,6 +26,11 @@ export async function installExtension(id: string) { await loadExtensions() } +export async function installExternalExtension(url: string) { + await getAdapter().installExternalExtension(url) + await loadExtensions() +} + export async function uninstallExtension(id: string) { await getAdapter().uninstallExtension(id) extensionsState.items = extensionsState.items.filter(e => e.id !== id) @@ -36,9 +41,16 @@ export async function updateExtension(id: string) { await loadExtensions() } +export async function updateAllExtensions() { + const updatable = extensionsState.items.filter(e => e.hasUpdate).map(e => e.id) + if (!updatable.length) return + await getAdapter().updateExtensions(updatable) + await loadExtensions() +} + export async function browseSource(sourceId: string, page: number) { extensionsState.browseLoading = true - extensionsState.browseError = null + extensionsState.browseError = null try { const result = await getAdapter().browseSource(sourceId, page) extensionsState.browseResults = result.items @@ -48,4 +60,4 @@ export async function browseSource(sourceId: string, page: number) { } finally { extensionsState.browseLoading = false } -} +} \ No newline at end of file diff --git a/src/lib/request-manager/manga.ts b/src/lib/request-manager/manga.ts index 624488a..4b18071 100644 --- a/src/lib/request-manager/manga.ts +++ b/src/lib/request-manager/manga.ts @@ -1,11 +1,12 @@ import { getAdapter } from '$lib/request-manager' import { libraryState } from '$lib/state/library.svelte' +import { toast } from '$lib/state/notifications.svelte' import { seriesState } from '$lib/state/series.svelte' import type { MangaFilters, MangaMeta } from '$lib/server-adapters/types' export async function loadLibrary(filters: MangaFilters = { inLibrary: true }) { libraryState.loading = true - libraryState.error = null + libraryState.error = null try { const result = await getAdapter().getMangaList(filters) libraryState.items = result.items @@ -18,7 +19,7 @@ export async function loadLibrary(filters: MangaFilters = { inLibrary: true }) { export async function loadManga(id: string) { seriesState.loading = true - seriesState.error = null + seriesState.error = null try { seriesState.current = await getAdapter().getManga(id) } catch (e) { @@ -28,9 +29,21 @@ export async function loadManga(id: string) { } } +export async function fetchManga(id: string) { + seriesState.loading = true + seriesState.error = null + try { + seriesState.current = await getAdapter().fetchManga(id) + } catch (e) { + seriesState.error = String(e) + } finally { + seriesState.loading = false + } +} + export async function searchManga(query: string, sourceId?: string) { libraryState.loading = true - libraryState.error = null + libraryState.error = null try { libraryState.searchResults = await getAdapter().searchManga(query, sourceId) } catch (e) { @@ -52,7 +65,67 @@ export async function removeFromLibrary(mangaId: string) { export async function updateMangaMeta(id: string, meta: Partial) { await getAdapter().updateMangaMeta(id, meta) - if (String(seriesState.current?.id) === id) { - await loadManga(id) + if (String(seriesState.current?.id) === id) await loadManga(id) +} + +export async function deleteMangaMeta(id: string, key: string) { + await getAdapter().deleteMangaMeta(id, key) + if (String(seriesState.current?.id) === id) await loadManga(id) +} + +export async function refreshLibrary() { + libraryState.refreshing = true + try { + await getAdapter().checkForUpdates() + await loadLibrary() + toast('success', 'Library updated') + } catch (e) { + toast('error', 'Update failed', String(e)) + } finally { + libraryState.refreshing = false } +} + +export async function stopLibraryUpdate() { + await getAdapter().stopLibraryUpdate() +} + +export async function pollLibraryUpdateStatus() { + return getAdapter().getLibraryUpdateStatus() +} + +export async function bulkRemoveFromLibrary(ids: Set) { + await Promise.allSettled([...ids].map(id => getAdapter().removeFromLibrary(String(id)))) + libraryState.items = libraryState.items.filter(m => !ids.has(m.id)) + libraryState.exitSelect() +} + +export async function loadCategories() { + try { + libraryState.categories = await getAdapter().getCategories() + } catch (e) { + libraryState.error = String(e) + } +} + +export async function createCategory(name: string) { + const category = await getAdapter().createCategory(name) + libraryState.categories = [...libraryState.categories, category] +} + +export async function deleteCategory(id: number) { + await getAdapter().deleteCategory(id) + libraryState.categories = libraryState.categories.filter(c => c.id !== id) +} + +export async function updateCategoryOrder(id: number, position: number) { + libraryState.categories = await getAdapter().updateCategoryOrder(id, position) +} + +export async function updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]) { + await getAdapter().updateMangaCategories(mangaId, addTo, removeFrom) +} + +export async function updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]) { + await getAdapter().updateMangasCategories(mangaIds, addTo, removeFrom) } \ No newline at end of file diff --git a/src/lib/request-manager/tracking.ts b/src/lib/request-manager/tracking.ts index 94eedb3..8a13ba3 100644 --- a/src/lib/request-manager/tracking.ts +++ b/src/lib/request-manager/tracking.ts @@ -3,7 +3,7 @@ import { trackingState } from '$lib/state/tracking.svelte' export async function loadTrackers() { trackingState.loading = true - trackingState.error = null + trackingState.error = null try { trackingState.trackers = await getAdapter().getTrackers() } catch (e) { @@ -13,9 +13,38 @@ export async function loadTrackers() { } } +export async function loadMangaTrackRecords(mangaId: string) { + trackingState.recordsLoading = true + trackingState.recordsError = null + try { + trackingState.records = await getAdapter().getMangaTrackRecords(mangaId) + } catch (e) { + trackingState.recordsError = String(e) + } finally { + trackingState.recordsLoading = false + } +} + +export async function searchTracker(trackerId: string, query: string) { + trackingState.searchLoading = true + trackingState.searchError = null + try { + trackingState.searchResults = await getAdapter().searchTracker(trackerId, query) + } catch (e) { + trackingState.searchError = String(e) + } finally { + trackingState.searchLoading = false + } +} + export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) { await getAdapter().linkTracker(mangaId, trackerId, remoteId) - await loadTrackers() + await loadMangaTrackRecords(mangaId) +} + +export async function unlinkTracker(mangaId: string, recordId: string) { + await getAdapter().unlinkTracker(recordId) + await loadMangaTrackRecords(mangaId) } export async function syncTracking(mangaId: string) { @@ -25,4 +54,4 @@ export async function syncTracking(mangaId: string) { } finally { trackingState.syncing = false } -} +} \ No newline at end of file diff --git a/src/lib/server-adapters/suwayomi/chapters.ts b/src/lib/server-adapters/suwayomi/chapters.ts index b509f1e..3d14f4d 100644 --- a/src/lib/server-adapters/suwayomi/chapters.ts +++ b/src/lib/server-adapters/suwayomi/chapters.ts @@ -9,6 +9,15 @@ export const GET_CHAPTERS = ` } ` +export const GET_CHAPTER = ` + query GetChapter($id: Int!) { + chapter(id: $id) { + id name chapterNumber sourceOrder isRead isDownloaded isBookmarked + pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator + } + } +` + export const GET_RECENTLY_UPDATED = ` query GetRecentlyUpdated { chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) { diff --git a/src/lib/server-adapters/suwayomi/index.ts b/src/lib/server-adapters/suwayomi/index.ts index c6f534e..43d2cbb 100644 --- a/src/lib/server-adapters/suwayomi/index.ts +++ b/src/lib/server-adapters/suwayomi/index.ts @@ -8,29 +8,51 @@ import type { Page, DownloadItem, UpdateResult, + LibraryUpdateProgress, } from '$lib/server-adapters/types' -import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' +import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types' import { GET_LIBRARY, GET_MANGA, GET_CATEGORIES, FETCH_MANGA, UPDATE_MANGA, - SET_MANGA_META, + UPDATE_MANGAS, + UPDATE_MANGA_CATEGORIES, + UPDATE_MANGAS_CATEGORIES, + CREATE_CATEGORY, + DELETE_CATEGORY, + UPDATE_CATEGORY_ORDER, + UPDATE_CATEGORY_MANGA, UPDATE_LIBRARY, + UPDATE_LIBRARY_MANGA, + UPDATE_STOP, + SET_MANGA_META, + DELETE_MANGA_META, FETCH_SOURCE_MANGA, + LIBRARY_UPDATE_STATUS, } from './manga' import { GET_CHAPTERS, + GET_CHAPTER, + GET_RECENTLY_UPDATED, FETCH_CHAPTERS, FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, + UPDATE_CHAPTERS_PROGRESS, + DELETE_DOWNLOADED_CHAPTERS, + SET_CHAPTER_META, + DELETE_CHAPTER_META, } from './chapters' import { GET_DOWNLOAD_STATUS, ENQUEUE_DOWNLOAD, + ENQUEUE_CHAPTERS_DOWNLOAD, DEQUEUE_DOWNLOAD, + DEQUEUE_CHAPTERS_DOWNLOAD, + START_DOWNLOADER, + STOP_DOWNLOADER, CLEAR_DOWNLOADER, } from './downloads' import { @@ -38,34 +60,32 @@ import { GET_SOURCES, FETCH_EXTENSIONS, UPDATE_EXTENSION, + UPDATE_EXTENSIONS, + INSTALL_EXTERNAL_EXTENSION, } from './extensions' import { GET_TRACKERS, + GET_MANGA_TRACK_RECORDS, + SEARCH_TRACKER, BIND_TRACK, + UNLINK_TRACK, TRACK_PROGRESS, + UPDATE_TRACK, } from './tracking' import { - GQLResponse, + type GQLResponse, mapManga, mapChapter, mapExtension, mapDownloadItem, + mapCategory, } from './types' -const GET_CHAPTER = ` - query GetChapter($id: Int!) { - chapter(id: $id) { - id name chapterNumber sourceOrder isRead isDownloaded isBookmarked - 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 - async connect(config: ServerConfig) { + async connect(config: ServerConfig): Promise { this.baseUrl = config.baseUrl.replace(/\/$/, '') if (config.credentials) { const { username, password } = config.credentials @@ -73,6 +93,10 @@ export class SuwayomiAdapter implements ServerAdapter { } } + getServerUrl(): string { + return this.baseUrl + } + async getStatus(): Promise { try { const res = await fetch(`${this.baseUrl}/api/graphql`, { @@ -92,11 +116,16 @@ export class SuwayomiAdapter implements ServerAdapter { return h } - private async gql(query: string, variables?: Record): Promise { + private async gql( + query: string, + variables?: Record, + signal?: AbortSignal, + ): Promise { const res = await fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers: this.headers(), body: JSON.stringify({ query, variables }), + signal, }) if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`) const json: GQLResponse = await res.json() @@ -104,44 +133,63 @@ export class SuwayomiAdapter implements ServerAdapter { return json.data } + // ─── Manga ─────────────────────────────────────────────────────────────── + async getManga(id: string): Promise { const data = await this.gql<{ manga: Record }>(GET_MANGA, { id: Number(id) }) return mapManga(data.manga) } + async fetchManga(id: string): Promise { + const data = await this.gql<{ fetchManga: { manga: Record } }>( + FETCH_MANGA, { id: Number(id) } + ) + return mapManga(data.fetchManga.manga) + } + async getMangaList(filters: MangaFilters): Promise> { const data = await this.gql<{ mangas: { nodes: Record[] } }>(GET_LIBRARY) 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.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0) - if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId) + 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 { if (!sourceId) return [] - const data = await this.gql<{ - fetchSourceManga: { mangas: Record[] } - }>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query }) + const data = await this.gql<{ fetchSourceManga: { mangas: Record[] } }>( + FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query } + ) return data.fetchSourceManga.mangas.map(mapManga) } - async addToLibrary(mangaId: string) { + async addToLibrary(mangaId: string): Promise { await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true }) } - async removeFromLibrary(mangaId: string) { + async removeFromLibrary(mangaId: string): Promise { await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false }) } - async updateMangaMeta(id: string, meta: Partial) { + async updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise { + await this.gql(UPDATE_MANGAS, { ids: ids.map(Number), ...patch }) + } + + async updateMangaMeta(id: string, meta: Partial): Promise { 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) }) } } + async deleteMangaMeta(id: string, key: string): Promise { + await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key }) + } + + // ─── Chapters ──────────────────────────────────────────────────────────── + async getChapters(mangaId: string): Promise { const data = await this.gql<{ chapters: { nodes: Record[] } }>( GET_CHAPTERS, { mangaId: Number(mangaId) } @@ -156,21 +204,56 @@ export class SuwayomiAdapter implements ServerAdapter { return mapChapter(data.chapter) } - async getChapterPages(id: string): Promise { + async getChapterPages(id: string, signal?: AbortSignal): Promise { const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>( - FETCH_CHAPTER_PAGES, { chapterId: Number(id) } + FETCH_CHAPTER_PAGES, { chapterId: Number(id) }, signal ) return data.fetchChapterPages.pages.map((url, index) => ({ index, url })) } - async markChapterRead(id: string, read: boolean) { + async fetchChapters(mangaId: string): Promise { + const data = await this.gql<{ fetchChapters: { chapters: Record[] } }>( + FETCH_CHAPTERS, { mangaId: Number(mangaId) } + ) + return data.fetchChapters.chapters.map(mapChapter) + } + + async getRecentlyUpdated(): Promise { + const data = await this.gql<{ chapters: { nodes: Record[] } }>( + GET_RECENTLY_UPDATED + ) + return data.chapters.nodes.map(mapChapter) + } + + async markChapterRead(id: string, read: boolean): Promise { await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read }) } - async markChaptersRead(ids: string[], read: boolean) { + async markChaptersRead(ids: string[], read: boolean): Promise { await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read }) } + async updateChaptersProgress( + ids: string[], + patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }, + ): Promise { + await this.gql(UPDATE_CHAPTERS_PROGRESS, { ids: ids.map(Number), ...patch }) + } + + async deleteDownloadedChapters(ids: string[]): Promise { + await this.gql(DELETE_DOWNLOADED_CHAPTERS, { ids: ids.map(Number) }) + } + + async setChapterMeta(chapterId: string, key: string, value: string): Promise { + await this.gql(SET_CHAPTER_META, { chapterId: Number(chapterId), key, value }) + } + + async deleteChapterMeta(chapterId: string, key: string): Promise { + await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key }) + } + + // ─── Downloads ─────────────────────────────────────────────────────────── + async getDownloads(): Promise { const data = await this.gql<{ downloadStatus: { queue: Record[] } }>( GET_DOWNLOAD_STATUS @@ -178,36 +261,62 @@ export class SuwayomiAdapter implements ServerAdapter { return data.downloadStatus.queue.map(mapDownloadItem) } - async enqueueDownload(chapterId: string) { + async enqueueDownload(chapterId: string): Promise { await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) } - async dequeueDownload(chapterId: string) { + async enqueueDownloads(chapterIds: string[]): Promise { + await this.gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) }) + } + + async dequeueDownload(chapterId: string): Promise { await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) } - async clearDownloads() { + async dequeueDownloads(chapterIds: string[]): Promise { + await this.gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) }) + } + + async clearDownloads(): Promise { await this.gql(CLEAR_DOWNLOADER) } + async startDownloader(): Promise { + await this.gql(START_DOWNLOADER) + } + + async stopDownloader(): Promise { + await this.gql(STOP_DOWNLOADER) + } + + // ─── Extensions ────────────────────────────────────────────────────────── + async getExtensions(): Promise { await this.gql(FETCH_EXTENSIONS) const data = await this.gql<{ extensions: { nodes: Record[] } }>(GET_EXTENSIONS) return data.extensions.nodes.map(mapExtension) } - async installExtension(id: string) { + async installExtension(id: string): Promise { await this.gql(UPDATE_EXTENSION, { id, install: true }) } - async uninstallExtension(id: string) { + async uninstallExtension(id: string): Promise { await this.gql(UPDATE_EXTENSION, { id, uninstall: true }) } - async updateExtension(id: string) { + async updateExtension(id: string): Promise { await this.gql(UPDATE_EXTENSION, { id, update: true }) } + async updateExtensions(ids: string[]): Promise { + await this.gql(UPDATE_EXTENSIONS, { ids, update: true }) + } + + async installExternalExtension(url: string): Promise { + await this.gql(INSTALL_EXTERNAL_EXTENSION, { url }) + } + async getSources(): Promise { const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) return data.sources.nodes @@ -223,12 +332,65 @@ export class SuwayomiAdapter implements ServerAdapter { } } + // ─── Categories ────────────────────────────────────────────────────────── + + async getCategories(): Promise { + const data = await this.gql<{ categories: { nodes: Record[] } }>(GET_CATEGORIES) + return data.categories.nodes.map(mapCategory) + } + + async createCategory(name: string): Promise { + const data = await this.gql<{ createCategory: { category: Record } }>( + CREATE_CATEGORY, { name } + ) + return mapCategory(data.createCategory.category) + } + + async deleteCategory(id: number): Promise { + await this.gql(DELETE_CATEGORY, { id }) + } + + async updateCategoryOrder(id: number, position: number): Promise { + const data = await this.gql<{ updateCategoryOrder: { categories: Record[] } }>( + UPDATE_CATEGORY_ORDER, { id, position } + ) + return data.updateCategoryOrder.categories.map(mapCategory) + } + + async updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise { + await this.gql(UPDATE_MANGA_CATEGORIES, { mangaId: Number(mangaId), addTo, removeFrom }) + } + + async updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise { + await this.gql(UPDATE_MANGAS_CATEGORIES, { ids: mangaIds.map(Number), addTo, removeFrom }) + } + + async updateCategoryManga(categoryId: number): Promise { + await this.gql(UPDATE_CATEGORY_MANGA, { categoryId }) + } + + // ─── Tracking ──────────────────────────────────────────────────────────── + async getTrackers(): Promise { const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) return data.trackers.nodes } - async linkTracker(mangaId: string, trackerId: string, remoteId: string) { + async getMangaTrackRecords(mangaId: string): Promise { + const data = await this.gql<{ + manga: { trackRecords: { nodes: unknown[] } } + }>(GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) }) + return data.manga.trackRecords.nodes + } + + async searchTracker(trackerId: string, query: string): Promise { + const data = await this.gql<{ + searchTracker: { trackSearches: unknown[] } + }>(SEARCH_TRACKER, { trackerId: Number(trackerId), query }) + return data.searchTracker.trackSearches + } + + async linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise { await this.gql(BIND_TRACK, { mangaId: Number(mangaId), trackerId: Number(trackerId), @@ -236,16 +398,26 @@ export class SuwayomiAdapter implements ServerAdapter { }) } - async syncTracking(mangaId: string) { + async unlinkTracker(recordId: string): Promise { + await this.gql(UNLINK_TRACK, { trackRecordId: Number(recordId) }) + } + + async fetchTrackRecord(recordId: string): Promise { + await this.gql(UPDATE_TRACK, { recordId: Number(recordId) }) + } + + async syncTracking(mangaId: string): Promise { await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) }) } + // ─── Library updates ───────────────────────────────────────────────────── + async checkForUpdates(mangaIds?: string[]): Promise { if (mangaIds?.length) { const results: UpdateResult[] = [] for (const id of mangaIds) { const before = await this.getChapters(id) - await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) }) + await this.gql(UPDATE_LIBRARY_MANGA, { mangaId: Number(id) }) const after = await this.getChapters(id) results.push({ mangaId: id, newChapters: after.length - before.length }) } @@ -254,4 +426,18 @@ export class SuwayomiAdapter implements ServerAdapter { await this.gql(UPDATE_LIBRARY) return [] } + + async stopLibraryUpdate(): Promise { + await this.gql(UPDATE_STOP) + } + + async getLibraryUpdateStatus(): Promise { + const data = await this.gql<{ + libraryUpdateStatus: { + jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number } + } + }>(LIBRARY_UPDATE_STATUS) + const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo + return { isRunning, finishedJobs, totalJobs } + } } \ No newline at end of file diff --git a/src/lib/server-adapters/suwayomi/manga.ts b/src/lib/server-adapters/suwayomi/manga.ts index 12717f7..03757e1 100644 --- a/src/lib/server-adapters/suwayomi/manga.ts +++ b/src/lib/server-adapters/suwayomi/manga.ts @@ -3,9 +3,12 @@ export const GET_LIBRARY = ` mangas(condition: { inLibrary: true }) { nodes { id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount - description status author artist genre inLibraryAt lastFetchedAt + description status author artist genre + inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched source { id name displayName } chapters { totalCount } + latestFetchedChapter { id uploadDate } + latestUploadedChapter { id uploadDate } lastReadChapter { id chapterNumber } firstUnreadChapter { id chapterNumber } } @@ -17,7 +20,7 @@ export const GET_MANGA = ` query GetManga($id: Int!) { manga(id: $id) { id title description thumbnailUrl status author artist genre inLibrary realUrl - inLibraryAt lastFetchedAt updateStrategy + inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy source { id name displayName } lastReadChapter { id chapterNumber lastPageRead } firstUnreadChapter { id chapterNumber } @@ -39,6 +42,21 @@ export const GET_CATEGORIES = ` } ` +export const LIBRARY_UPDATE_STATUS = ` + query LibraryUpdateStatus { + libraryUpdateStatus { + jobsInfo { + isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount + } + mangaUpdates { + status + manga { id title thumbnailUrl unreadCount } + } + } + lastUpdateTimestamp { timestamp } + } +` + export const MANGAS_BY_GENRE = ` query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) { mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) { @@ -52,18 +70,9 @@ export const MANGAS_BY_GENRE = ` } ` -export const LIBRARY_UPDATE_STATUS = ` - query LibraryUpdateStatus { - libraryUpdateStatus { - jobsInfo { - isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount - } - mangaUpdates { - status - manga { id title thumbnailUrl unreadCount } - } - } - lastUpdateTimestamp { timestamp } +export const GET_DOWNLOADS_PATH = ` + query GetDownloadsPath { + settings { downloadsPath localSourcePath } } ` @@ -142,6 +151,14 @@ export const UPDATE_CATEGORY_ORDER = ` } ` +export const UPDATE_CATEGORY_MANGA = ` + mutation UpdateCategoryManga($categoryId: Int!) { + updateCategoryManga(input: { categoryId: $categoryId }) { + updateStatus { jobsInfo { isRunning finishedJobs totalJobs } } + } + } +` + export const UPDATE_LIBRARY = ` mutation UpdateLibrary { updateLibrary(input: {}) { @@ -158,6 +175,14 @@ export const UPDATE_LIBRARY_MANGA = ` } ` +export const UPDATE_STOP = ` + mutation UpdateStop { + updateStop(input: {}) { + updateStatus { jobsInfo { isRunning finishedJobs totalJobs } } + } + } +` + export const SET_MANGA_META = ` mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) { setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) { @@ -189,8 +214,11 @@ export const RESTORE_BACKUP = ` } ` -export const GET_RESTORE_STATUS = ` - query GetRestoreStatus($id: String!) { - restoreStatus(id: $id) { mangaProgress state totalManga } +export const FETCH_SOURCE_MANGA = ` + mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) { + fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) { + mangas { id title thumbnailUrl inLibrary } + hasNextPage + } } ` \ No newline at end of file diff --git a/src/lib/server-adapters/suwayomi/types.ts b/src/lib/server-adapters/suwayomi/types.ts index af9576e..5ebe4bf 100644 --- a/src/lib/server-adapters/suwayomi/types.ts +++ b/src/lib/server-adapters/suwayomi/types.ts @@ -1,4 +1,4 @@ -import type { Manga, Chapter, Extension } from '$lib/types' +import type { Manga, Chapter, Extension, Category } from '$lib/types' import type { DownloadItem } from '$lib/server-adapters/types' export interface GQLResponse { @@ -7,33 +7,46 @@ export interface GQLResponse { } export function mapManga(raw: Record): Manga { - const inLibraryAt = raw.inLibraryAt as string | null | undefined return { - ...(raw as unknown as Manga), - tags: raw.genre as string[] | undefined, - addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : undefined, + id: raw.id as number, + title: raw.title as string, + description: raw.description as string | null | undefined, + thumbnailUrl: raw.thumbnailUrl as string | null | undefined, + status: raw.status as string | undefined, + author: raw.author as string | null | undefined, + artist: raw.artist as string | null | undefined, + tags: raw.genre as string[] | undefined, + inLibrary: raw.inLibrary as boolean, + realUrl: raw.realUrl as string | null | undefined, + source: raw.source as Manga['source'], + unreadCount: raw.unreadCount as number | undefined, + downloadCount: raw.downloadCount as number | undefined, + bookmarkCount: raw.bookmarkCount as number | undefined, + lastReadChapter: raw.lastReadChapter as Manga['lastReadChapter'], + firstUnreadChapter: raw.firstUnreadChapter as Manga['firstUnreadChapter'], + addedAt: raw.inLibraryAt ? new Date(raw.inLibraryAt as string).getTime() : undefined, lastReadAt: raw.lastReadChapter ? Date.now() : undefined, } } export function mapChapter(raw: Record): Chapter { return { - id: raw.id as number, - name: raw.name as string, + id: raw.id as number, + name: raw.name as string, chapterNumber: raw.chapterNumber as number, - sourceOrder: raw.sourceOrder as number, - read: (raw.isRead as boolean) ?? false, - downloaded: (raw.isDownloaded as boolean) ?? false, - bookmarked: (raw.isBookmarked as boolean) ?? false, - pageCount: (raw.pageCount as number) ?? 0, - mangaId: raw.mangaId as number, - fetchedAt: raw.fetchedAt as string | undefined, - uploadDate: raw.uploadDate as string | null | undefined, - realUrl: raw.realUrl as string | null | undefined, - lastPageRead: raw.lastPageRead as number | undefined, - lastReadAt: raw.lastReadAt as string | undefined, - scanlator: raw.scanlator as string | null | undefined, - manga: raw.manga as Chapter['manga'], + sourceOrder: raw.sourceOrder as number, + read: (raw.isRead as boolean) ?? false, + downloaded: (raw.isDownloaded as boolean) ?? false, + bookmarked: (raw.isBookmarked as boolean) ?? false, + pageCount: (raw.pageCount as number) ?? 0, + mangaId: raw.mangaId as number, + fetchedAt: raw.fetchedAt as string | undefined, + uploadDate: raw.uploadDate as string | null | undefined, + realUrl: raw.realUrl as string | null | undefined, + lastPageRead: raw.lastPageRead as number | undefined, + lastReadAt: raw.lastReadAt as string | undefined, + scanlator: raw.scanlator as string | null | undefined, + manga: raw.manga as Chapter['manga'], } } @@ -46,14 +59,14 @@ export function mapExtension(raw: Record): Extension { export function mapDownloadItem(raw: Record): DownloadItem { const chapter = raw.chapter as Record - const manga = chapter?.manga as Record + const manga = chapter?.manga as Record return { - chapterId: String(chapter?.id), - mangaId: String(chapter?.mangaId ?? manga?.id), + chapterId: String(chapter?.id), + mangaId: String(chapter?.mangaId ?? manga?.id), chapterName: chapter?.name as string, - mangaTitle: manga?.title as string, - progress: (raw.progress as number) ?? 0, - state: mapDownloadState(raw.state as string), + mangaTitle: manga?.title as string, + progress: (raw.progress as number) ?? 0, + state: mapDownloadState(raw.state as string), } } @@ -64,4 +77,16 @@ function mapDownloadState(state: string): DownloadItem['state'] { case 'ERROR': return 'error' default: return 'queued' } +} + +export function mapCategory(raw: Record): Category { + return { + id: raw.id as number, + name: raw.name as string, + order: raw.order as number, + default: raw.default as boolean, + includeInUpdate: raw.includeInUpdate as boolean, + includeInDownload: raw.includeInDownload as boolean, + mangas: (raw.mangas as { nodes: Record[] })?.nodes?.map(mapManga), + } } \ No newline at end of file diff --git a/src/lib/server-adapters/types.ts b/src/lib/server-adapters/types.ts index 75d8865..e374d86 100644 --- a/src/lib/server-adapters/types.ts +++ b/src/lib/server-adapters/types.ts @@ -1,10 +1,4 @@ -import type { - Manga, - Chapter, - Extension, - Source, - Tracker, -} from '$lib/types' +import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types' export interface ServerConfig { baseUrl: string @@ -21,7 +15,13 @@ export interface MangaFilters { 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 { items: T[] @@ -47,6 +47,7 @@ export interface DownloadItem { mangaId: string chapterName: string mangaTitle: string + thumbnailUrl?: string progress: number state: 'queued' | 'downloading' | 'finished' | 'error' } @@ -56,39 +57,75 @@ export interface UpdateResult { newChapters: number } +export interface LibraryUpdateProgress { + isRunning: boolean + finishedJobs: number + totalJobs: number +} + export interface ServerAdapter { connect(config: ServerConfig): Promise getStatus(): Promise + getServerUrl(): string getManga(id: string): Promise getMangaList(filters: MangaFilters): Promise> searchManga(query: string, sourceId?: string): Promise + fetchManga(id: string): Promise addToLibrary(mangaId: string): Promise removeFromLibrary(mangaId: string): Promise + updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise updateMangaMeta(id: string, meta: Partial): Promise + deleteMangaMeta(id: string, key: string): Promise getChapters(mangaId: string): Promise getChapter(id: string): Promise getChapterPages(id: string): Promise + fetchChapters(mangaId: string): Promise + getRecentlyUpdated(): Promise markChapterRead(id: string, read: boolean): Promise markChaptersRead(ids: string[], read: boolean): Promise + updateChaptersProgress(ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise + deleteDownloadedChapters(ids: string[]): Promise + setChapterMeta(chapterId: string, key: string, value: string): Promise + deleteChapterMeta(chapterId: string, key: string): Promise getDownloads(): Promise enqueueDownload(chapterId: string): Promise + enqueueDownloads(chapterIds: string[]): Promise dequeueDownload(chapterId: string): Promise + dequeueDownloads(chapterIds: string[]): Promise clearDownloads(): Promise + startDownloader(): Promise + stopDownloader(): Promise getExtensions(): Promise installExtension(id: string): Promise uninstallExtension(id: string): Promise updateExtension(id: string): Promise + updateExtensions(ids: string[]): Promise + installExternalExtension(url: string): Promise getSources(): Promise browseSource(sourceId: string, page: number): Promise> + getCategories(): Promise + createCategory(name: string): Promise + deleteCategory(id: number): Promise + updateCategoryOrder(id: number, position: number): Promise + updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise + updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise + updateCategoryManga(categoryId: number): Promise + getTrackers(): Promise + getMangaTrackRecords(mangaId: string): Promise + searchTracker(trackerId: string, query: string): Promise linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise + unlinkTracker(recordId: string): Promise + fetchTrackRecord(recordId: string): Promise syncTracking(mangaId: string): Promise checkForUpdates(mangaIds?: string[]): Promise -} + stopLibraryUpdate(): Promise + getLibraryUpdateStatus(): Promise +} \ No newline at end of file diff --git a/src/lib/state/home.svelte.ts b/src/lib/state/home.svelte.ts new file mode 100644 index 0000000..36ce9f0 --- /dev/null +++ b/src/lib/state/home.svelte.ts @@ -0,0 +1,42 @@ +export interface HistoryEntry { + mangaId: number + mangaTitle: string + thumbnailUrl: string + chapterId: number + chapterName: string + chapterNumber: number + pageNumber: number + readAt: number +} + +export interface ReadingStats { + currentStreakDays: number + totalChaptersRead: number + totalMinutesRead: number + totalMangaRead: number + longestStreakDays: number +} + +export const homeState = $state({ + history: [] as HistoryEntry[], + dailyReadCounts: {} as Record, + stats: { + currentStreakDays: 0, + totalChaptersRead: 0, + totalMinutesRead: 0, + totalMangaRead: 0, + longestStreakDays: 0, + } as ReadingStats, + heroSlots: [null, null, null, null] as [number | null, number | null, number | null, number | null], +}) + +export function setHeroSlot(i: 1 | 2 | 3, mangaId: number | null) { + homeState.heroSlots[i] = mangaId +} + +export function recordRead(entry: HistoryEntry) { + homeState.history = [entry, ...homeState.history.filter(e => e.chapterId !== entry.chapterId)] + const dateStr = new Date(entry.readAt).toISOString().slice(0, 10) + homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1 + homeState.stats.totalChaptersRead++ +} \ No newline at end of file diff --git a/src/lib/state/library.svelte.ts b/src/lib/state/library.svelte.ts index d0ecb4d..567d720 100644 --- a/src/lib/state/library.svelte.ts +++ b/src/lib/state/library.svelte.ts @@ -2,52 +2,88 @@ import type { Manga } from '$lib/types' import type { MangaStatus } from '$lib/server-adapters/types' export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded' +export type LibraryTab = 'saved' | 'downloaded' -export const libraryState = $state({ - items: [] as Manga[], - searchResults: [] as Manga[], - loading: false, - error: null as string | null, - filter: { - status: 'all' as MangaStatus | 'all', - tags: [] as string[], - unread: false, - query: '', - }, - sort: 'alphabetical' as LibrarySortOption, - sortDesc: false, - view: 'grid' as 'grid' | 'list', - selected: new Set(), -}) +class LibraryState { + items = $state([]) + loading = $state(false) + error = $state(null) + refreshing = $state(false) -export const filteredItems = $derived.by(() => { - let result = libraryState.items + tab = $state('saved') + sort = $state('alphabetical') + sortDesc = $state(false) - if (libraryState.filter.unread) { - result = result.filter(m => m.unreadCount > 0) - } - if (libraryState.filter.status !== 'all') { - result = result.filter(m => m.status === libraryState.filter.status) - } - if (libraryState.filter.tags.length > 0) { - result = result.filter(m => - libraryState.filter.tags.every(tag => m.tags?.includes(tag)) - ) - } - if (libraryState.filter.query) { - const q = libraryState.filter.query.toLowerCase() - result = result.filter(m => m.title.toLowerCase().includes(q)) - } - - const sorted = [...result].sort((a, b) => { - switch (libraryState.sort) { - case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0) - case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0) - case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0) - case 'alphabetical': - default: return a.title.localeCompare(b.title) - } + filter = $state({ + status: 'all' as MangaStatus | 'all', + unread: false, + downloaded: false, + bookmarked: false, + query: '', }) - return libraryState.sortDesc ? sorted.reverse() : sorted -}) + selected = $state(new Set()) + selectMode = $state(false) + + filteredItems = $derived.by(() => { + let result = this.tab === 'downloaded' + ? this.items.filter(m => (m.downloadCount ?? 0) > 0) + : this.items.filter(m => m.inLibrary) + + if (this.filter.unread) result = result.filter(m => (m.unreadCount ?? 0) > 0) + if (this.filter.downloaded) result = result.filter(m => (m.downloadCount ?? 0) > 0) + if (this.filter.bookmarked) result = result.filter(m => (m.bookmarkCount ?? 0) > 0) + + if (this.filter.status !== 'all') { + result = result.filter( + m => m.status?.toUpperCase().replace(/\s+/g, '_') === this.filter.status + ) + } + + if (this.filter.query) { + const q = this.filter.query.toLowerCase() + result = result.filter(m => m.title.toLowerCase().includes(q)) + } + + const sorted = [...result].sort((a, b) => { + switch (this.sort) { + case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0) + case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0) + case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0) + default: return a.title.localeCompare(b.title) + } + }) + + return this.sortDesc ? sorted.reverse() : sorted + }) + + get hasActiveFilters() { + return this.filter.status !== 'all' + || this.filter.unread + || this.filter.downloaded + || this.filter.bookmarked + } + + enterSelect(id?: number) { + this.selectMode = true + if (id !== undefined) this.selected = new Set([id]) + } + + exitSelect() { + this.selectMode = false + this.selected = new Set() + } + + toggleSelect(id: number) { + const next = new Set(this.selected) + if (next.has(id)) next.delete(id); else next.add(id) + this.selected = next + if (next.size === 0) this.exitSelect() + } + + selectAll() { + this.selected = new Set(this.filteredItems.map(m => m.id)) + } +} + +export const libraryState = new LibraryState() \ No newline at end of file diff --git a/src/lib/state/series.svelte.ts b/src/lib/state/series.svelte.ts index c6cecba..36af8c5 100644 --- a/src/lib/state/series.svelte.ts +++ b/src/lib/state/series.svelte.ts @@ -1,36 +1,28 @@ import type { Manga, Chapter } from '$lib/types' -export const seriesState = $state({ - current: null as Manga | null, - loading: false, - error: null as string | null, +class SeriesState { + current = $state(null) + loading = $state(false) + error = $state(null) - chapters: [] as Chapter[], - chaptersLoading: false, - chaptersError: null as string | null, + chapters = $state([]) + chaptersLoading = $state(false) + chaptersError = $state(null) - chapterFilter: { - unread: false, - downloaded: false, - query: '', - }, - chapterSortDesc: true, -}) + chapterSortDesc = $state(true) + chapterFilter = $state({ unread: false, downloaded: false, query: '' }) -export const filteredChapters = $derived.by(() => { - let result = seriesState.chapters + filteredChapters = $derived.by(() => { + let result = this.chapters + if (this.chapterFilter.unread) result = result.filter(c => !c.read) + if (this.chapterFilter.downloaded) result = result.filter(c => c.downloaded) + if (this.chapterFilter.query) { + const q = this.chapterFilter.query.toLowerCase() + result = result.filter(c => c.name.toLowerCase().includes(q)) + } + const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber) + return this.chapterSortDesc ? sorted.reverse() : sorted + }) +} - if (seriesState.chapterFilter.unread) { - result = result.filter(c => !c.read) - } - if (seriesState.chapterFilter.downloaded) { - result = result.filter(c => c.downloaded) - } - if (seriesState.chapterFilter.query) { - const q = seriesState.chapterFilter.query.toLowerCase() - result = result.filter(c => c.name.toLowerCase().includes(q)) - } - - const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber) - return seriesState.chapterSortDesc ? sorted.reverse() : sorted -}) +export const seriesState = new SeriesState() \ No newline at end of file diff --git a/src/lib/state/tracking.svelte.ts b/src/lib/state/tracking.svelte.ts index c3a6440..5866ec7 100644 --- a/src/lib/state/tracking.svelte.ts +++ b/src/lib/state/tracking.svelte.ts @@ -1,8 +1,16 @@ import type { Tracker } from '$lib/types' export const trackingState = $state({ - trackers: [] as Tracker[], - loading: false, - error: null as string | null, - syncing: false, -}) + trackers: [] as Tracker[], + loading: false, + error: null as string | null, + syncing: false, + + records: [] as unknown[], + recordsLoading: false, + recordsError: null as string | null, + + searchResults: [] as unknown[], + searchLoading: false, + searchError: null as string | null, +}) \ No newline at end of file diff --git a/src/lib/types/chapter.ts b/src/lib/types/chapter.ts index 07363f7..5680b24 100644 --- a/src/lib/types/chapter.ts +++ b/src/lib/types/chapter.ts @@ -1,19 +1,23 @@ export interface Chapter { - id: number - name: string + id: number + name: string chapterNumber: number - sourceOrder: number - read: boolean - downloaded: boolean - bookmarked: boolean - pageCount: number - mangaId: number - fetchedAt?: string - uploadDate?: string | null - realUrl?: string | null - url?: string + sourceOrder: number + read: boolean + downloaded: boolean + bookmarked: boolean + pageCount: number + mangaId: number + fetchedAt?: string + uploadDate?: string | null + realUrl?: string | null lastPageRead?: number - lastReadAt?: string - scanlator?: string | null - manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null -} + lastReadAt?: string + scanlator?: string | null + manga?: { + id: number + title: string + thumbnailUrl: string + inLibrary: boolean + } | null +} \ No newline at end of file diff --git a/src/lib/types/extension.ts b/src/lib/types/extension.ts index 91b1c01..3c3e405 100644 --- a/src/lib/types/extension.ts +++ b/src/lib/types/extension.ts @@ -1,24 +1,23 @@ export interface Source { - id: string - name: string - lang: string - displayName: string - iconUrl: string - isNsfw: boolean - isConfigurable: boolean - supportsLatest: boolean - baseUrl?: string | null + id: string + name: string + lang: string + displayName: string + iconUrl: string + isNsfw: boolean + isConfigurable: boolean + supportsLatest: boolean } export interface Extension { - apkName: string - pkgName: string - name: string - lang: string + id: string + apkName: string + pkgName: string + name: string + lang: string versionName: string isInstalled: boolean - isObsolete: boolean - hasUpdate: boolean - iconUrl: string - id: string -} + isObsolete: boolean + hasUpdate: boolean + iconUrl: string +} \ No newline at end of file diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 2b550d6..241346c 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,539 +1,4 @@ -import type { - ServerAdapter, - ServerConfig, - ServerStatus, - MangaFilters, - MangaMeta, - PaginatedResult, - Page, - DownloadItem, - UpdateResult, -} from '$lib/server-adapters/types' -import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' - -// ─── GQL client ──────────────────────────────────────────────────────────── - -interface GQLResponse { - data: T - errors?: { message: string }[] -} - -// ─── Queries ──────────────────────────────────────────────────────────────── - -const GET_LIBRARY = ` - query GetLibrary { - mangas(condition: { inLibrary: true }) { - nodes { - id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount - description status author artist genre inLibraryAt lastFetchedAt - source { id name displayName } - chapters { totalCount } - lastReadChapter { id chapterNumber } - firstUnreadChapter { id chapterNumber } - } - } - } -` - -const GET_MANGA = ` - query GetManga($id: Int!) { - manga(id: $id) { - id title description thumbnailUrl status author artist genre inLibrary realUrl - inLibraryAt lastFetchedAt updateStrategy - source { id name displayName } - lastReadChapter { id chapterNumber lastPageRead } - firstUnreadChapter { id chapterNumber } - highestNumberedChapter { id chapterNumber } - } - } -` - -const GET_CHAPTERS = ` - query GetChapters($mangaId: Int!) { - chapters(condition: { mangaId: $mangaId }) { - nodes { - id name chapterNumber sourceOrder isRead isDownloaded isBookmarked - pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator - } - } - } -` - -const GET_DOWNLOAD_STATUS = ` - query GetDownloadStatus { - downloadStatus { - state - queue { - progress state tries - chapter { - id name pageCount mangaId - manga { id title thumbnailUrl } - } - } - } - } -` - -const GET_EXTENSIONS = ` - query GetExtensions { - extensions { - nodes { - apkName pkgName name lang versionName - isInstalled isObsolete hasUpdate iconUrl - } - } - } -` - -const GET_SOURCES = ` - query GetSources { - sources { - nodes { - id name lang displayName iconUrl isNsfw - isConfigurable supportsLatest - } - } - } -` - -const GET_TRACKERS = ` - query GetTrackers { - trackers { - nodes { - id name icon isLoggedIn isTokenExpired authUrl - supportsPrivateTracking supportsReadingDates supportsTrackDeletion - scores - statuses { value name } - } - } - } -` - -// ─── Mutations ────────────────────────────────────────────────────────────── - -const FETCH_MANGA = ` - mutation FetchManga($id: Int!) { - fetchManga(input: { id: $id }) { - manga { - id title description thumbnailUrl status author artist genre inLibrary realUrl - source { id name displayName } - } - } - } -` - -const FETCH_SOURCE_MANGA = ` - mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) { - fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) { - mangas { id title thumbnailUrl inLibrary } - hasNextPage - } - } -` - -const UPDATE_MANGA = ` - mutation UpdateManga($id: Int!, $inLibrary: Boolean) { - updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) { - manga { id inLibrary } - } - } -` - -const SET_MANGA_META = ` - mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) { - setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) { - meta { key value } - } - } -` - -const FETCH_CHAPTERS = ` - mutation FetchChapters($mangaId: Int!) { - fetchChapters(input: { mangaId: $mangaId }) { - chapters { - id name chapterNumber sourceOrder isRead isDownloaded isBookmarked - pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator - } - } - } -` - -const FETCH_CHAPTER_PAGES = ` - mutation FetchChapterPages($chapterId: Int!) { - fetchChapterPages(input: { chapterId: $chapterId }) { pages } - } -` - -const MARK_CHAPTER_READ = ` - mutation MarkChapterRead($id: Int!, $isRead: Boolean!) { - updateChapter(input: { id: $id, patch: { isRead: $isRead } }) { - chapter { id isRead } - } - } -` - -const MARK_CHAPTERS_READ = ` - mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) { - updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) { - chapters { id isRead } - } - } -` - -const ENQUEUE_DOWNLOAD = ` - mutation EnqueueDownload($chapterId: Int!) { - enqueueChapterDownload(input: { id: $chapterId }) { - downloadStatus { state } - } - } -` - -const DEQUEUE_DOWNLOAD = ` - mutation DequeueDownload($chapterId: Int!) { - dequeueChapterDownload(input: { id: $chapterId }) { - downloadStatus { state } - } - } -` - -const CLEAR_DOWNLOADER = ` - mutation ClearDownloader { - clearDownloader(input: {}) { - downloadStatus { state } - } - } -` - -const FETCH_EXTENSIONS = ` - mutation FetchExtensions { - fetchExtensions(input: {}) { - extensions { - apkName pkgName name lang versionName - isInstalled isObsolete hasUpdate iconUrl - } - } - } -` - -const UPDATE_EXTENSION = ` - mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) { - updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) { - extension { apkName pkgName name isInstalled hasUpdate } - } - } -` - -const BIND_TRACK = ` - mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) { - bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) { - trackRecord { id trackerId remoteId } - } - } -` - -const TRACK_PROGRESS = ` - mutation TrackProgress($mangaId: Int!) { - trackProgress(input: { mangaId: $mangaId }) { - trackRecords { id trackerId lastChapterRead status } - } - } -` - -const UPDATE_LIBRARY = ` - mutation UpdateLibrary { - updateLibrary(input: {}) { - updateStatus { jobsInfo { isRunning finishedJobs totalJobs } } - } - } -` - -// ─── Mappers ──────────────────────────────────────────────────────────────── - -function mapChapter(raw: Record): Chapter { - return { - id: raw.id as number, - name: raw.name as string, - chapterNumber: raw.chapterNumber as number, - sourceOrder: raw.sourceOrder as number, - read: (raw.isRead as boolean) ?? false, - downloaded: (raw.isDownloaded as boolean) ?? false, - bookmarked: (raw.isBookmarked as boolean) ?? false, - pageCount: (raw.pageCount as number) ?? 0, - mangaId: raw.mangaId as number, - fetchedAt: raw.fetchedAt as string | undefined, - uploadDate: raw.uploadDate as string | null | undefined, - realUrl: raw.realUrl as string | null | undefined, - lastPageRead: raw.lastPageRead as number | undefined, - lastReadAt: raw.lastReadAt as string | undefined, - scanlator: raw.scanlator as string | null | undefined, - manga: raw.manga as Chapter['manga'], - } -} - -function mapManga(raw: Record): Manga { - const inLibraryAt = raw.inLibraryAt as string | null | undefined - return { - ...(raw as unknown as Manga), - tags: raw.genre as string[] | undefined, - addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : undefined, - lastReadAt: raw.lastReadChapter - ? Date.now() - : undefined, - } -} - -function mapExtension(raw: Record): Extension { - return { - ...(raw as unknown as Extension), - id: raw.pkgName as string, - } -} - -function mapDownloadItem(raw: Record): DownloadItem { - const chapter = raw.chapter as Record - const manga = chapter?.manga as Record - return { - chapterId: String(chapter?.id), - mangaId: String(chapter?.mangaId ?? manga?.id), - chapterName: chapter?.name as string, - mangaTitle: manga?.title as string, - progress: (raw.progress as number) ?? 0, - state: mapDownloadState(raw.state as string), - } -} - -function mapDownloadState(state: string): DownloadItem['state'] { - switch (state) { - case 'DOWNLOADING': return 'downloading' - case 'FINISHED': return 'finished' - case 'ERROR': return 'error' - default: return 'queued' - } -} - -// ─── Adapter ──────────────────────────────────────────────────────────────── - -export class SuwayomiAdapter implements ServerAdapter { - private baseUrl = 'http://127.0.0.1:4567' - private authHeader: string | null = null - - async connect(config: ServerConfig) { - this.baseUrl = config.baseUrl.replace(/\/$/, '') - if (config.credentials) { - const { username, password } = config.credentials - this.authHeader = 'Basic ' + btoa(`${username}:${password}`) - } - } - - async getStatus(): Promise { - try { - const res = await fetch(`${this.baseUrl}/api/graphql`, { - method: 'POST', - headers: this.headers(), - body: JSON.stringify({ query: '{ aboutServer { name } }' }), - }) - return res.ok ? 'connected' : 'error' - } catch { - return 'disconnected' - } - } - - private headers(): Record { - const h: Record = { 'Content-Type': 'application/json' } - if (this.authHeader) h['Authorization'] = this.authHeader - return h - } - - private async gql(query: string, variables?: Record): Promise { - 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 = await res.json() - if (json.errors?.length) throw new Error(json.errors[0].message) - return json.data - } - - // ── Manga ────────────────────────────────────────────────────────────── - - async getManga(id: string): Promise { - const data = await this.gql<{ manga: Record }>( - GET_MANGA, { id: Number(id) } - ) - return mapManga(data.manga) - } - - async getMangaList(filters: MangaFilters): Promise> { - if (filters.inLibrary) { - const data = await this.gql<{ mangas: { nodes: Record[] } }>(GET_LIBRARY) - return { items: data.mangas.nodes.map(mapManga), hasNextPage: false } - } - const data = await this.gql<{ mangas: { nodes: Record[] } }>(GET_LIBRARY) - return { items: data.mangas.nodes.map(mapManga), hasNextPage: false } - } - - async searchManga(query: string, sourceId?: string): Promise { - if (!sourceId) return [] - const data = await this.gql<{ - fetchSourceManga: { mangas: Record[] } - }>(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 }) - } - - async removeFromLibrary(mangaId: string) { - await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false }) - } - - async updateMangaMeta(id: string, meta: Partial) { - 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), - }) - } - } - - // ── Chapters ─────────────────────────────────────────────────────────── - - async getChapters(mangaId: string): Promise { - const data = await this.gql<{ chapters: { nodes: Record[] } }>( - GET_CHAPTERS, { mangaId: Number(mangaId) } - ) - return data.chapters.nodes.map(mapChapter) - } - - async getChapter(id: string): Promise { - const chapters = await this.gql<{ chapters: { nodes: Record[] } }>( - GET_CHAPTERS, { mangaId: 0 } - ) - const found = chapters.chapters.nodes.find(c => String(c.id) === id) - if (!found) throw new Error(`Chapter ${id} not found`) - return mapChapter(found) - } - - async getChapterPages(id: string): Promise { - 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 }) - } - - async markChaptersRead(ids: string[], read: boolean) { - await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read }) - } - - // ── Downloads ────────────────────────────────────────────────────────── - - async getDownloads(): Promise { - const data = await this.gql<{ - downloadStatus: { queue: Record[] } - }>(GET_DOWNLOAD_STATUS) - return data.downloadStatus.queue.map(mapDownloadItem) - } - - async enqueueDownload(chapterId: string) { - await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) - } - - async dequeueDownload(chapterId: string) { - await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) - } - - async clearDownloads() { - await this.gql(CLEAR_DOWNLOADER) - } - - // ── Extensions ───────────────────────────────────────────────────────── - - async getExtensions(): Promise { - await this.gql(FETCH_EXTENSIONS) - const data = await this.gql<{ extensions: { nodes: Record[] } }>( - GET_EXTENSIONS - ) - return data.extensions.nodes.map(mapExtension) - } - - async installExtension(id: string) { - await this.gql(UPDATE_EXTENSION, { id, install: true }) - } - - async uninstallExtension(id: string) { - await this.gql(UPDATE_EXTENSION, { id, uninstall: true }) - } - - async updateExtension(id: string) { - await this.gql(UPDATE_EXTENSION, { id, update: true }) - } - - async getSources(): Promise { - const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - return data.sources.nodes - } - - async browseSource(sourceId: string, page: number): Promise> { - const data = await this.gql<{ - fetchSourceManga: { mangas: Record[]; hasNextPage: boolean } - }>(FETCH_SOURCE_MANGA, { - source: sourceId, - type: 'LATEST', - page, - }) - return { - items: data.fetchSourceManga.mangas.map(mapManga), - hasNextPage: data.fetchSourceManga.hasNextPage, - } - } - - // ── Tracking ─────────────────────────────────────────────────────────── - - async getTrackers(): Promise { - const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) - return data.trackers.nodes - } - - async linkTracker(mangaId: string, trackerId: string, remoteId: string) { - await this.gql(BIND_TRACK, { - mangaId: Number(mangaId), - trackerId: Number(trackerId), - remoteId, - }) - } - - async syncTracking(mangaId: string) { - await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) }) - } - - // ── Updates ──────────────────────────────────────────────────────────── - - async checkForUpdates(mangaIds?: string[]): Promise { - if (mangaIds?.length) { - 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 }) - } - return results - } - await this.gql(UPDATE_LIBRARY) - return [] - } -} +export type { Manga, MangaDetail, Category, ChapterRef } from './manga' +export type { Chapter } from './chapter' +export type { Extension, Source } from './extension' +export type { Tracker, TrackRecord, TrackerStatus } from './tracking' \ No newline at end of file diff --git a/src/lib/types/manga.ts b/src/lib/types/manga.ts index 693408a..fab333b 100644 --- a/src/lib/types/manga.ts +++ b/src/lib/types/manga.ts @@ -1,13 +1,3 @@ -export interface Category { - id: number - name: string - order: number - default: boolean - includeInUpdate: string - includeInDownload: string - mangas?: { nodes: Manga[] } -} - export interface ChapterRef { id: number chapterNumber: number @@ -15,48 +5,49 @@ export interface ChapterRef { lastPageRead?: number } -export interface Manga { - id: number - title: string - thumbnailUrl: string - inLibrary: boolean - initialized?: boolean - downloadCount?: number - unreadCount?: number - bookmarkCount?: number - hasDuplicateChapters?: boolean - chapters?: { totalCount: number } - description?: string | null - status?: string | null - author?: string | null - artist?: string | null - genre?: string[] - tags?: string[] - realUrl?: string | null - url?: string - sourceId?: string - inLibraryAt?: string | null - lastFetchedAt?: string | null - chaptersLastFetchedAt?: string | null - thumbnailUrlLastFetched?: string | null - addedAt?: number - lastReadAt?: number - age?: string | null - chaptersAge?: string | null - updateStrategy?: 'ALWAYS_UPDATE' | 'ONLY_FETCH_ONCE' - latestFetchedChapter?: ChapterRef | null - latestUploadedChapter?: ChapterRef | null - latestReadChapter?: ChapterRef | null - lastReadChapter?: ChapterRef | null - firstUnreadChapter?: ChapterRef | null - highestNumberedChapter?: ChapterRef | null - source?: { id: string; name: string; displayName: string } | null +export interface Category { + id: number + name: string + order: number + default: boolean + includeInUpdate: boolean + includeInDownload: boolean + mangas?: Manga[] } -export interface MangaDetail extends Manga { - description: string | null - author: string | null - artist: string | null - status: string | null - genre: string[] -} +export interface Manga { + id: number + title: string + thumbnailUrl: string + inLibrary: boolean + + downloadCount?: number + unreadCount?: number + bookmarkCount?: number + + description?: string | null + status?: string | null + author?: string | null + artist?: string | null + genre?: string[] + tags?: string[] + realUrl?: string | null + sourceId?: string + + inLibraryAt?: string | null + lastFetchedAt?: string | null + chaptersLastFetchedAt?: string | null + thumbnailUrlLastFetched?: string | null + addedAt?: number + lastReadAt?: number + + updateStrategy?: 'ALWAYS_UPDATE' | 'ONLY_FETCH_ONCE' + + latestFetchedChapter?: ChapterRef | null + latestUploadedChapter?: ChapterRef | null + lastReadChapter?: ChapterRef | null + firstUnreadChapter?: ChapterRef | null + highestNumberedChapter?: ChapterRef | null + + source?: { id: string; name: string; displayName: string } | null +} \ No newline at end of file diff --git a/src/lib/types/tracking.ts b/src/lib/types/tracking.ts index e689a22..4341c87 100644 --- a/src/lib/types/tracking.ts +++ b/src/lib/types/tracking.ts @@ -1,37 +1,42 @@ export interface TrackerStatus { value: number - name: string + name: string } export interface TrackRecord { - id: number - trackerId: number - remoteId: string - title: string - status: number - score: number - displayScore: string + id: number + trackerId: number + remoteId: string + title: string + status: number + score: number + displayScore: string lastChapterRead: number - totalChapters: number - remoteUrl: string - startDate?: string - finishDate?: string - private: boolean - libraryId?: string - manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } + totalChapters: number + remoteUrl: string + startDate?: string + finishDate?: string + private: boolean + libraryId?: string + manga?: { + id: number + title: string + thumbnailUrl: string + inLibrary: boolean + } } export interface Tracker { - id: number - name: string - icon: string - isLoggedIn: boolean - isTokenExpired: boolean - authUrl: string + id: number + name: string + icon: string + isLoggedIn: boolean + isTokenExpired: boolean + authUrl: string supportsPrivateTracking: boolean - supportsReadingDates: boolean - supportsTrackDeletion: boolean - scores: string[] - statuses: TrackerStatus[] + supportsReadingDates: boolean + supportsTrackDeletion: boolean + scores: string[] + statuses: TrackerStatus[] trackRecords?: { nodes: TrackRecord[] } -} +} \ No newline at end of file diff --git a/src/lib/ui/home/ActivityFeed.svelte b/src/lib/ui/home/ActivityFeed.svelte new file mode 100644 index 0000000..bd61f62 --- /dev/null +++ b/src/lib/ui/home/ActivityFeed.svelte @@ -0,0 +1,151 @@ + + +
+
+ Recent Activity + {#if entries.length > 0} + + {/if} +
+ +
+ {#if entries.length > 0} + {#each entries as entry (entry.chapterId)} + + {/each} + {:else} +
+ {#each Array(5) as _, i} +
+
+
+
+
+
+
+
+ {/each} +
+ +
+
+ {/if} +
+
+ + \ No newline at end of file diff --git a/src/lib/ui/home/ActivityHeatmap.svelte b/src/lib/ui/home/ActivityHeatmap.svelte new file mode 100644 index 0000000..fa632ad --- /dev/null +++ b/src/lib/ui/home/ActivityHeatmap.svelte @@ -0,0 +1,207 @@ + + +
+
+
+
+ {#each visibleWeeks as _week, ci} + {@const lbl = monthLabels.find(l => l.colIndex === ci)} +
{lbl?.label ?? ''}
+ {/each} +
+
+ +
+
+ {#each DAY_LABELS as d} + {d} + {/each} +
+
+ {#each visibleWeeks as week} +
+ {#each week as cell} + + + {/each} +
+ {/each} +
+
+ +
+ Less + {#each [0, 1, 2, 3, 4] as lvl} +
+ {/each} + More +
+
+ +{#if tip} +
{tip.text}
+{/if} + + \ No newline at end of file diff --git a/src/lib/ui/home/HeroSlotPicker.svelte b/src/lib/ui/home/HeroSlotPicker.svelte new file mode 100644 index 0000000..f643b35 --- /dev/null +++ b/src/lib/ui/home/HeroSlotPicker.svelte @@ -0,0 +1,129 @@ + + + + + \ No newline at end of file diff --git a/src/lib/ui/home/HeroStage.svelte b/src/lib/ui/home/HeroStage.svelte new file mode 100644 index 0000000..1263c7d --- /dev/null +++ b/src/lib/ui/home/HeroStage.svelte @@ -0,0 +1,452 @@ + + +
+ {#key heroThumb} + {#if heroThumb} +
+ {:else} +
+ {/if} + {/key} +
+ + + +
+ {#if activeSlot?.kind === 'empty'} +

Nothing here yet

+

+ {activeSlot.slotIndex === 0 + ? 'Read a manga to see it here' + : 'Pin a manga or keep reading to fill this slot'} +

+ {#if activeSlot.slotIndex !== 0} + + {/if} + {:else} +
+ {#if activeSlot?.kind === 'continue'} + Reading + {:else} + Pinned + {/if} + {#if heroNewChapter && !heroNewChapter.isRead} + New ch.{Math.floor(heroNewChapter.chapterNumber)} + {/if} + {#each (heroManga?.genre ?? []).slice(0, 3) as g} + + {/each} +
+ +

{heroTitle}

+ {#if heroManga?.author}

{heroManga.author}

{/if} + + {#if heroEntry} +

+ + {heroEntry.chapterName} + {#if heroEntry.pageNumber > 1} · p.{heroEntry.pageNumber}{/if} + {timeAgo(heroEntry.readAt)} +

+ {/if} + + {#if heroManga?.description} +

{heroManga.description}

+ {/if} + +
+ {#if activeSlot?.kind === 'continue'} + + {:else if heroManga} + + {/if} + {#if activeSlot?.slotIndex !== 0} + {#if activeSlot?.kind === 'pinned'} + + {:else} + + {/if} + {/if} +
+ {/if} + +
+ +
+ {#each resolvedSlots as slot, i} + + {/each} +
+ + {activeIdx + 1}/{TOTAL_SLOTS} +
+
+ +
+
+ Up Next +
+ + {#if activeSlot?.kind === 'empty'} +

No chapters to show

+ {:else if loadingHeroChapters} + {#each Array(4) as _} +
+
+
+
+
+
+
+ {/each} + {:else if heroChapters.length === 0} +

No chapters available

+ {:else} + {#each heroChapters as ch (ch.id)} + {@const isCurrent = heroEntry?.chapterId === ch.id} + + {/each} + {#if heroManga} + + {/if} + {/if} +
+
+ + \ No newline at end of file diff --git a/src/lib/ui/home/RecsRow.svelte b/src/lib/ui/home/RecsRow.svelte new file mode 100644 index 0000000..41853d4 --- /dev/null +++ b/src/lib/ui/home/RecsRow.svelte @@ -0,0 +1,33 @@ + + +
+
+ Recommended +
+

Recommendations coming soon

+
+ + \ No newline at end of file diff --git a/src/lib/ui/home/StatsGrid.svelte b/src/lib/ui/home/StatsGrid.svelte new file mode 100644 index 0000000..9e1647a --- /dev/null +++ b/src/lib/ui/home/StatsGrid.svelte @@ -0,0 +1,102 @@ + + +
+
+ Your Stats +
+
+
+
+
+ {stats.currentStreakDays} + Day streak +
+
+
+
+
+ {stats.totalChaptersRead} + Chapters read +
+
+
+
+
+ {formatReadTime(stats.totalMinutesRead)} + Read time +
+
+
+
+
+ {stats.totalMangaRead} + Series started +
+
+
+
+
+ {updateCount} + New updates +
+
+
+
+
+ {stats.longestStreakDays}d + Best streak +
+
+
+
+ + \ No newline at end of file diff --git a/src/lib/ui/home/homeHelpers.ts b/src/lib/ui/home/homeHelpers.ts new file mode 100644 index 0000000..35ee68c --- /dev/null +++ b/src/lib/ui/home/homeHelpers.ts @@ -0,0 +1,39 @@ +export function timeAgo(ts: number): string { + const diff = Date.now() - ts + const m = Math.floor(diff / 60000) + if (m < 1) return 'Just now' + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + if (d < 7) return `${d}d ago` + return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +export function timeAgoRefresh(ts: number): string { + if (!ts) return '' + const diff = Date.now() - ts + const m = Math.floor(diff / 60000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.floor(h / 24)}d ago` +} + +export function formatReadTime(mins: number): string { + if (mins < 1) return `${Math.round(mins * 60)}s` + if (mins < 60) return `${Math.round(mins)}m` + const h = Math.floor(mins / 60) + const r = Math.round(mins % 60) + if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m` + const d = Math.floor(h / 24) + const rh = h % 24 + return rh === 0 ? `${d}d` : `${d}d ${rh}h` +} + +export function handleRowWheel(e: WheelEvent) { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return + ;(e.currentTarget as HTMLElement).scrollLeft += e.deltaY + e.stopPropagation() +} \ No newline at end of file diff --git a/src/lib/ui/library/LibraryFilters.svelte b/src/lib/ui/library/LibraryFilters.svelte new file mode 100644 index 0000000..9f3d87d --- /dev/null +++ b/src/lib/ui/library/LibraryFilters.svelte @@ -0,0 +1,170 @@ + + +
+ + + {#if open} + + {/if} +
+ + \ No newline at end of file diff --git a/src/lib/ui/library/LibraryGrid.svelte b/src/lib/ui/library/LibraryGrid.svelte new file mode 100644 index 0000000..5e66630 --- /dev/null +++ b/src/lib/ui/library/LibraryGrid.svelte @@ -0,0 +1,246 @@ + + +{#if selectMode} +
+ {selected.size} selected + +
+ +
+
+{/if} + + + + \ No newline at end of file diff --git a/src/lib/ui/library/LibraryToolbar.svelte b/src/lib/ui/library/LibraryToolbar.svelte new file mode 100644 index 0000000..35e4742 --- /dev/null +++ b/src/lib/ui/library/LibraryToolbar.svelte @@ -0,0 +1,263 @@ + + +
+ Library + +
+ + +
+ +
+
+ + onQuery((e.target as HTMLInputElement).value)} + /> +
+ + + +
+ + + {#if sortOpen} + + {/if} +
+ +
+ { filterOpen = !filterOpen; sortOpen = false }} + {onStatus} {onUnread} {onDownloaded} {onBookmarked} + onClear={onFilterClear} + /> +
+
+
+ + \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 85c9b3e..31879b0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,3 +1,259 @@ \ No newline at end of file + const TOTAL_SLOTS = 4 + + interface HeroSlot { + kind: 'continue' | 'pinned' | 'empty' + entry?: HistoryEntry + manga?: Manga + slotIndex: number + } + + onMount(() => { loadLibrary() }) + + const manga = $derived(libraryState.items) + + const continueReading = $derived((() => { + const seen = new Set() + const out: HistoryEntry[] = [] + for (const e of homeState.history) { + if (seen.has(e.mangaId)) continue + seen.add(e.mangaId) + out.push(e) + if (out.length >= 10) break + } + return out + })()) + + const resolvedSlots = $derived((() => { + const pins = homeState.heroSlots + const slots: HeroSlot[] = [] + const first = continueReading[0] + slots.push(first ? { kind: 'continue', entry: first, slotIndex: 0 } : { kind: 'empty', slotIndex: 0 }) + let hi = 1 + for (let i = 1; i < TOTAL_SLOTS; i++) { + const pinId = pins[i] + if (pinId != null) { + const m = manga.find(m => m.id === pinId) + if (m) { slots.push({ kind: 'pinned', manga: m, slotIndex: i }); continue } + } + const entry = continueReading[hi++] + slots.push(entry ? { kind: 'continue', entry, slotIndex: i } : { kind: 'empty', slotIndex: i }) + } + return slots + })()) + + let activeIdx = $state(0) + + const activeSlot = $derived(resolvedSlots[activeIdx]) + const heroManga = $derived( + activeSlot?.kind === 'pinned' ? activeSlot.manga : + activeSlot?.kind === 'continue' ? manga.find(m => m.id === activeSlot.entry?.mangaId) : null + ) + const heroEntry = $derived(activeSlot?.kind === 'continue' ? activeSlot.entry ?? null : null) + const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null) + const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? '') + const heroThumbSrc = $derived( + heroManga?.thumbnailUrl ?? + (activeSlot?.kind === 'continue' ? activeSlot.entry?.thumbnailUrl : undefined) ?? + '' + ) + + let heroThumb = $state('') + $effect(() => { + const path = heroThumbSrc + if (!path) { heroThumb = ''; return } + heroThumb = path + }) + + const heroNewChapter = $derived( + heroManga ? (manga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null + ) + + let heroChapters: Chapter[] = $state([]) + let heroAllChapters: Chapter[] = $state([]) + let loadingHeroChapters = $state(false) + let heroChaptersFor: number | null = null + + $effect(() => { + const id = heroMangaId + if (id) untrack(() => loadHeroChapters(id)) + }) + + async function loadHeroChapters(mangaId: number) { + heroChaptersFor = mangaId + loadingHeroChapters = true + heroChapters = [] + heroAllChapters = [] + try { + const chapters = await getAdapter().getChapters(String(mangaId)) + if (heroChaptersFor !== mangaId) return + const all = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder) + heroAllChapters = all + const lastReadIdx = heroEntry + ? all.findLastIndex(c => c.id === heroEntry!.chapterId) + : all.findLastIndex(c => c.isRead) + const startIdx = Math.max(0, lastReadIdx) + heroChapters = all.slice(startIdx, startIdx + 5) + } catch { + heroChapters = [] + heroAllChapters = [] + } finally { + loadingHeroChapters = false + } + } + + let resuming = $state(false) + + async function openChapter(chapter: Chapter) { + if (!heroMangaId) return + goto(`/reader/${heroMangaId}/${chapter.id}`) + } + + async function resumeActive() { + if (!heroEntry && heroManga) { goto(`/series/${heroManga.id}`); return } + if (!heroEntry) return + const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0] + if (target) openChapter(target) + } + + function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] } + function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] } + function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = [] } } + + let pickerOpen = $state(false) + let pickerSlotIndex: 1 | 2 | 3 | null = $state(null) + + function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true } + function closePicker() { pickerOpen = false; pickerSlotIndex = null } + function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker() } } + function unpinSlot(i: 1 | 2 | 3) { setHeroSlot(i, null) } + + function resumeEntry(entry: HistoryEntry) { + const target = homeState.history.find(e => e.chapterId === entry.chapterId) + if (target) goto(`/reader/${entry.mangaId}/${entry.chapterId}`) + } + + +
+
+ heroManga && goto(`/series/${heroManga.id}`)} + /> +
+ +
+
+
+ goto('/recent')} + onopenlibrary={() => goto('/library')} + /> +
+
+
+ goto(`/series/${m.id}`)} + /> +
+
+ +
+
+ Activity + +
+
+
+ +
+
+
+
+ +{#if pickerOpen && pickerSlotIndex !== null} + +{/if} + + \ No newline at end of file diff --git a/src/routes/browse/[sourceId]/+page.svelte b/src/routes/browse/[sourceId]/+page.svelte new file mode 100644 index 0000000..bbe5d93 --- /dev/null +++ b/src/routes/browse/[sourceId]/+page.svelte @@ -0,0 +1,7 @@ + + +

Browse — stub

\ No newline at end of file diff --git a/src/routes/browse/series/[mangaid]/+page.svelte b/src/routes/browse/series/[mangaid]/+page.svelte new file mode 100644 index 0000000..1841b10 --- /dev/null +++ b/src/routes/browse/series/[mangaid]/+page.svelte @@ -0,0 +1,15 @@ + + +

Series {$page.params.mangaId} — stub

\ No newline at end of file diff --git a/src/routes/downloads/+page.svelte b/src/routes/downloads/+page.svelte index 4c7d13c..bae9e88 100644 --- a/src/routes/downloads/+page.svelte +++ b/src/routes/downloads/+page.svelte @@ -1 +1,7 @@ -

downloads

\ No newline at end of file + + +

Downloads — stub

\ No newline at end of file diff --git a/src/routes/extensions/+page.svelte b/src/routes/extensions/+page.svelte index c5a99ce..05d39e8 100644 --- a/src/routes/extensions/+page.svelte +++ b/src/routes/extensions/+page.svelte @@ -1 +1,7 @@ -

extensions

\ No newline at end of file + + +

Extensions — stub

\ No newline at end of file diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index f245826..a15f9b7 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -1 +1,113 @@ -

library

\ No newline at end of file + + +
+ {#if libraryState.error} +
+

Could not load library

+

{libraryState.error}

+ +
+ {:else} + libraryState.tab = t} + onQuery={(q) => libraryState.filter.query = q} + onSort={(s) => libraryState.sort = s} + onSortDesc={() => libraryState.sortDesc = !libraryState.sortDesc} + onStatus={(s) => libraryState.filter.status = s} + onUnread={() => libraryState.filter.unread = !libraryState.filter.unread} + onDownloaded={() => libraryState.filter.downloaded = !libraryState.filter.downloaded} + onBookmarked={() => libraryState.filter.bookmarked = !libraryState.filter.bookmarked} + onFilterClear={() => { + libraryState.filter.status = 'all' + libraryState.filter.unread = false + libraryState.filter.downloaded = false + libraryState.filter.bookmarked = false + }} + onRefresh={refreshLibrary} + /> + + libraryState.selectAll()} + onExitSelect={() => libraryState.exitSelect()} + {onBulkRemove} + /> + {/if} +
+ + \ No newline at end of file diff --git a/src/routes/reader/[mangaId]/[chapterId]/+page.svelte b/src/routes/reader/[mangaId]/[chapterId]/+page.svelte new file mode 100644 index 0000000..2cf9b55 --- /dev/null +++ b/src/routes/reader/[mangaId]/[chapterId]/+page.svelte @@ -0,0 +1,19 @@ + + +

Reader {$page.params.mangaId} / {$page.params.chapterId} — stub

\ No newline at end of file diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index b9784a9..cc359cd 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1 +1,5 @@ -

settings

\ No newline at end of file + + +

Settings — stub

\ No newline at end of file diff --git a/src/routes/tracking/+page.svelte b/src/routes/tracking/+page.svelte index 9b95035..ce070c3 100644 --- a/src/routes/tracking/+page.svelte +++ b/src/routes/tracking/+page.svelte @@ -1 +1,7 @@ -

tracking

\ No newline at end of file + + +

Tracking — stub

\ No newline at end of file