Chore: Port over SeriesDetail (WIP Panels)

This commit is contained in:
Youwes09
2026-05-28 23:05:02 -05:00
parent 584b917f98
commit 8c250021a0
53 changed files with 4570 additions and 885 deletions
@@ -1,14 +1,13 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { getVersion } from '@tauri-apps/api/app'
import { open as openUrl } from '@tauri-apps/plugin-shell'
import { platformService } from '$lib/platform-service'
import { autoBackupAppData } from '$lib/core/backup'
import { requestManager } from '$lib/request-manager'
import type { ReleaseInfo } from '$lib/platform-adapters/types'
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string }
type UpdatePhase = 'idle' | 'downloading' | 'launching' | 'ready' | 'error'
const IS_WINDOWS = navigator.userAgent.includes('Windows')
const supportsUpdates = platformService.isSupported('app-updates')
const IS_WINDOWS = navigator.userAgent.includes('Windows')
interface AboutServer { name: string; version: string; buildType: string; buildTime: number; github: string; discord: string }
interface AboutWebUI { channel: string; tag: string; updateTimestamp: number }
@@ -29,32 +28,33 @@
let webuiInfo = $state<AboutWebUI | null>(null)
$effect(() => {
getVersion().then(v => appVersion = v).catch(() => appVersion = 'unknown')
platformService.getVersion().then(v => appVersion = v).catch(() => appVersion = 'unknown')
if (!releasesLoaded) { releasesLoaded = true; loadReleases() }
loadServerInfo()
})
$effect(() => { loadServerInfo() })
$effect(() => {
if (!supportsUpdates) return
let unlisten: (() => void) | undefined
listen<{ downloaded: number; total: number | null }>('update-progress', e => {
dlBytes = e.payload.downloaded; dlTotal = e.payload.total ?? null
platformService.onUpdateProgress(p => {
dlBytes = p.downloaded; dlTotal = p.total ?? null
}).then(fn => { unlisten = fn })
return () => unlisten?.()
})
$effect(() => {
if (!supportsUpdates) return
let unlisten: (() => void) | undefined
listen('update-launching', () => { updatePhase = 'launching' }).then(fn => { unlisten = fn })
platformService.onUpdateLaunching(() => { updatePhase = 'launching' }).then(fn => { unlisten = fn })
return () => unlisten?.()
})
async function loadReleases() {
if (!supportsUpdates) return
releasesLoading = true; releasesError = null
try {
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Request timed out after 10s')), 10_000))
const all = await Promise.race([invoke<ReleaseInfo[]>('list_releases'), timeout])
releases = all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim())
releases = await Promise.race([platformService.listReleases(), timeout])
} catch (e: any) {
releasesError = e instanceof Error ? e.message : String(e)
} finally { releasesLoading = false }
@@ -81,7 +81,7 @@
}
const onLatestVersion = $derived((() => {
if (releasesLoading || releases.length === 0 || !appVersion || appVersion === '…') return false
if (!supportsUpdates || releasesLoading || releases.length === 0 || !appVersion || appVersion === '…') return false
const sorted = releases.slice().sort((a, b) => compareSemver(a.tag_name, b.tag_name))
return compareSemver(appVersion, sorted[0].tag_name) >= 0
})())
@@ -115,11 +115,11 @@
try {
if (IS_WINDOWS) {
await autoBackupAppData()
try { await invoke('kill_server') } catch {}
await invoke('download_and_install_update', { tag: release.tag_name })
try { await platformService.stopServer() } catch {}
await platformService.installAppUpdate(release.tag_name)
updatePhase = 'ready'
} else {
await openUrl(release.html_url)
await platformService.openExternal(release.html_url)
updatePhase = 'idle'; targetTag = null
}
} catch (e: any) {
@@ -128,7 +128,7 @@
}
}
async function restartNow() { await invoke('restart_app') }
async function restartNow() { await platformService.restartApp() }
function cancelUpdate() { updatePhase = 'idle'; updateError = null; targetTag = null; dlBytes = 0; dlTotal = null }
</script>
@@ -149,9 +149,11 @@
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Installed</span><span class="s-desc">v{appVersion}</span></div>
<button class="s-btn" onclick={() => { releasesError = null; loadReleases() }} disabled={releasesLoading}>
{releasesLoading ? 'Loading…' : 'Refresh'}
</button>
{#if supportsUpdates}
<button class="s-btn" onclick={() => { releasesError = null; loadReleases() }} disabled={releasesLoading}>
{releasesLoading ? 'Loading…' : 'Refresh'}
</button>
{/if}
</div>
{#if onLatestVersion}
<div class="s-row">
@@ -225,58 +227,60 @@
</div>
{/if}
<div class="s-section">
<p class="s-section-title">Releases</p>
<div class="s-section-body">
{#if releasesError}
<p class="s-empty" style="color:var(--color-error)">{releasesError}</p>
{:else if releasesLoading}
<p class="s-empty">Fetching releases…</p>
{:else if releases.length === 0}
<p class="s-empty">No releases found.</p>
{:else}
<div class="s-release-scroll">
{#each releases as release}
{@const isCurrent = isCurrentVersion(release.tag_name)}
{@const isExpanded = expandedTag === release.tag_name}
{@const isTarget = targetTag === release.tag_name}
{@const isInstalling = isTarget && updatePhase === 'downloading'}
<div class="s-release-row" class:current={isCurrent}>
<div class="s-release-header">
<div class="s-release-meta">
<span class="s-release-tag">{release.tag_name}</span>
{#if isCurrent}<span class="s-release-badge">installed</span>{/if}
{#if release.published_at}<span class="s-release-date">{fmtDate(release.published_at)}</span>{/if}
</div>
<div class="s-btn-row">
{#if release.body.trim()}
<button class="s-btn" onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
{isExpanded ? 'Hide' : 'Changelog'}
</button>
{/if}
{#if !isCurrent}
{#if IS_WINDOWS}
<button class="s-btn" class:s-btn-accent={!isInstalling}
disabled={updatePhase === 'downloading'} onclick={() => installUpdate(release)}>
{isInstalling ? 'Downloading…' : 'Install'}
{#if supportsUpdates}
<div class="s-section">
<p class="s-section-title">Releases</p>
<div class="s-section-body">
{#if releasesError}
<p class="s-empty" style="color:var(--color-error)">{releasesError}</p>
{:else if releasesLoading}
<p class="s-empty">Fetching releases…</p>
{:else if releases.length === 0}
<p class="s-empty">No releases found.</p>
{:else}
<div class="s-release-scroll">
{#each releases as release}
{@const isCurrent = isCurrentVersion(release.tag_name)}
{@const isExpanded = expandedTag === release.tag_name}
{@const isTarget = targetTag === release.tag_name}
{@const isInstalling = isTarget && updatePhase === 'downloading'}
<div class="s-release-row" class:current={isCurrent}>
<div class="s-release-header">
<div class="s-release-meta">
<span class="s-release-tag">{release.tag_name}</span>
{#if isCurrent}<span class="s-release-badge">installed</span>{/if}
{#if release.published_at}<span class="s-release-date">{fmtDate(release.published_at)}</span>{/if}
</div>
<div class="s-btn-row">
{#if release.body.trim()}
<button class="s-btn" onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
{isExpanded ? 'Hide' : 'Changelog'}
</button>
{:else}
<button class="s-btn" onclick={() => installUpdate(release)}>Open on GitHub</button>
{/if}
{/if}
{#if !isCurrent}
{#if IS_WINDOWS}
<button class="s-btn" class:s-btn-accent={!isInstalling}
disabled={updatePhase === 'downloading'} onclick={() => installUpdate(release)}>
{isInstalling ? 'Downloading…' : 'Install'}
</button>
{:else}
<button class="s-btn" onclick={() => installUpdate(release)}>Open on GitHub</button>
{/if}
{/if}
</div>
</div>
{#if isExpanded && release.body.trim()}
<div class="s-release-body">
<pre class="s-release-body pre">{release.body.trim()}</pre>
</div>
{/if}
</div>
{#if isExpanded && release.body.trim()}
<div class="s-release-body">
<pre class="s-release-body pre">{release.body.trim()}</pre>
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="s-section">
<p class="s-section-title">Links</p>
@@ -114,14 +114,16 @@
const reordered = [...sortable]
const [moved] = reordered.splice(sFromIdx, 1)
reordered.splice(sToIdx, 0, moved)
categories = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
getAdapter().updateCategoryOrder({ id: fromNumId, position: sToIdx + 1 })
const optimistic = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
categories = optimistic
const serverPosition = sToIdx + 1
getAdapter().updateCategoryOrder({ id: fromNumId, position: serverPosition })
.then((updated: Category[]) => {
categories = [
...zeroCat,
...updated.sort((a: Category, b: Category) => a.order - b.order).map((fresh: Category) => {
const existing = categories.find(c => c.id === fresh.id)
return existing ? { ...existing, ...fresh } : fresh
const local = optimistic.find(c => c.id === fresh.id)
return local ? { ...fresh, mangas: local.mangas } : fresh
}),
]
})
@@ -203,7 +205,7 @@
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
<span class="s-folder-count">{cat?.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-count">{cat?.mangas?.nodes?.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
@@ -1,7 +1,12 @@
<script lang="ts">
export { eventToKeybind, matchesKeybind } from './keybindEngine'
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
export type { Keybinds } from './defaultBinds'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
import { DEFAULT_KEYBINDS, KEYBIND_LABELS } from '$lib/core/keybinds/defaultBinds'
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
function resetKeybinds() {
updateSettings({ keybinds: { ...DEFAULT_KEYBINDS } })
}
let listeningKey: keyof Keybinds | null = $state(null)
@@ -1,8 +1,7 @@
<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 { 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'
@@ -14,6 +13,8 @@
import { clearPageCache } from '$lib/request-manager'
import { cache as queryCache } from '$lib/core/cache/queryCache'
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 }
@@ -76,8 +77,8 @@
clearPageCache()
queryCache.clearAll()
await Promise.all([
invoke('clear_moku_cache'),
invoke('clear_suwayomi_cache'),
platformService.clearMokuCache(),
platformService.clearSuwayomiCache(),
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
])
}
@@ -98,14 +99,14 @@
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 })
patchReset(key, { state: 'done' })
await showExitCountdown()
invoke('exit_app')
platformService.exitApp()
return
case 'suwayomi-data':
localStorage.clear()
await invoke('reset_suwayomi_data')
await platformService.resetSuwayomiData()
patchReset(key, { state: 'done' })
await showExitCountdown()
invoke('exit_app')
platformService.exitApp()
return
}
patchReset(key, { state: 'done' })
@@ -138,8 +139,9 @@
let defaultDownloadsPath = $state('')
$effect(() => {
if (!supportsFilesystem) return
if (!isExternalServer) {
invoke<string>('get_default_downloads_path').then(p => { defaultDownloadsPath = p })
platformService.getDefaultDownloadsPath().then(p => { defaultDownloadsPath = p })
} else {
defaultDownloadsPath = ''
}
@@ -163,6 +165,7 @@
let resetSectionOpen = $state(false)
async function fetchStorage() {
if (!supportsFilesystem) return
storageLoading = true; storageError = null
try {
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
@@ -183,7 +186,7 @@
}
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 })))
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')
@@ -195,17 +198,16 @@
}
async function validatePath(path: string): Promise<string | null> {
if (!path.trim()) return null
if (isExternalServer) return null
if (!path.trim() || isExternalServer || !supportsFilesystem) return null
try {
const exists = await invoke<boolean>('check_path_exists', { path: path.trim() })
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 invoke('create_directory', { path })
await platformService.createDirectory(path)
}
async function savePaths() {
@@ -219,11 +221,11 @@
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) {
if (supportsFilesystem && !isExternalServer) {
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
const newDl = dl || defaultDownloadsPath
if (newDl && oldDl && newDl !== oldDl) {
const hadContent = await invoke<boolean>('check_path_exists', { path: oldDl })
const hadContent = await platformService.checkPathExists(oldDl)
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl }
}
}
@@ -238,12 +240,9 @@
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 }
)
migrateUnlisten = await platformService.onMigrateProgress(p => { migrateProgress = p })
try {
await invoke('migrate_downloads', { src: migrateFrom, dst: migrateTo })
await platformService.migrateDownloads(migrateFrom, migrateTo)
migrateFrom = null; migrateTo = null; migrateProgress = null
await fetchStorage()
} catch (e: any) {
@@ -254,17 +253,17 @@
function dismissMigration() { migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null }
async function browseDownloadsFolder() {
const picked = await invoke<string | null>('pick_downloads_folder')
const picked = await platformService.pickFolder()
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; await savePaths() }
}
async function browseLocalSourceFolder() {
const picked = await invoke<string | null>('pick_downloads_folder')
const picked = await platformService.pickFolder()
if (picked) { localSourcePathInput = picked; pathsFieldError = { ...pathsFieldError, loc: undefined }; await savePaths() }
}
async function browseExtraScanDir() {
const picked = await invoke<string | null>('pick_downloads_folder')
const picked = await platformService.pickFolder()
if (picked) { newScanDir = picked; addExtraScanDir() }
}
@@ -410,6 +409,11 @@
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
stopRestorePoll()
try {
const form = buildBackupFormData(
restoreFile,
`mutation RestoreBackup($backup: Upload!) { restoreBackup(input: { backup: $backup }) { id status { mangaProgress state totalManga } } }`,
{ 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)
@@ -445,7 +449,8 @@
let appDataBackupDir = $state<string | null>(null)
$effect(() => {
invoke<string>('get_auto_backup_dir').then(d => { appDataBackupDir = d }).catch(() => {})
if (!supportsFilesystem) return
platformService.getAutoBackupDir().then(d => { appDataBackupDir = d }).catch(() => {})
})
async function handleExportAppData() {
@@ -507,6 +512,8 @@
<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}
@@ -551,7 +558,7 @@
spellcheck="false"
onkeydown={(e) => e.key === 'Enter' && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined } }} />
{#if !isExternalServer}
{#if !isExternalServer && supportsFilesystem}
<button class="s-btn" onclick={browseDownloadsFolder}>Browse</button>
{/if}
</div>
@@ -565,7 +572,7 @@
{/if}
</div>
<div class="s-btn-row">
{#if pathsFieldError.dl && !isExternalServer}
{#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' } }
@@ -624,10 +631,10 @@
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
onkeydown={(e) => e.key === 'Enter' && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined } }} />
{#if !isExternalServer}
{#if !isExternalServer && supportsFilesystem}
<button class="s-btn" onclick={browseLocalSourceFolder}>Browse</button>
{/if}
{#if pathsFieldError.loc && !isExternalServer}
{#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' } }
@@ -656,7 +663,7 @@
<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}
{#if !isExternalServer && supportsFilesystem}
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
{/if}
</div>
@@ -790,7 +797,7 @@
<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}>
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting || !supportsFilesystem}>
{appDataExporting ? 'Saving…' : 'Export'}
</button>
</div>
@@ -800,7 +807,7 @@
<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}>
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting || !supportsFilesystem}>
{appDataImporting ? 'Importing…' : 'Import'}
</button>
</div>
@@ -821,7 +828,7 @@
<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>
<button class="s-btn" onclick={() => platformService.openPath(appDataBackupDir!)}>Open folder</button>
</div>
{/if}