mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
820 lines
37 KiB
Svelte
820 lines
37 KiB
Svelte
<script lang="ts">
|
||
import { Trash, ClockCounterClockwise } from 'phosphor-svelte'
|
||
import { untrack } from 'svelte'
|
||
import { platformService } from '$lib/platform-service'
|
||
import { toast } from '$lib/state/notifications.svelte'
|
||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||
import { exportAppData, importAppData } from '$lib/core/backup'
|
||
import { loadBackups, saveBackups, saveSettings, saveLibrary } 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'
|
||
import { getAdapter } from '$lib/request-manager'
|
||
import { requestManager } from '$lib/request-manager'
|
||
import type { ValidateBackupResult, RestoreStatus } from '$lib/server-adapters/types'
|
||
|
||
const supportsFilesystem = platformService.isSupported('filesystem')
|
||
|
||
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([
|
||
platformService.clearMokuCache(),
|
||
platformService.clearSuwayomiCache(),
|
||
getAdapter().clearCachedImages({ cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
|
||
])
|
||
}
|
||
|
||
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 saveLibrary({ sessions: [], bookmarks: [], markers: [], dailyReadCounts: {} })
|
||
break
|
||
case 'moku-settings':
|
||
localStorage.clear()
|
||
await saveSettings({ settings: DEFAULT_SETTINGS, storeVersion: 2 })
|
||
patchReset(key, { state: 'done' })
|
||
await showExitCountdown()
|
||
platformService.exitApp()
|
||
return
|
||
case 'suwayomi-data':
|
||
localStorage.clear()
|
||
await platformService.resetSuwayomiData()
|
||
patchReset(key, { state: 'done' })
|
||
await showExitCountdown()
|
||
platformService.exitApp()
|
||
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 (!supportsFilesystem) return
|
||
if (!isExternalServer) {
|
||
platformService.getDefaultDownloadsPath().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() {
|
||
if (!supportsFilesystem) return
|
||
storageLoading = true; storageError = null
|
||
try {
|
||
const { downloadsPath: dl, localSourcePath: loc } = await getAdapter().getDownloadsPath()
|
||
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 => platformService.getStorageInfo(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() || isExternalServer || !supportsFilesystem) return null
|
||
try {
|
||
const exists = await platformService.checkPathExists(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 platformService.createDirectory(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 getAdapter().setDownloadsPath(dl)
|
||
if (loc) await getAdapter().setLocalSourcePath(loc)
|
||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||
if (supportsFilesystem && !isExternalServer) {
|
||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
||
const newDl = dl || defaultDownloadsPath
|
||
if (newDl && oldDl && newDl !== oldDl) {
|
||
const hadContent = await platformService.checkPathExists(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: '' }
|
||
migrateUnlisten = await platformService.onMigrateProgress(p => { migrateProgress = p })
|
||
try {
|
||
await platformService.migrateDownloads(migrateFrom, 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 platformService.pickFolder()
|
||
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; await savePaths() }
|
||
}
|
||
|
||
async function browseLocalSourceFolder() {
|
||
const picked = await platformService.pickFolder()
|
||
if (picked) { localSourcePathInput = picked; pathsFieldError = { ...pathsFieldError, loc: undefined }; await savePaths() }
|
||
}
|
||
|
||
async function browseExtraScanDir() {
|
||
const picked = await platformService.pickFolder()
|
||
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 saveBackups(backupList.map(({ url, name }) => ({ url, name })))
|
||
}
|
||
|
||
async function createBackup() {
|
||
backupLoading = true; backupError = null
|
||
try {
|
||
const { url } = await getAdapter().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 getAdapter().deleteBackup(url)
|
||
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 blob = await getAdapter().downloadBackup(backup.url)
|
||
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 restoreStatus = $state<RestoreStatus | null>(null)
|
||
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
||
let validateLoading = $state(false)
|
||
let validateError = $state<string | null>(null)
|
||
let validateResult = $state<ValidateBackupResult | null>(null)
|
||
let restoreFile = $state<File | null>(null)
|
||
|
||
function stopRestorePoll() {
|
||
if (restorePollInterval) { clearInterval(restorePollInterval); restorePollInterval = null }
|
||
}
|
||
|
||
async function pollRestoreStatus(id: string) {
|
||
try {
|
||
const status = await getAdapter().pollRestoreStatus(id)
|
||
restoreStatus = status
|
||
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
||
} catch {}
|
||
}
|
||
|
||
async function submitRestore() {
|
||
if (!restoreFile) return
|
||
restoreLoading = true; restoreError = null; restoreStatus = null
|
||
stopRestorePoll()
|
||
try {
|
||
const result = await requestManager.meta.restoreBackup(restoreFile)
|
||
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 {
|
||
validateResult = await requestManager.meta.validateBackup(restoreFile)
|
||
} 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(() => {
|
||
if (!supportsFilesystem) return
|
||
platformService.getAutoBackupDir().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 !supportsFilesystem}
|
||
<p class="s-empty">Disk usage is unavailable in web mode.</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 && supportsFilesystem}
|
||
<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 && supportsFilesystem}
|
||
<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 && supportsFilesystem}
|
||
<button class="s-btn" onclick={browseLocalSourceFolder}>Browse</button>
|
||
{/if}
|
||
{#if pathsFieldError.loc && !isExternalServer && supportsFilesystem}
|
||
<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 && supportsFilesystem}
|
||
<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 || !supportsFilesystem}>
|
||
{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 || !supportsFilesystem}>
|
||
{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={() => platformService.openPath(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> |