mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Port over Settings (Barely Works)
This commit is contained in:
@@ -1,11 +1,46 @@
|
||||
export type NavPage =
|
||||
| 'home' | 'library' | 'sources' | 'explore'
|
||||
| 'downloads' | 'extensions' | 'history' | 'search' | 'tracking'
|
||||
|
||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
|
||||
|
||||
class AppStore {
|
||||
navPage: NavPage = $state('home')
|
||||
settingsOpen: boolean = $state(false)
|
||||
searchPrefill: string = $state('')
|
||||
searchQuery: string = $state('')
|
||||
genreFilter: string = $state('')
|
||||
scrollPositions: Map<string, number> = $state(new Map())
|
||||
|
||||
setNavPage(next: NavPage) { this.navPage = next }
|
||||
setSettingsOpen(next: boolean) { this.settingsOpen = next }
|
||||
setSearchPrefill(next: string) { this.searchPrefill = next }
|
||||
setSearchQuery(next: string) { this.searchQuery = next }
|
||||
setGenreFilter(next: string) { this.genreFilter = next }
|
||||
saveScroll(key: string, top: number) {
|
||||
const m = new Map(this.scrollPositions)
|
||||
m.set(key, top)
|
||||
this.scrollPositions = m
|
||||
}
|
||||
getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0 }
|
||||
}
|
||||
|
||||
export const app = new AppStore()
|
||||
|
||||
export const appState = $state({
|
||||
status: 'booting' as AppStatus,
|
||||
error: null as string | null,
|
||||
serverUrl: '',
|
||||
authenticated: false,
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
version: '',
|
||||
})
|
||||
status: 'booting' as AppStatus,
|
||||
error: null as string | null,
|
||||
serverUrl: '',
|
||||
authenticated: false,
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
version: '',
|
||||
})
|
||||
|
||||
export function setNavPage(next: NavPage) { app.setNavPage(next) }
|
||||
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
|
||||
export function setSearchPrefill(next: string) { app.setSearchPrefill(next) }
|
||||
export function setSearchQuery(next: string) { app.setSearchQuery(next) }
|
||||
export function setGenreFilter(next: string) { app.setGenreFilter(next) }
|
||||
export function saveScroll(key: string, top: number) { app.saveScroll(key, top) }
|
||||
export function getScroll(key: string): number { return app.getScroll(key) }
|
||||
@@ -0,0 +1,156 @@
|
||||
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
|
||||
const MAX_ATTEMPTS = 15
|
||||
const BG_MAX_ATTEMPTS = 60
|
||||
|
||||
export const boot = $state({
|
||||
failed: false,
|
||||
notConfigured: false,
|
||||
loginRequired: false,
|
||||
loginError: null as string | null,
|
||||
loginBusy: false,
|
||||
loginUser: '',
|
||||
loginPass: '',
|
||||
sessionExpired: false,
|
||||
skipped: false,
|
||||
})
|
||||
|
||||
let probeGeneration = 0
|
||||
|
||||
function handleProbeSuccess(gen: number) {
|
||||
if (gen !== probeGeneration) return
|
||||
boot.failed = false
|
||||
boot.skipped = false
|
||||
appState.authenticated = true
|
||||
appState.status = 'ready'
|
||||
}
|
||||
|
||||
function handleAuthRequired(gen: number, authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', user: string, pass: string) {
|
||||
if (gen !== probeGeneration) return
|
||||
boot.failed = false
|
||||
|
||||
if (authMode === 'BASIC_AUTH' && user && pass) {
|
||||
loginBasic(user, pass)
|
||||
.then(() => { if (gen === probeGeneration) handleProbeSuccess(gen) })
|
||||
.catch(() => {
|
||||
if (gen !== probeGeneration) return
|
||||
boot.loginUser = user
|
||||
boot.loginRequired = true
|
||||
appState.status = 'auth'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
boot.loginUser = user
|
||||
boot.loginRequired = true
|
||||
appState.status = 'auth'
|
||||
}
|
||||
|
||||
export function startProbe(
|
||||
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
|
||||
user = '',
|
||||
pass = '',
|
||||
) {
|
||||
const gen = ++probeGeneration
|
||||
boot.failed = false
|
||||
boot.loginRequired = false
|
||||
boot.skipped = false
|
||||
appState.status = 'booting'
|
||||
let tries = 0
|
||||
|
||||
async function probe() {
|
||||
if (gen !== probeGeneration) return
|
||||
tries++
|
||||
const result = await probeServer()
|
||||
if (gen !== probeGeneration) return
|
||||
|
||||
if (result === 'ok') { handleProbeSuccess(gen); return }
|
||||
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return }
|
||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; appState.status = 'error'; startBackgroundProbe(gen, authMode, user, pass); return }
|
||||
|
||||
setTimeout(probe, Math.min(300 + tries * 150, 1500))
|
||||
}
|
||||
|
||||
setTimeout(probe, 100)
|
||||
}
|
||||
|
||||
function startBackgroundProbe(
|
||||
gen: number,
|
||||
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
user: string,
|
||||
pass: string,
|
||||
) {
|
||||
let bgTries = 0
|
||||
|
||||
async function bgProbe() {
|
||||
if (gen !== probeGeneration) return
|
||||
bgTries++
|
||||
const result = await probeServer()
|
||||
if (gen !== probeGeneration) return
|
||||
|
||||
if (result === 'ok') { handleProbeSuccess(gen); return }
|
||||
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return }
|
||||
if (bgTries >= BG_MAX_ATTEMPTS) return
|
||||
|
||||
setTimeout(bgProbe, 2000)
|
||||
}
|
||||
|
||||
setTimeout(bgProbe, 2000)
|
||||
}
|
||||
|
||||
export function stopProbe() {
|
||||
probeGeneration++
|
||||
}
|
||||
|
||||
export async function submitLogin(): Promise<void> {
|
||||
if (!boot.loginUser.trim() || !boot.loginPass.trim()) {
|
||||
boot.loginError = 'Username and password are required'
|
||||
return
|
||||
}
|
||||
boot.loginBusy = true
|
||||
boot.loginError = null
|
||||
try {
|
||||
if (appState.authMode === 'UI_LOGIN') {
|
||||
await loginUI(boot.loginUser.trim(), boot.loginPass.trim())
|
||||
} else {
|
||||
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim())
|
||||
}
|
||||
boot.loginRequired = false
|
||||
boot.sessionExpired = false
|
||||
boot.skipped = false
|
||||
boot.loginPass = ''
|
||||
boot.loginError = null
|
||||
appState.authenticated = true
|
||||
appState.status = 'ready'
|
||||
} catch (e: unknown) {
|
||||
boot.loginError = e instanceof Error ? e.message : 'Login failed'
|
||||
} finally {
|
||||
boot.loginBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
export function retryBoot(
|
||||
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
|
||||
user = '',
|
||||
pass = '',
|
||||
) {
|
||||
boot.failed = false
|
||||
boot.notConfigured = false
|
||||
boot.loginRequired = false
|
||||
boot.skipped = false
|
||||
startProbe(authMode, user, pass)
|
||||
}
|
||||
|
||||
export function bypassBoot(
|
||||
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
|
||||
user = '',
|
||||
pass = '',
|
||||
) {
|
||||
const gen = probeGeneration
|
||||
boot.loginRequired = false
|
||||
boot.sessionExpired = false
|
||||
boot.skipped = true
|
||||
appState.status = 'ready'
|
||||
startBackgroundProbe(gen, authMode, user, pass)
|
||||
}
|
||||
@@ -5,12 +5,14 @@ export const downloadsState = $state({
|
||||
error: null as string | null,
|
||||
})
|
||||
|
||||
export const activeDownloads = $derived(
|
||||
downloadsState.items.filter(d => d.state === 'downloading')
|
||||
)
|
||||
export function activeDownloads() {
|
||||
return downloadsState.items.filter(d => d.state === 'downloading')
|
||||
}
|
||||
|
||||
export const queuedDownloads = $derived(
|
||||
downloadsState.items.filter(d => d.state === 'queued')
|
||||
)
|
||||
export function queuedDownloads() {
|
||||
return downloadsState.items.filter(d => d.state === 'queued')
|
||||
}
|
||||
|
||||
export const downloadCount = $derived(downloadsState.items.length)
|
||||
export function downloadCount() {
|
||||
return downloadsState.items.length
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const extensionsState = $state({
|
||||
browseHasMore: false,
|
||||
})
|
||||
|
||||
export const filteredExtensions = $derived.by(() => {
|
||||
export function filteredExtensions() {
|
||||
let result = extensionsState.items
|
||||
|
||||
if (extensionsState.filter.installed) {
|
||||
@@ -33,4 +33,4 @@ export const filteredExtensions = $derived.by(() => {
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,38 @@
|
||||
export type ToastKind = 'info' | 'success' | 'error' | 'download'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
kind: ToastKind
|
||||
message: string
|
||||
detail?: string
|
||||
id: string
|
||||
kind: ToastKind
|
||||
message: string
|
||||
detail?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export const notificationsState = $state({
|
||||
toasts: [] as Toast[],
|
||||
})
|
||||
export interface ActiveDownload {
|
||||
chapterId: number
|
||||
mangaId: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
export function toast(kind: ToastKind, message: string, detail?: string, duration = 4000) {
|
||||
const id = crypto.randomUUID()
|
||||
notificationsState.toasts.push({ id, kind, message, detail, duration })
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismissToast(id), duration)
|
||||
class NotificationStore {
|
||||
toasts: Toast[] = $state([])
|
||||
activeDownloads: ActiveDownload[] = $state([])
|
||||
|
||||
toast(toast: Omit<Toast, 'id'>) {
|
||||
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5)
|
||||
}
|
||||
|
||||
dismissToast(id: string) {
|
||||
this.toasts = this.toasts.filter(x => x.id !== id)
|
||||
}
|
||||
|
||||
setActiveDownloads(next: ActiveDownload[]) {
|
||||
this.activeDownloads = next
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
notificationsState.toasts = notificationsState.toasts.filter(t => t.id !== id)
|
||||
}
|
||||
export const notifications = new NotificationStore()
|
||||
|
||||
export function toast(toast: Omit<Toast, 'id'>) { notifications.toast(toast) }
|
||||
export function dismissToast(id: string) { notifications.dismissToast(id) }
|
||||
export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next) }
|
||||
@@ -25,17 +25,20 @@ export const readerState = $state({
|
||||
fullscreen: false,
|
||||
})
|
||||
|
||||
export const currentPageData = $derived(
|
||||
readerState.pages[readerState.currentPage] ?? null
|
||||
)
|
||||
export function currentPageData() {
|
||||
return readerState.pages[readerState.currentPage] ?? null
|
||||
}
|
||||
|
||||
export const progress = $derived(
|
||||
readerState.pages.length > 0
|
||||
export function progress() {
|
||||
return readerState.pages.length > 0
|
||||
? (readerState.currentPage + 1) / readerState.pages.length
|
||||
: 0
|
||||
)
|
||||
}
|
||||
|
||||
export const hasPrev = $derived(readerState.currentPage > 0)
|
||||
export const hasNext = $derived(
|
||||
readerState.currentPage < readerState.pages.length - 1
|
||||
)
|
||||
export function hasPrev() {
|
||||
return readerState.currentPage > 0
|
||||
}
|
||||
|
||||
export function hasNext() {
|
||||
return readerState.currentPage < readerState.pages.length - 1
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Settings } from '$lib/types/settings'
|
||||
import { DEFAULT_SETTINGS } from '$lib/types/settings'
|
||||
|
||||
const KEY = 'moku_settings'
|
||||
|
||||
function load(): Settings {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }
|
||||
} catch {}
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
|
||||
function save(s: Settings) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(s)) } catch {}
|
||||
}
|
||||
|
||||
export const settingsState = $state({ settings: load() })
|
||||
|
||||
export function updateSettings(patch: Partial<Settings>) {
|
||||
Object.assign(settingsState.settings, patch)
|
||||
save(settingsState.settings)
|
||||
}
|
||||
|
||||
export function resetSettings() {
|
||||
settingsState.settings = { ...DEFAULT_SETTINGS }
|
||||
save(settingsState.settings)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Tracker } from '$lib/types'
|
||||
import type { Tracker, TrackRecord } from '$lib/types'
|
||||
import type { Chapter } from '$lib/types/chapter'
|
||||
import type { MangaPrefs } from '$lib/types/settings'
|
||||
|
||||
export const trackingState = $state({
|
||||
trackers: [] as Tracker[],
|
||||
@@ -13,4 +15,42 @@ export const trackingState = $state({
|
||||
searchResults: [] as unknown[],
|
||||
searchLoading: false,
|
||||
searchError: null as string | null,
|
||||
})
|
||||
})
|
||||
|
||||
export async function syncBackFromTracker(
|
||||
records: TrackRecord[],
|
||||
chapters: Chapter[],
|
||||
opts: {
|
||||
threshold: number | null
|
||||
respectScanlatorFilter: boolean
|
||||
chapterPrefs: Partial<MangaPrefs>
|
||||
},
|
||||
markChaptersRead: (ids: string[], read: boolean) => Promise<void>,
|
||||
): Promise<Chapter[]> {
|
||||
const marked: Chapter[] = []
|
||||
|
||||
const activeScanlators: string[] | null =
|
||||
opts.respectScanlatorFilter && opts.chapterPrefs.scanlatorFilter?.length
|
||||
? opts.chapterPrefs.scanlatorFilter
|
||||
: null
|
||||
|
||||
for (const record of records) {
|
||||
const lastRead = record.lastChapterRead ?? 0
|
||||
if (lastRead <= 0) continue
|
||||
|
||||
const toMark = chapters.filter(ch => {
|
||||
if (ch.read) return false
|
||||
if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false
|
||||
return opts.threshold !== null
|
||||
? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - opts.threshold
|
||||
: ch.chapterNumber <= lastRead
|
||||
})
|
||||
|
||||
if (toMark.length === 0) continue
|
||||
|
||||
await markChaptersRead(toMark.map(ch => String(ch.id)), true)
|
||||
marked.push(...toMark)
|
||||
}
|
||||
|
||||
return marked
|
||||
}
|
||||
Reference in New Issue
Block a user