Chore: Port over Home & Fix Suwayomi-Server Detection on Web

This commit is contained in:
Youwes09
2026-05-24 12:09:29 -05:00
parent 6c39ef538f
commit ae5d9748c7
42 changed files with 3195 additions and 1342 deletions
+99 -375
View File
@@ -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<string, string> {
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<string | null> | 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, unknown>): string {
return JSON.stringify({ query, variables })
}
function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: 'Basic ' + btoa(`${user}:${pass}`) }
}
function bearerHeader(token: string): Record<string, string> {
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<UiSession, 'base'>) {
_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<JwtSettings | null> {
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<JwtSettings | null> {
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<Response> {
const baseHeaders = { ...(init.headers as Record<string, string> ?? {}) }
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<string | null> {
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<string | null> {
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<void> {
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<string, unknown>): Promise<unknown> {
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<void> {
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<void> {
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<string, string> = { '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<void> {
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<void> {
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<boolean> {
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
}
}
+6 -6
View File
@@ -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 {
+2 -88
View File
@@ -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<AppUpdateInfo | null> {
return getAdapter().checkForAppUpdate()
}
export function installAppUpdate() {
return getAdapter().installAppUpdate()
}
+45 -7
View File
@@ -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
}
}
+19
View File
@@ -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()
}
+15 -3
View File
@@ -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
}
}
}
+78 -5
View File
@@ -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<MangaMeta>) {
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<number>) {
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)
}
+32 -3
View File
@@ -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
}
}
}
@@ -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) {
+222 -36
View File
@@ -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<void> {
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<ServerStatus> {
try {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
@@ -92,11 +116,16 @@ export class SuwayomiAdapter implements ServerAdapter {
return h
}
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
private async gql<T>(
query: string,
variables?: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
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<T> = await res.json()
@@ -104,44 +133,63 @@ export class SuwayomiAdapter implements ServerAdapter {
return json.data
}
// ─── Manga ───────────────────────────────────────────────────────────────
async getManga(id: string): Promise<Manga> {
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
return mapManga(data.manga)
}
async fetchManga(id: string): Promise<Manga> {
const data = await this.gql<{ fetchManga: { manga: Record<string, unknown> } }>(
FETCH_MANGA, { id: Number(id) }
)
return mapManga(data.fetchManga.manga)
}
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
let items = data.mangas.nodes.map(mapManga)
if (filters.status) items = items.filter(m => m.status === filters.status)
if (filters.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<Manga[]> {
if (!sourceId) return []
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[] }
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query })
const data = await this.gql<{ fetchSourceManga: { mangas: Record<string, unknown>[] } }>(
FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query }
)
return data.fetchSourceManga.mangas.map(mapManga)
}
async addToLibrary(mangaId: string) {
async addToLibrary(mangaId: string): Promise<void> {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
}
async removeFromLibrary(mangaId: string) {
async removeFromLibrary(mangaId: string): Promise<void> {
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
}
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
async updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise<void> {
await this.gql(UPDATE_MANGAS, { ids: ids.map(Number), ...patch })
}
async updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void> {
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<void> {
await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key })
}
// ─── Chapters ────────────────────────────────────────────────────────────
async getChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_CHAPTERS, { mangaId: Number(mangaId) }
@@ -156,21 +204,56 @@ export class SuwayomiAdapter implements ServerAdapter {
return mapChapter(data.chapter)
}
async getChapterPages(id: string): Promise<Page[]> {
async getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]> {
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<Chapter[]> {
const data = await this.gql<{ fetchChapters: { chapters: Record<string, unknown>[] } }>(
FETCH_CHAPTERS, { mangaId: Number(mangaId) }
)
return data.fetchChapters.chapters.map(mapChapter)
}
async getRecentlyUpdated(): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_RECENTLY_UPDATED
)
return data.chapters.nodes.map(mapChapter)
}
async markChapterRead(id: string, read: boolean): Promise<void> {
await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read })
}
async markChaptersRead(ids: string[], read: boolean) {
async markChaptersRead(ids: string[], read: boolean): Promise<void> {
await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read })
}
async updateChaptersProgress(
ids: string[],
patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number },
): Promise<void> {
await this.gql(UPDATE_CHAPTERS_PROGRESS, { ids: ids.map(Number), ...patch })
}
async deleteDownloadedChapters(ids: string[]): Promise<void> {
await this.gql(DELETE_DOWNLOADED_CHAPTERS, { ids: ids.map(Number) })
}
async setChapterMeta(chapterId: string, key: string, value: string): Promise<void> {
await this.gql(SET_CHAPTER_META, { chapterId: Number(chapterId), key, value })
}
async deleteChapterMeta(chapterId: string, key: string): Promise<void> {
await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key })
}
// ─── Downloads ───────────────────────────────────────────────────────────
async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
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<void> {
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
}
async dequeueDownload(chapterId: string) {
async enqueueDownloads(chapterIds: string[]): Promise<void> {
await this.gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) })
}
async dequeueDownload(chapterId: string): Promise<void> {
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
}
async clearDownloads() {
async dequeueDownloads(chapterIds: string[]): Promise<void> {
await this.gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) })
}
async clearDownloads(): Promise<void> {
await this.gql(CLEAR_DOWNLOADER)
}
async startDownloader(): Promise<void> {
await this.gql(START_DOWNLOADER)
}
async stopDownloader(): Promise<void> {
await this.gql(STOP_DOWNLOADER)
}
// ─── Extensions ──────────────────────────────────────────────────────────
async getExtensions(): Promise<Extension[]> {
await this.gql(FETCH_EXTENSIONS)
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
return data.extensions.nodes.map(mapExtension)
}
async installExtension(id: string) {
async installExtension(id: string): Promise<void> {
await this.gql(UPDATE_EXTENSION, { id, install: true })
}
async uninstallExtension(id: string) {
async uninstallExtension(id: string): Promise<void> {
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
}
async updateExtension(id: string) {
async updateExtension(id: string): Promise<void> {
await this.gql(UPDATE_EXTENSION, { id, update: true })
}
async updateExtensions(ids: string[]): Promise<void> {
await this.gql(UPDATE_EXTENSIONS, { ids, update: true })
}
async installExternalExtension(url: string): Promise<void> {
await this.gql(INSTALL_EXTERNAL_EXTENSION, { url })
}
async getSources(): Promise<Source[]> {
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<Category[]> {
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
return data.categories.nodes.map(mapCategory)
}
async createCategory(name: string): Promise<Category> {
const data = await this.gql<{ createCategory: { category: Record<string, unknown> } }>(
CREATE_CATEGORY, { name }
)
return mapCategory(data.createCategory.category)
}
async deleteCategory(id: number): Promise<void> {
await this.gql(DELETE_CATEGORY, { id })
}
async updateCategoryOrder(id: number, position: number): Promise<Category[]> {
const data = await this.gql<{ updateCategoryOrder: { categories: Record<string, unknown>[] } }>(
UPDATE_CATEGORY_ORDER, { id, position }
)
return data.updateCategoryOrder.categories.map(mapCategory)
}
async updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise<void> {
await this.gql(UPDATE_MANGA_CATEGORIES, { mangaId: Number(mangaId), addTo, removeFrom })
}
async updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise<void> {
await this.gql(UPDATE_MANGAS_CATEGORIES, { ids: mangaIds.map(Number), addTo, removeFrom })
}
async updateCategoryManga(categoryId: number): Promise<void> {
await this.gql(UPDATE_CATEGORY_MANGA, { categoryId })
}
// ─── Tracking ────────────────────────────────────────────────────────────
async getTrackers(): Promise<Tracker[]> {
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<unknown[]> {
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<unknown[]> {
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<void> {
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<void> {
await this.gql(UNLINK_TRACK, { trackRecordId: Number(recordId) })
}
async fetchTrackRecord(recordId: string): Promise<void> {
await this.gql(UPDATE_TRACK, { recordId: Number(recordId) })
}
async syncTracking(mangaId: string): Promise<void> {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
}
// ─── Library updates ─────────────────────────────────────────────────────
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
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<void> {
await this.gql(UPDATE_STOP)
}
async getLibraryUpdateStatus(): Promise<LibraryUpdateProgress> {
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 }
}
}
+45 -17
View File
@@ -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
}
}
`
+51 -26
View File
@@ -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<T> {
@@ -7,33 +7,46 @@ export interface GQLResponse<T> {
}
export function mapManga(raw: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): Extension {
export function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
const chapter = raw.chapter as Record<string, unknown>
const manga = chapter?.manga as Record<string, unknown>
const manga = chapter?.manga as Record<string, unknown>
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<string, unknown>): 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<string, unknown>[] })?.nodes?.map(mapManga),
}
}
+46 -9
View File
@@ -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<T> {
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<void>
getStatus(): Promise<ServerStatus>
getServerUrl(): string
getManga(id: string): Promise<Manga>
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
searchManga(query: string, sourceId?: string): Promise<Manga[]>
fetchManga(id: string): Promise<Manga>
addToLibrary(mangaId: string): Promise<void>
removeFromLibrary(mangaId: string): Promise<void>
updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise<void>
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
deleteMangaMeta(id: string, key: string): Promise<void>
getChapters(mangaId: string): Promise<Chapter[]>
getChapter(id: string): Promise<Chapter>
getChapterPages(id: string): Promise<Page[]>
fetchChapters(mangaId: string): Promise<Chapter[]>
getRecentlyUpdated(): Promise<Chapter[]>
markChapterRead(id: string, read: boolean): Promise<void>
markChaptersRead(ids: string[], read: boolean): Promise<void>
updateChaptersProgress(ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise<void>
deleteDownloadedChapters(ids: string[]): Promise<void>
setChapterMeta(chapterId: string, key: string, value: string): Promise<void>
deleteChapterMeta(chapterId: string, key: string): Promise<void>
getDownloads(): Promise<DownloadItem[]>
enqueueDownload(chapterId: string): Promise<void>
enqueueDownloads(chapterIds: string[]): Promise<void>
dequeueDownload(chapterId: string): Promise<void>
dequeueDownloads(chapterIds: string[]): Promise<void>
clearDownloads(): Promise<void>
startDownloader(): Promise<void>
stopDownloader(): Promise<void>
getExtensions(): Promise<Extension[]>
installExtension(id: string): Promise<void>
uninstallExtension(id: string): Promise<void>
updateExtension(id: string): Promise<void>
updateExtensions(ids: string[]): Promise<void>
installExternalExtension(url: string): Promise<void>
getSources(): Promise<Source[]>
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
getCategories(): Promise<Category[]>
createCategory(name: string): Promise<Category>
deleteCategory(id: number): Promise<void>
updateCategoryOrder(id: number, position: number): Promise<Category[]>
updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise<void>
updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise<void>
updateCategoryManga(categoryId: number): Promise<void>
getTrackers(): Promise<Tracker[]>
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
searchTracker(trackerId: string, query: string): Promise<unknown[]>
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
unlinkTracker(recordId: string): Promise<void>
fetchTrackRecord(recordId: string): Promise<void>
syncTracking(mangaId: string): Promise<void>
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
}
stopLibraryUpdate(): Promise<void>
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
}
+42
View File
@@ -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<string, number>,
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++
}
+80 -44
View File
@@ -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<string>(),
})
class LibraryState {
items = $state<Manga[]>([])
loading = $state(false)
error = $state<string | null>(null)
refreshing = $state(false)
export const filteredItems = $derived.by(() => {
let result = libraryState.items
tab = $state<LibraryTab>('saved')
sort = $state<LibrarySortOption>('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<number>())
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()
+22 -30
View File
@@ -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<Manga | null>(null)
loading = $state(false)
error = $state<string | null>(null)
chapters: [] as Chapter[],
chaptersLoading: false,
chaptersError: null as string | null,
chapters = $state<Chapter[]>([])
chaptersLoading = $state(false)
chaptersError = $state<string | null>(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()
+13 -5
View File
@@ -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,
})
+20 -16
View File
@@ -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
}
+17 -18
View File
@@ -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
}
+4 -539
View File
@@ -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<T> {
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): Extension {
return {
...(raw as unknown as Extension),
id: raw.pkgName as string,
}
}
function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
const chapter = raw.chapter as Record<string, unknown>
const manga = chapter?.manga as Record<string, unknown>
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<ServerStatus> {
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<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' }
if (this.authHeader) h['Authorization'] = this.authHeader
return h
}
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query, variables }),
})
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
const json: GQLResponse<T> = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
return json.data
}
// ── Manga ──────────────────────────────────────────────────────────────
async getManga(id: string): Promise<Manga> {
const data = await this.gql<{ manga: Record<string, unknown> }>(
GET_MANGA, { id: Number(id) }
)
return mapManga(data.manga)
}
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
if (filters.inLibrary) {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
}
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
}
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
if (!sourceId) return []
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[] }
}>(FETCH_SOURCE_MANGA, {
source: sourceId,
type: 'SEARCH',
page: 1,
query,
})
return data.fetchSourceManga.mangas.map(mapManga)
}
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<MangaMeta>) {
for (const [key, value] of Object.entries(meta)) {
if (value === undefined) continue
await this.gql(SET_MANGA_META, {
mangaId: Number(id),
key,
value: String(value),
})
}
}
// ── Chapters ───────────────────────────────────────────────────────────
async getChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_CHAPTERS, { mangaId: Number(mangaId) }
)
return data.chapters.nodes.map(mapChapter)
}
async getChapter(id: string): Promise<Chapter> {
const chapters = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
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<Page[]> {
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<DownloadItem[]> {
const data = await this.gql<{
downloadStatus: { queue: Record<string, unknown>[] }
}>(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<Extension[]> {
await this.gql(FETCH_EXTENSIONS)
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(
GET_EXTENSIONS
)
return data.extensions.nodes.map(mapExtension)
}
async installExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, install: true })
}
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<Source[]> {
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
return data.sources.nodes
}
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
}>(FETCH_SOURCE_MANGA, {
source: sourceId,
type: 'LATEST',
page,
})
return {
items: data.fetchSourceManga.mangas.map(mapManga),
hasNextPage: data.fetchSourceManga.hasNextPage,
}
}
// ── Tracking ───────────────────────────────────────────────────────────
async getTrackers(): Promise<Tracker[]> {
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<UpdateResult[]> {
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'
+44 -53
View File
@@ -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
}
+31 -26
View File
@@ -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[] }
}
}
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from 'phosphor-svelte'
import { timeAgo } from '$lib/ui/home/homeHelpers'
import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
let {
entries,
libraryManga,
onresume,
onviewhistory,
onopenlibrary,
}: {
entries: HistoryEntry[]
libraryManga: Manga[]
onresume: (entry: HistoryEntry) => void
onviewhistory: () => void
onopenlibrary: () => void
} = $props()
function thumbFor(entry: HistoryEntry): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? ''
}
</script>
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
{#if entries.length > 0}
<button class="see-all" onclick={onviewhistory}>
Full History <ArrowRight size={9} weight="bold" />
</button>
{/if}
</div>
<div class="list">
{#if entries.length > 0}
{#each entries as entry (entry.chapterId)}
<button class="row" onclick={() => onresume(entry)}>
<img src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info">
<span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub">
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ''}
</span>
</div>
<span class="row-time">{timeAgo(entry.readAt)}</span>
<span class="row-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
{:else}
<div class="placeholder">
{#each Array(5) as _, i}
<div class="row row-sk">
<div class="sk-thumb"></div>
<div class="row-info">
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
</div>
<div class="sk sk-time"></div>
</div>
{/each}
<div class="placeholder-overlay">
<button class="placeholder-cta" onclick={onopenlibrary}>
<BookOpen size={12} weight="light" /> Start reading
</button>
</div>
</div>
{/if}
</div>
</div>
<style>
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
}
.section-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.see-all {
display: flex; align-items: center; gap: 4px;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0;
transition: color var(--t-base);
}
.see-all:hover { color: var(--accent-fg); }
.list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 7px var(--sp-2); border-radius: var(--radius-md);
border: 1px solid transparent; background: none;
text-align: left; cursor: pointer; width: 100%;
transition: background var(--t-fast), border-color var(--t-fast);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .row-play { opacity: 1; }
.row-thumb {
width: 33px; height: 48px; border-radius: var(--radius-sm);
object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim);
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-sub {
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-time {
font-family: var(--font-ui); font-size: var(--text-sm);
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.row-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.row-sk { cursor: default; pointer-events: none; }
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-title { height: 11px; margin-bottom: 5px; }
.sk-sub { height: 9px; }
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
.placeholder { position: relative; }
.placeholder-overlay {
position: absolute; left: 0; right: 0; top: 0; bottom: -1px;
display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4);
pointer-events: none;
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%);
}
.placeholder-cta {
pointer-events: all;
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 16px; border-radius: var(--radius-full);
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.13);
color: rgba(255,255,255,0.62); cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
+207
View File
@@ -0,0 +1,207 @@
<script lang="ts">
let {
dailyReadCounts,
}: {
dailyReadCounts: Record<string, number>
} = $props()
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
if (count === 0) return 0
if (count === 1) return 1
if (count <= 3) return 2
if (count <= 6) return 3
return 4
}
let tip: { text: string; x: number; y: number } | null = $state(null)
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const label = cell.count === 0
? `No chapters — ${fmtDate(cell.dateStr)}`
: `${cell.count} chapter${cell.count !== 1 ? 's' : ''}${fmtDate(cell.dateStr)}`
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 }
}
function hideTip() { tip = null }
function fmtDate(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function localDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
let wrapEl: HTMLElement
let cellSize = $state(12)
let numWeeks = $state(26)
const GAP = 3
const DAY_GUTTER = 28
const LEGEND_H = 20
const MONTH_H = 14
const ROWS = 7
$effect(() => {
if (!wrapEl) return
const obs = new ResizeObserver(() => {
const h = wrapEl.clientHeight
const w = wrapEl.clientWidth
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS))
cellSize = cs
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)))
})
obs.observe(wrapEl)
return () => obs.disconnect()
})
const visibleWeeks = $derived((() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = localDateStr(today)
const endDow = today.getDay()
const weekEnd = new Date(today)
weekEnd.setDate(weekEnd.getDate() + (6 - endDow))
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = []
for (let wi = numWeeks - 1; wi >= 0; wi--) {
const week: typeof weeks[0] = []
for (let di = 0; di < 7; di++) {
const d = new Date(weekEnd)
d.setDate(d.getDate() - wi * 7 - (6 - di))
const dateStr = localDateStr(d)
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today })
}
weeks.push(week)
}
return weeks
})())
const monthLabels = $derived((() => {
const labels: { label: string; colIndex: number }[] = []
let lastMonth = -1
visibleWeeks.forEach((week, ci) => {
const first = week[0]
if (!first) return
const m = new Date(first.dateStr + 'T00:00:00').getMonth()
if (m !== lastMonth) {
labels.push({ label: new Date(first.dateStr + 'T00:00:00').toLocaleDateString('en-US', { month: 'short' }), colIndex: ci })
lastMonth = m
}
})
return labels
})())
const DAY_LABELS = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat']
</script>
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
<div class="month-row">
<div class="day-gutter"></div>
<div class="month-cells">
{#each visibleWeeks as _week, ci}
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
<div class="month-label">{lbl?.label ?? ''}</div>
{/each}
</div>
</div>
<div class="grid-row">
<div class="day-labels">
{#each DAY_LABELS as d}
<span class="day-label">{d}</span>
{/each}
</div>
<div class="cell-grid">
{#each visibleWeeks as week}
<div class="week-col">
{#each week as cell}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<button
class="cell intensity-{intensity(cell.count)}"
class:cell-today={cell.isToday}
class:cell-future={cell.isFuture}
onmouseover={(e) => showTip(e, cell)}
onmouseleave={hideTip}
aria-label="{cell.count} chapters on {cell.dateStr}"
></button>
{/each}
</div>
{/each}
</div>
</div>
<div class="legend">
<span class="legend-label">Less</span>
{#each [0, 1, 2, 3, 4] as lvl}
<div class="legend-cell intensity-{lvl}"></div>
{/each}
<span class="legend-label">More</span>
</div>
</div>
{#if tip}
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
{/if}
<style>
.heatmap-wrap {
display: flex; flex-direction: column; justify-content: center;
gap: 4px; width: 100%; height: 100%;
min-width: 0; min-height: 0; overflow: hidden; box-sizing: border-box;
}
.month-row { display: flex; gap: 4px; flex-shrink: 0; }
.day-gutter { width: 28px; flex-shrink: 0; }
.month-cells {
display: grid; grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px; overflow: hidden;
}
.month-label {
font-family: var(--font-ui); font-size: 9px; color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding-left: 1px; white-space: nowrap; overflow: hidden;
}
.grid-row { display: flex; gap: 4px; align-items: flex-start; flex-shrink: 0; }
.day-labels { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; width: 28px; }
.day-label {
font-family: var(--font-ui); font-size: 8px; color: var(--text-faint);
letter-spacing: var(--tracking-wide); height: var(--cell); line-height: var(--cell); text-align: right;
}
.cell-grid {
display: grid; grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px; overflow: visible; padding: 4px; margin: -4px;
}
.week-col { display: flex; flex-direction: column; gap: 3px; }
.cell {
width: var(--cell); height: var(--cell); border-radius: 3px;
border: none; padding: 0; cursor: pointer;
transition: filter var(--t-fast), transform var(--t-fast);
}
.cell:hover:not(.cell-future) { filter: brightness(1.5); transform: scale(1.2); z-index: 1; position: relative; }
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
.legend { display: flex; align-items: center; gap: 3px; justify-content: flex-end; flex-shrink: 0; padding-top: 2px; }
.legend-cell { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.legend-label { font-family: var(--font-ui); font-size: 9px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.heatmap-tip {
position: fixed; transform: translate(-50%, -100%);
background: var(--bg-overlay); border: 1px solid var(--border-base);
border-radius: var(--radius-sm); padding: 4px 8px;
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary);
letter-spacing: var(--tracking-wide); white-space: nowrap; pointer-events: none;
z-index: 9999; box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import { MagnifyingGlass, X as XIcon } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
let {
slotIndex,
libraryManga,
loading,
onpin,
onclose,
}: {
slotIndex: 1 | 2 | 3
libraryManga: Manga[]
loading: boolean
onpin: (m: Manga) => void
onclose: () => void
} = $props()
let search = $state('')
function focusEl(node: HTMLElement) { node.focus() }
const results = $derived(
search.trim()
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
: libraryManga.slice(0, 20)
)
</script>
<div
class="backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose() }}
onkeydown={(e) => { if (e.key === 'Escape') onclose() }}
>
<div class="modal">
<div class="modal-header">
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
</div>
<div class="list">
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if results.length === 0}
<p class="empty-msg">No results</p>
{:else}
{#each results as m (m.id)}
<button class="list-row" onclick={() => onpin(m)}>
<img src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
<div class="row-info">
<span class="row-title">{m.title}</span>
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.62);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.modal {
width: min(460px, calc(100vw - 48px)); max-height: 68vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.14s ease both;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.modal-close {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.search-input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
.search-input::placeholder { color: var(--text-faint); }
.list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.list::-webkit-scrollbar { display: none; }
.empty-msg {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center;
}
.list-row {
display: flex; align-items: center; gap: var(--sp-3); width: 100%;
padding: 8px var(--sp-3); border-radius: var(--radius-md);
border: none; background: none; text-align: left; cursor: pointer;
transition: background var(--t-fast);
}
.list-row:hover { background: var(--bg-raised); }
.row-thumb {
height: 50px; width: 35px; aspect-ratio: 1/1.42;
border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0;
border: 1px solid var(--border-dim); background: var(--bg-raised); display: block;
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+452
View File
@@ -0,0 +1,452 @@
<script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from 'phosphor-svelte'
import { goto } from '$app/navigation'
import { timeAgo } from '$lib/ui/home/homeHelpers'
import type { Manga } from '$lib/types'
import type { Chapter } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
interface HeroSlot {
kind: 'continue' | 'pinned' | 'empty'
entry?: HistoryEntry
manga?: Manga
slotIndex: number
}
let {
resolvedSlots,
activeIdx = $bindable(),
heroThumb,
heroTitle,
heroManga,
heroEntry,
heroMangaId,
heroChapters,
heroNewChapter,
loadingHeroChapters,
resuming,
onresume,
onopenchapter,
oncyclenext,
oncycleprev,
ongotoslot,
onopenpicker,
onunpin,
onviewall,
}: {
resolvedSlots: HeroSlot[]
activeIdx: number
heroThumb: string
heroTitle: string
heroManga: Manga | null | undefined
heroEntry: HistoryEntry | null
heroMangaId: number | null
heroChapters: Chapter[]
heroNewChapter: Chapter | null
loadingHeroChapters: boolean
resuming: boolean
onresume: () => void
onopenchapter: (ch: Chapter) => void
oncyclenext: () => void
oncycleprev: () => void
ongotoslot: (i: number) => void
onopenpicker: (i: 1 | 2 | 3) => void
onunpin: (i: 1 | 2 | 3) => void
onviewall: () => void
} = $props()
const activeSlot = $derived(resolvedSlots[activeIdx])
const TOTAL_SLOTS = 4
</script>
<div class="hero-stage">
{#key heroThumb}
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
{:else}
<div class="hero-backdrop hero-bd-empty"></div>
{/if}
{/key}
<div class="hero-scrim"></div>
<button
class="hero-cover-col"
onclick={onresume}
disabled={resuming || activeSlot?.kind === 'empty'}
aria-label={heroTitle ? `Resume ${heroTitle}` : 'No manga selected'}
>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === 'continue'}
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
{/if}
{:else}
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
{/if}
</button>
<div class="hero-details">
{#if activeSlot?.kind === 'empty'}
<p class="hero-empty-title">Nothing here yet</p>
<p class="hero-empty-sub">
{activeSlot.slotIndex === 0
? 'Read a manga to see it here'
: 'Pin a manga or keep reading to fill this slot'}
</p>
{#if activeSlot.slotIndex !== 0}
<button class="hero-cta" onclick={() => onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
<PushPin size={11} weight="fill" /> Pin manga
</button>
{/if}
{:else}
<div class="hero-tags">
{#if activeSlot?.kind === 'continue'}
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
{:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if}
{#if heroNewChapter && !heroNewChapter.isRead}
<span class="hero-tag hero-tag-new">New ch.{Math.floor(heroNewChapter.chapterNumber)}</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button
class="hero-tag hero-tag-genre"
onclick={() => goto(`/browse?genre=${encodeURIComponent(g)}`)}
>{g}</button>
{/each}
</div>
<h2 class="hero-title">{heroTitle}</h2>
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
{#if heroEntry}
<p class="hero-progress">
<Clock size={10} weight="light" />
{heroEntry.chapterName}
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
</p>
{/if}
{#if heroManga?.description}
<p class="hero-desc">{heroManga.description}</p>
{/if}
<div class="hero-actions">
{#if activeSlot?.kind === 'continue'}
<button class="hero-cta" onclick={onresume} disabled={resuming}>
<Play size={11} weight="fill" />{resuming ? 'Loading…' : 'Resume'}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => goto(`/series/${heroManga!.id}`)}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
{#if activeSlot?.slotIndex !== 0}
{#if activeSlot?.kind === 'pinned'}
<button class="hero-cta-ghost" onclick={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
{:else}
<button class="hero-cta-ghost" onclick={() => onopenpicker(activeSlot!.slotIndex as 1 | 2 | 3)}>
<PushPin size={10} weight="light" /> Pin
</button>
{/if}
{/if}
</div>
{/if}
<div class="hero-nav-row">
<button class="hero-nav-btn" onclick={oncycleprev} aria-label="Previous">
<ArrowLeft size={12} weight="bold" />
</button>
<div class="hero-dots">
{#each resolvedSlots as slot, i}
<button
class="hero-dot"
class:active={activeIdx === i}
class:pinned={slot.kind === 'pinned'}
onclick={() => ongotoslot(i)}
aria-label="Slot {i + 1}"
></button>
{/each}
</div>
<button class="hero-nav-btn" onclick={oncyclenext} aria-label="Next">
<ArrowRight size={12} weight="bold" />
</button>
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
</div>
</div>
<div class="hero-chapters">
<div class="hero-chapters-header">
<ListBullets size={11} weight="bold" /><span>Up Next</span>
</div>
{#if activeSlot?.kind === 'empty'}
<p class="hero-chapters-empty">No chapters to show</p>
{:else if loadingHeroChapters}
{#each Array(4) as _}
<div class="chapter-row-sk">
<div class="sk sk-num"></div>
<div class="sk-info">
<div class="sk sk-name"></div>
<div class="sk sk-meta"></div>
</div>
</div>
{/each}
{:else if heroChapters.length === 0}
<p class="hero-chapters-empty">No chapters available</p>
{:else}
{#each heroChapters as ch (ch.id)}
{@const isCurrent = heroEntry?.chapterId === ch.id}
<button
class="chapter-row"
class:chapter-row-current={isCurrent}
class:chapter-row-read={ch.isRead && !isCurrent}
onclick={() => onopenchapter(ch)}
>
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
<div class="ch-info">
<span class="ch-name">{ch.name}</span>
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
{:else if ch.isRead}
<span class="ch-meta ch-read">Read</span>
{:else if ch.uploadDate}
<span class="ch-meta">
{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate) * 1000)
.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
{/if}
</div>
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
</button>
{/each}
{#if heroManga}
<button class="ch-view-all" onclick={onviewall}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
{/if}
</div>
</div>
<style>
.hero-stage {
position: relative;
display: flex;
align-items: stretch;
height: 374px;
overflow: hidden;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
}
.hero-backdrop {
position: absolute;
inset: -14px;
background-size: cover;
background-position: center 25%;
filter: blur(22px) saturate(2.4) brightness(0.4);
transform: scale(1.07);
pointer-events: none;
z-index: 0;
animation: backdropIn 0.5s ease both;
}
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(110deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-cover-col {
position: relative;
z-index: 2;
flex-shrink: 0;
width: 256px;
height: 374px;
overflow: hidden;
cursor: pointer;
background: var(--bg-raised);
padding: 0;
border: none;
border-right: 1px solid rgba(255,255,255,0.07);
}
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
.hero-cover-empty {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: var(--bg-overlay); color: var(--text-faint);
}
.cover-resume-hint {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #fff; background: rgba(0,0,0,0.38);
opacity: 0; transition: opacity 0.18s ease; pointer-events: none;
}
.hero-details {
position: relative; z-index: 2;
flex: 1; min-width: 0;
padding: var(--sp-5) var(--sp-5) var(--sp-4);
display: flex; flex-direction: column; gap: var(--sp-2);
overflow: hidden;
border-right: 1px solid rgba(255,255,255,0.05);
}
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 9px;
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 3px 8px; border-radius: var(--radius-sm);
background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
border: 1px solid rgba(255,255,255,0.13);
}
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
.hero-tag-new { background: rgba(74,222,128,0.15); color: #86efac; border-color: rgba(74,222,128,0.25); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
.hero-title {
font-size: var(--text-xl); font-weight: var(--weight-semibold);
color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
text-shadow: 0 2px 12px rgba(0,0,0,0.55); letter-spacing: -0.01em;
}
.hero-author {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.hero-progress {
display: flex; align-items: center; gap: 5px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.55); letter-spacing: var(--tracking-wide);
}
.hero-prog-page { color: rgba(255,255,255,0.35); }
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.3); }
.hero-desc {
font-size: var(--text-xs); color: rgba(255,255,255,0.38); line-height: 1.6;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0;
}
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.48); flex-shrink: 0; }
.hero-empty-sub {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.26); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug);
}
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
.hero-cta {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 18px; border-radius: var(--radius-md);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
cursor: pointer; transition: filter var(--t-base); white-space: nowrap;
}
.hero-cta:hover:not(:disabled) { filter: brightness(1.18); }
.hero-cta:disabled { opacity: 0.5; cursor: default; }
.hero-cta-ghost {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 14px; border-radius: var(--radius-md);
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.11);
color: rgba(255,255,255,0.48); cursor: pointer;
transition: background var(--t-base), color var(--t-base); white-space: nowrap;
}
.hero-cta-ghost:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.82); }
.hero-nav-row {
display: flex; align-items: center; gap: var(--sp-2);
flex-shrink: 0; margin-top: auto; padding-top: var(--sp-3);
border-top: 1px solid rgba(255,255,255,0.07);
}
.hero-nav-btn {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.11);
color: rgba(255,255,255,0.55); cursor: pointer; flex-shrink: 0;
transition: background var(--t-base), color var(--t-base);
}
.hero-nav-btn:hover { background: rgba(255,255,255,0.18); color: #fff; }
.hero-dots { display: flex; gap: 5px; align-items: center; }
.hero-dot {
width: 5px; height: 5px; border-radius: 50%;
background: rgba(255,255,255,0.2); border: none; cursor: pointer; padding: 0;
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
}
.hero-dot:hover { background: rgba(255,255,255,0.48); }
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
.hero-dot.pinned { background: rgba(168,132,232,0.5); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter {
font-family: var(--font-ui); font-size: 10px;
color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); margin-left: auto;
}
.hero-chapters {
position: relative; z-index: 2;
width: clamp(180px, 30%, 232px); flex-shrink: 0;
display: flex; flex-direction: column;
padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden;
}
.hero-chapters-header {
display: flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wider); text-transform: uppercase;
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
}
.hero-chapters-empty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.22); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0;
}
.chapter-row {
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 7px var(--sp-2); border-radius: var(--radius-sm);
background: none; border: none; text-align: left; cursor: pointer;
transition: background var(--t-fast);
}
.chapter-row:hover { background: rgba(255,255,255,0.07); }
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
.ch-num {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.32); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px;
}
.chapter-row-current .ch-num { color: var(--accent-fg); }
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-row-read .ch-name { color: rgba(255,255,255,0.32); }
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.26); letter-spacing: var(--tracking-wide); }
.ch-read { color: rgba(255,255,255,0.18); }
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
.sk-name { height: 11px; width: 85%; }
.sk-meta { height: 9px; width: 50%; }
.ch-view-all {
display: flex; align-items: center; gap: 4px; margin-top: auto;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide);
background: none; border: none; cursor: pointer;
padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base);
}
.ch-view-all:hover { color: var(--accent-fg); }
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import { Sparkle } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
let {
libraryManga,
history,
onopenrecommended,
}: {
libraryManga: Manga[]
history: HistoryEntry[]
onopenrecommended: (m: Manga) => void
} = $props()
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><Sparkle size={10} weight="bold" /> Recommended</span>
</div>
<p class="stub">Recommendations coming soon</p>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); flex-shrink: 0; }
.col-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.stub { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from 'phosphor-svelte'
import { formatReadTime } from '$lib/ui/home/homeHelpers'
import type { ReadingStats } from '$lib/state/home.svelte'
let {
stats,
updateCount,
}: {
stats: ReadingStats
updateCount: number
} = $props()
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
</div>
<div class="grid">
<div class="card">
<div class="icon-wrap fire"><Fire size={15} weight="fill" /></div>
<div class="body">
<span class="val">{stats.currentStreakDays}</span>
<span class="label">Day streak</span>
</div>
</div>
<div class="card">
<div class="icon-wrap accent"><BookOpen size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalChaptersRead}</span>
<span class="label">Chapters read</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><Clock size={15} weight="light" /></div>
<div class="body">
<span class="val">{formatReadTime(stats.totalMinutesRead)}</span>
<span class="label">Read time</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><TrendUp size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalMangaRead}</span>
<span class="label">Series started</span>
</div>
</div>
<div class="card">
<div class="icon-wrap green"><Bell size={15} weight="light" /></div>
<div class="body">
<span class="val">{updateCount}</span>
<span class="label">New updates</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><CalendarBlank size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.longestStreakDays}d</span>
<span class="label">Best streak</span>
</div>
</div>
</div>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; }
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.col-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.card {
display: flex; align-items: center; gap: var(--sp-3);
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: var(--sp-3);
transition: border-color var(--t-fast);
}
.card:hover { border-color: var(--border-base); }
.icon-wrap {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: var(--radius-sm); flex-shrink: 0;
}
.fire { background: rgba(251,146,60,0.15); color: #fb923c; }
.accent { background: var(--accent-muted); color: var(--accent-fg); }
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
.green { background: rgba(34,197,94,0.12); color: #22c55e; }
.body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.val {
font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1;
}
.label {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap;
}
</style>
+39
View File
@@ -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()
}
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import { Check, Funnel } from 'phosphor-svelte'
import type { MangaStatus } from '$lib/server-adapters/types'
interface Props {
status: MangaStatus | 'all'
unread: boolean
downloaded: boolean
bookmarked: boolean
hasActive: boolean
open: boolean
onToggle: () => void
onStatus: (s: MangaStatus | 'all') => void
onUnread: () => void
onDownloaded: () => void
onBookmarked: () => void
onClear: () => void
}
let {
status, unread, downloaded, bookmarked, hasActive, open,
onToggle, onStatus, onUnread, onDownloaded, onBookmarked, onClear,
}: Props = $props()
const STATUSES: [MangaStatus, string][] = [
['ONGOING', 'Ongoing'],
['COMPLETED', 'Completed'],
['ON_HIATUS', 'Hiatus'],
['CANCELLED', 'Cancelled'],
['PUBLISHING_FINISHED', 'Publishing finished'],
]
</script>
<div class="wrap">
<button
class="icon-btn"
class:active={hasActive}
title="Filter"
onclick={onToggle}
>
<Funnel size={15} weight={hasActive ? 'fill' : 'bold'} />
</button>
{#if open}
<div class="panel" role="menu">
<div class="panel-head">
<span class="panel-title">Filter</span>
{#if hasActive}
<button class="clear-btn" onclick={onClear}>Clear all</button>
{/if}
</div>
<div class="divider"></div>
<p class="section-label">Content</p>
{#each [
{ label: 'Unread', active: unread, handler: onUnread },
{ label: 'Downloaded', active: downloaded, handler: onDownloaded },
{ label: 'Bookmarked', active: bookmarked, handler: onBookmarked },
] as f}
<button
class="item"
class:item-active={f.active}
role="menuitem"
onclick={f.handler}
>
<span class="check" class:check-on={f.active}>
{#if f.active}<Check size={9} weight="bold" />{/if}
</span>
{f.label}
</button>
{/each}
<div class="divider"></div>
<p class="section-label">Status</p>
{#each STATUSES as [s, label]}
<button
class="item"
class:item-active={status === s}
role="menuitem"
onclick={() => onStatus(status === s ? 'all' : s)}
>
<span class="check" class:check-on={status === s}>
{#if status === s}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
</div>
{/if}
</div>
<style>
.wrap { position: relative; }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-faint);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999;
min-width: 220px;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-1);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: fadeIn 0.1s ease both;
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px 4px;
}
.panel-title {
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
font-weight: var(--weight-medium, 500);
}
.clear-btn {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); color: var(--text-faint);
background: none; border: none; cursor: pointer; padding: 0;
transition: color var(--t-base);
}
.clear-btn:hover { color: var(--color-error); }
.divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); padding: 4px 8px 8px;
}
.item {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px 10px;
border-radius: var(--radius-sm); border: none;
background: transparent; color: var(--text-muted);
font-family: var(--font-ui); font-size: var(--text-xs);
cursor: pointer; text-align: left;
transition: background var(--t-base), color var(--t-base);
}
.item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.item-active:hover { background: var(--accent-dim); }
.check {
width: 13px; height: 13px; border-radius: 2px;
border: 1px solid var(--border-strong);
background: transparent; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: var(--bg-base);
transition: background var(--t-base), border-color var(--t-base);
}
.check-on { background: var(--accent); border-color: var(--accent); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
+246
View File
@@ -0,0 +1,246 @@
<script lang="ts">
import { CheckSquare, Trash } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
interface Props {
items: Manga[]
loading: boolean
selectMode: boolean
selected: Set<number>
tab: string
onCardClick: (e: MouseEvent, m: Manga) => void
onSelectAll: () => void
onExitSelect: () => void
onBulkRemove: () => void
}
let {
items, loading, selectMode, selected, tab,
onCardClick, onSelectAll, onExitSelect, onBulkRemove,
}: Props = $props()
const THUMB_BASE = 'http://127.0.0.1:4567'
function coverUrl(m: Manga) {
const url = m.thumbnailUrl ?? ''
return url.startsWith('http') ? url : `${THUMB_BASE}${url}`
}
</script>
{#if selectMode}
<div class="select-bar">
<span class="sel-count">{selected.size} selected</span>
<button class="sel-text-btn" onclick={onSelectAll}>Select all</button>
<div class="sel-right">
<button
class="sel-action-btn sel-danger"
disabled={selected.size === 0}
onclick={onBulkRemove}
>
<Trash size={13} weight="bold" />
Remove
</button>
</div>
</div>
{/if}
<div
class="content"
role="presentation"
onclick={(e) => {
if (selectMode && !(e.target as HTMLElement).closest('.card')) onExitSelect()
}}
>
{#if loading}
<div class="grid">
{#each Array(12) as _}
<div class="card-skeleton">
<div class="cover-skeleton skeleton"></div>
<div class="title-skeleton skeleton"></div>
</div>
{/each}
</div>
{:else if items.length === 0}
<div class="empty">
{tab === 'downloaded'
? 'No downloaded manga.'
: 'No manga saved to library — browse sources to add some.'}
</div>
{:else}
<div class="grid">
{#each items as m (m.id)}
{@const isSelected = selected.has(m.id)}
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
<button
class="card"
class:card-selected={isSelected}
class:select-mode={selectMode}
onclick={(e) => onCardClick(e, m)}
oncontextmenu={(e) => {
e.preventDefault()
onCardClick(e, m)
}}
>
<div class="cover-wrap" class:completed={isCompleted}>
<img
class="cover"
src={coverUrl(m)}
alt={m.title}
draggable="false"
loading="lazy"
/>
<div class="overlay">
<div class="badges">
{#if isCompleted}
<span class="badge badge-done">✓ Done</span>
{:else if m.unreadCount}
<span class="badge badge-unread">{m.unreadCount} new</span>
{/if}
{#if m.downloadCount}
<span class="badge badge-dl">{m.downloadCount}</span>
{/if}
</div>
</div>
{#if selectMode}
<div class="select-overlay" aria-hidden="true">
<div class="select-check" class:checked={isSelected}>
{#if isSelected}
<CheckSquare size={20} weight="fill" />
{:else}
<div class="check-empty"></div>
{/if}
</div>
</div>
{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{/if}
</div>
<style>
.content {
flex: 1; overflow-y: auto;
padding: var(--sp-5) var(--sp-6) var(--sp-6);
-webkit-overflow-scrolling: touch;
}
.select-bar {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-6);
background: var(--bg-raised); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; z-index: 10; position: relative;
animation: fadeIn 0.1s ease both;
}
.sel-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.sel-count {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap;
}
.sel-text-btn {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); background: none; border: none;
cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm);
transition: color var(--t-base);
}
.sel-text-btn:hover { color: var(--text-primary); }
.sel-action-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-xs);
padding: 5px 10px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-muted); cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-danger:hover:not(:disabled) {
color: var(--color-error, #e05c5c);
border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent);
background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: var(--sp-4);
}
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:not(.select-mode):hover .cover-wrap {
transform: translateY(-3px);
border-color: var(--border-strong);
box-shadow: 0 6px 20px rgba(0,0,0,0.35);
}
.card:not(.select-mode):hover .title { color: var(--text-primary); }
.card.select-mode { cursor: default; }
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
.card.card-selected .title { color: var(--accent-fg); }
.cover-wrap {
position: relative; aspect-ratio: 2/3; overflow: hidden;
border-radius: var(--radius-md); background: var(--bg-raised);
border: 1px solid var(--border-dim); will-change: transform;
transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1);
}
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.cover { width: 100%; height: 100%; object-fit: cover; display: block; }
.overlay {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
padding: 32px 6px 10px;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%);
opacity: 0; pointer-events: none;
transition: opacity 0.18s ease;
}
.card:not(.select-mode):hover .overlay { opacity: 1; }
.badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
.badge {
font-family: var(--font-ui); font-size: 9.5px; font-weight: 700;
letter-spacing: 0.04em; line-height: 1; padding: 3px 7px;
border-radius: 20px; white-space: nowrap;
}
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
.select-overlay {
position: absolute; inset: 0; z-index: 3;
background: rgba(0,0,0,0.18);
display: flex; align-items: flex-start; justify-content: flex-end;
padding: 6px; pointer-events: none;
}
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
.select-check.checked { color: var(--accent-fg); opacity: 1; }
.check-empty {
width: 20px; height: 20px; border-radius: 4px;
border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3);
}
.title {
margin-top: var(--sp-2); font-size: var(--text-sm);
color: var(--text-secondary); line-height: var(--leading-snug);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; height: 2lh;
transition: color var(--t-base);
}
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
.skeleton { background: var(--bg-raised); animation: pulse 1.4s ease infinite; }
.empty {
display: flex; align-items: center; justify-content: center;
height: 60%; color: var(--text-muted); font-size: var(--text-sm);
text-align: center;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
</style>
+263
View File
@@ -0,0 +1,263 @@
<script lang="ts">
import {
MagnifyingGlass, Books, DownloadSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise,
} from 'phosphor-svelte'
import LibraryFilters from './LibraryFilters.svelte'
import type { LibrarySortOption, LibraryTab } from '$lib/state/library.svelte'
import type { MangaStatus } from '$lib/server-adapters/types'
interface Props {
tab: LibraryTab
savedCount: number
dlCount: number
sort: LibrarySortOption
sortDesc: boolean
status: MangaStatus | 'all'
unread: boolean
downloaded: boolean
bookmarked: boolean
hasActiveFilters: boolean
refreshing: boolean
query: string
onTab: (t: LibraryTab) => void
onQuery: (q: string) => void
onSort: (s: LibrarySortOption) => void
onSortDesc: () => void
onStatus: (s: MangaStatus | 'all') => void
onUnread: () => void
onDownloaded: () => void
onBookmarked: () => void
onFilterClear: () => void
onRefresh: () => void
}
let {
tab, savedCount, dlCount, sort, sortDesc,
status, unread, downloaded, bookmarked, hasActiveFilters, refreshing, query,
onTab, onQuery, onSort, onSortDesc,
onStatus, onUnread, onDownloaded, onBookmarked, onFilterClear, onRefresh,
}: Props = $props()
let sortOpen = $state(false)
let filterOpen = $state(false)
const SORT_LABELS: Record<LibrarySortOption, string> = {
alphabetical: 'AZ',
unread: 'Unread chapters',
lastRead: 'Recently read',
dateAdded: 'Date added',
}
function onDocDown(e: MouseEvent) {
const t = e.target as HTMLElement
if (sortOpen && !t.closest('.sort-wrap')) sortOpen = false
if (filterOpen && !t.closest('.filter-wrap')) filterOpen = false
}
$effect(() => {
document.addEventListener('mousedown', onDocDown, true)
return () => document.removeEventListener('mousedown', onDocDown, true)
})
</script>
<div class="toolbar">
<span class="heading">Library</span>
<div class="tabs">
<button class="tab" class:active={tab === 'saved'} onclick={() => onTab('saved')}>
<Books size={11} weight="bold" />
Saved
<span class="count">{savedCount}</span>
</button>
<button class="tab" class:active={tab === 'downloaded'} onclick={() => onTab('downloaded')}>
<DownloadSimple size={11} weight="bold" />
Downloaded
<span class="count">{dlCount}</span>
</button>
</div>
<div class="right">
<div class="search-wrap">
<MagnifyingGlass size={13} class="search-icon" weight="light" />
<input
class="search"
placeholder="Search"
value={query}
oninput={(e) => onQuery((e.target as HTMLInputElement).value)}
/>
</div>
<button
class="icon-btn"
class:spinning={refreshing}
title={refreshing ? 'Checking for updates…' : 'Check for updates'}
onclick={onRefresh}
disabled={refreshing}
>
<ArrowsClockwise size={15} weight="bold" />
</button>
<div class="sort-wrap">
<button
class="icon-btn"
class:active={sort !== 'alphabetical' || sortDesc}
title="Sort"
onclick={() => { sortOpen = !sortOpen; filterOpen = false }}
>
<SortAscending size={15} weight="bold" />
</button>
{#if sortOpen}
<div class="panel sort-panel" role="menu">
<div class="panel-head">
<span class="panel-title">Sort</span>
</div>
<div class="divider"></div>
<p class="section-label">Order by</p>
{#each Object.entries(SORT_LABELS) as [s, label]}
<button
class="item"
class:item-active={sort === s}
role="menuitem"
onclick={() => { onSort(s as LibrarySortOption); sortOpen = false }}
>
{label}
{#if sort === s}
{#if sortDesc}<CaretDown size={11} weight="bold" />
{:else}<CaretUp size={11} weight="bold" />
{/if}
{/if}
</button>
{/each}
<button class="item dir-toggle" role="menuitem" onclick={onSortDesc}>
{sortDesc ? 'Descending' : 'Ascending'}
{#if sortDesc}<CaretDown size={11} weight="bold" />
{:else}<CaretUp size={11} weight="bold" />
{/if}
</button>
</div>
{/if}
</div>
<div class="filter-wrap">
<LibraryFilters
{status} {unread} {downloaded} {bookmarked}
hasActive={hasActiveFilters}
open={filterOpen}
onToggle={() => { filterOpen = !filterOpen; sortOpen = false }}
{onStatus} {onUnread} {onDownloaded} {onBookmarked}
onClear={onFilterClear}
/>
</div>
</div>
</div>
<style>
.toolbar {
position: relative; z-index: 100;
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; min-width: 0;
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase; flex-shrink: 0;
}
.tabs {
display: flex; align-items: center; gap: 2px;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 2px;
}
.tab {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 4px 10px; border-radius: var(--radius-sm);
border: 1px solid transparent; color: var(--text-faint);
white-space: nowrap;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
cursor: pointer;
}
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.count { font-size: var(--text-2xs); opacity: 0.6; }
.right {
display: flex; align-items: center; gap: var(--sp-2);
margin-left: auto; flex-shrink: 0;
}
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
.search {
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 5px 10px 5px 28px;
color: var(--text-primary); font-size: var(--text-sm); width: 180px;
outline: none; transition: border-color var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.5; cursor: default; }
.icon-btn.spinning :global(svg) { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.sort-wrap, .filter-wrap { position: relative; }
.panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999;
min-width: 220px; background: var(--bg-raised);
border: 1px solid var(--border-base); border-radius: var(--radius-lg);
padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: fadeIn 0.1s ease both;
}
.panel-head { display: flex; align-items: center; padding: 6px 10px 4px; }
.panel-title {
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
font-weight: var(--weight-medium, 500);
}
.divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); padding: 4px 8px 8px;
}
.item {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 7px 10px; border-radius: var(--radius-sm);
border: none; background: transparent; color: var(--text-muted);
font-family: var(--font-ui); font-size: var(--text-xs);
cursor: pointer; text-align: left; gap: var(--sp-2);
transition: background var(--t-base), color var(--t-base);
}
.item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.item-active:hover { background: var(--accent-dim); }
.dir-toggle {
justify-content: flex-start; color: var(--text-secondary);
border-top: 1px solid var(--border-dim);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
margin-top: 2px; padding-top: 9px;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>