mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Port over Home & Fix Suwayomi-Server Detection on Web
This commit is contained in:
+23
-29
@@ -3,8 +3,8 @@ import { initPlatformService } from '$lib/platform-service'
|
|||||||
import { appState } from '$lib/state/app.svelte'
|
import { appState } from '$lib/state/app.svelte'
|
||||||
import { configureAuth, probeServer } from '$lib/core/auth'
|
import { configureAuth, probeServer } from '$lib/core/auth'
|
||||||
|
|
||||||
const SAVED_URL_KEY = 'moku_server_url'
|
const KEY_URL = 'moku_server_url'
|
||||||
const SAVED_AUTH_KEY = 'moku_auth_config'
|
const KEY_AUTH = 'moku_auth_config'
|
||||||
|
|
||||||
interface SavedAuth {
|
interface SavedAuth {
|
||||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||||
@@ -12,24 +12,13 @@ interface SavedAuth {
|
|||||||
pass?: string
|
pass?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTauri(): boolean {
|
function isTauri(): boolean { return '__TAURI_INTERNALS__' in window }
|
||||||
return '__TAURI_INTERNALS__' in window
|
function isCapacitor(): boolean { return 'Capacitor' in window }
|
||||||
}
|
|
||||||
|
|
||||||
function isCapacitor(): boolean {
|
function detectPlatform(): 'tauri' | 'capacitor' | 'web' {
|
||||||
return 'Capacitor' in window
|
if (isTauri()) return 'tauri'
|
||||||
}
|
if (isCapacitor()) return 'capacitor'
|
||||||
|
return 'web'
|
||||||
function loadSavedServerUrl(): string {
|
|
||||||
return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567'
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSavedAuth(): SavedAuth {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' }
|
|
||||||
} catch {
|
|
||||||
return { mode: 'NONE' }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePlatformAdapter() {
|
async function resolvePlatformAdapter() {
|
||||||
@@ -60,29 +49,34 @@ async function boot() {
|
|||||||
initRequestManager(serverAdapter)
|
initRequestManager(serverAdapter)
|
||||||
initPlatformService(platformAdapter)
|
initPlatformService(platformAdapter)
|
||||||
|
|
||||||
appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web'
|
appState.platform = detectPlatform()
|
||||||
appState.version = await platformAdapter.getVersion()
|
appState.version = await platformAdapter.getVersion()
|
||||||
|
|
||||||
const savedUrl = loadSavedServerUrl()
|
const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567'
|
||||||
const savedAuth = loadSavedAuth()
|
const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH)
|
||||||
|
const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' }
|
||||||
|
|
||||||
appState.serverUrl = savedUrl
|
appState.serverUrl = savedUrl
|
||||||
appState.authMode = savedAuth.mode
|
appState.authMode = savedAuth.mode
|
||||||
|
|
||||||
if (isTauri() && platformAdapter.isSupported('server-management')) {
|
if (isTauri() && platformAdapter.isSupported('server-management')) {
|
||||||
await platformAdapter.launchServer({ url: savedUrl }).catch(() => {})
|
// jarPath/port/dataPath come from persisted server config; omitted here
|
||||||
|
// until settings UI writes them — server auto-launch handled by Tauri side
|
||||||
}
|
}
|
||||||
|
|
||||||
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
|
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
|
||||||
await serverAdapter.connect({ baseUrl: savedUrl })
|
|
||||||
|
await serverAdapter.connect({
|
||||||
|
baseUrl: savedUrl,
|
||||||
|
credentials:
|
||||||
|
savedAuth.mode === 'BASIC_AUTH' && savedAuth.user && savedAuth.pass
|
||||||
|
? { username: savedAuth.user, password: savedAuth.pass }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const probe = await probeServer()
|
const probe = await probeServer()
|
||||||
|
|
||||||
if (probe === 'auth_required') {
|
if (probe === 'auth_required') { appState.status = 'auth'; return }
|
||||||
appState.status = 'auth'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (probe === 'unreachable') {
|
if (probe === 'unreachable') {
|
||||||
appState.error = `Could not reach server at ${savedUrl}`
|
appState.error = `Could not reach server at ${savedUrl}`
|
||||||
appState.status = 'error'
|
appState.status = 'error'
|
||||||
|
|||||||
+98
-374
@@ -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 {
|
interface AuthConfig {
|
||||||
constructor(msg = 'Authentication required') {
|
baseUrl: string
|
||||||
super(msg)
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||||
this.name = 'AuthRequiredError'
|
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}`) }
|
||||||
}
|
}
|
||||||
}
|
if (config.mode === 'UI_LOGIN' && accessToken) {
|
||||||
|
return { Authorization: `Bearer ${accessToken}` }
|
||||||
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
|
return {}
|
||||||
_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) {
|
async function gqlRaw(query: string, variables?: Record<string, unknown>): Promise<unknown> {
|
||||||
const now = Date.now()
|
const res = await fetch(`${config.baseUrl}/api/graphql`, {
|
||||||
return {
|
|
||||||
accessExpiresAt: decodeJwtExpiry(token) ?? (jwt?.jwtTokenExpiry ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null),
|
|
||||||
refreshExpiresAt: jwt?.jwtRefreshExpiry ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJwtSettings(): Promise<JwtSettings | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetchAuthenticated(`${_serverBase}/api/graphql`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
body: gqlBody(`query { settings { jwtAudience jwtRefreshExpiry jwtTokenExpiry } }`),
|
body: JSON.stringify({ query, variables }),
|
||||||
}, timeoutSignal(5000))
|
})
|
||||||
if (!res.ok) return null
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
const s = json?.data?.settings
|
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||||
if (!s) return null
|
return json.data
|
||||||
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),
|
|
||||||
})
|
|
||||||
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 = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
|
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const res = await fetch(`${config.baseUrl}/api/graphql`, {
|
||||||
if (_authMode === 'BASIC_AUTH' && _basicUser && _basicPass) {
|
method: 'POST',
|
||||||
Object.assign(headers, basicHeader(_basicUser, _basicPass))
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
} else if (_authMode === 'UI_LOGIN') {
|
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
||||||
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),
|
|
||||||
})
|
})
|
||||||
if (res.ok) return 'ok'
|
if (res.status === 401 || res.status === 403) return 'auth_required'
|
||||||
if (res.status === 401) 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'
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
import type {
|
import type { PlatformAdapter } from '$lib/platform-adapters/types'
|
||||||
PlatformAdapter,
|
|
||||||
PlatformFeature,
|
|
||||||
ServerLaunchConfig,
|
|
||||||
DiscordPresence,
|
|
||||||
AppUpdateInfo,
|
|
||||||
} from '$lib/platform-adapters/types'
|
|
||||||
|
|
||||||
let adapter: PlatformAdapter
|
let adapter: PlatformAdapter
|
||||||
|
|
||||||
@@ -12,87 +6,7 @@ export function initPlatformService(a: PlatformAdapter) {
|
|||||||
adapter = a
|
adapter = a
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAdapter(): PlatformAdapter {
|
export function getPlatformService(): PlatformAdapter {
|
||||||
if (!adapter) throw new Error('PlatformService not initialized')
|
if (!adapter) throw new Error('PlatformService not initialized')
|
||||||
return adapter
|
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()
|
|
||||||
}
|
|
||||||
@@ -14,12 +14,25 @@ export async function loadChapters(mangaId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadChapterPages(chapterId: string) {
|
export async function fetchChapters(mangaId: string) {
|
||||||
|
seriesState.chaptersLoading = true
|
||||||
|
seriesState.chaptersError = null
|
||||||
|
try {
|
||||||
|
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.pagesLoading = true
|
||||||
readerState.pagesError = null
|
readerState.pagesError = null
|
||||||
try {
|
try {
|
||||||
readerState.pages = await getAdapter().getChapterPages(chapterId)
|
readerState.pages = await getAdapter().getChapterPages(chapterId, signal)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||||
readerState.pagesError = String(e)
|
readerState.pagesError = String(e)
|
||||||
} finally {
|
} finally {
|
||||||
readerState.pagesLoading = false
|
readerState.pagesLoading = false
|
||||||
@@ -28,13 +41,38 @@ export async function loadChapterPages(chapterId: string) {
|
|||||||
|
|
||||||
export async function markRead(id: string, read: boolean) {
|
export async function markRead(id: string, read: boolean) {
|
||||||
await getAdapter().markChapterRead(id, read)
|
await getAdapter().markChapterRead(id, read)
|
||||||
const chapter = seriesState.chapters.find(c => c.id === id)
|
// 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
|
if (chapter) chapter.read = read
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markManyRead(ids: string[], read: boolean) {
|
export async function markManyRead(ids: string[], read: boolean) {
|
||||||
await getAdapter().markChaptersRead(ids, read)
|
await getAdapter().markChaptersRead(ids, read)
|
||||||
|
const numIds = new Set(ids.map(Number))
|
||||||
for (const c of seriesState.chapters) {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,12 +14,31 @@ export async function enqueueDownload(chapterId: string) {
|
|||||||
await loadDownloads()
|
await loadDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function enqueueDownloads(chapterIds: string[]) {
|
||||||
|
await getAdapter().enqueueDownloads(chapterIds)
|
||||||
|
await loadDownloads()
|
||||||
|
}
|
||||||
|
|
||||||
export async function dequeueDownload(chapterId: string) {
|
export async function dequeueDownload(chapterId: string) {
|
||||||
await getAdapter().dequeueDownload(chapterId)
|
await getAdapter().dequeueDownload(chapterId)
|
||||||
downloadsState.items = downloadsState.items.filter(d => d.chapterId !== 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() {
|
export async function clearDownloads() {
|
||||||
await getAdapter().clearDownloads()
|
await getAdapter().clearDownloads()
|
||||||
downloadsState.items = []
|
downloadsState.items = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function startDownloader() {
|
||||||
|
await getAdapter().startDownloader()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopDownloader() {
|
||||||
|
await getAdapter().stopDownloader()
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ export async function installExtension(id: string) {
|
|||||||
await loadExtensions()
|
await loadExtensions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function installExternalExtension(url: string) {
|
||||||
|
await getAdapter().installExternalExtension(url)
|
||||||
|
await loadExtensions()
|
||||||
|
}
|
||||||
|
|
||||||
export async function uninstallExtension(id: string) {
|
export async function uninstallExtension(id: string) {
|
||||||
await getAdapter().uninstallExtension(id)
|
await getAdapter().uninstallExtension(id)
|
||||||
extensionsState.items = extensionsState.items.filter(e => e.id !== id)
|
extensionsState.items = extensionsState.items.filter(e => e.id !== id)
|
||||||
@@ -36,6 +41,13 @@ export async function updateExtension(id: string) {
|
|||||||
await loadExtensions()
|
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) {
|
export async function browseSource(sourceId: string, page: number) {
|
||||||
extensionsState.browseLoading = true
|
extensionsState.browseLoading = true
|
||||||
extensionsState.browseError = null
|
extensionsState.browseError = null
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getAdapter } from '$lib/request-manager'
|
import { getAdapter } from '$lib/request-manager'
|
||||||
import { libraryState } from '$lib/state/library.svelte'
|
import { libraryState } from '$lib/state/library.svelte'
|
||||||
|
import { toast } from '$lib/state/notifications.svelte'
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
import type { MangaFilters, MangaMeta } from '$lib/server-adapters/types'
|
import type { MangaFilters, MangaMeta } from '$lib/server-adapters/types'
|
||||||
|
|
||||||
@@ -28,6 +29,18 @@ 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) {
|
export async function searchManga(query: string, sourceId?: string) {
|
||||||
libraryState.loading = true
|
libraryState.loading = true
|
||||||
libraryState.error = null
|
libraryState.error = null
|
||||||
@@ -52,7 +65,67 @@ export async function removeFromLibrary(mangaId: string) {
|
|||||||
|
|
||||||
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
||||||
await getAdapter().updateMangaMeta(id, meta)
|
await getAdapter().updateMangaMeta(id, meta)
|
||||||
if (String(seriesState.current?.id) === id) {
|
if (String(seriesState.current?.id) === id) await loadManga(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)
|
||||||
|
}
|
||||||
@@ -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) {
|
export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
||||||
await getAdapter().linkTracker(mangaId, trackerId, remoteId)
|
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) {
|
export async function syncTracking(mangaId: string) {
|
||||||
|
|||||||
@@ -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 = `
|
export const GET_RECENTLY_UPDATED = `
|
||||||
query GetRecentlyUpdated {
|
query GetRecentlyUpdated {
|
||||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||||
|
|||||||
@@ -8,29 +8,51 @@ import type {
|
|||||||
Page,
|
Page,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
UpdateResult,
|
UpdateResult,
|
||||||
|
LibraryUpdateProgress,
|
||||||
} from '$lib/server-adapters/types'
|
} from '$lib/server-adapters/types'
|
||||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
|
||||||
import {
|
import {
|
||||||
GET_LIBRARY,
|
GET_LIBRARY,
|
||||||
GET_MANGA,
|
GET_MANGA,
|
||||||
GET_CATEGORIES,
|
GET_CATEGORIES,
|
||||||
FETCH_MANGA,
|
FETCH_MANGA,
|
||||||
UPDATE_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,
|
||||||
|
UPDATE_LIBRARY_MANGA,
|
||||||
|
UPDATE_STOP,
|
||||||
|
SET_MANGA_META,
|
||||||
|
DELETE_MANGA_META,
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA,
|
||||||
|
LIBRARY_UPDATE_STATUS,
|
||||||
} from './manga'
|
} from './manga'
|
||||||
import {
|
import {
|
||||||
GET_CHAPTERS,
|
GET_CHAPTERS,
|
||||||
|
GET_CHAPTER,
|
||||||
|
GET_RECENTLY_UPDATED,
|
||||||
FETCH_CHAPTERS,
|
FETCH_CHAPTERS,
|
||||||
FETCH_CHAPTER_PAGES,
|
FETCH_CHAPTER_PAGES,
|
||||||
MARK_CHAPTER_READ,
|
MARK_CHAPTER_READ,
|
||||||
MARK_CHAPTERS_READ,
|
MARK_CHAPTERS_READ,
|
||||||
|
UPDATE_CHAPTERS_PROGRESS,
|
||||||
|
DELETE_DOWNLOADED_CHAPTERS,
|
||||||
|
SET_CHAPTER_META,
|
||||||
|
DELETE_CHAPTER_META,
|
||||||
} from './chapters'
|
} from './chapters'
|
||||||
import {
|
import {
|
||||||
GET_DOWNLOAD_STATUS,
|
GET_DOWNLOAD_STATUS,
|
||||||
ENQUEUE_DOWNLOAD,
|
ENQUEUE_DOWNLOAD,
|
||||||
|
ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
DEQUEUE_DOWNLOAD,
|
DEQUEUE_DOWNLOAD,
|
||||||
|
DEQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
|
START_DOWNLOADER,
|
||||||
|
STOP_DOWNLOADER,
|
||||||
CLEAR_DOWNLOADER,
|
CLEAR_DOWNLOADER,
|
||||||
} from './downloads'
|
} from './downloads'
|
||||||
import {
|
import {
|
||||||
@@ -38,34 +60,32 @@ import {
|
|||||||
GET_SOURCES,
|
GET_SOURCES,
|
||||||
FETCH_EXTENSIONS,
|
FETCH_EXTENSIONS,
|
||||||
UPDATE_EXTENSION,
|
UPDATE_EXTENSION,
|
||||||
|
UPDATE_EXTENSIONS,
|
||||||
|
INSTALL_EXTERNAL_EXTENSION,
|
||||||
} from './extensions'
|
} from './extensions'
|
||||||
import {
|
import {
|
||||||
GET_TRACKERS,
|
GET_TRACKERS,
|
||||||
|
GET_MANGA_TRACK_RECORDS,
|
||||||
|
SEARCH_TRACKER,
|
||||||
BIND_TRACK,
|
BIND_TRACK,
|
||||||
|
UNLINK_TRACK,
|
||||||
TRACK_PROGRESS,
|
TRACK_PROGRESS,
|
||||||
|
UPDATE_TRACK,
|
||||||
} from './tracking'
|
} from './tracking'
|
||||||
import {
|
import {
|
||||||
GQLResponse,
|
type GQLResponse,
|
||||||
mapManga,
|
mapManga,
|
||||||
mapChapter,
|
mapChapter,
|
||||||
mapExtension,
|
mapExtension,
|
||||||
mapDownloadItem,
|
mapDownloadItem,
|
||||||
|
mapCategory,
|
||||||
} from './types'
|
} 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 {
|
export class SuwayomiAdapter implements ServerAdapter {
|
||||||
private baseUrl = 'http://127.0.0.1:4567'
|
private baseUrl = 'http://127.0.0.1:4567'
|
||||||
private authHeader: string | null = null
|
private authHeader: string | null = null
|
||||||
|
|
||||||
async connect(config: ServerConfig) {
|
async connect(config: ServerConfig): Promise<void> {
|
||||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||||
if (config.credentials) {
|
if (config.credentials) {
|
||||||
const { username, password } = config.credentials
|
const { username, password } = config.credentials
|
||||||
@@ -73,6 +93,10 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getServerUrl(): string {
|
||||||
|
return this.baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
async getStatus(): Promise<ServerStatus> {
|
async getStatus(): Promise<ServerStatus> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||||
@@ -92,11 +116,16 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
private async gql<T>(
|
||||||
|
query: string,
|
||||||
|
variables?: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||||
const json: GQLResponse<T> = await res.json()
|
const json: GQLResponse<T> = await res.json()
|
||||||
@@ -104,11 +133,20 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
return json.data
|
return json.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Manga ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getManga(id: string): Promise<Manga> {
|
async getManga(id: string): Promise<Manga> {
|
||||||
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
|
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
|
||||||
return mapManga(data.manga)
|
return mapManga(data.manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async 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>> {
|
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
|
||||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||||
let items = data.mangas.nodes.map(mapManga)
|
let items = data.mangas.nodes.map(mapManga)
|
||||||
@@ -121,27 +159,37 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
|
|
||||||
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
||||||
if (!sourceId) return []
|
if (!sourceId) return []
|
||||||
const data = await this.gql<{
|
const data = await this.gql<{ fetchSourceManga: { mangas: Record<string, unknown>[] } }>(
|
||||||
fetchSourceManga: { mangas: Record<string, unknown>[] }
|
FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query }
|
||||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query })
|
)
|
||||||
return data.fetchSourceManga.mangas.map(mapManga)
|
return data.fetchSourceManga.mangas.map(mapManga)
|
||||||
}
|
}
|
||||||
|
|
||||||
async addToLibrary(mangaId: string) {
|
async addToLibrary(mangaId: string): Promise<void> {
|
||||||
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
|
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeFromLibrary(mangaId: string) {
|
async removeFromLibrary(mangaId: string): Promise<void> {
|
||||||
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
|
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
async 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)) {
|
for (const [key, value] of Object.entries(meta)) {
|
||||||
if (value === undefined) continue
|
if (value === undefined) continue
|
||||||
await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value) })
|
await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteMangaMeta(id: string, key: string): Promise<void> {
|
||||||
|
await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chapters ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getChapters(mangaId: string): Promise<Chapter[]> {
|
async getChapters(mangaId: string): Promise<Chapter[]> {
|
||||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||||
GET_CHAPTERS, { mangaId: Number(mangaId) }
|
GET_CHAPTERS, { mangaId: Number(mangaId) }
|
||||||
@@ -156,21 +204,56 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
return mapChapter(data.chapter)
|
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[] } }>(
|
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 }))
|
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 })
|
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 })
|
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[]> {
|
async getDownloads(): Promise<DownloadItem[]> {
|
||||||
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
|
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
|
||||||
GET_DOWNLOAD_STATUS
|
GET_DOWNLOAD_STATUS
|
||||||
@@ -178,36 +261,62 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
return data.downloadStatus.queue.map(mapDownloadItem)
|
return data.downloadStatus.queue.map(mapDownloadItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueueDownload(chapterId: string) {
|
async enqueueDownload(chapterId: string): Promise<void> {
|
||||||
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
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) })
|
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)
|
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[]> {
|
async getExtensions(): Promise<Extension[]> {
|
||||||
await this.gql(FETCH_EXTENSIONS)
|
await this.gql(FETCH_EXTENSIONS)
|
||||||
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
|
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
|
||||||
return data.extensions.nodes.map(mapExtension)
|
return data.extensions.nodes.map(mapExtension)
|
||||||
}
|
}
|
||||||
|
|
||||||
async installExtension(id: string) {
|
async installExtension(id: string): Promise<void> {
|
||||||
await this.gql(UPDATE_EXTENSION, { id, install: true })
|
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 })
|
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 })
|
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[]> {
|
async getSources(): Promise<Source[]> {
|
||||||
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
return data.sources.nodes
|
return data.sources.nodes
|
||||||
@@ -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[]> {
|
async getTrackers(): Promise<Tracker[]> {
|
||||||
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||||
return data.trackers.nodes
|
return data.trackers.nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
async 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, {
|
await this.gql(BIND_TRACK, {
|
||||||
mangaId: Number(mangaId),
|
mangaId: Number(mangaId),
|
||||||
trackerId: Number(trackerId),
|
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) })
|
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Library updates ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
||||||
if (mangaIds?.length) {
|
if (mangaIds?.length) {
|
||||||
const results: UpdateResult[] = []
|
const results: UpdateResult[] = []
|
||||||
for (const id of mangaIds) {
|
for (const id of mangaIds) {
|
||||||
const before = await this.getChapters(id)
|
const before = await this.getChapters(id)
|
||||||
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) })
|
await this.gql(UPDATE_LIBRARY_MANGA, { mangaId: Number(id) })
|
||||||
const after = await this.getChapters(id)
|
const after = await this.getChapters(id)
|
||||||
results.push({ mangaId: id, newChapters: after.length - before.length })
|
results.push({ mangaId: id, newChapters: after.length - before.length })
|
||||||
}
|
}
|
||||||
@@ -254,4 +426,18 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
await this.gql(UPDATE_LIBRARY)
|
await this.gql(UPDATE_LIBRARY)
|
||||||
return []
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,12 @@ export const GET_LIBRARY = `
|
|||||||
mangas(condition: { inLibrary: true }) {
|
mangas(condition: { inLibrary: true }) {
|
||||||
nodes {
|
nodes {
|
||||||
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
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 }
|
source { id name displayName }
|
||||||
chapters { totalCount }
|
chapters { totalCount }
|
||||||
|
latestFetchedChapter { id uploadDate }
|
||||||
|
latestUploadedChapter { id uploadDate }
|
||||||
lastReadChapter { id chapterNumber }
|
lastReadChapter { id chapterNumber }
|
||||||
firstUnreadChapter { id chapterNumber }
|
firstUnreadChapter { id chapterNumber }
|
||||||
}
|
}
|
||||||
@@ -17,7 +20,7 @@ export const GET_MANGA = `
|
|||||||
query GetManga($id: Int!) {
|
query GetManga($id: Int!) {
|
||||||
manga(id: $id) {
|
manga(id: $id) {
|
||||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
inLibraryAt lastFetchedAt updateStrategy
|
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
|
||||||
source { id name displayName }
|
source { id name displayName }
|
||||||
lastReadChapter { id chapterNumber lastPageRead }
|
lastReadChapter { id chapterNumber lastPageRead }
|
||||||
firstUnreadChapter { id chapterNumber }
|
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 = `
|
export const MANGAS_BY_GENRE = `
|
||||||
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||||
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
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 = `
|
export const GET_DOWNLOADS_PATH = `
|
||||||
query LibraryUpdateStatus {
|
query GetDownloadsPath {
|
||||||
libraryUpdateStatus {
|
settings { downloadsPath localSourcePath }
|
||||||
jobsInfo {
|
|
||||||
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
|
|
||||||
}
|
|
||||||
mangaUpdates {
|
|
||||||
status
|
|
||||||
manga { id title thumbnailUrl unreadCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastUpdateTimestamp { timestamp }
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -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 = `
|
export const UPDATE_LIBRARY = `
|
||||||
mutation UpdateLibrary {
|
mutation UpdateLibrary {
|
||||||
updateLibrary(input: {}) {
|
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 = `
|
export const SET_MANGA_META = `
|
||||||
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
||||||
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
||||||
@@ -189,8 +214,11 @@ export const RESTORE_BACKUP = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const GET_RESTORE_STATUS = `
|
export const FETCH_SOURCE_MANGA = `
|
||||||
query GetRestoreStatus($id: String!) {
|
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||||
restoreStatus(id: $id) { mangaProgress state totalManga }
|
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||||
|
mangas { id title thumbnailUrl inLibrary }
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
@@ -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'
|
import type { DownloadItem } from '$lib/server-adapters/types'
|
||||||
|
|
||||||
export interface GQLResponse<T> {
|
export interface GQLResponse<T> {
|
||||||
@@ -7,11 +7,24 @@ export interface GQLResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapManga(raw: Record<string, unknown>): Manga {
|
export function mapManga(raw: Record<string, unknown>): Manga {
|
||||||
const inLibraryAt = raw.inLibraryAt as string | null | undefined
|
|
||||||
return {
|
return {
|
||||||
...(raw as unknown as Manga),
|
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,
|
tags: raw.genre as string[] | undefined,
|
||||||
addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : 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,
|
lastReadAt: raw.lastReadChapter ? Date.now() : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,3 +78,15 @@ function mapDownloadState(state: string): DownloadItem['state'] {
|
|||||||
default: return 'queued'
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
import type {
|
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
|
||||||
Manga,
|
|
||||||
Chapter,
|
|
||||||
Extension,
|
|
||||||
Source,
|
|
||||||
Tracker,
|
|
||||||
} from '$lib/types'
|
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -21,7 +15,13 @@ export interface MangaFilters {
|
|||||||
sourceId?: string
|
sourceId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MangaStatus = 'ONGOING' | 'COMPLETED' | 'LICENSED' | 'PUBLISHING_FINISHED' | 'CANCELLED' | 'ON_HIATUS'
|
export type MangaStatus =
|
||||||
|
| 'ONGOING'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'LICENSED'
|
||||||
|
| 'PUBLISHING_FINISHED'
|
||||||
|
| 'CANCELLED'
|
||||||
|
| 'ON_HIATUS'
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
@@ -47,6 +47,7 @@ export interface DownloadItem {
|
|||||||
mangaId: string
|
mangaId: string
|
||||||
chapterName: string
|
chapterName: string
|
||||||
mangaTitle: string
|
mangaTitle: string
|
||||||
|
thumbnailUrl?: string
|
||||||
progress: number
|
progress: number
|
||||||
state: 'queued' | 'downloading' | 'finished' | 'error'
|
state: 'queued' | 'downloading' | 'finished' | 'error'
|
||||||
}
|
}
|
||||||
@@ -56,39 +57,75 @@ export interface UpdateResult {
|
|||||||
newChapters: number
|
newChapters: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibraryUpdateProgress {
|
||||||
|
isRunning: boolean
|
||||||
|
finishedJobs: number
|
||||||
|
totalJobs: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerAdapter {
|
export interface ServerAdapter {
|
||||||
connect(config: ServerConfig): Promise<void>
|
connect(config: ServerConfig): Promise<void>
|
||||||
getStatus(): Promise<ServerStatus>
|
getStatus(): Promise<ServerStatus>
|
||||||
|
getServerUrl(): string
|
||||||
|
|
||||||
getManga(id: string): Promise<Manga>
|
getManga(id: string): Promise<Manga>
|
||||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
||||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
||||||
|
fetchManga(id: string): Promise<Manga>
|
||||||
addToLibrary(mangaId: string): Promise<void>
|
addToLibrary(mangaId: string): Promise<void>
|
||||||
removeFromLibrary(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>
|
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
|
||||||
|
deleteMangaMeta(id: string, key: string): Promise<void>
|
||||||
|
|
||||||
getChapters(mangaId: string): Promise<Chapter[]>
|
getChapters(mangaId: string): Promise<Chapter[]>
|
||||||
getChapter(id: string): Promise<Chapter>
|
getChapter(id: string): Promise<Chapter>
|
||||||
getChapterPages(id: string): Promise<Page[]>
|
getChapterPages(id: string): Promise<Page[]>
|
||||||
|
fetchChapters(mangaId: string): Promise<Chapter[]>
|
||||||
|
getRecentlyUpdated(): Promise<Chapter[]>
|
||||||
markChapterRead(id: string, read: boolean): Promise<void>
|
markChapterRead(id: string, read: boolean): Promise<void>
|
||||||
markChaptersRead(ids: 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[]>
|
getDownloads(): Promise<DownloadItem[]>
|
||||||
enqueueDownload(chapterId: string): Promise<void>
|
enqueueDownload(chapterId: string): Promise<void>
|
||||||
|
enqueueDownloads(chapterIds: string[]): Promise<void>
|
||||||
dequeueDownload(chapterId: string): Promise<void>
|
dequeueDownload(chapterId: string): Promise<void>
|
||||||
|
dequeueDownloads(chapterIds: string[]): Promise<void>
|
||||||
clearDownloads(): Promise<void>
|
clearDownloads(): Promise<void>
|
||||||
|
startDownloader(): Promise<void>
|
||||||
|
stopDownloader(): Promise<void>
|
||||||
|
|
||||||
getExtensions(): Promise<Extension[]>
|
getExtensions(): Promise<Extension[]>
|
||||||
installExtension(id: string): Promise<void>
|
installExtension(id: string): Promise<void>
|
||||||
uninstallExtension(id: string): Promise<void>
|
uninstallExtension(id: string): Promise<void>
|
||||||
updateExtension(id: string): Promise<void>
|
updateExtension(id: string): Promise<void>
|
||||||
|
updateExtensions(ids: string[]): Promise<void>
|
||||||
|
installExternalExtension(url: string): Promise<void>
|
||||||
|
|
||||||
getSources(): Promise<Source[]>
|
getSources(): Promise<Source[]>
|
||||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
||||||
|
|
||||||
|
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[]>
|
getTrackers(): Promise<Tracker[]>
|
||||||
|
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
|
||||||
|
searchTracker(trackerId: string, query: string): Promise<unknown[]>
|
||||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
||||||
|
unlinkTracker(recordId: string): Promise<void>
|
||||||
|
fetchTrackRecord(recordId: string): Promise<void>
|
||||||
syncTracking(mangaId: string): Promise<void>
|
syncTracking(mangaId: string): Promise<void>
|
||||||
|
|
||||||
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
|
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
|
||||||
|
stopLibraryUpdate(): Promise<void>
|
||||||
|
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
|
||||||
}
|
}
|
||||||
@@ -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++
|
||||||
|
}
|
||||||
@@ -2,52 +2,88 @@ import type { Manga } from '$lib/types'
|
|||||||
import type { MangaStatus } from '$lib/server-adapters/types'
|
import type { MangaStatus } from '$lib/server-adapters/types'
|
||||||
|
|
||||||
export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded'
|
export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded'
|
||||||
|
export type LibraryTab = 'saved' | 'downloaded'
|
||||||
|
|
||||||
export const libraryState = $state({
|
class LibraryState {
|
||||||
items: [] as Manga[],
|
items = $state<Manga[]>([])
|
||||||
searchResults: [] as Manga[],
|
loading = $state(false)
|
||||||
loading: false,
|
error = $state<string | null>(null)
|
||||||
error: null as string | null,
|
refreshing = $state(false)
|
||||||
filter: {
|
|
||||||
|
tab = $state<LibraryTab>('saved')
|
||||||
|
sort = $state<LibrarySortOption>('alphabetical')
|
||||||
|
sortDesc = $state(false)
|
||||||
|
|
||||||
|
filter = $state({
|
||||||
status: 'all' as MangaStatus | 'all',
|
status: 'all' as MangaStatus | 'all',
|
||||||
tags: [] as string[],
|
|
||||||
unread: false,
|
unread: false,
|
||||||
|
downloaded: false,
|
||||||
|
bookmarked: false,
|
||||||
query: '',
|
query: '',
|
||||||
},
|
})
|
||||||
sort: 'alphabetical' as LibrarySortOption,
|
|
||||||
sortDesc: false,
|
|
||||||
view: 'grid' as 'grid' | 'list',
|
|
||||||
selected: new Set<string>(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const filteredItems = $derived.by(() => {
|
selected = $state(new Set<number>())
|
||||||
let result = libraryState.items
|
selectMode = $state(false)
|
||||||
|
|
||||||
if (libraryState.filter.unread) {
|
filteredItems = $derived.by(() => {
|
||||||
result = result.filter(m => m.unreadCount > 0)
|
let result = this.tab === 'downloaded'
|
||||||
}
|
? this.items.filter(m => (m.downloadCount ?? 0) > 0)
|
||||||
if (libraryState.filter.status !== 'all') {
|
: this.items.filter(m => m.inLibrary)
|
||||||
result = result.filter(m => m.status === libraryState.filter.status)
|
|
||||||
}
|
if (this.filter.unread) result = result.filter(m => (m.unreadCount ?? 0) > 0)
|
||||||
if (libraryState.filter.tags.length > 0) {
|
if (this.filter.downloaded) result = result.filter(m => (m.downloadCount ?? 0) > 0)
|
||||||
result = result.filter(m =>
|
if (this.filter.bookmarked) result = result.filter(m => (m.bookmarkCount ?? 0) > 0)
|
||||||
libraryState.filter.tags.every(tag => m.tags?.includes(tag))
|
|
||||||
|
if (this.filter.status !== 'all') {
|
||||||
|
result = result.filter(
|
||||||
|
m => m.status?.toUpperCase().replace(/\s+/g, '_') === this.filter.status
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (libraryState.filter.query) {
|
|
||||||
const q = libraryState.filter.query.toLowerCase()
|
if (this.filter.query) {
|
||||||
|
const q = this.filter.query.toLowerCase()
|
||||||
result = result.filter(m => m.title.toLowerCase().includes(q))
|
result = result.filter(m => m.title.toLowerCase().includes(q))
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...result].sort((a, b) => {
|
const sorted = [...result].sort((a, b) => {
|
||||||
switch (libraryState.sort) {
|
switch (this.sort) {
|
||||||
case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0)
|
case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0)
|
||||||
case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0)
|
case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0)
|
||||||
case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0)
|
case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0)
|
||||||
case 'alphabetical':
|
|
||||||
default: return a.title.localeCompare(b.title)
|
default: return a.title.localeCompare(b.title)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return libraryState.sortDesc ? sorted.reverse() : sorted
|
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()
|
||||||
@@ -1,36 +1,28 @@
|
|||||||
import type { Manga, Chapter } from '$lib/types'
|
import type { Manga, Chapter } from '$lib/types'
|
||||||
|
|
||||||
export const seriesState = $state({
|
class SeriesState {
|
||||||
current: null as Manga | null,
|
current = $state<Manga | null>(null)
|
||||||
loading: false,
|
loading = $state(false)
|
||||||
error: null as string | null,
|
error = $state<string | null>(null)
|
||||||
|
|
||||||
chapters: [] as Chapter[],
|
chapters = $state<Chapter[]>([])
|
||||||
chaptersLoading: false,
|
chaptersLoading = $state(false)
|
||||||
chaptersError: null as string | null,
|
chaptersError = $state<string | null>(null)
|
||||||
|
|
||||||
chapterFilter: {
|
chapterSortDesc = $state(true)
|
||||||
unread: false,
|
chapterFilter = $state({ unread: false, downloaded: false, query: '' })
|
||||||
downloaded: false,
|
|
||||||
query: '',
|
|
||||||
},
|
|
||||||
chapterSortDesc: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const filteredChapters = $derived.by(() => {
|
filteredChapters = $derived.by(() => {
|
||||||
let result = seriesState.chapters
|
let result = this.chapters
|
||||||
|
if (this.chapterFilter.unread) result = result.filter(c => !c.read)
|
||||||
if (seriesState.chapterFilter.unread) {
|
if (this.chapterFilter.downloaded) result = result.filter(c => c.downloaded)
|
||||||
result = result.filter(c => !c.read)
|
if (this.chapterFilter.query) {
|
||||||
}
|
const q = this.chapterFilter.query.toLowerCase()
|
||||||
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))
|
result = result.filter(c => c.name.toLowerCase().includes(q))
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber)
|
const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber)
|
||||||
return seriesState.chapterSortDesc ? sorted.reverse() : sorted
|
return this.chapterSortDesc ? sorted.reverse() : sorted
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seriesState = new SeriesState()
|
||||||
@@ -5,4 +5,12 @@ export const trackingState = $state({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
|
||||||
|
records: [] as unknown[],
|
||||||
|
recordsLoading: false,
|
||||||
|
recordsError: null as string | null,
|
||||||
|
|
||||||
|
searchResults: [] as unknown[],
|
||||||
|
searchLoading: false,
|
||||||
|
searchError: null as string | null,
|
||||||
})
|
})
|
||||||
@@ -11,9 +11,13 @@ export interface Chapter {
|
|||||||
fetchedAt?: string
|
fetchedAt?: string
|
||||||
uploadDate?: string | null
|
uploadDate?: string | null
|
||||||
realUrl?: string | null
|
realUrl?: string | null
|
||||||
url?: string
|
|
||||||
lastPageRead?: number
|
lastPageRead?: number
|
||||||
lastReadAt?: string
|
lastReadAt?: string
|
||||||
scanlator?: string | null
|
scanlator?: string | null
|
||||||
manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null
|
manga?: {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
thumbnailUrl: string
|
||||||
|
inLibrary: boolean
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,10 @@ export interface Source {
|
|||||||
isNsfw: boolean
|
isNsfw: boolean
|
||||||
isConfigurable: boolean
|
isConfigurable: boolean
|
||||||
supportsLatest: boolean
|
supportsLatest: boolean
|
||||||
baseUrl?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Extension {
|
export interface Extension {
|
||||||
|
id: string
|
||||||
apkName: string
|
apkName: string
|
||||||
pkgName: string
|
pkgName: string
|
||||||
name: string
|
name: string
|
||||||
@@ -20,5 +20,4 @@ export interface Extension {
|
|||||||
isObsolete: boolean
|
isObsolete: boolean
|
||||||
hasUpdate: boolean
|
hasUpdate: boolean
|
||||||
iconUrl: string
|
iconUrl: string
|
||||||
id: string
|
|
||||||
}
|
}
|
||||||
+4
-539
@@ -1,539 +1,4 @@
|
|||||||
import type {
|
export type { Manga, MangaDetail, Category, ChapterRef } from './manga'
|
||||||
ServerAdapter,
|
export type { Chapter } from './chapter'
|
||||||
ServerConfig,
|
export type { Extension, Source } from './extension'
|
||||||
ServerStatus,
|
export type { Tracker, TrackRecord, TrackerStatus } from './tracking'
|
||||||
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 []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-25
@@ -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 {
|
export interface ChapterRef {
|
||||||
id: number
|
id: number
|
||||||
chapterNumber: number
|
chapterNumber: number
|
||||||
@@ -15,17 +5,26 @@ export interface ChapterRef {
|
|||||||
lastPageRead?: number
|
lastPageRead?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
order: number
|
||||||
|
default: boolean
|
||||||
|
includeInUpdate: boolean
|
||||||
|
includeInDownload: boolean
|
||||||
|
mangas?: Manga[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Manga {
|
export interface Manga {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
thumbnailUrl: string
|
thumbnailUrl: string
|
||||||
inLibrary: boolean
|
inLibrary: boolean
|
||||||
initialized?: boolean
|
|
||||||
downloadCount?: number
|
downloadCount?: number
|
||||||
unreadCount?: number
|
unreadCount?: number
|
||||||
bookmarkCount?: number
|
bookmarkCount?: number
|
||||||
hasDuplicateChapters?: boolean
|
|
||||||
chapters?: { totalCount: number }
|
|
||||||
description?: string | null
|
description?: string | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
author?: string | null
|
author?: string | null
|
||||||
@@ -33,30 +32,22 @@ export interface Manga {
|
|||||||
genre?: string[]
|
genre?: string[]
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
realUrl?: string | null
|
realUrl?: string | null
|
||||||
url?: string
|
|
||||||
sourceId?: string
|
sourceId?: string
|
||||||
|
|
||||||
inLibraryAt?: string | null
|
inLibraryAt?: string | null
|
||||||
lastFetchedAt?: string | null
|
lastFetchedAt?: string | null
|
||||||
chaptersLastFetchedAt?: string | null
|
chaptersLastFetchedAt?: string | null
|
||||||
thumbnailUrlLastFetched?: string | null
|
thumbnailUrlLastFetched?: string | null
|
||||||
addedAt?: number
|
addedAt?: number
|
||||||
lastReadAt?: number
|
lastReadAt?: number
|
||||||
age?: string | null
|
|
||||||
chaptersAge?: string | null
|
|
||||||
updateStrategy?: 'ALWAYS_UPDATE' | 'ONLY_FETCH_ONCE'
|
updateStrategy?: 'ALWAYS_UPDATE' | 'ONLY_FETCH_ONCE'
|
||||||
|
|
||||||
latestFetchedChapter?: ChapterRef | null
|
latestFetchedChapter?: ChapterRef | null
|
||||||
latestUploadedChapter?: ChapterRef | null
|
latestUploadedChapter?: ChapterRef | null
|
||||||
latestReadChapter?: ChapterRef | null
|
|
||||||
lastReadChapter?: ChapterRef | null
|
lastReadChapter?: ChapterRef | null
|
||||||
firstUnreadChapter?: ChapterRef | null
|
firstUnreadChapter?: ChapterRef | null
|
||||||
highestNumberedChapter?: ChapterRef | null
|
highestNumberedChapter?: ChapterRef | null
|
||||||
|
|
||||||
source?: { id: string; name: string; displayName: string } | null
|
source?: { id: string; name: string; displayName: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MangaDetail extends Manga {
|
|
||||||
description: string | null
|
|
||||||
author: string | null
|
|
||||||
artist: string | null
|
|
||||||
status: string | null
|
|
||||||
genre: string[]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ export interface TrackRecord {
|
|||||||
finishDate?: string
|
finishDate?: string
|
||||||
private: boolean
|
private: boolean
|
||||||
libraryId?: string
|
libraryId?: string
|
||||||
manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean }
|
manga?: {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
thumbnailUrl: string
|
||||||
|
inLibrary: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tracker {
|
export interface Tracker {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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: 'A–Z',
|
||||||
|
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>
|
||||||
@@ -1,3 +1,259 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { loadLibrary } from '$lib/request-manager/manga'
|
||||||
|
import { getAdapter } from '$lib/request-manager'
|
||||||
|
import { libraryState } from '$lib/state/library.svelte'
|
||||||
|
import { homeState, setHeroSlot } from '$lib/state/home.svelte'
|
||||||
|
import type { HistoryEntry } from '$lib/state/home.svelte'
|
||||||
|
import HeroStage from '$lib/ui/home/HeroStage.svelte'
|
||||||
|
import HeroSlotPicker from '$lib/ui/home/HeroSlotPicker.svelte'
|
||||||
|
import ActivityFeed from '$lib/ui/home/ActivityFeed.svelte'
|
||||||
|
import ActivityHeatmap from '$lib/ui/home/ActivityHeatmap.svelte'
|
||||||
|
import RecsRow from '$lib/ui/home/RecsRow.svelte'
|
||||||
|
import StatsGrid from '$lib/ui/home/StatsGrid.svelte'
|
||||||
|
import type { Manga, Chapter } from '$lib/types'
|
||||||
|
|
||||||
|
const TOTAL_SLOTS = 4
|
||||||
|
|
||||||
|
interface HeroSlot {
|
||||||
|
kind: 'continue' | 'pinned' | 'empty'
|
||||||
|
entry?: HistoryEntry
|
||||||
|
manga?: Manga
|
||||||
|
slotIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => { loadLibrary() })
|
||||||
|
|
||||||
|
const manga = $derived(libraryState.items)
|
||||||
|
|
||||||
|
const continueReading = $derived((() => {
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const out: HistoryEntry[] = []
|
||||||
|
for (const e of homeState.history) {
|
||||||
|
if (seen.has(e.mangaId)) continue
|
||||||
|
seen.add(e.mangaId)
|
||||||
|
out.push(e)
|
||||||
|
if (out.length >= 10) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})())
|
||||||
|
|
||||||
|
const resolvedSlots = $derived((() => {
|
||||||
|
const pins = homeState.heroSlots
|
||||||
|
const slots: HeroSlot[] = []
|
||||||
|
const first = continueReading[0]
|
||||||
|
slots.push(first ? { kind: 'continue', entry: first, slotIndex: 0 } : { kind: 'empty', slotIndex: 0 })
|
||||||
|
let hi = 1
|
||||||
|
for (let i = 1; i < TOTAL_SLOTS; i++) {
|
||||||
|
const pinId = pins[i]
|
||||||
|
if (pinId != null) {
|
||||||
|
const m = manga.find(m => m.id === pinId)
|
||||||
|
if (m) { slots.push({ kind: 'pinned', manga: m, slotIndex: i }); continue }
|
||||||
|
}
|
||||||
|
const entry = continueReading[hi++]
|
||||||
|
slots.push(entry ? { kind: 'continue', entry, slotIndex: i } : { kind: 'empty', slotIndex: i })
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
})())
|
||||||
|
|
||||||
|
let activeIdx = $state(0)
|
||||||
|
|
||||||
|
const activeSlot = $derived(resolvedSlots[activeIdx])
|
||||||
|
const heroManga = $derived(
|
||||||
|
activeSlot?.kind === 'pinned' ? activeSlot.manga :
|
||||||
|
activeSlot?.kind === 'continue' ? manga.find(m => m.id === activeSlot.entry?.mangaId) : null
|
||||||
|
)
|
||||||
|
const heroEntry = $derived(activeSlot?.kind === 'continue' ? activeSlot.entry ?? null : null)
|
||||||
|
const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null)
|
||||||
|
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? '')
|
||||||
|
const heroThumbSrc = $derived(
|
||||||
|
heroManga?.thumbnailUrl ??
|
||||||
|
(activeSlot?.kind === 'continue' ? activeSlot.entry?.thumbnailUrl : undefined) ??
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
let heroThumb = $state('')
|
||||||
|
$effect(() => {
|
||||||
|
const path = heroThumbSrc
|
||||||
|
if (!path) { heroThumb = ''; return }
|
||||||
|
heroThumb = path
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroNewChapter = $derived(
|
||||||
|
heroManga ? (manga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
|
||||||
|
)
|
||||||
|
|
||||||
|
let heroChapters: Chapter[] = $state([])
|
||||||
|
let heroAllChapters: Chapter[] = $state([])
|
||||||
|
let loadingHeroChapters = $state(false)
|
||||||
|
let heroChaptersFor: number | null = null
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = heroMangaId
|
||||||
|
if (id) untrack(() => loadHeroChapters(id))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadHeroChapters(mangaId: number) {
|
||||||
|
heroChaptersFor = mangaId
|
||||||
|
loadingHeroChapters = true
|
||||||
|
heroChapters = []
|
||||||
|
heroAllChapters = []
|
||||||
|
try {
|
||||||
|
const chapters = await getAdapter().getChapters(String(mangaId))
|
||||||
|
if (heroChaptersFor !== mangaId) return
|
||||||
|
const all = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
||||||
|
heroAllChapters = all
|
||||||
|
const lastReadIdx = heroEntry
|
||||||
|
? all.findLastIndex(c => c.id === heroEntry!.chapterId)
|
||||||
|
: all.findLastIndex(c => c.isRead)
|
||||||
|
const startIdx = Math.max(0, lastReadIdx)
|
||||||
|
heroChapters = all.slice(startIdx, startIdx + 5)
|
||||||
|
} catch {
|
||||||
|
heroChapters = []
|
||||||
|
heroAllChapters = []
|
||||||
|
} finally {
|
||||||
|
loadingHeroChapters = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resuming = $state(false)
|
||||||
|
|
||||||
|
async function openChapter(chapter: Chapter) {
|
||||||
|
if (!heroMangaId) return
|
||||||
|
goto(`/reader/${heroMangaId}/${chapter.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeActive() {
|
||||||
|
if (!heroEntry && heroManga) { goto(`/series/${heroManga.id}`); return }
|
||||||
|
if (!heroEntry) return
|
||||||
|
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0]
|
||||||
|
if (target) openChapter(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
|
||||||
|
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
|
||||||
|
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = [] } }
|
||||||
|
|
||||||
|
let pickerOpen = $state(false)
|
||||||
|
let pickerSlotIndex: 1 | 2 | 3 | null = $state(null)
|
||||||
|
|
||||||
|
function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true }
|
||||||
|
function closePicker() { pickerOpen = false; pickerSlotIndex = null }
|
||||||
|
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker() } }
|
||||||
|
function unpinSlot(i: 1 | 2 | 3) { setHeroSlot(i, null) }
|
||||||
|
|
||||||
|
function resumeEntry(entry: HistoryEntry) {
|
||||||
|
const target = homeState.history.find(e => e.chapterId === entry.chapterId)
|
||||||
|
if (target) goto(`/reader/${entry.mangaId}/${entry.chapterId}`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<div class="hero-shrink-guard">
|
||||||
|
<HeroStage
|
||||||
|
{resolvedSlots}
|
||||||
|
bind:activeIdx
|
||||||
|
{heroThumb}
|
||||||
|
{heroTitle}
|
||||||
|
{heroManga}
|
||||||
|
{heroEntry}
|
||||||
|
{heroMangaId}
|
||||||
|
{heroChapters}
|
||||||
|
{heroNewChapter}
|
||||||
|
{loadingHeroChapters}
|
||||||
|
{resuming}
|
||||||
|
onresume={resumeActive}
|
||||||
|
onopenchapter={openChapter}
|
||||||
|
oncyclenext={cycleNext}
|
||||||
|
oncycleprev={cyclePrev}
|
||||||
|
ongotoslot={goToSlot}
|
||||||
|
onopenpicker={openPicker}
|
||||||
|
onunpin={unpinSlot}
|
||||||
|
onviewall={() => heroManga && goto(`/series/${heroManga.id}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-body">
|
||||||
|
<div class="mid-row">
|
||||||
|
<div class="mid-left">
|
||||||
|
<ActivityFeed
|
||||||
|
entries={homeState.history.slice(0, 6)}
|
||||||
|
libraryManga={manga}
|
||||||
|
onresume={resumeEntry}
|
||||||
|
onviewhistory={() => goto('/recent')}
|
||||||
|
onopenlibrary={() => goto('/library')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mid-divider"></div>
|
||||||
|
<div class="mid-right">
|
||||||
|
<RecsRow
|
||||||
|
libraryManga={manga}
|
||||||
|
history={homeState.history}
|
||||||
|
onopenrecommended={(m) => goto(`/series/${m.id}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-row">
|
||||||
|
<div class="bottom-heatmap">
|
||||||
|
<span class="bottom-label">Activity</span>
|
||||||
|
<ActivityHeatmap dailyReadCounts={homeState.dailyReadCounts} />
|
||||||
|
</div>
|
||||||
|
<div class="bottom-divider"></div>
|
||||||
|
<div class="bottom-stats">
|
||||||
|
<StatsGrid stats={homeState.stats} updateCount={0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pickerOpen && pickerSlotIndex !== null}
|
||||||
|
<HeroSlotPicker
|
||||||
|
slotIndex={pickerSlotIndex}
|
||||||
|
libraryManga={manga}
|
||||||
|
loading={libraryState.loading}
|
||||||
|
onpin={pinManga}
|
||||||
|
onclose={closePicker}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
height: 100%; overflow: hidden;
|
||||||
|
animation: fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
.hero-shrink-guard { flex-shrink: 0; }
|
||||||
|
.scroll-body {
|
||||||
|
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||||
|
min-height: 0; scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scroll-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.mid-row {
|
||||||
|
display: grid; grid-template-columns: 1fr 1px 1.4fr;
|
||||||
|
border-top: 1px solid var(--border-dim); flex-shrink: 0; min-height: 0;
|
||||||
|
}
|
||||||
|
.mid-left { min-width: 0; overflow: hidden; }
|
||||||
|
.mid-left :global(.section) { border-top: none; }
|
||||||
|
.mid-divider { background: var(--border-dim); align-self: stretch; }
|
||||||
|
.mid-right { min-width: 0; overflow: hidden; padding: var(--sp-3) var(--sp-4) var(--sp-4); }
|
||||||
|
|
||||||
|
.bottom-row {
|
||||||
|
display: grid; grid-template-columns: 1fr 1px 1fr;
|
||||||
|
border-top: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
||||||
|
.bottom-heatmap {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||||
|
padding: var(--sp-4) var(--sp-4) var(--sp-5); min-width: 0;
|
||||||
|
}
|
||||||
|
.bottom-stats { padding: var(--sp-4) var(--sp-4) var(--sp-5); min-width: 0; overflow: hidden; }
|
||||||
|
.bottom-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: translateY(0) } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { loadSources } from '$lib/request-manager/extensions'
|
||||||
|
import { extensionsState } from '$lib/state/extensions.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Browse — stub</p>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import { loadManga, fetchManga } from '$lib/request-manager/manga'
|
||||||
|
import { loadChapters } from '$lib/request-manager/chapters'
|
||||||
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
|
|
||||||
|
const mangaId = $derived($page.params.mangaId)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadManga(mangaId)
|
||||||
|
loadChapters(mangaId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Series {$page.params.mangaId} — stub</p>
|
||||||
@@ -1 +1,7 @@
|
|||||||
<p>downloads</p>
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { loadDownloads, startDownloader, stopDownloader, clearDownloads } from '$lib/request-manager/downloads'
|
||||||
|
import { downloadsState } from '$lib/state/downloads.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Downloads — stub</p>
|
||||||
@@ -1 +1,7 @@
|
|||||||
<p>extensions</p>
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { loadExtensions, loadSources } from '$lib/request-manager/extensions'
|
||||||
|
import { extensionsState } from '$lib/state/extensions.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Extensions — stub</p>
|
||||||
@@ -1 +1,113 @@
|
|||||||
<p>library</p>
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { libraryState } from '$lib/state/library.svelte'
|
||||||
|
import { loadLibrary, refreshLibrary, removeFromLibrary, bulkRemoveFromLibrary } from '$lib/request-manager/manga'
|
||||||
|
import LibraryToolbar from '$lib/ui/library/LibraryToolbar.svelte'
|
||||||
|
import LibraryGrid from '$lib/ui/library/LibraryGrid.svelte'
|
||||||
|
import type { Manga } from '$lib/types'
|
||||||
|
|
||||||
|
const saved = $derived(libraryState.items.filter(m => m.inLibrary).length)
|
||||||
|
const downloaded = $derived(libraryState.items.filter(m => (m.downloadCount ?? 0) > 0).length)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadLibrary()
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (libraryState.tab) libraryState.exitSelect()
|
||||||
|
})
|
||||||
|
|
||||||
|
function onCardClick(e: MouseEvent, m: Manga) {
|
||||||
|
if (libraryState.selectMode) {
|
||||||
|
libraryState.toggleSelect(m.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
libraryState.enterSelect(m.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
goto(`/reader/${m.id}/${m.firstUnreadChapter?.id ?? m.lastReadChapter?.id ?? ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBulkRemove() {
|
||||||
|
bulkRemoveFromLibrary(libraryState.selected)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
{#if libraryState.error}
|
||||||
|
<div class="center">
|
||||||
|
<p class="error">Could not load library</p>
|
||||||
|
<p class="error-detail">{libraryState.error}</p>
|
||||||
|
<button class="retry-btn" onclick={() => loadLibrary()}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<LibraryToolbar
|
||||||
|
tab={libraryState.tab}
|
||||||
|
savedCount={saved}
|
||||||
|
dlCount={downloaded}
|
||||||
|
sort={libraryState.sort}
|
||||||
|
sortDesc={libraryState.sortDesc}
|
||||||
|
status={libraryState.filter.status}
|
||||||
|
unread={libraryState.filter.unread}
|
||||||
|
downloaded={libraryState.filter.downloaded}
|
||||||
|
bookmarked={libraryState.filter.bookmarked}
|
||||||
|
hasActiveFilters={libraryState.hasActiveFilters}
|
||||||
|
refreshing={libraryState.refreshing}
|
||||||
|
query={libraryState.filter.query}
|
||||||
|
onTab={(t) => libraryState.tab = t}
|
||||||
|
onQuery={(q) => libraryState.filter.query = q}
|
||||||
|
onSort={(s) => libraryState.sort = s}
|
||||||
|
onSortDesc={() => libraryState.sortDesc = !libraryState.sortDesc}
|
||||||
|
onStatus={(s) => libraryState.filter.status = s}
|
||||||
|
onUnread={() => libraryState.filter.unread = !libraryState.filter.unread}
|
||||||
|
onDownloaded={() => libraryState.filter.downloaded = !libraryState.filter.downloaded}
|
||||||
|
onBookmarked={() => libraryState.filter.bookmarked = !libraryState.filter.bookmarked}
|
||||||
|
onFilterClear={() => {
|
||||||
|
libraryState.filter.status = 'all'
|
||||||
|
libraryState.filter.unread = false
|
||||||
|
libraryState.filter.downloaded = false
|
||||||
|
libraryState.filter.bookmarked = false
|
||||||
|
}}
|
||||||
|
onRefresh={refreshLibrary}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LibraryGrid
|
||||||
|
items={libraryState.filteredItems}
|
||||||
|
loading={libraryState.loading}
|
||||||
|
selectMode={libraryState.selectMode}
|
||||||
|
selected={libraryState.selected}
|
||||||
|
tab={libraryState.tab}
|
||||||
|
{onCardClick}
|
||||||
|
onSelectAll={() => libraryState.selectAll()}
|
||||||
|
onExitSelect={() => libraryState.exitSelect()}
|
||||||
|
{onBulkRemove}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
height: 100%; overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; height: 60%; gap: var(--sp-2);
|
||||||
|
color: var(--text-muted); text-align: center;
|
||||||
|
}
|
||||||
|
.error { color: var(--color-error); font-size: var(--text-base); }
|
||||||
|
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||||
|
.retry-btn {
|
||||||
|
margin-top: var(--sp-3); padding: 6px 16px;
|
||||||
|
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); color: var(--text-muted);
|
||||||
|
cursor: pointer; font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import { loadChapterPages } from '$lib/request-manager/chapters'
|
||||||
|
import { readerState } from '$lib/state/reader.svelte'
|
||||||
|
|
||||||
|
const mangaId = $derived($page.params.mangaId)
|
||||||
|
const chapterId = $derived($page.params.chapterId)
|
||||||
|
|
||||||
|
let controller = $state<AbortController | null>(null)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
controller?.abort()
|
||||||
|
controller = new AbortController()
|
||||||
|
loadChapterPages(chapterId, controller.signal)
|
||||||
|
return () => controller?.abort()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Reader {$page.params.mangaId} / {$page.params.chapterId} — stub</p>
|
||||||
@@ -1 +1,5 @@
|
|||||||
<p>settings</p>
|
<script lang="ts">
|
||||||
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Settings — stub</p>
|
||||||
@@ -1 +1,7 @@
|
|||||||
<p>tracking</p>
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { loadTrackers } from '$lib/request-manager/tracking'
|
||||||
|
import { trackingState } from '$lib/state/tracking.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Tracking — stub</p>
|
||||||
Reference in New Issue
Block a user