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