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:
@@ -0,0 +1,869 @@
|
||||
<script lang="ts">
|
||||
import { Trash, ClockCounterClockwise } from 'phosphor-svelte'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { untrack } from 'svelte'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { toast } from '$lib/state/notifications.svelte'
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { exportAppData, importAppData } from '$lib/core/backup'
|
||||
import { loadBackups, persistBackups, persistSettings, persistLibrary } from '$lib/core/persistence/persist'
|
||||
import type { BackupEntry } from '$lib/core/persistence/persist'
|
||||
import { DEFAULT_SETTINGS } from '$lib/types/settings'
|
||||
import { DEFAULT_READING_STATS } from '$lib/types/history'
|
||||
import { clearBlobCache } from '$lib/core/cache/imageCache'
|
||||
import { clearPageCache } from '$lib/request-manager'
|
||||
import { cache as queryCache } from '$lib/core/cache/queryCache'
|
||||
|
||||
type ResetState = 'idle' | 'busy' | 'done' | 'error'
|
||||
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean }
|
||||
|
||||
let resetItems = $state<ResetItem[]>([
|
||||
{ key: 'all-cache', label: 'Clear all caches', desc: 'Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.', state: 'idle', error: null, confirm: false },
|
||||
{ key: 'reading-history', label: 'Clear reading history', desc: 'Erases chapter history, read log, reading stats, and daily read counts.', state: 'idle', error: null, confirm: true },
|
||||
{ key: 'moku-settings', label: 'Reset Moku settings', desc: 'Restores all app settings to their defaults. Does not affect library data.', state: 'idle', error: null, confirm: true },
|
||||
{ key: 'suwayomi-data', label: 'Reset Suwayomi data', desc: 'Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.', state: 'idle', error: null, confirm: true },
|
||||
])
|
||||
|
||||
let confirming = $state<string | null>(null)
|
||||
|
||||
function patchReset(key: string, update: Partial<ResetItem>) {
|
||||
resetItems = resetItems.map(i => i.key === key ? { ...i, ...update } : i)
|
||||
}
|
||||
|
||||
function showExitCountdown(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const backdrop = document.createElement('div')
|
||||
backdrop.className = 's-backdrop'
|
||||
backdrop.style.cssText = 'z-index:99999'
|
||||
const modal = document.createElement('div')
|
||||
modal.style.cssText = 'background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7);width:min(400px,calc(100vw - 40px));display:flex;flex-direction:column;overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both'
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)'
|
||||
const title = document.createElement('p')
|
||||
title.style.cssText = 'margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em'
|
||||
title.textContent = 'Reset complete'
|
||||
header.appendChild(title)
|
||||
const body = document.createElement('div')
|
||||
body.style.cssText = 'padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)'
|
||||
const sub = document.createElement('p')
|
||||
sub.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)'
|
||||
sub.textContent = 'Moku will close so you can relaunch with the reset applied.'
|
||||
const counter = document.createElement('p')
|
||||
counter.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)'
|
||||
counter.textContent = 'Closing in 3…'
|
||||
body.append(sub, counter)
|
||||
const footer = document.createElement('div')
|
||||
footer.style.cssText = 'padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end'
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 's-btn s-btn-danger'
|
||||
btn.textContent = 'Close now'
|
||||
footer.appendChild(btn)
|
||||
modal.append(header, body, footer)
|
||||
backdrop.appendChild(modal)
|
||||
document.body.appendChild(backdrop)
|
||||
let secs = 3
|
||||
const tick = setInterval(() => {
|
||||
secs--
|
||||
counter.textContent = secs > 0 ? `Closing in ${secs}…` : 'Closing…'
|
||||
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve() }
|
||||
}, 1000)
|
||||
btn.addEventListener('click', () => { clearInterval(tick); backdrop.remove(); resolve() })
|
||||
})
|
||||
}
|
||||
|
||||
async function clearAllCaches(): Promise<void> {
|
||||
clearBlobCache()
|
||||
clearPageCache()
|
||||
queryCache.clearAll()
|
||||
await Promise.all([
|
||||
invoke('clear_moku_cache'),
|
||||
invoke('clear_suwayomi_cache'),
|
||||
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
|
||||
])
|
||||
}
|
||||
|
||||
async function runReset(key: string) {
|
||||
confirming = null
|
||||
patchReset(key, { state: 'busy', error: null })
|
||||
try {
|
||||
switch (key) {
|
||||
case 'all-cache':
|
||||
await clearAllCaches()
|
||||
break
|
||||
case 'reading-history':
|
||||
await persistLibrary({ history: [], bookmarks: [], markers: [], readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} })
|
||||
break
|
||||
case 'moku-settings':
|
||||
localStorage.clear()
|
||||
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 })
|
||||
patchReset(key, { state: 'done' })
|
||||
await showExitCountdown()
|
||||
invoke('exit_app')
|
||||
return
|
||||
case 'suwayomi-data':
|
||||
localStorage.clear()
|
||||
await invoke('reset_suwayomi_data')
|
||||
patchReset(key, { state: 'done' })
|
||||
await showExitCountdown()
|
||||
invoke('exit_app')
|
||||
return
|
||||
}
|
||||
patchReset(key, { state: 'done' })
|
||||
setTimeout(() => patchReset(key, { state: 'idle' }), 3000)
|
||||
} catch (e: any) {
|
||||
patchReset(key, { state: 'error', error: e?.message ?? String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string }
|
||||
|
||||
const isExternalServer = $derived.by(() => {
|
||||
const url = (settingsState.settings.serverUrl ?? 'http://localhost:4567').toLowerCase().trim()
|
||||
try {
|
||||
const host = new URL(url).hostname
|
||||
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
|
||||
} catch { return false }
|
||||
})
|
||||
|
||||
let storageInfo = $state<StorageInfo | null>(null)
|
||||
let storageLoading = $state(false)
|
||||
let storageError = $state<string | null>(null)
|
||||
|
||||
let downloadsPathInput = $state(settingsState.settings.serverDownloadsPath ?? '')
|
||||
let localSourcePathInput = $state(settingsState.settings.serverLocalSourcePath ?? '')
|
||||
let pathsSaving = $state(false)
|
||||
let pathsError = $state<string | null>(null)
|
||||
let pathsFieldError = $state<{ dl?: string; loc?: string }>({})
|
||||
let pathsSaved = $state(false)
|
||||
|
||||
let defaultDownloadsPath = $state('')
|
||||
$effect(() => {
|
||||
if (!isExternalServer) {
|
||||
invoke<string>('get_default_downloads_path').then(p => { defaultDownloadsPath = p })
|
||||
} else {
|
||||
defaultDownloadsPath = ''
|
||||
}
|
||||
})
|
||||
|
||||
let confirmedDownloadsPath = $state(settingsState.settings.serverDownloadsPath ?? '')
|
||||
let confirmedLocalSourcePath = $state(settingsState.settings.serverLocalSourcePath ?? '')
|
||||
|
||||
let migrateFrom = $state<string | null>(null)
|
||||
let migrateTo = $state<string | null>(null)
|
||||
let migrating = $state(false)
|
||||
let migrateProgress = $state<{ done: number; total: number; current: string } | null>(null)
|
||||
let migrateError = $state<string | null>(null)
|
||||
let migrateUnlisten: (() => void) | null = null
|
||||
|
||||
let extraScanDirs = $state<string[]>([...(settingsState.settings.extraScanDirs ?? [])])
|
||||
let newScanDir = $state('')
|
||||
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([])
|
||||
let advStorageOpen = $state(false)
|
||||
let backupSectionOpen = $state(false)
|
||||
let resetSectionOpen = $state(false)
|
||||
|
||||
async function fetchStorage() {
|
||||
storageLoading = true; storageError = null
|
||||
try {
|
||||
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
|
||||
`{ downloadsPath localSourcePath }`
|
||||
)
|
||||
const dl = pathData.downloadsPath ?? ''
|
||||
const loc = pathData.localSourcePath ?? ''
|
||||
downloadsPathInput = dl; localSourcePathInput = loc
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||
if (isExternalServer) { multiStorageInfos = []; storageInfo = null; return }
|
||||
const effectiveDl = dl || defaultDownloadsPath
|
||||
const dirsToScan: { path: string; label: string }[] = []
|
||||
if (effectiveDl) dirsToScan.push({ path: effectiveDl, label: dl ? 'Downloads' : 'Downloads (default)' })
|
||||
if (loc && loc !== effectiveDl) dirsToScan.push({ path: loc, label: 'Local source' })
|
||||
for (const p of extraScanDirs) {
|
||||
if (p && !dirsToScan.find(d => d.path === p)) dirsToScan.push({ path: p, label: p })
|
||||
}
|
||||
if (dirsToScan.length === 0) { multiStorageInfos = []; storageInfo = null; return }
|
||||
const results = await Promise.allSettled(
|
||||
dirsToScan.map(d => invoke<StorageInfo>('get_storage_info', { downloadsPath: d.path }).then(info => ({ ...info, label: d.label })))
|
||||
)
|
||||
multiStorageInfos = results
|
||||
.filter((r): r is PromiseFulfilledResult<StorageInfo & { label: string }> => r.status === 'fulfilled')
|
||||
.map(r => r.value)
|
||||
storageInfo = multiStorageInfos[0] ?? null
|
||||
} catch (e: any) {
|
||||
storageError = e instanceof Error ? e.message : String(e)
|
||||
} finally { storageLoading = false }
|
||||
}
|
||||
|
||||
async function validatePath(path: string): Promise<string | null> {
|
||||
if (!path.trim()) return null
|
||||
if (isExternalServer) return null
|
||||
try {
|
||||
const exists = await invoke<boolean>('check_path_exists', { path: path.trim() })
|
||||
return exists ? null : 'Directory does not exist'
|
||||
} catch { return 'Could not check path' }
|
||||
}
|
||||
|
||||
async function createDirectory(path: string): Promise<void> {
|
||||
if (isExternalServer) throw new Error('Cannot create directories on an external server')
|
||||
await invoke('create_directory', { path })
|
||||
}
|
||||
|
||||
async function savePaths() {
|
||||
const dl = downloadsPathInput.trim()
|
||||
const loc = localSourcePathInput.trim()
|
||||
pathsError = null; pathsFieldError = {}
|
||||
const [dlErr, locErr] = await Promise.all([validatePath(dl), validatePath(loc)])
|
||||
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
||||
pathsSaving = true
|
||||
try {
|
||||
await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
|
||||
if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||
if (!isExternalServer) {
|
||||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
||||
const newDl = dl || defaultDownloadsPath
|
||||
if (newDl && oldDl && newDl !== oldDl) {
|
||||
const hadContent = await invoke<boolean>('check_path_exists', { path: oldDl })
|
||||
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl }
|
||||
}
|
||||
}
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||
pathsSaved = true; setTimeout(() => pathsSaved = false, 2000)
|
||||
await fetchStorage()
|
||||
} catch (e: any) {
|
||||
pathsError = e?.message ?? 'Failed to save paths'
|
||||
} finally { pathsSaving = false }
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
if (!migrateFrom || !migrateTo) return
|
||||
migrating = true; migrateError = null; migrateProgress = { done: 0, total: 0, current: '' }
|
||||
const { listen: tauriListen } = await import('@tauri-apps/api/event')
|
||||
migrateUnlisten = await tauriListen<{ done: number; total: number; current: string }>(
|
||||
'migrate_progress', e => { migrateProgress = e.payload }
|
||||
)
|
||||
try {
|
||||
await invoke('migrate_downloads', { src: migrateFrom, dst: migrateTo })
|
||||
migrateFrom = null; migrateTo = null; migrateProgress = null
|
||||
await fetchStorage()
|
||||
} catch (e: any) {
|
||||
migrateError = e?.message ?? 'Migration failed'
|
||||
} finally { migrating = false; migrateUnlisten?.(); migrateUnlisten = null }
|
||||
}
|
||||
|
||||
function dismissMigration() { migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null }
|
||||
|
||||
async function browseDownloadsFolder() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; await savePaths() }
|
||||
}
|
||||
|
||||
async function browseLocalSourceFolder() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { localSourcePathInput = picked; pathsFieldError = { ...pathsFieldError, loc: undefined }; await savePaths() }
|
||||
}
|
||||
|
||||
async function browseExtraScanDir() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { newScanDir = picked; addExtraScanDir() }
|
||||
}
|
||||
|
||||
function addExtraScanDir() {
|
||||
const dir = newScanDir.trim()
|
||||
if (!dir || extraScanDirs.includes(dir)) return
|
||||
extraScanDirs = [...extraScanDirs, dir]
|
||||
updateSettings({ extraScanDirs }); newScanDir = ''; fetchStorage()
|
||||
}
|
||||
|
||||
function removeExtraScanDir(path: string) {
|
||||
extraScanDirs = extraScanDirs.filter(d => d !== path)
|
||||
updateSettings({ extraScanDirs }); fetchStorage()
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
let backupLoading = $state(false)
|
||||
let backupError = $state<string | null>(null)
|
||||
let backupList = $state<(BackupEntry & { deleting?: boolean })[]>([])
|
||||
|
||||
async function loadBackupList() {
|
||||
backupList = (await loadBackups()).map(b => ({ ...b }))
|
||||
}
|
||||
|
||||
async function saveBackupList() {
|
||||
await persistBackups(backupList.map(({ url, name }) => ({ url, name })))
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
backupLoading = true; backupError = null
|
||||
try {
|
||||
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
|
||||
const { url } = data.createBackup
|
||||
const name = url.split('/').pop() ?? url
|
||||
backupList = [{ url, name }, ...backupList]
|
||||
await saveBackupList()
|
||||
} catch (e: any) { backupError = e?.message ?? 'Failed to create backup' }
|
||||
finally { backupLoading = false }
|
||||
}
|
||||
|
||||
async function deleteBackup(url: string) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
|
||||
try {
|
||||
await fetch(`${serverUrl()}${url}`, { method: 'DELETE', headers: buildAuthHeaders() })
|
||||
backupList = backupList.filter(b => b.url !== url)
|
||||
await saveBackupList()
|
||||
} catch (e: any) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b)
|
||||
backupError = e?.message ?? 'Failed to delete backup'
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup(backup: BackupEntry) {
|
||||
try {
|
||||
const resp = await fetch(`${serverUrl()}${backup.url}`, { headers: buildAuthHeaders() })
|
||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
|
||||
const blob = await resp.blob()
|
||||
if ('showSaveFilePicker' in window) {
|
||||
try {
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
suggestedName: backup.name,
|
||||
types: [{ description: 'Backup file', accept: { 'application/octet-stream': ['.tachibk', '.proto.gz'] } }],
|
||||
})
|
||||
const writable = await handle.createWritable()
|
||||
await writable.write(blob); await writable.close()
|
||||
toast({ kind: 'success', message: 'Backup saved', detail: backup.name }); return
|
||||
} catch (pickerErr: any) { if (pickerErr?.name === 'AbortError') return }
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = objectUrl; a.download = backup.name
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000)
|
||||
toast({ kind: 'download', message: 'Backup downloaded', detail: backup.name })
|
||||
} catch (e: any) { backupError = e?.message ?? 'Failed to download backup' }
|
||||
}
|
||||
|
||||
let restoreLoading = $state(false)
|
||||
let restoreError = $state<string | null>(null)
|
||||
let restoreJobId = $state<string | null>(null)
|
||||
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null)
|
||||
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
||||
let validateLoading = $state(false)
|
||||
let validateError = $state<string | null>(null)
|
||||
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
|
||||
let restoreFile = $state<File | null>(null)
|
||||
|
||||
function stopRestorePoll() {
|
||||
if (restorePollInterval) { clearInterval(restorePollInterval); restorePollInterval = null }
|
||||
}
|
||||
|
||||
async function pollRestoreStatus(id: string) {
|
||||
try {
|
||||
const data = await gql<{ restoreStatus: { mangaProgress: number; state: string; totalManga: number } }>(
|
||||
`query($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } }`,
|
||||
{ id }
|
||||
)
|
||||
const status = data.restoreStatus
|
||||
restoreStatus = status
|
||||
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
|
||||
const form = new FormData()
|
||||
form.append('operations', JSON.stringify({ query, variables }))
|
||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
||||
form.append('0', file, file.name)
|
||||
return form
|
||||
}
|
||||
|
||||
function buildAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
const pass = settingsState.settings.serverAuthPass ?? '', user = settingsState.settings.serverAuthUser ?? ''
|
||||
if (settingsState.settings.serverAuthMode === 'BASIC_AUTH' && user && pass)
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${user}:${pass}`)
|
||||
return headers
|
||||
}
|
||||
|
||||
function serverUrl(): string {
|
||||
return (settingsState.settings.serverUrl ?? 'http://localhost:4567').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
async function gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${serverUrl()}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders() },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
async function submitRestore() {
|
||||
if (!restoreFile) return
|
||||
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
|
||||
stopRestorePoll()
|
||||
try {
|
||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
||||
const json = await resp.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
const result = json.data.restoreBackup
|
||||
restoreJobId = result.id; restoreStatus = result.status
|
||||
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
|
||||
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
|
||||
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
|
||||
finally { restoreLoading = false }
|
||||
}
|
||||
|
||||
async function submitValidate() {
|
||||
if (!restoreFile) return
|
||||
validateLoading = true; validateError = null; validateResult = null
|
||||
try {
|
||||
const form = buildBackupFormData(
|
||||
restoreFile,
|
||||
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
|
||||
{ backup: null }
|
||||
)
|
||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
||||
const json = await resp.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
validateResult = json.data.validateBackup
|
||||
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
|
||||
finally { validateLoading = false }
|
||||
}
|
||||
|
||||
let appDataExporting = $state(false)
|
||||
let appDataImporting = $state(false)
|
||||
let appDataError = $state<string | null>(null)
|
||||
let appDataMsg = $state<string | null>(null)
|
||||
let appDataBackupDir = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
invoke<string>('get_auto_backup_dir').then(d => { appDataBackupDir = d }).catch(() => {})
|
||||
})
|
||||
|
||||
async function handleExportAppData() {
|
||||
appDataExporting = true; appDataError = null; appDataMsg = null
|
||||
try {
|
||||
await exportAppData()
|
||||
appDataMsg = 'Backup saved.'
|
||||
setTimeout(() => appDataMsg = null, 3000)
|
||||
} catch (e: any) {
|
||||
if (String(e).includes('Cancelled')) return
|
||||
appDataError = e?.message ?? String(e)
|
||||
} finally { appDataExporting = false }
|
||||
}
|
||||
|
||||
async function handleImportAppData() {
|
||||
appDataImporting = true; appDataError = null; appDataMsg = null
|
||||
try {
|
||||
await importAppData()
|
||||
} catch (e: any) {
|
||||
if (String(e).includes('Cancelled')) { appDataImporting = false; return }
|
||||
appDataError = e?.message ?? String(e)
|
||||
appDataImporting = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { untrack(() => { loadBackupList(); fetchStorage() }) })
|
||||
$effect(() => { return () => stopRestorePoll() })
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
{#if migrateFrom && !isExternalServer}
|
||||
<div class="s-migrate-banner">
|
||||
<div class="s-migrate-body">
|
||||
<span class="s-migrate-title">Manga found at previous path — move to new location?</span>
|
||||
<span class="s-migrate-paths">{migrateFrom} → {migrateTo}</span>
|
||||
{#if migrateProgress && migrateProgress.total > 0}
|
||||
<div class="s-migrate-bar"><div class="s-migrate-fill" style="width:{Math.round((migrateProgress.done/migrateProgress.total)*100)}%"></div></div>
|
||||
<span class="s-migrate-paths">{migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total}</span>
|
||||
{/if}
|
||||
{#if migrateError}<span class="s-desc" style="color:var(--color-error)">{migrateError}</span>{/if}
|
||||
</div>
|
||||
<div class="s-migrate-actions">
|
||||
<button class="s-btn s-btn-accent" onclick={startMigration} disabled={migrating}>
|
||||
{migrating ? (migrateProgress ? `Moving… ${migrateProgress.done}/${migrateProgress.total}` : 'Starting…') : 'Move files'}
|
||||
</button>
|
||||
<button class="s-btn" onclick={dismissMigration} disabled={migrating}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Disk Usage
|
||||
<button class="s-btn" onclick={fetchStorage} disabled={storageLoading}>{storageLoading ? '…' : '↻'}</button>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
{#if storageLoading}
|
||||
<p class="s-empty">Reading filesystem…</p>
|
||||
{:else if storageError}
|
||||
<p class="s-empty" style="color:var(--color-error)">{storageError}</p>
|
||||
{:else if isExternalServer}
|
||||
<p class="s-empty">Disk usage is unavailable for external servers — filesystem access requires a local connection.</p>
|
||||
{:else if multiStorageInfos.length > 0}
|
||||
{#each multiStorageInfos as info}
|
||||
{@const limitGb = settingsState.settings.storageLimitGb ?? null}
|
||||
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
||||
{@const available = info.manga_bytes + info.free_bytes}
|
||||
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
||||
{@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
|
||||
<div class="s-storage-wrap">
|
||||
<div class="s-storage-header">
|
||||
<span class="s-storage-label">{info.label}</span>
|
||||
<span class="s-storage-used">{fmtBytes(info.manga_bytes)} of {fmtBytes(cap)}</span>
|
||||
</div>
|
||||
<div class="s-storage-bar">
|
||||
<div class="s-storage-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
|
||||
</div>
|
||||
<div class="s-storage-footer">
|
||||
<span>{info.path}</span>
|
||||
<span>{fmtBytes(info.free_bytes)} free</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="s-empty">No download path configured.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Downloads Path</p>
|
||||
<div class="s-section-body">
|
||||
{#if isExternalServer}
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Connected to an external server. The path below is read from the server — changes here will update the server's config directly.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row" style="gap:var(--sp-2)">
|
||||
<input class="s-input full" class:error={!!pathsFieldError.dl}
|
||||
bind:value={downloadsPathInput}
|
||||
placeholder={isExternalServer ? 'Server default' : (defaultDownloadsPath || 'Default location')}
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && savePaths()}
|
||||
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined } }} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseDownloadsFolder}>Browse</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
{#if pathsFieldError.dl}
|
||||
<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.dl}</span>
|
||||
{/if}
|
||||
{#if pathsError}
|
||||
<span class="s-desc" style="color:var(--color-error)">{pathsError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if pathsFieldError.dl && !isExternalServer}
|
||||
<button class="s-btn" onclick={async () => {
|
||||
try { await createDirectory(downloadsPathInput.trim()); pathsFieldError = { ...pathsFieldError, dl: undefined } }
|
||||
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? 'Failed' } }
|
||||
}}>Create</button>
|
||||
{/if}
|
||||
{#if downloadsPathInput.trim() !== confirmedDownloadsPath}
|
||||
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
|
||||
{pathsSaved ? 'Saved ✓' : pathsSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Storage Limit</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Warn when limit is reached</span>
|
||||
<span class="s-desc">{settingsState.settings.storageLimitGb === null ? 'No limit set' : `Warn above ${settingsState.settings.storageLimitGb} GB`}</span>
|
||||
</div>
|
||||
{#if settingsState.settings.storageLimitGb === null}
|
||||
<button class="s-btn" onclick={() => updateSettings({ storageLimitGb: 10 })}>Set limit</button>
|
||||
{:else}
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: Math.max(1, (settingsState.settings.storageLimitGb ?? 10) - 1) })} disabled={(settingsState.settings.storageLimitGb ?? 10) <= 1}>−</button>
|
||||
<input type="number" min="1" step="1" class="s-slider-val" style="width:52px"
|
||||
value={settingsState.settings.storageLimitGb}
|
||||
oninput={(e) => { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }) }} />
|
||||
<span class="s-slider-unit">GB</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: (settingsState.settings.storageLimitGb ?? 10) + 1 })}>+</button>
|
||||
<button class="s-btn-icon" title="Remove limit" onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => advStorageOpen = !advStorageOpen}>
|
||||
<span class="s-label">Advanced</span>
|
||||
<svg class="s-collapsible-caret" class:open={advStorageOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if advStorageOpen}
|
||||
<div class="s-collapsible-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Local source path</span>
|
||||
<span class="s-desc">Read manga already on disk without an extension. Leave blank if unused.</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px">
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input mono" class:error={!!pathsFieldError.loc}
|
||||
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && savePaths()}
|
||||
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined } }} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseLocalSourceFolder}>Browse</button>
|
||||
{/if}
|
||||
{#if pathsFieldError.loc && !isExternalServer}
|
||||
<button class="s-btn" onclick={async () => {
|
||||
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined } }
|
||||
catch (e: any) { pathsFieldError = { ...pathsFieldError, loc: e?.message ?? 'Failed' } }
|
||||
}}>Create</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pathsFieldError.loc}<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.loc}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each extraScanDirs as dir}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label mono" style="font-family:monospace;font-size:var(--text-xs)">{dir}</span>
|
||||
<span class="s-desc">Extra scan directory</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-danger" onclick={() => removeExtraScanDir(dir)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Additional scan path</span>
|
||||
<span class="s-desc">Include an extra directory in disk usage readings</span>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && addExtraScanDir()} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => backupSectionOpen = !backupSectionOpen}>
|
||||
<span class="s-label">Backup</span>
|
||||
<svg class="s-collapsible-caret" class:open={backupSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if backupSectionOpen}
|
||||
<div class="s-collapsible-body">
|
||||
|
||||
<p class="s-subsection-title">Library backup</p>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Create backup</span>
|
||||
<span class="s-desc">Snapshot your library, categories, and tracker links</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={createBackup} disabled={backupLoading}>
|
||||
{backupLoading ? 'Creating…' : 'Create backup'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if backupError}
|
||||
<div class="s-banner s-banner-error">{backupError}</div>
|
||||
{/if}
|
||||
|
||||
{#if backupList.length === 0}
|
||||
<p class="s-empty">No backups yet — create one above.</p>
|
||||
{:else}
|
||||
{#each backupList as backup}
|
||||
<div class="s-folder-row">
|
||||
<ClockCounterClockwise size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<span class="s-folder-name" style="font-family:monospace;font-size:var(--text-xs)">{backup.name}</span>
|
||||
<button class="s-btn-icon" onclick={() => downloadBackup(backup)} title="Download">↓</button>
|
||||
<button class="s-btn-icon danger" onclick={() => deleteBackup(backup.url)} disabled={backup.deleting} title="Delete">
|
||||
<Trash size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Restore from file</span>
|
||||
<span class="s-desc">{restoreFile ? restoreFile.name : 'Select a .tachibk file'}</span>
|
||||
</div>
|
||||
<label class="s-btn" style="cursor:pointer">
|
||||
Browse
|
||||
<input type="file" accept=".tachibk,.proto.gz" style="display:none"
|
||||
onchange={(e) => {
|
||||
const f = (e.currentTarget as HTMLInputElement).files?.[0] ?? null
|
||||
restoreFile = f; restoreStatus = null; restoreError = null; validateResult = null; validateError = null
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if restoreFile}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<div class="s-btn-row">
|
||||
<button class="s-btn" onclick={submitValidate} disabled={validateLoading || restoreLoading}>
|
||||
{validateLoading ? 'Checking…' : 'Validate'}
|
||||
</button>
|
||||
<button class="s-btn s-btn-accent" onclick={submitRestore} disabled={restoreLoading || validateLoading}>
|
||||
{restoreLoading ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if validateError}
|
||||
<div class="s-banner s-banner-error">{validateError}</div>
|
||||
{/if}
|
||||
|
||||
{#if validateResult}
|
||||
{#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0}
|
||||
<div class="s-row"><span class="s-desc" style="color:var(--color-success,#4caf50)">✓ All sources and trackers present</span></div>
|
||||
{:else}
|
||||
{#if validateResult.missingSources.length > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label" style="color:var(--color-error)">Missing sources</span>
|
||||
<span class="s-desc">{validateResult.missingSources.map(s => s.name).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if validateResult.missingTrackers.length > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label" style="color:var(--color-error)">Missing trackers</span>
|
||||
<span class="s-desc">{validateResult.missingTrackers.map(t => t.name).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if restoreError}
|
||||
<div class="s-banner s-banner-error">{restoreError}</div>
|
||||
{/if}
|
||||
|
||||
{#if restoreStatus}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">
|
||||
{restoreStatus.state === 'SUCCESS' ? '✓ Restore complete' :
|
||||
restoreStatus.state === 'FAILURE' ? '✗ Restore failed' : 'Restoring…'}
|
||||
</span>
|
||||
{#if restoreStatus.totalManga > 0}
|
||||
<span class="s-desc">{restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if restoreStatus.state !== 'SUCCESS' && restoreStatus.state !== 'FAILURE' && restoreStatus.totalManga > 0}
|
||||
<div class="s-storage-bar" style="width:160px;flex-shrink:0">
|
||||
<div class="s-storage-fill" style="width:{Math.round((restoreStatus.mangaProgress / restoreStatus.totalManga) * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="s-subsection-title">App data backup</p>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Export settings</span>
|
||||
<span class="s-desc">Save all Moku app settings to a .zip via a native save dialog.</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
|
||||
{appDataExporting ? 'Saving…' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Import settings</span>
|
||||
<span class="s-desc">Restore from a previously exported .zip file. Reloads the app immediately.</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
|
||||
{appDataImporting ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if appDataError}
|
||||
<div class="s-banner s-banner-error">{appDataError}</div>
|
||||
{/if}
|
||||
|
||||
{#if appDataMsg}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if appDataBackupDir}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Auto-backup location</span>
|
||||
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={() => invoke('open_path', { path: appDataBackupDir })}>Open folder</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => resetSectionOpen = !resetSectionOpen}>
|
||||
<span class="s-label">Reset</span>
|
||||
<svg class="s-collapsible-caret" class:open={resetSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if resetSectionOpen}
|
||||
<div class="s-collapsible-body">
|
||||
{#each resetItems as item}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">{item.label}</span>
|
||||
<span class="s-desc">{item.desc}</span>
|
||||
{#if item.error}<span class="s-desc" style="color:var(--color-error)">{item.error}</span>{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if item.state === 'done'}
|
||||
<span class="s-pill on">Done</span>
|
||||
{:else if item.state === 'busy'}
|
||||
<button class="s-btn" disabled>Working…</button>
|
||||
{:else if confirming === item.key}
|
||||
<span class="s-desc" style="color:var(--text-muted)">Sure?</span>
|
||||
<button class="s-btn s-btn-danger" onclick={() => runReset(item.key)}>Confirm</button>
|
||||
<button class="s-btn" onclick={() => confirming = null}>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
class="s-btn"
|
||||
class:s-btn-danger={item.confirm}
|
||||
onclick={() => item.confirm ? (confirming = item.key) : runReset(item.key)}
|
||||
>Reset</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user