Compare commits

...

2 Commits

Author SHA1 Message Date
Youwes09 615fa1e92f Chore: GQL Cleanup P.3 2026-06-07 18:37:52 -05:00
Youwes09 248b046627 Fix: Home-Screen Recommendations & GQL Cleanup P.2 2026-06-07 15:40:18 -05:00
23 changed files with 331 additions and 236 deletions
@@ -204,7 +204,7 @@
{#each visibleItems as m, i (m.id)}
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
<div class="cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
</div>
<p class="card-title">{m.title}</p>
+30 -21
View File
@@ -37,6 +37,8 @@
let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null;
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
let kw_localQuery = $state(query);
let kw_pending = $state(false);
interface SourceResult {
source: Source;
@@ -57,18 +59,23 @@
if (!loadingSources && pendingPrefill && allSources.length) {
const q = pendingPrefill;
onPrefillConsumed();
kw_localQuery = q;
onQueryChange(q);
kwDoSearch(q);
}
});
$effect(() => {
const q = query;
function kwHandleInput(value: string) {
kw_localQuery = value;
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
});
if (!value.trim()) { kw_abortCtrl?.abort(); kw_results = []; kw_pending = false; onQueryChange(""); return; }
kw_pending = true;
kw_debounceTimer = setTimeout(() => {
kw_pending = false;
onQueryChange(value);
kwDoSearch(value);
}, 2000);
}
function kwGetVisibleSources(): Source[] {
let srcs = allSources;
@@ -142,18 +149,17 @@
</svg>
<input
bind:this={kw_inputEl}
value={query}
oninput={(e) => onQueryChange((e.target as HTMLInputElement).value)}
value={kw_localQuery}
oninput={(e) => kwHandleInput((e.target as HTMLInputElement).value)}
class="searchInput"
placeholder="Search across sources…"
use:focusOnMount
/>
{#if kw_anyLoading}
{#if kw_pending || kw_anyLoading}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else if query}
<button class="clearBtn" title="Clear" onclick={() => { onQueryChange(""); kw_results = []; kw_inputEl?.focus(); }}>×</button>
{:else if kw_localQuery}
<button class="clearBtn" title="Clear" onclick={() => { kwHandleInput(""); kw_inputEl?.focus(); }}>×</button>
{/if}
{#if hasMultipleLangs}
<button
@@ -193,7 +199,7 @@
{/if}
</div>
{#if !query.trim()}
{#if !kw_localQuery.trim()}
{#if popularLoading && popularResults.length === 0}
<div class="searchGrid">
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
@@ -206,7 +212,7 @@
{#each popularResults as m (m.id)}
<button class="srchCard" onclick={() => onPreview(m)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="srchFooter">
@@ -235,16 +241,20 @@
</p>
</div>
{/if}
{:else if kw_pending}
<div class="searchGrid">
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
</div>
{:else}
{#if kw_flatResults.length > 0}
<div class="searchHeader">
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} for "{kw_localQuery.trim()}"</span>
</div>
<div class="searchGrid">
{#each kw_flatResults as m (m.id)}
<button class="srchCard" onclick={() => onPreview(m)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="srchFooter">
@@ -264,16 +274,15 @@
</div>
{:else if kw_allDone && !kw_hasResults}
<div class="empty">
<p class="emptyText">No results for "{query.trim()}"</p>
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<p class="emptyText">No results for "{kw_localQuery.trim()}"</p>
<p class="emptyHint">Try a different spelling or fewer words</p>
</div>
{/if}
{/if}
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
<style>
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
+1 -1
View File
@@ -261,7 +261,7 @@
{#each src_browseResults as m, i (m.id)}
<button class="card" onclick={() => onPreview(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div>
<p class="cardTitle">{m.title}</p>
+1 -1
View File
@@ -360,7 +360,7 @@
{#each tag_mergedResults as m, i (m.id)}
<button class="card" onclick={() => onPreview(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div>
<p class="cardTitle">{m.title}</p>
@@ -104,8 +104,7 @@
async function loadRepos() {
reposLoading = true;
try {
const d = await (getAdapter() as any).gql<{ settings: { extensionRepos: string[] } }>(`query GetSettings { settings { extensionRepos } }`);
repos = d.settings.extensionRepos ?? [];
repos = await getAdapter().getExtensionRepos();
} catch (e) { console.error(e); }
finally { reposLoading = false; }
}
@@ -113,11 +112,11 @@
async function saveRepos(updated: string[], intent: "add" | "remove") {
savingRepos = true;
try {
const d = await (getAdapter() as any).gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(`mutation SetExtensionRepos($repos: [String!]!) { setSettings(input: { settings: { extensionRepos: $repos } }) { settings { extensionRepos } } }`, { repos: updated });
repos = d.setSettings.settings.extensionRepos;
const removed = repos.find(r => !updated.includes(r)) ?? "";
repos = await getAdapter().setExtensionRepos(updated);
addToast(intent === "add"
? { kind: "success", title: "Repo added", body: updated[updated.length - 1] }
: { kind: "info", title: "Repo removed", body: repos.find(r => !updated.includes(r)) ?? "" }
: { kind: "info", title: "Repo removed", body: removed }
);
} catch (e: any) {
repoError = e instanceof Error ? e.message : "Failed to save";
@@ -138,13 +137,11 @@
async function mutate(pkgName: string, op: "install" | "update" | "uninstall") {
working = new Set(working).add(pkgName);
const label = extensions.find((e) => e.pkgName === pkgName)?.name ?? pkgName;
const gqlArgs = {
install: { id: pkgName, install: true },
update: { id: pkgName, update: true },
uninstall: { id: pkgName, uninstall: true },
}[op];
try {
await getAdapter()[{ install: 'installExtension', update: 'updateExtension', uninstall: 'uninstallExtension' }[op] as 'installExtension'](pkgName);
const adapter = getAdapter();
if (op === "install") await adapter.installExtension(pkgName);
else if (op === "update") await adapter.updateExtension(pkgName);
else await adapter.uninstallExtension(pkgName);
await load();
addToast({
install: { kind: "download" as const, title: "Extension installed", body: label },
@@ -66,17 +66,7 @@
editKey = null;
listOpen = null;
try {
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
`query GetSourceSettings($id: LongString!) { source(id: $id) { preferences {
... on CheckBoxPreference { type: __typename CheckBoxTitle: title CheckBoxSummary: summary CheckBoxDefault: default CheckBoxCurrentValue: currentValue key }
... on SwitchPreference { type: __typename SwitchPreferenceTitle: title SwitchPreferenceSummary: summary SwitchPreferenceDefault: default SwitchPreferenceCurrentValue: currentValue key }
... on ListPreference { type: __typename ListPreferenceTitle: title ListPreferenceSummary: summary ListPreferenceDefault: default ListPreferenceCurrentValue: currentValue entries entryValues key }
... on EditTextPreference { type: __typename EditTextPreferenceTitle: title EditTextPreferenceSummary: summary EditTextPreferenceDefault: default EditTextPreferenceCurrentValue: currentValue dialogTitle dialogMessage key }
... on MultiSelectListPreference { type: __typename MultiSelectListPreferenceTitle: title MultiSelectListPreferenceSummary: summary MultiSelectListPreferenceDefault: default MultiSelectListPreferenceCurrentValue: currentValue entries entryValues key }
} } }`,
{ id: String(src.id) },
);
prefs = d.source.preferences ?? [];
prefs = (await getAdapter().getSourceSettings(src.id)) as Preference[];
} catch (e: any) {
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
} finally {
@@ -86,24 +76,9 @@
async function save(position: number, changeType: string, value: unknown) {
if (!activeSource) return;
const pref = prefs[position];
saving = pref.key;
saving = prefs[position].key;
try {
await (getAdapter() as any).gql(
`mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) { updateSourcePreference(input: { source: $source, change: $change }) { source { id } } }`,
{ source: String(activeSource.id), change: { position, [changeType]: value } },
);
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
`query GetSourceSettings($id: LongString!) { source(id: $id) { preferences {
... on CheckBoxPreference { type: __typename CheckBoxTitle: title CheckBoxCurrentValue: currentValue key }
... on SwitchPreference { type: __typename SwitchPreferenceTitle: title SwitchPreferenceCurrentValue: currentValue key }
... on ListPreference { type: __typename ListPreferenceTitle: title ListPreferenceCurrentValue: currentValue entries entryValues key }
... on EditTextPreference { type: __typename EditTextPreferenceTitle: title EditTextPreferenceCurrentValue: currentValue key }
... on MultiSelectListPreference { type: __typename MultiSelectListPreferenceTitle: title MultiSelectListPreferenceCurrentValue: currentValue entries entryValues key }
} } }`,
{ id: String(activeSource.id) },
);
prefs = d.source.preferences ?? [];
prefs = (await getAdapter().updateSourcePreference(activeSource.id, position, changeType, value)) as Preference[];
} catch (e: any) {
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
} finally {
+24 -17
View File
@@ -9,6 +9,7 @@ export interface RecommendedManga {
const TOP_GENRES = 6
const TARGET_PER_GENRE = 20
const FALLBACK_GENRES = ['Action', 'Adventure', 'Fantasy', 'Romance', 'Comedy', 'Drama']
export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] {
const byId = new Map(libraryManga.map(m => [m.id, m]))
@@ -16,8 +17,8 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
for (const session of history) {
const manga = byId.get(session.mangaId)
if (!manga?.genre?.length) continue
for (const g of manga.genre) {
if (!manga?.tags?.length) continue
for (const g of manga.tags) {
const key = g.toLowerCase()
const existing = tally.get(key)
if (existing) existing.count++
@@ -25,10 +26,12 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
}
}
return [...tally.values()]
const derived = [...tally.values()]
.sort((a, b) => b.count - a.count)
.slice(0, TOP_GENRES)
.map(e => e.original)
return derived.length > 0 ? derived : FALLBACK_GENRES
}
export async function fetchRecommendations(
@@ -36,20 +39,22 @@ export async function fetchRecommendations(
libraryManga: Manga[],
signal?: AbortSignal,
): Promise<RecommendedManga[]> {
if (!history.length || !libraryManga.length) return []
const genres = topGenres(history, libraryManga)
if (!genres.length) return []
const adapter = getAdapter()
const adapter = getAdapter() as any
const globalSeen = new Set<number>(libraryManga.map(m => m.id))
const perGenre = await Promise.all(
genres.map(async genre => {
if (signal?.aborted) return []
try {
const { items } = await adapter.getMangaList({ tags: [genre], inLibrary: false })
return items
const { items } = await adapter.getMangasByGenre(
{ genre: { like: `%${genre}%` } },
TARGET_PER_GENRE,
0,
signal,
)
return items as Manga[]
} catch {
return []
}
@@ -57,19 +62,21 @@ export async function fetchRecommendations(
)
const merged: Manga[] = []
for (const items of perGenre) {
outer: for (const items of perGenre) {
for (const m of items) {
if (signal?.aborted) break outer
if (globalSeen.has(m.id)) continue
globalSeen.add(m.id)
merged.push(m)
if (merged.length >= genres.length * TARGET_PER_GENRE) break
}
}
return merged.map(m => ({
manga: m,
matchedGenres: (m.genre ?? []).filter(g =>
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
),
}))
return merged.map(m => {
const mTagsLower = (m.tags ?? []).map(g => g.toLowerCase())
const matched = genres.filter(g => mTagsLower.includes(g.toLowerCase()))
return {
manga: m,
matchedGenres: matched.length > 0 ? matched : [genres[0]],
}
})
}
+3 -15
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import type { Manga, Category } from '$lib/types'
interface Props {
@@ -24,15 +25,8 @@
onCardClick, onCardContextMenu, onSelectAll, onExitSelect, onBulkRemove, onBulkMove,
}: Props = $props()
const THUMB_BASE = 'http://127.0.0.1:4567'
let movePanelOpen = $state(false)
function coverUrl(m: Manga) {
const url = m.thumbnailUrl ?? ''
return url.startsWith('http') ? url : `${THUMB_BASE}${url}`
}
function onDocDown(e: MouseEvent) {
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
}
@@ -124,13 +118,7 @@
oncontextmenu={(e) => onCardContextMenu(e, m)}
>
<div class="cover-wrap" class:completed={isCompleted}>
<img
class="cover"
src={coverUrl(m)}
alt={m.title}
draggable="false"
loading="lazy"
/>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" id={m.id} />
<div class="overlay">
<div class="badges">
{#if isCompleted}
@@ -247,7 +235,7 @@
}
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.cover { width: 100%; height: 100%; object-fit: cover; display: block; }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
.overlay {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
+3 -26
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { readerState } from "$lib/state/reader.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { getAdapter } from "$lib/request-manager";
import type { Chapter } from "$lib/types";
@@ -15,28 +14,6 @@
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume, barPosition }: Props = $props();
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
const headers: Record<string, string> = { "Content-Type": "application/json" };
const mode = settingsState.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") {
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
}
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
headers,
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
}
const ENQUEUE_ONE = `mutation EnqueueOne($id: Int!) { fetchChapterPages(input: { chapterId: $id }) { chapter { id } } }`;
const ENQUEUE_MANY = `mutation EnqueueMany($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
async function runDl(fn: () => Promise<void>) {
readerState.dlBusy = true;
try { await fn(); } catch (e) { console.error(e); }
@@ -60,14 +37,14 @@
<p class="dl-title">Download</p>
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
onclick={() => runDl(() => gqlMutation(ENQUEUE_ONE, { id: chapter.id }))}>
onclick={() => runDl(() => getAdapter().enqueueDownload(String(chapter.id)))}>
This chapter
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
</button>
<div class="dl-row">
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
onclick={() => runDl(() => getAdapter().enqueueDownloads(queueable.slice(0, readerState.nextN).map(c => String(c.id))))}>
Next chapters
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
</button>
@@ -79,7 +56,7 @@
</div>
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.map(c => c.id) }))}>
onclick={() => runDl(() => getAdapter().enqueueDownloads(queueable.map(c => String(c.id))))}>
All remaining
<span class="dl-sub">{queueable.length} not yet downloaded</span>
</button>
@@ -93,7 +93,7 @@
<div class="cover-wrap">
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" />
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" id={seriesState.activeManga?.id ?? manga?.id} />
</button>
</div>
@@ -76,7 +76,7 @@
let entries = 0, oldest: number | null = null, newest: number | null = null
const foundKeys: string[] = []
const checkKey = (k: string) => {
const age = cache.ageOf(k)
const age = cache?.ageOf?.(k)
if (age !== undefined) {
entries++
foundKeys.push(k)
@@ -85,7 +85,7 @@
if (newest === null || ts > newest) newest = ts
}
}
['library', 'sources', 'popular'].forEach(checkKey)
['library', 'sources', 'popular'].forEach(checkKey);
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
@@ -104,7 +104,7 @@
function triggerSplash() {
splashTriggered = true
setTimeout(() => splashTriggered = false, 200)
;(window as any).__mokuShowSplash?.()
appState.idleSplash = true
}
async function testWindowsHello() {
@@ -192,7 +192,7 @@
<div class="s-dev-grid">
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history.length} entries</span>
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history?.length ?? 0} entries</span>
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
@@ -83,8 +83,8 @@
<div class="s-section">
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history.length} entries</span></div>
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={homeState.history.length === 0}>Clear</button>
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history?.length ?? 0} entries</span></div>
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={!homeState.history?.length}>Clear</button>
</div>
</div>
</div>
@@ -12,6 +12,9 @@
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')
@@ -79,7 +82,7 @@
await Promise.all([
platformService.clearMokuCache(),
platformService.clearSuwayomiCache(),
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
getAdapter().clearCachedImages({ cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
])
}
@@ -168,11 +171,7 @@
if (!supportsFilesystem) return
storageLoading = true; storageError = null
try {
const pathData = await gql<{ settings: { downloadsPath: string | null; localSourcePath: string | null } }>(
`{ settings { downloadsPath localSourcePath } }`
)
const dl = pathData.settings.downloadsPath ?? ''
const loc = pathData.settings.localSourcePath ?? ''
const { downloadsPath: dl, localSourcePath: loc } = await getAdapter().getDownloadsPath()
downloadsPathInput = dl; localSourcePathInput = loc
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
@@ -218,8 +217,8 @@
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
pathsSaving = true
try {
await gql(`mutation($path: String!) { setSettings(input: { settings: { downloadsPath: $path } }) { settings { downloadsPath } } }`, { path: dl })
if (loc) await gql(`mutation($path: String!) { setSettings(input: { settings: { localSourcePath: $path } }) { settings { localSourcePath } } }`, { path: loc })
await getAdapter().setDownloadsPath(dl)
if (loc) await getAdapter().setLocalSourcePath(loc)
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
if (supportsFilesystem && !isExternalServer) {
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
@@ -301,8 +300,7 @@
async function createBackup() {
backupLoading = true; backupError = null
try {
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
const { url } = data.createBackup
const { url } = await getAdapter().createBackup()
const name = url.split('/').pop() ?? url
backupList = [{ url, name }, ...backupList]
await saveBackupList()
@@ -313,7 +311,7 @@
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() })
await getAdapter().deleteBackup(url)
backupList = backupList.filter(b => b.url !== url)
await saveBackupList()
} catch (e: any) {
@@ -324,9 +322,7 @@
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()
const blob = await getAdapter().downloadBackup(backup.url)
if ('showSaveFilePicker' in window) {
try {
const handle = await (window as any).showSaveFilePicker({
@@ -349,12 +345,11 @@
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 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<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
let validateResult = $state<ValidateBackupResult | null>(null)
let restoreFile = $state<File | null>(null)
function stopRestorePoll() {
@@ -363,62 +358,19 @@
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
const status = await getAdapter().pollRestoreStatus(id)
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
restoreLoading = true; restoreError = null; restoreStatus = 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)
const result = json.data.restoreBackup
restoreJobId = result.id; restoreStatus = result.status
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' }
@@ -429,15 +381,7 @@
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
validateResult = await requestManager.meta.validateBackup(restoreFile)
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
finally { validateLoading = false }
}
@@ -3,6 +3,7 @@
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { toast } from "$lib/state/notifications.svelte";
import { getAdapter } from "$lib/request-manager";
import { platformService } from "$lib/platform-service";
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
import { trackingState } from "$lib/state/tracking.svelte";
import type { Tracker, TrackRecord } from "$lib/types/index";
@@ -41,7 +42,7 @@
async function startOAuth(tracker: Tracker) {
if (!tracker.authUrl) return;
oauthTrackerId = tracker.id; oauthCallbackInput = "";
window.open(tracker.authUrl, "_blank");
await platformService.openExternal(tracker.authUrl);
}
async function submitOAuth() {
@@ -274,6 +275,7 @@
<style>
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.s-tracker-status-row .s-pill { border-radius: 4px; }
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
.s-banner-dismissible:hover { opacity: 0.85; }
@@ -4,6 +4,7 @@
let {
src,
id = undefined,
alt = "",
class: cls = "",
loading = "lazy",
@@ -13,6 +14,7 @@
...rest
}: {
src: string | null | undefined;
id?: string | number;
alt?: string;
class?: string;
loading?: string;
@@ -27,10 +29,14 @@
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
}
function withBust(url: string): string {
return id != null ? `${url}${url.includes('?') ? '&' : '?'}id=${id}` : url;
}
function plainThumbUrl(path: string | null | undefined): string {
if (!path) return "";
if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`;
const base = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
return withBust(base);
}
const isAuth = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
@@ -45,15 +51,15 @@
if (!_isAuth || !_src) { blobUrl = ""; return; }
const id = ++reqId;
const myId = ++reqId;
const bareUrl = _src.startsWith("http") ? _src : `${getServerUrl()}${_src}`;
getBlobUrl(bareUrl, _priority)
.then(u => { if (id === reqId) blobUrl = u; })
.catch(() => { if (id === reqId) blobUrl = ""; });
getBlobUrl(withBust(bareUrl), _priority)
.then(u => { if (myId === reqId) blobUrl = u; })
.catch(() => { if (myId === reqId) blobUrl = ""; });
});
const plainUrl = $derived(plainThumbUrl(src));
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
const plainUrl = $derived(plainThumbUrl(src));
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
</script>
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
+9
View File
@@ -1,4 +1,5 @@
import { getAdapter } from "$lib/request-manager";
import type { ValidateBackupResult, RestoreStatus } from "$lib/server-adapters/types";
export async function getAboutServer() {
return getAdapter().getAboutServer();
@@ -6,4 +7,12 @@ export async function getAboutServer() {
export async function getAboutWebUI() {
return getAdapter().getAboutWebUI();
}
export async function restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
return getAdapter().restoreBackup(file);
}
export async function validateBackup(file: File): Promise<ValidateBackupResult> {
return getAdapter().validateBackup(file);
}
+127 -25
View File
@@ -16,6 +16,8 @@ import type {
TrackRecordPatch,
AboutServer,
AboutWebUI,
RestoreStatus,
ValidateBackupResult,
} from '$lib/server-adapters/types'
import type { DownloadStatus } from '$lib/types/api'
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
@@ -39,11 +41,10 @@ import {
UPDATE_STOP,
SET_MANGA_META,
DELETE_MANGA_META,
CREATE_BACKUP,
RESTORE_BACKUP,
FETCH_SOURCE_MANGA,
LIBRARY_UPDATE_STATUS,
MANGAS_BY_GENRE,
POLL_RESTORE_STATUS,
} from './manga'
import {
GET_CHAPTERS,
@@ -101,6 +102,7 @@ import {
UPDATE_TRACK,
LOGIN_TRACKER_CREDENTIALS,
LOGOUT_TRACKER,
LOGIN_TRACKER_OAUTH,
} from './tracking'
import {
GET_ABOUT_SERVER,
@@ -110,6 +112,9 @@ import {
GET_METAS,
SET_SOCKS_PROXY,
SET_FLARE_SOLVERR,
RESTORE_BACKUP,
VALIDATE_BACKUP,
CREATE_BACKUP,
} from './meta'
import {
type GQLResponse,
@@ -136,7 +141,7 @@ function mapDownloadStatus(raw: { state: string; queue: RawQueueItem[] }): Downl
}
export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567'
private baseUrl = 'http://127.0.0.1:4567'
private authHeader: string | null = null
async connect(config: ServerConfig): Promise<void> {
@@ -220,6 +225,26 @@ export class SuwayomiAdapter implements ServerAdapter {
return { items, hasNextPage: false }
}
async getMangasByGenre(
filter: Record<string, unknown>,
first: number,
offset: number,
signal?: AbortSignal,
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
const data = await this.gql<{
mangas: {
nodes: Record<string, unknown>[]
pageInfo: { hasNextPage: boolean }
totalCount: number
}
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
return {
items: data.mangas.nodes.map(mapManga),
hasNextPage: data.mangas.pageInfo.hasNextPage,
totalCount: data.mangas.totalCount,
}
}
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
if (!sourceId) return []
const data = await this.gql<{ fetchSourceManga: { mangas: Record<string, unknown>[] } }>(
@@ -280,9 +305,7 @@ export class SuwayomiAdapter implements ServerAdapter {
}
async getRecentlyUpdated(): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_RECENTLY_UPDATED
)
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(GET_RECENTLY_UPDATED)
return data.chapters.nodes.map(mapChapter)
}
@@ -357,9 +380,7 @@ export class SuwayomiAdapter implements ServerAdapter {
reorderChapterDownload: { downloadStatus: { state: string; queue: RawQueueItem[] } }
}>(REORDER_DOWNLOAD, { chapterId: Number(chapterId), to })
return mapDownloadStatus(data.reorderChapterDownload.downloadStatus)
} catch {
return null
}
} catch { return null }
}
async clearDownloads(): Promise<void> {
@@ -410,6 +431,18 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(INSTALL_EXTERNAL_EXTENSION, { url })
}
async getExtensionRepos(): Promise<string[]> {
const data = await this.gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS)
return data.settings.extensionRepos ?? []
}
async setExtensionRepos(repos: string[]): Promise<string[]> {
const data = await this.gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
SET_EXTENSION_REPOS, { repos }
)
return data.setSettings.settings.extensionRepos
}
async getSources(): Promise<Source[]> {
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
return data.sources.nodes
@@ -425,6 +458,29 @@ export class SuwayomiAdapter implements ServerAdapter {
}
}
async getSourceSettings(sourceId: string): Promise<unknown[]> {
const data = await this.gql<{ source: { preferences: unknown[] } }>(
GET_SOURCE_SETTINGS, { id: sourceId }
)
return data.source.preferences ?? []
}
async updateSourcePreference(
sourceId: string,
position: number,
changeType: string,
value: unknown,
): Promise<unknown[]> {
await this.gql(UPDATE_SOURCE_PREFERENCE, {
source: sourceId,
change: { position, [changeType]: value },
})
const data = await this.gql<{ source: { preferences: unknown[] } }>(
GET_SOURCE_SETTINGS, { id: sourceId }
)
return data.source.preferences ?? []
}
async getCategories(): Promise<Category[]> {
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
return data.categories.nodes.map(mapCategory)
@@ -510,6 +566,18 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
}
async loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise<void> {
await this.gql(LOGIN_TRACKER_OAUTH, { trackerId: Number(trackerId), callbackUrl })
}
async loginTrackerCredentials(trackerId: string, username: string, password: string): Promise<void> {
await this.gql(LOGIN_TRACKER_CREDENTIALS, { trackerId: Number(trackerId), username, password })
}
async logoutTracker(trackerId: string): Promise<void> {
await this.gql(LOGOUT_TRACKER, { trackerId: Number(trackerId) })
}
async getServerSecurity(): Promise<ServerSecurity> {
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
return data.settings
@@ -546,26 +614,60 @@ export class SuwayomiAdapter implements ServerAdapter {
}
}
async getMangasByGenre(
filter: Record<string, unknown>,
first: number,
offset: number,
signal?: AbortSignal,
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
const data = await this.gql<{
mangas: {
nodes: Record<string, unknown>[]
pageInfo: { hasNextPage: boolean }
totalCount: number
}
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
async getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }> {
const data = await this.gql<{ settings: { downloadsPath: string | null; localSourcePath: string | null } }>(
GET_DOWNLOADS_PATH
)
return {
items: data.mangas.nodes.map(mapManga),
hasNextPage: data.mangas.pageInfo.hasNextPage,
totalCount: data.mangas.totalCount,
downloadsPath: data.settings.downloadsPath ?? '',
localSourcePath: data.settings.localSourcePath ?? '',
}
}
async setDownloadsPath(path: string): Promise<void> {
await this.gql(SET_DOWNLOADS_PATH, { path })
}
async setLocalSourcePath(path: string): Promise<void> {
await this.gql(SET_LOCAL_SOURCE_PATH, { path })
}
async createBackup(): Promise<{ url: string }> {
const data = await this.gql<{ createBackup: { url: string } }>(CREATE_BACKUP)
return data.createBackup
}
private multipartGql<T>(query: string, file: File): Promise<T> {
const form = new FormData()
form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
form.append('0', file, file.name)
const headers: Record<string, string> = { Accept: 'application/json' }
if (this.authHeader) headers['Authorization'] = this.authHeader
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form })
.then(r => { if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`); return r.json() })
.then((json: GQLResponse<T>) => { if (json.errors?.length) throw new Error(json.errors[0].message); return json.data })
}
async restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
const data = await this.multipartGql<{ restoreBackup: { id: string; status: RestoreStatus } }>(RESTORE_BACKUP, file)
return data.restoreBackup
}
async validateBackup(file: File): Promise<ValidateBackupResult> {
const data = await this.multipartGql<{ validateBackup: ValidateBackupResult }>(VALIDATE_BACKUP, file)
return data.validateBackup
}
async pollRestoreStatus(id: string): Promise<RestoreStatus> {
const data = await this.gql<{ restoreStatus: RestoreStatus }>(POLL_RESTORE_STATUS, { id })
return data.restoreStatus
}
async clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise<void> {
await this.gql(CLEAR_CACHED_IMAGES, opts)
}
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
if (mangaIds?.length) {
const results: UpdateResult[] = []
+16 -1
View File
@@ -59,7 +59,7 @@ export const LIBRARY_UPDATE_STATUS = `
export const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
mangas(filter: $filter, first: $first, offset: $offset) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
@@ -76,6 +76,12 @@ export const GET_DOWNLOADS_PATH = `
}
`
export const POLL_RESTORE_STATUS = `
query PollRestoreStatus($id: String!) {
restoreStatus(id: $id) { mangaProgress state totalManga }
}
`
export const FETCH_MANGA = `
mutation FetchManga($id: Int!) {
fetchManga(input: { id: $id }) {
@@ -214,6 +220,15 @@ export const RESTORE_BACKUP = `
}
`
export const VALIDATE_BACKUP = `
query ValidateBackup($backup: Upload!) {
validateBackup(input: { backup: $backup }) {
missingSources { id name }
missingTrackers { name }
}
}
`
export const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
+22
View File
@@ -60,6 +60,28 @@ export const SET_SOCKS_PROXY = `
}
`
export const RESTORE_BACKUP = `
mutation RestoreBackup($backup: Upload!) {
restoreBackup(input: { backup: $backup }) {
id status { mangaProgress state totalManga }
}
}
`
export const VALIDATE_BACKUP = `
query ValidateBackup($backup: Upload!) {
validateBackup(input: { backup: $backup }) {
missingSources { id name } missingTrackers { name }
}
}
`
export const CREATE_BACKUP = `
mutation CreateBackup {
createBackup(input: {}) { url }
}
`
export const SET_FLARE_SOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
@@ -114,6 +114,14 @@ export const LOGIN_TRACKER_CREDENTIALS = `
}
`
export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
}
}
`
export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
+28
View File
@@ -128,6 +128,17 @@ export interface TrackRecordPatch {
private?: boolean
}
export interface RestoreStatus {
mangaProgress: number
state: string
totalManga: number
}
export interface ValidateBackupResult {
missingSources: { id: string; name: string }[]
missingTrackers: { name: string }[]
}
export interface ServerAdapter {
connect(config: ServerConfig): Promise<void>
getStatus(): Promise<ServerStatus>
@@ -135,6 +146,7 @@ export interface ServerAdapter {
getManga(id: string, signal?: AbortSignal): Promise<Manga>
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
getMangasByGenre(filter: Record<string, unknown>, first: number, offset: number, signal?: AbortSignal): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }>
searchManga(query: string, sourceId?: string): Promise<Manga[]>
fetchManga(id: string): Promise<Manga>
addToLibrary(mangaId: string): Promise<void>
@@ -175,9 +187,13 @@ export interface ServerAdapter {
updateExtension(id: string): Promise<void>
updateExtensions(ids: string[]): Promise<void>
installExternalExtension(url: string): Promise<void>
getExtensionRepos(): Promise<string[]>
setExtensionRepos(repos: string[]): Promise<string[]>
getSources(): Promise<Source[]>
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
getSourceSettings(sourceId: string): Promise<unknown[]>
updateSourcePreference(sourceId: string, position: number, changeType: string, value: unknown): Promise<unknown[]>
getCategories(): Promise<Category[]>
createCategory(name: string): Promise<Category>
@@ -196,12 +212,24 @@ export interface ServerAdapter {
updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord>
fetchTrackRecord(recordId: string): Promise<TrackRecord>
syncTracking(mangaId: string): Promise<void>
loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise<void>
loginTrackerCredentials(trackerId: string, username: string, password: string): Promise<void>
logoutTracker(trackerId: string): Promise<void>
getServerSecurity(): Promise<ServerSecurity>
setServerAuth(input: SetServerAuthInput): Promise<void>
setSocksProxy(input: SetSocksProxyInput): Promise<void>
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }>
setDownloadsPath(path: string): Promise<void>
setLocalSourcePath(path: string): Promise<void>
createBackup(): Promise<{ url: string }>
restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }>
validateBackup(file: File): Promise<ValidateBackupResult>
pollRestoreStatus(id: string): Promise<RestoreStatus>
clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise<void>
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
stopLibraryUpdate(): Promise<void>
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
+1
View File
@@ -35,6 +35,7 @@ export const appState = $state({
history: [] as unknown[],
toasts: [] as unknown[],
appDir: '',
idleSplash: false,
})
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
+5
View File
@@ -130,6 +130,7 @@
function onSplashReady() { splashVisible = false }
function onSplashUnlock() { appState.status = 'ready'; splashVisible = false }
function onSplashBypass() { bypassed = true; splashVisible = false }
function onIdleDismiss() { appState.idleSplash = false }
function onSplashRetry() {
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
@@ -158,6 +159,10 @@
/>
{/if}
{#if appState.idleSplash}
<SplashScreen mode="idle" onDismiss={onIdleDismiss} />
{/if}
{#if showApp}
{#if strippedLayout}
{@render children()}