diff --git a/Todo b/Todo index 7dc8b13..16173f3 100644 --- a/Todo +++ b/Todo @@ -1,39 +1,4 @@ -Major Revisions: - - Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) - - Moku-Share allows exporting of Manga - - Compressed Format (Storage) - - Import as Local-Source - - Takes existing Local-Source or Creates Own +Revival of the TODO List!!!!! -Minor Revisions: - - Investigate feasibility of Multi-Page Screenshot (Reader) - - Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073) -Priority Bugs: - - Fix Library-Refresh System (TESTING) - - - Suwayomi RESET - - Allow User to Wipe Suwayomi (Scratch) - - If Possible, Component based Wipe (Library, Etc) - -Pending/On-Hold: -- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching) - - Working on 3D Display Cards - - Add Flathub Support (Pending Video) - - - Change Auto-Link Threshold - - Fix Auto-Link De-dupe for Images - - Optimize Auto-Link Latency (IP) - -In-Progress: -- Fix Tracking Login - - Pasting OAuth URL is not User-Friendly, Look for Alternatives - - - Apply Syer's Fix for Library on Backup Load (Manga Metadata) - - Note User's have to always install extensions manually - - Create "Missing Source" for Manga - - - Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR) - - - UI LOGIN DOES NOT WORK OFFLINE -Notes from last time: +- Reminder to Completely Test Settings diff --git a/src/lib/components/library/LibraryFilters.svelte b/src/lib/components/library/LibraryFilters.svelte index 9f3d87d..351bb00 100644 --- a/src/lib/components/library/LibraryFilters.svelte +++ b/src/lib/components/library/LibraryFilters.svelte @@ -1,28 +1,31 @@ {#if selectMode} @@ -32,9 +48,36 @@ {selected.size} selected
+ {#if visibleCategories.length > 0} +
+ + {#if movePanelOpen} + + {/if} +
+ {/if} + + {/each} + + {:else} + {#each pageChapters as ch} + {@const idxInSorted = sortedChapters.indexOf(ch)} + {@const isSelected = selectedIds.has(ch.id)} + {@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} +
hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)} + onkeydown={(e) => e.key === 'Enter' && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))} + oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted } }} + > + +
+ {ch.name} +
+ {#if ch.scanlator}{ch.scanlator}{/if} + {#if ch.uploadDate}{formatDate(ch.uploadDate)}{/if} + {#if ch.lastPageRead && ch.lastPageRead > 0 && !ch.isRead}p.{ch.lastPageRead}{/if} +
+
+
+ {#if ch.isRead}{/if} + {#if ch.isDownloaded} +
+ + +
+ {:else if enqueueing.has(ch.id)} + + {:else} + + {/if} +
+
+ {/each} + {/if} +
+ +{#if totalPages > 1} +
+ + {chapterPage} / {totalPages} + +
+{/if} + +{#if ctx} + ctx = null} /> +{/if} + + \ No newline at end of file diff --git a/src/lib/components/series/SeriesActions.svelte b/src/lib/components/series/SeriesActions.svelte new file mode 100644 index 0000000..0c2b1be --- /dev/null +++ b/src/lib/components/series/SeriesActions.svelte @@ -0,0 +1,658 @@ + + +
+
+ {#if hasSelection} + {selectedCount} selected + + + + + + {:else} +
+ + {#if sortMenuOpen} + + {/if} +
+ + {/if} +
+ +
+ +
+ + {#if jumpOpen} +
+ { if (e.key === 'Enter') doJump(); if (e.key === 'Escape') { jumpOpen = false; jumpInput = '' } }} + /> + {#if jumpChapter} + + {:else if jumpInput.trim()} +

No match

+ {/if} +
+ {/if} +
+ + {#if availableScanlators.length > 1} +
+ + {#if scanFilterOpen} + + {/if} +
+ {/if} + + + +
+ + {#if folderPickerOpen} +
+ {#if catsLoading} +

Loading…

+ {:else if allCategories.length === 0 && !folderCreating} +

No folders yet

+ {/if} + {#each allCategories as cat} + {@const isIn = mangaCategories.some(c => c.id === cat.id)} + + {/each} +
+ {#if folderCreating} +
+ { if (e.key === 'Enter') submitNewFolder(); if (e.key === 'Escape') { folderCreating = false; folderNewName = '' } }} + /> + + +
+ {:else} + + {/if} +
+ {/if} +
+ + {#if chapters.length > 0} +
+ + {#if dlOpen} +
+ {#if downloadedCount > 0} + +
+ {/if} + {#if continueChapter} + {@const contIdx = sortedChapters.indexOf(continueChapter.chapter)} + {#if contIdx >= 0} + +
+ {#each [5, 10, 25] as n} + {@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.isDownloaded).length} + + {/each} +
+
+ {/if} + {/if} + {#if !showRange} + + {:else} +
+ + e.key === 'Enter' && enqueueRange()} use:focusOnMount /> + + e.key === 'Enter' && enqueueRange()} /> + +
+ {/if} +
+ + + {#if downloadedCount > 0} +
+ + {/if} +
+ {/if} +
+ {/if} + + {#if totalPages > 1} + + {/if} +
+
+ + \ No newline at end of file diff --git a/src/lib/components/series/SeriesDetail.svelte b/src/lib/components/series/SeriesDetail.svelte new file mode 100644 index 0000000..98c19ff --- /dev/null +++ b/src/lib/components/series/SeriesDetail.svelte @@ -0,0 +1,747 @@ + + +{#if seriesState.activeMangaId} + + +{#if markersOpen && manga} + +{/if} + +{#if autoOpen && manga} + +{/if} + +{#if trackingOpen && manga} + +{/if} + +{#if linkPickerOpen && manga} + +{/if} + +{#if coverPickerOpen && manga} + +{/if} + +{#if migrateOpen && manga} + migrateOpen = false} + onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false }} + /> +{/if} + +{/if} + + \ No newline at end of file diff --git a/src/lib/components/series/SeriesHeader.svelte b/src/lib/components/series/SeriesHeader.svelte new file mode 100644 index 0000000..4f796a5 --- /dev/null +++ b/src/lib/components/series/SeriesHeader.svelte @@ -0,0 +1,376 @@ + + + + + \ No newline at end of file diff --git a/src/lib/components/series/lib/chapterList.ts b/src/lib/components/series/lib/chapterList.ts new file mode 100644 index 0000000..ed8e9a0 --- /dev/null +++ b/src/lib/components/series/lib/chapterList.ts @@ -0,0 +1,79 @@ +import type { Chapter } from '$lib/types' + +export type ChapterSortMode = 'source' | 'chapterNumber' | 'uploadDate' +export type ChapterSortDir = 'asc' | 'desc' + +export interface ChapterDisplayPrefs { + sortMode?: ChapterSortMode + sortDir?: ChapterSortDir + preferredScanlator?: string + scanlatorFilter?: string[] + scanlatorBlacklist?: string[] + scanlatorForce?: boolean +} + +function sortByMode(a: Chapter, b: Chapter, mode: ChapterSortMode): number { + if (mode === 'chapterNumber') return a.chapterNumber - b.chapterNumber + if (mode === 'uploadDate') return Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0) + return a.sourceOrder - b.sourceOrder +} + +export function buildChapterList(chapters: Chapter[], prefs: ChapterDisplayPrefs = {}): Chapter[] { + const { + sortMode = 'source', + sortDir = 'asc', + preferredScanlator = '', + scanlatorFilter = [], + scanlatorBlacklist = [], + scanlatorForce = false, + } = prefs + + let base = [...chapters] + + if (scanlatorBlacklist.length > 0) { + base = base.filter(c => !scanlatorBlacklist.includes(c.scanlator ?? '')) + } + + base.sort((a, b) => sortByMode(a, b, sortMode)) + + if (preferredScanlator) { + const pref: Chapter[] = [], rest: Chapter[] = [] + for (const c of base) (c.scanlator === preferredScanlator ? pref : rest).push(c) + base = [...pref, ...rest] + } + + if (scanlatorFilter.length > 0) { + const seen = new Map() + for (const ch of base) { + const existing = seen.get(ch.chapterNumber) + if (!existing) { + if (!scanlatorForce || scanlatorFilter.includes(ch.scanlator ?? '')) { + seen.set(ch.chapterNumber, ch) + } + } else { + const np = scanlatorFilter.indexOf(ch.scanlator ?? '') + const op = scanlatorFilter.indexOf(existing.scanlator ?? '') + if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch) + } + } + base = [...seen.values()].sort((a, b) => sortByMode(a, b, sortMode)) + } + + return sortDir === 'desc' ? base.reverse() : base +} + +export function chaptersAscending(chapters: Chapter[]): Chapter[] { + return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder) +} + +export function buildReaderChapterList( + chapters: Chapter[], + prefs: Pick | undefined, +): Chapter[] { + return buildChapterList(chapters, { + sortMode: 'source', + sortDir: 'asc', + preferredScanlator: prefs?.preferredScanlator, + scanlatorFilter: prefs?.scanlatorFilter, + }) +} \ No newline at end of file diff --git a/src/lib/components/series/lib/mangaPrefs.ts b/src/lib/components/series/lib/mangaPrefs.ts new file mode 100644 index 0000000..9e21afe --- /dev/null +++ b/src/lib/components/series/lib/mangaPrefs.ts @@ -0,0 +1,18 @@ +import { settingsState, updateSettings } from '$lib/state/settings.svelte' +import type { MangaPrefs } from '$lib/state/series.svelte' + +export { DEFAULT_MANGA_PREFS } from '$lib/state/series.svelte' + +export function getPref(mangaId: number, key: K): MangaPrefs[K] { + const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {} + return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K] +} + +export function setPref(mangaId: number, key: K, value: MangaPrefs[K]) { + updateSettings({ + mangaPrefs: { + ...settingsState.settings.mangaPrefs, + [mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value }, + }, + }) +} \ No newline at end of file diff --git a/src/lib/components/series/panels/AutomationPanel.svelte b/src/lib/components/series/panels/AutomationPanel.svelte new file mode 100644 index 0000000..c6aae4f --- /dev/null +++ b/src/lib/components/series/panels/AutomationPanel.svelte @@ -0,0 +1,14 @@ + + +
+ +

AutomationPanel

+
+ + \ No newline at end of file diff --git a/src/lib/components/series/panels/CoverPickerPanel.svelte b/src/lib/components/series/panels/CoverPickerPanel.svelte new file mode 100644 index 0000000..ed5b1ab --- /dev/null +++ b/src/lib/components/series/panels/CoverPickerPanel.svelte @@ -0,0 +1,14 @@ + + +
+ +

CoverPickerPanel

+
+ + \ No newline at end of file diff --git a/src/lib/components/series/panels/MarkersPanel.svelte b/src/lib/components/series/panels/MarkersPanel.svelte new file mode 100644 index 0000000..e94ea1b --- /dev/null +++ b/src/lib/components/series/panels/MarkersPanel.svelte @@ -0,0 +1,14 @@ + + +
+ +

MarkersPanel

+
+ + \ No newline at end of file diff --git a/src/lib/components/settings/sections/AboutSettings.svelte b/src/lib/components/settings/sections/AboutSettings.svelte index 6c94ea3..ba67e5f 100644 --- a/src/lib/components/settings/sections/AboutSettings.svelte +++ b/src/lib/components/settings/sections/AboutSettings.svelte @@ -1,14 +1,13 @@ @@ -149,9 +149,11 @@
Installedv{appVersion}
- + {#if supportsUpdates} + + {/if}
{#if onLatestVersion}
@@ -225,58 +227,60 @@
{/if} -
-

Releases

-
- {#if releasesError} -

{releasesError}

- {:else if releasesLoading} -

Fetching releases…

- {:else if releases.length === 0} -

No releases found.

- {:else} -
- {#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'} -
-
-
- {release.tag_name} - {#if isCurrent}installed{/if} - {#if release.published_at}{fmtDate(release.published_at)}{/if} -
-
- {#if release.body.trim()} - - {/if} - {#if !isCurrent} - {#if IS_WINDOWS} - - {:else} - {/if} - {/if} + {#if !isCurrent} + {#if IS_WINDOWS} + + {:else} + + {/if} + {/if} +
+ {#if isExpanded && release.body.trim()} +
+
{release.body.trim()}
+
+ {/if}
- {#if isExpanded && release.body.trim()} -
-
{release.body.trim()}
-
- {/if} -
- {/each} -
- {/if} + {/each} +
+ {/if} +
- + {/if}

Links

diff --git a/src/lib/components/settings/sections/FoldersSettings.svelte b/src/lib/components/settings/sections/FoldersSettings.svelte index d0b782d..70d46f1 100644 --- a/src/lib/components/settings/sections/FoldersSettings.svelte +++ b/src/lib/components/settings/sections/FoldersSettings.svelte @@ -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 @@ {cat?.name ?? 'Completed'} - {cat?.mangas?.nodes.length ?? 0} manga + {cat?.mangas?.nodes?.length ?? 0} manga built-in
+

MigrateModal

+
+ + \ No newline at end of file diff --git a/src/lib/components/shared/manga/SeriesLinkPanel.svelte b/src/lib/components/shared/manga/SeriesLinkPanel.svelte new file mode 100644 index 0000000..a35f3d8 --- /dev/null +++ b/src/lib/components/shared/manga/SeriesLinkPanel.svelte @@ -0,0 +1,14 @@ + + +
+ +

SeriesLinkPanel

+
+ + \ No newline at end of file diff --git a/src/lib/components/shared/manga/ThreeDCard.svelte b/src/lib/components/shared/manga/ThreeDCard.svelte index 83c21cb..31142ef 100644 --- a/src/lib/components/shared/manga/ThreeDCard.svelte +++ b/src/lib/components/shared/manga/ThreeDCard.svelte @@ -3,49 +3,39 @@ interface Props { children: Snippet; - class?: string; + class?: string; + enabled?: boolean; } - let { children, class: cls = "" }: Props = $props(); + let { children, class: cls = "", enabled = true }: Props = $props(); -
-
- {@render children()} -
-
-
+{#if enabled} +
+
+ {@render children()} +
+
+
+
-
-
-
-
-
-
-
-
-
+{:else} +
+ {@render children()} +
+{/if} \ No newline at end of file diff --git a/src/lib/components/shared/manga/Thumbnail.svelte b/src/lib/components/shared/manga/Thumbnail.svelte index 8cb0ca6..2182cfd 100644 --- a/src/lib/components/shared/manga/Thumbnail.svelte +++ b/src/lib/components/shared/manga/Thumbnail.svelte @@ -1,7 +1,6 @@ diff --git a/src/lib/components/shared/ui/ContextMenu.svelte b/src/lib/components/shared/ui/ContextMenu.svelte new file mode 100644 index 0000000..4ca4f77 --- /dev/null +++ b/src/lib/components/shared/ui/ContextMenu.svelte @@ -0,0 +1,231 @@ + + + + + \ No newline at end of file diff --git a/src/lib/components/shared/ui/SourceList.svelte b/src/lib/components/shared/ui/SourceList.svelte new file mode 100644 index 0000000..63a65cc --- /dev/null +++ b/src/lib/components/shared/ui/SourceList.svelte @@ -0,0 +1,120 @@ + + +
+
+

Sources

+
+ + +
+
+ +
+
+ {#each langs as l} + + {/each} +
+ + {#if extensionsState.loading} +
+ {:else if groups.length === 0} +
No sources found.
+ {:else} +
+ {#each groups as g} + {@const single = g.sources.length === 1} + {@const open = expanded.has(g.name)} +
+ + {#if !single && open} + {#each g.sources as src} + + {/each} + {/if} +
+ {/each} +
+ {/if} +
+
+ + \ No newline at end of file diff --git a/src/lib/components/tracking/TrackingPanel.svelte b/src/lib/components/tracking/TrackingPanel.svelte new file mode 100644 index 0000000..543b4b7 --- /dev/null +++ b/src/lib/components/tracking/TrackingPanel.svelte @@ -0,0 +1,14 @@ + + +
+ +

TrackingPanel

+
+ + \ No newline at end of file diff --git a/src/lib/core/cover/autoLink.ts b/src/lib/core/cover/autoLink.ts index ce50ecd..71dfa3e 100644 --- a/src/lib/core/cover/autoLink.ts +++ b/src/lib/core/cover/autoLink.ts @@ -1,13 +1,25 @@ -import { appState } from '$lib/state/app.svelte' +import { settingsState, updateSettings } from '$lib/state/settings.svelte' import type { Manga } from '$lib/types' -export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise { +function linkManga(focalId: number, targetId: number) { + const existing = settingsState.settings.mangaLinks?.[focalId] ?? [] + if (existing.includes(targetId)) return + updateSettings({ + mangaLinks: { + ...settingsState.settings.mangaLinks, + [focalId]: [...existing, targetId], + }, + }) +} + +export function autoLinkLibrary(focal: Manga | null | undefined, allManga: Manga[]): Promise { + if (!focal) return Promise.resolve(0) return new Promise(resolve => { const worker = new Worker(new URL('./autoLinkWorker.ts', import.meta.url), { type: 'module' }) worker.onmessage = (e: MessageEvent) => { const matches = e.data - for (const id of matches) appState.linkManga(focal.id, id) + for (const id of matches) linkManga(focal.id, id) worker.terminate() resolve(matches.length) } @@ -18,7 +30,7 @@ export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise ({ id: m.id, title: m.title })), - linkedIds: appState.settings.mangaLinks?.[focal.id] ?? [], + linkedIds: settingsState.settings.mangaLinks?.[focal.id] ?? [], }) }) } \ No newline at end of file diff --git a/src/lib/core/cover/coverResolver.ts b/src/lib/core/cover/coverResolver.ts index da4da85..8dc8a5d 100644 --- a/src/lib/core/cover/coverResolver.ts +++ b/src/lib/core/cover/coverResolver.ts @@ -1,4 +1,5 @@ -import { appState } from '$lib/state/app.svelte' +import { settingsState } from '$lib/state/settings.svelte' +import { seriesState } from '$lib/state/series.svelte' import { searchWithScore } from '$lib/core/algorithms/search' import { getHash, areDuplicates } from '$lib/core/cover/coverHash' @@ -24,7 +25,7 @@ function normalizeUrl(url: string): string { } export function resolvedCover(mangaId: number, ownUrl: string): string { - return appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl + return settingsState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl } function fuzzyMatchIds( @@ -47,9 +48,9 @@ export function coverCandidatesSync( ownUrl: string, mangaById: Map, ): CoverCandidate[] { - const linkedIds = appState.getLinkedMangaIds(mangaId) + const linkedIds = seriesState.settings.mangaLinks?.[mangaId] ?? [] const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById) - const current = appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl + const current = settingsState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds])) const raw: { mangaId: number; url: string; label: string }[] = [ diff --git a/src/lib/platform-adapters/tauri/index.ts b/src/lib/platform-adapters/tauri/index.ts index 02d187a..de49d6e 100644 --- a/src/lib/platform-adapters/tauri/index.ts +++ b/src/lib/platform-adapters/tauri/index.ts @@ -1,15 +1,20 @@ -import { invoke } from '@tauri-apps/api/core' +import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' -import { open } from '@tauri-apps/plugin-dialog' +import { listen } from '@tauri-apps/api/event' +import { open } from '@tauri-apps/plugin-dialog' import { readFile, writeFile } from '@tauri-apps/plugin-fs' -import { open as openUrl } from '@tauri-apps/plugin-shell' -import { getVersion } from '@tauri-apps/api/app' +import { open as openUrl } from '@tauri-apps/plugin-shell' +import { getVersion } from '@tauri-apps/api/app' import type { PlatformAdapter, PlatformFeature, ServerLaunchConfig, DiscordPresence, AppUpdateInfo, + StorageInfo, + ReleaseInfo, + UpdateProgress, + MigrateProgress, } from '$lib/platform-adapters/types' export class TauriAdapter implements PlatformAdapter { @@ -106,8 +111,8 @@ export class TauriAdapter implements PlatformAdapter { async checkForAppUpdate(): Promise { const releases = await invoke>('list_releases') - const current = await getVersion() - const valid = releases.filter(r => r.tag_name?.trim()) + const current = await getVersion() + const valid = releases.filter(r => r.tag_name?.trim()) if (!valid.length) return null const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) const latest = valid.map(r => r.tag_name).sort((a, b) => { @@ -124,4 +129,69 @@ export class TauriAdapter implements PlatformAdapter { async installAppUpdate(tag: string) { await invoke('download_and_install_update', { tag }) } + + async restartApp() { + await invoke('restart_app') + } + + async getDefaultDownloadsPath(): Promise { + return invoke('get_default_downloads_path') + } + + async getStorageInfo(downloadsPath: string): Promise { + return invoke('get_storage_info', { downloadsPath }) + } + + async checkPathExists(path: string): Promise { + return invoke('check_path_exists', { path }) + } + + async createDirectory(path: string) { + await invoke('create_directory', { path }) + } + + async openPath(path: string) { + await invoke('open_path', { path }) + } + + async getAutoBackupDir(): Promise { + return invoke('get_auto_backup_dir') + } + + async clearMokuCache() { + await invoke('clear_moku_cache') + } + + async clearSuwayomiCache() { + await invoke('clear_suwayomi_cache') + } + + async resetSuwayomiData() { + await invoke('reset_suwayomi_data') + } + + async exitApp() { + await invoke('exit_app') + } + + async listReleases(): Promise { + const all = await invoke('list_releases') + return all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim()) + } + + async onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void> { + return listen('update-progress', e => cb(e.payload)) + } + + async onUpdateLaunching(cb: () => void): Promise<() => void> { + return listen('update-launching', cb) + } + + async onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void> { + return listen('migrate_progress', e => cb(e.payload)) + } + + async migrateDownloads(src: string, dst: string) { + await invoke('migrate_downloads', { src, dst }) + } } \ No newline at end of file diff --git a/src/lib/platform-adapters/tauri/updater.ts b/src/lib/platform-adapters/tauri/updater.ts index 7fc6e82..3ef0979 100644 --- a/src/lib/platform-adapters/tauri/updater.ts +++ b/src/lib/platform-adapters/tauri/updater.ts @@ -1,40 +1,39 @@ -import { invoke } from "@tauri-apps/api/core"; -import { getVersion } from "@tauri-apps/api/app"; -import { toast } from "$lib/state/app.svelte"; +import { invoke } from '@tauri-apps/api/core' +import { getVersion } from '@tauri-apps/api/app' +import { toast } from '$lib/state/notifications.svelte' function parse(tag: string): number[] { - return tag.replace(/^v/, "").split(".").map(Number); + return tag.replace(/^v/, '').split('.').map(Number) } function compare(a: number[], b: number[]): number { for (let i = 0; i < 3; i++) { - if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0); + if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0) } - return 0; + return 0 } export async function checkForUpdateSilently(): Promise { try { const [currentVersion, releases] = await Promise.all([ getVersion(), - invoke>("list_releases"), - ]); + invoke>('list_releases'), + ]) - const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim()); - if (!valid.length) return; + const valid = releases.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim()) + if (!valid.length) return const latestTag = valid .map(r => r.tag_name) .sort((a, b) => compare(parse(a), parse(b)))[0] - .replace(/^v/, ""); + .replace(/^v/, '') if (compare(parse(latestTag), parse(currentVersion)) < 0) { toast({ - kind: "info", - title: `Update available — v${latestTag}`, - body: "Open Settings → About to install.", - duration: 8000, - }); + kind: 'info', + message: `Update available — v${latestTag}`, + detail: 'Open Settings → About to install.', + }) } } catch {} } \ No newline at end of file diff --git a/src/lib/platform-adapters/types.ts b/src/lib/platform-adapters/types.ts index 8c2cd97..4e21414 100644 --- a/src/lib/platform-adapters/types.ts +++ b/src/lib/platform-adapters/types.ts @@ -7,21 +7,38 @@ export type PlatformFeature = | 'discord-rpc' export interface ServerLaunchConfig { - jarPath: string - port: number - dataPath: string + port?: number + [key: string]: unknown } export interface DiscordPresence { - title: string - chapter: string - startTimestamp?: number + state?: string + details?: string + [key: string]: unknown } export interface AppUpdateInfo { version: string - url: string - notes?: string + url: string + notes: string +} + +export interface StorageInfo { + manga_bytes: number + total_bytes: number + free_bytes: number + path: string +} + +export interface MigrateProgress { + done: number + total: number + current: string +} + +export interface UpdateProgress { + downloaded: number + total: number | null } export interface PlatformAdapter { @@ -52,5 +69,32 @@ export interface PlatformAdapter { getVersion(): Promise openExternal(url: string): Promise checkForAppUpdate(): Promise - installAppUpdate(): Promise + installAppUpdate(tag: string): Promise + restartApp(): Promise + + getDefaultDownloadsPath(): Promise + getStorageInfo(downloadsPath: string): Promise + checkPathExists(path: string): Promise + createDirectory(path: string): Promise + openPath(path: string): Promise + getAutoBackupDir(): Promise + + clearMokuCache(): Promise + clearSuwayomiCache(): Promise + resetSuwayomiData(): Promise + exitApp(): Promise + + onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void> + onUpdateLaunching(cb: () => void): Promise<() => void> + listReleases(): Promise + onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void> + migrateDownloads(src: string, dst: string): Promise +} + +export interface ReleaseInfo { + tag_name: string + name: string + body: string + published_at: string + html_url: string } \ No newline at end of file diff --git a/src/lib/platform-adapters/web/index.ts b/src/lib/platform-adapters/web/index.ts index 727cd01..f8ba6ae 100644 --- a/src/lib/platform-adapters/web/index.ts +++ b/src/lib/platform-adapters/web/index.ts @@ -4,6 +4,10 @@ import type { ServerLaunchConfig, DiscordPresence, AppUpdateInfo, + StorageInfo, + ReleaseInfo, + UpdateProgress, + MigrateProgress, } from '$lib/platform-adapters/types' export class WebAdapter implements PlatformAdapter { @@ -48,5 +52,27 @@ export class WebAdapter implements PlatformAdapter { } async checkForAppUpdate(): Promise { return null } - async installAppUpdate() {} + async installAppUpdate(_tag: string) {} + async restartApp() {} + + async getDefaultDownloadsPath(): Promise { return '' } + async getStorageInfo(_downloadsPath: string): Promise { + return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' } + } + async checkPathExists(_path: string): Promise { return false } + async createDirectory(_path: string) {} + async openPath(_path: string) {} + async getAutoBackupDir(): Promise { return '' } + + async clearMokuCache() {} + async clearSuwayomiCache() {} + async resetSuwayomiData() {} + async exitApp() {} + + async listReleases(): Promise { return [] } + + async onUpdateProgress(_cb: (p: UpdateProgress) => void): Promise<() => void> { return () => {} } + async onUpdateLaunching(_cb: () => void): Promise<() => void> { return () => {} } + async onMigrateProgress(_cb: (p: MigrateProgress) => void): Promise<() => void> { return () => {} } + async migrateDownloads(_src: string, _dst: string) {} } \ No newline at end of file diff --git a/src/lib/platform-service/index.ts b/src/lib/platform-service/index.ts index 82f8a13..bd7a4db 100644 --- a/src/lib/platform-service/index.ts +++ b/src/lib/platform-service/index.ts @@ -1,5 +1,14 @@ -import type { PlatformAdapter } from '$lib/platform-adapters/types' -import type { ServerLaunchConfig, DiscordPresence, AppUpdateInfo, PlatformFeature } from '$lib/platform-adapters/types' +import type { + PlatformAdapter, + PlatformFeature, + ServerLaunchConfig, + DiscordPresence, + AppUpdateInfo, + StorageInfo, + ReleaseInfo, + UpdateProgress, + MigrateProgress, +} from '$lib/platform-adapters/types' let adapter: PlatformAdapter @@ -13,32 +22,52 @@ function get(): PlatformAdapter { } export const platformService = { - isSupported: (f: PlatformFeature) => get().isSupported(f), - init: () => get().init(), + isSupported: (f: PlatformFeature) => get().isSupported(f), + init: () => get().init(), - launchServer: (c: ServerLaunchConfig) => get().launchServer(c), - stopServer: () => get().stopServer(), - getServerStatus: () => get().getServerStatus(), + launchServer: (c: ServerLaunchConfig) => get().launchServer(c), + stopServer: () => get().stopServer(), + getServerStatus: () => get().getServerStatus(), - readFile: (path: string) => get().readFile(path), - writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data), - pickFolder: () => get().pickFolder(), + readFile: (path: string) => get().readFile(path), + writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data), + pickFolder: () => get().pickFolder(), - authenticateBiometric: () => get().authenticateBiometric(), - storeCredential: (k: string, v: string) => get().storeCredential(k, v), - getCredential: (k: string) => get().getCredential(k), + authenticateBiometric: () => get().authenticateBiometric(), + storeCredential: (k: string, v: string) => get().storeCredential(k, v), + getCredential: (k: string) => get().getCredential(k), - setTitle: (title: string) => get().setTitle(title), - minimize: () => get().minimize(), - maximize: () => get().maximize(), - close: () => get().close(), - toggleFullscreen: () => get().toggleFullscreen(), + setTitle: (title: string) => get().setTitle(title), + minimize: () => get().minimize(), + maximize: () => get().maximize(), + close: () => get().close(), + toggleFullscreen: () => get().toggleFullscreen(), - setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p), - clearDiscordPresence: () => get().clearDiscordPresence(), + setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p), + clearDiscordPresence: () => get().clearDiscordPresence(), - getVersion: () => get().getVersion(), - openExternal: (url: string) => get().openExternal(url), - checkForAppUpdate: () => get().checkForAppUpdate(), - installAppUpdate: () => get().installAppUpdate(), + getVersion: () => get().getVersion(), + openExternal: (url: string) => get().openExternal(url), + checkForAppUpdate: () => get().checkForAppUpdate(), + installAppUpdate: (tag: string) => get().installAppUpdate(tag), + restartApp: () => get().restartApp(), + + getDefaultDownloadsPath: () => get().getDefaultDownloadsPath(), + getStorageInfo: (downloadsPath: string) => get().getStorageInfo(downloadsPath), + checkPathExists: (path: string) => get().checkPathExists(path), + createDirectory: (path: string) => get().createDirectory(path), + openPath: (path: string) => get().openPath(path), + getAutoBackupDir: () => get().getAutoBackupDir(), + + clearMokuCache: () => get().clearMokuCache(), + clearSuwayomiCache: () => get().clearSuwayomiCache(), + resetSuwayomiData: () => get().resetSuwayomiData(), + exitApp: () => get().exitApp(), + + listReleases: () => get().listReleases(), + + onUpdateProgress: (cb: (p: UpdateProgress) => void) => get().onUpdateProgress(cb), + onUpdateLaunching: (cb: () => void) => get().onUpdateLaunching(cb), + onMigrateProgress: (cb: (p: MigrateProgress) => void) => get().onMigrateProgress(cb), + migrateDownloads: (src: string, dst: string) => get().migrateDownloads(src, dst), } \ No newline at end of file diff --git a/src/lib/request-manager/chapters.ts b/src/lib/request-manager/chapters.ts index d0869b1..f60c38b 100644 --- a/src/lib/request-manager/chapters.ts +++ b/src/lib/request-manager/chapters.ts @@ -1,57 +1,67 @@ -import { getAdapter } from '$lib/request-manager' -import { seriesState } from '$lib/state/series.svelte' -import { readerState } from '$lib/state/reader.svelte' +import { getAdapter } from "$lib/request-manager"; +import { seriesState } from "$lib/state/series.svelte"; +import { readerState } from "$lib/state/reader.svelte"; +import type { Chapter } from "$lib/types"; -export async function loadChapters(mangaId: string) { - seriesState.chaptersLoading = true - seriesState.chaptersError = null - try { - seriesState.chapters = await getAdapter().getChapters(mangaId) - } catch (e) { - seriesState.chaptersError = String(e) - } finally { - seriesState.chaptersLoading = false - } +export async function getChapters(mangaId: number, signal?: AbortSignal): Promise { + return getAdapter().getChapters(String(mangaId), signal); } -export async function fetchChapters(mangaId: string) { - seriesState.chaptersLoading = true - seriesState.chaptersError = null +export async function fetchChapters(mangaId: number, signal?: AbortSignal): Promise { + return getAdapter().fetchChapters(String(mangaId), signal); +} + +export async function loadChapters(mangaId: string) { + seriesState.chaptersLoading = true; + seriesState.chaptersError = null; try { - seriesState.chapters = await getAdapter().fetchChapters(mangaId) + seriesState.chapters = await getAdapter().getChapters(mangaId); } catch (e) { - seriesState.chaptersError = String(e) + seriesState.chaptersError = String(e); } finally { - seriesState.chaptersLoading = false + seriesState.chaptersLoading = false; } } export async function loadChapterPages(chapterId: string, signal?: AbortSignal) { - readerState.pagesLoading = true - readerState.pagesError = null + readerState.pagesLoading = true; + readerState.pagesError = null; try { - readerState.pages = await getAdapter().getChapterPages(chapterId, signal) + readerState.pages = await getAdapter().getChapterPages(chapterId, signal); } catch (e) { - if (e instanceof DOMException && e.name === 'AbortError') return - readerState.pagesError = String(e) + if (e instanceof DOMException && e.name === "AbortError") return; + readerState.pagesError = String(e); } finally { - readerState.pagesLoading = false + readerState.pagesLoading = false; + } +} + +export async function markChapterRead(id: number, read: boolean) { + await getAdapter().markChapterRead(String(id), read); + const chapter = seriesState.chapters.find(c => c.id === id); + if (chapter) chapter.read = read; +} + +export async function markChaptersRead(ids: number[], read: boolean) { + await getAdapter().markChaptersRead(ids.map(String), read); + const idSet = new Set(ids); + for (const c of seriesState.chapters) { + if (idSet.has(c.id)) c.read = read; } } export async function markRead(id: string, read: boolean) { - await getAdapter().markChapterRead(id, read) - // chapter.id is a number; route params arrive as strings — compare via Number() - const numId = Number(id) - const chapter = seriesState.chapters.find(c => c.id === numId) - if (chapter) chapter.read = read + await getAdapter().markChapterRead(id, read); + const numId = Number(id); + const chapter = seriesState.chapters.find(c => c.id === numId); + if (chapter) chapter.read = read; } export async function markManyRead(ids: string[], read: boolean) { - await getAdapter().markChaptersRead(ids, read) - const numIds = new Set(ids.map(Number)) + await getAdapter().markChaptersRead(ids, read); + const numIds = new Set(ids.map(Number)); for (const c of seriesState.chapters) { - if (numIds.has(c.id)) c.read = read + if (numIds.has(c.id)) c.read = read; } } @@ -59,20 +69,20 @@ export async function updateChaptersProgress( ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }, ) { - await getAdapter().updateChaptersProgress(ids, patch) - const numIds = new Set(ids.map(Number)) + await getAdapter().updateChaptersProgress(ids, patch); + const numIds = new Set(ids.map(Number)); for (const c of seriesState.chapters) { - if (!numIds.has(c.id)) continue - if (patch.isRead !== undefined) c.read = patch.isRead - if (patch.isBookmarked !== undefined) c.bookmarked = patch.isBookmarked - if (patch.lastPageRead !== undefined) c.lastPageRead = patch.lastPageRead + if (!numIds.has(c.id)) continue; + if (patch.isRead !== undefined) c.read = patch.isRead; + if (patch.isBookmarked !== undefined) c.bookmarked = patch.isBookmarked; + if (patch.lastPageRead !== undefined) c.lastPageRead = patch.lastPageRead; } } -export async function deleteDownloadedChapters(ids: string[]) { - await getAdapter().deleteDownloadedChapters(ids) - const numIds = new Set(ids.map(Number)) +export async function deleteDownloadedChapters(ids: number[]) { + await getAdapter().deleteDownloadedChapters(ids.map(String)); + const idSet = new Set(ids); for (const c of seriesState.chapters) { - if (numIds.has(c.id)) c.downloaded = false + if (idSet.has(c.id)) c.downloaded = false; } } \ No newline at end of file diff --git a/src/lib/request-manager/downloads.ts b/src/lib/request-manager/downloads.ts index 47264b6..6a18321 100644 --- a/src/lib/request-manager/downloads.ts +++ b/src/lib/request-manager/downloads.ts @@ -1,44 +1,44 @@ -import { getAdapter } from '$lib/request-manager' -import { downloadsState } from '$lib/state/downloads.svelte' +import { getAdapter } from "$lib/request-manager"; +import { downloadsState } from "$lib/state/downloads.svelte"; export async function loadDownloads() { try { - downloadsState.items = await getAdapter().getDownloads() + downloadsState.items = await getAdapter().getDownloads(); } catch (e) { - downloadsState.error = String(e) + downloadsState.error = String(e); } } export async function enqueueDownload(chapterId: string) { - await getAdapter().enqueueDownload(chapterId) - await loadDownloads() + await getAdapter().enqueueDownload(chapterId); + await loadDownloads(); } export async function enqueueDownloads(chapterIds: string[]) { - await getAdapter().enqueueDownloads(chapterIds) - await loadDownloads() + await getAdapter().enqueueDownloads(chapterIds); + await loadDownloads(); } export async function dequeueDownload(chapterId: string) { - await getAdapter().dequeueDownload(chapterId) - downloadsState.items = downloadsState.items.filter(d => d.chapterId !== chapterId) + await getAdapter().dequeueDownload(chapterId); + downloadsState.items = downloadsState.items.filter(d => d.chapterId !== chapterId); } export async function dequeueDownloads(chapterIds: string[]) { - const ids = new Set(chapterIds) - await getAdapter().dequeueDownloads(chapterIds) - downloadsState.items = downloadsState.items.filter(d => !ids.has(d.chapterId)) + const ids = new Set(chapterIds); + await getAdapter().dequeueDownloads(chapterIds); + downloadsState.items = downloadsState.items.filter(d => !ids.has(d.chapterId)); } export async function clearDownloads() { - await getAdapter().clearDownloads() - downloadsState.items = [] + await getAdapter().clearDownloads(); + downloadsState.items = []; } export async function startDownloader() { - await getAdapter().startDownloader() + await getAdapter().startDownloader(); } export async function stopDownloader() { - await getAdapter().stopDownloader() + await getAdapter().stopDownloader(); } \ No newline at end of file diff --git a/src/lib/request-manager/extensions.ts b/src/lib/request-manager/extensions.ts index 26797b3..7f0f9c8 100644 --- a/src/lib/request-manager/extensions.ts +++ b/src/lib/request-manager/extensions.ts @@ -1,80 +1,80 @@ -import { getAdapter } from '$lib/request-manager' -import { extensionsState } from '$lib/state/extensions.svelte' -import type { SetServerAuthInput, SetSocksProxyInput, SetFlareSolverrInput } from '$lib/server-adapters/types' +import { getAdapter } from "$lib/request-manager"; +import { extensionsState } from "$lib/state/extensions.svelte"; +import type { SetServerAuthInput, SetSocksProxyInput, SetFlareSolverrInput } from "$lib/server-adapters/types"; export async function loadExtensions() { - extensionsState.loading = true - extensionsState.error = null + extensionsState.loading = true; + extensionsState.error = null; try { - extensionsState.items = await getAdapter().getExtensions() + extensionsState.items = await getAdapter().getExtensions(); } catch (e) { - extensionsState.error = String(e) + extensionsState.error = String(e); } finally { - extensionsState.loading = false + extensionsState.loading = false; } } export async function loadSources() { try { - extensionsState.sources = await getAdapter().getSources() + extensionsState.sources = await getAdapter().getSources(); } catch (e) { - extensionsState.error = String(e) + extensionsState.error = String(e); } } export async function installExtension(id: string) { - await getAdapter().installExtension(id) - await loadExtensions() + await getAdapter().installExtension(id); + await loadExtensions(); } export async function installExternalExtension(url: string) { - await getAdapter().installExternalExtension(url) - await loadExtensions() + await getAdapter().installExternalExtension(url); + await loadExtensions(); } export async function uninstallExtension(id: string) { - await getAdapter().uninstallExtension(id) - extensionsState.items = extensionsState.items.filter(e => e.id !== id) + await getAdapter().uninstallExtension(id); + extensionsState.items = extensionsState.items.filter(e => e.id !== id); } export async function updateExtension(id: string) { - await getAdapter().updateExtension(id) - await loadExtensions() + await getAdapter().updateExtension(id); + await loadExtensions(); } export async function updateAllExtensions() { - const updatable = extensionsState.items.filter(e => e.hasUpdate).map(e => e.id) - if (!updatable.length) return - await getAdapter().updateExtensions(updatable) - await loadExtensions() + const updatable = extensionsState.items.filter(e => e.hasUpdate).map(e => e.id); + if (!updatable.length) return; + await getAdapter().updateExtensions(updatable); + await loadExtensions(); } export async function browseSource(sourceId: string, page: number) { - extensionsState.browseLoading = true - extensionsState.browseError = null + extensionsState.browseLoading = true; + extensionsState.browseError = null; try { - const result = await getAdapter().browseSource(sourceId, page) - extensionsState.browseResults = result.items - extensionsState.browseHasMore = result.hasNextPage + const result = await getAdapter().browseSource(sourceId, page); + extensionsState.browseResults = result.items; + extensionsState.browseHasMore = result.hasNextPage; } catch (e) { - extensionsState.browseError = String(e) + extensionsState.browseError = String(e); } finally { - extensionsState.browseLoading = false + extensionsState.browseLoading = false; } } export async function getServerSecurity() { - return getAdapter().getServerSecurity() + return getAdapter().getServerSecurity(); } export async function setServerAuth(input: SetServerAuthInput) { - await getAdapter().setServerAuth(input) + await getAdapter().setServerAuth(input); } export async function setSocksProxy(input: SetSocksProxyInput) { - await getAdapter().setSocksProxy(input) + await getAdapter().setSocksProxy(input); } export async function setFlareSolverr(input: SetFlareSolverrInput) { - await getAdapter().setFlareSolverr(input) + await getAdapter().setFlareSolverr(input); } \ No newline at end of file diff --git a/src/lib/request-manager/index.ts b/src/lib/request-manager/index.ts index 727d40b..6a267e5 100644 --- a/src/lib/request-manager/index.ts +++ b/src/lib/request-manager/index.ts @@ -1,23 +1,23 @@ -import type { ServerAdapter } from '$lib/server-adapters/types' -import * as extensions from './extensions' -import * as chapters from './chapters' -import * as downloads from './downloads' -import * as manga from './manga' -import * as tracking from './tracking' +import type { ServerAdapter } from "$lib/server-adapters/types"; +import * as extensions from "./extensions"; +import * as chapters from "./chapters"; +import * as downloads from "./downloads"; +import * as manga from "./manga"; +import * as tracking from "./tracking"; -let adapter: ServerAdapter +let adapter: ServerAdapter; export function initRequestManager(a: ServerAdapter) { - adapter = a + adapter = a; } export function getAdapter(): ServerAdapter { - if (!adapter) throw new Error('RequestManager not initialized') - return adapter + if (!adapter) throw new Error("RequestManager not initialized"); + return adapter; } export function clearPageCache(chapterId?: number): void { - getAdapter().clearPageCache(chapterId) + getAdapter().clearPageCache(chapterId); } export const requestManager = { @@ -26,4 +26,4 @@ export const requestManager = { downloads, manga, tracking, -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/lib/request-manager/manga.ts b/src/lib/request-manager/manga.ts index e2793bf..66eaa75 100644 --- a/src/lib/request-manager/manga.ts +++ b/src/lib/request-manager/manga.ts @@ -1,131 +1,153 @@ -import { getAdapter } from '$lib/request-manager' -import { libraryState } from '$lib/state/library.svelte' -import { toast } from '$lib/state/notifications.svelte' -import { seriesState } from '$lib/state/series.svelte' -import type { MangaFilters, MangaMeta } from '$lib/server-adapters/types' +import { getAdapter } from "$lib/request-manager"; +import { libraryState } from "$lib/state/library.svelte"; +import { addToast } from "$lib/state/notifications.svelte"; +import { seriesState } from "$lib/state/series.svelte"; +import type { MangaFilters, MangaMeta } from "$lib/server-adapters/types"; +import type { Manga, Chapter, Category } from "$lib/types"; export async function loadLibrary(filters: MangaFilters = { inLibrary: true }) { - libraryState.loading = true - libraryState.error = null + libraryState.loading = true; + libraryState.error = null; try { - const result = await getAdapter().getMangaList(filters) - libraryState.items = result.items + const result = await getAdapter().getMangaList(filters); + libraryState.items = result.items; } catch (e) { - libraryState.error = String(e) + libraryState.error = String(e); } finally { - libraryState.loading = false + libraryState.loading = false; } } +export async function getManga(id: number, signal?: AbortSignal): Promise { + return getAdapter().getManga(String(id), signal); +} + +export async function getMangaList(): Promise { + const result = await getAdapter().getMangaList({}); + return result.items; +} + +export async function getCategories(): Promise { + return getAdapter().getCategories(); +} + +export async function updateManga(id: number, patch: { inLibrary?: boolean }): Promise { + if (patch.inLibrary === true) await getAdapter().addToLibrary(String(id)); + if (patch.inLibrary === false) await getAdapter().removeFromLibrary(String(id)); +} + export async function loadManga(id: string) { - seriesState.loading = true - seriesState.error = null + seriesState.loading = true; + seriesState.error = null; try { - seriesState.current = await getAdapter().getManga(id) + seriesState.current = await getAdapter().getManga(id); } catch (e) { - seriesState.error = String(e) + seriesState.error = String(e); } finally { - seriesState.loading = false + seriesState.loading = false; } } export async function fetchManga(id: string) { - seriesState.loading = true - seriesState.error = null + seriesState.loading = true; + seriesState.error = null; try { - seriesState.current = await getAdapter().fetchManga(id) + seriesState.current = await getAdapter().fetchManga(id); } catch (e) { - seriesState.error = String(e) + seriesState.error = String(e); } finally { - seriesState.loading = false + seriesState.loading = false; } } export async function searchManga(query: string, sourceId?: string) { - libraryState.loading = true - libraryState.error = null + libraryState.loading = true; + libraryState.error = null; try { - libraryState.searchResults = await getAdapter().searchManga(query, sourceId) + (libraryState as any).searchResults = await getAdapter().searchManga(query, sourceId); } catch (e) { - libraryState.error = String(e) + libraryState.error = String(e); } finally { - libraryState.loading = false + libraryState.loading = false; } } export async function addToLibrary(mangaId: string) { - await getAdapter().addToLibrary(mangaId) - await loadLibrary() + await getAdapter().addToLibrary(mangaId); + await loadLibrary(); } export async function removeFromLibrary(mangaId: string) { - await getAdapter().removeFromLibrary(mangaId) - libraryState.items = libraryState.items.filter(m => String(m.id) !== mangaId) + await getAdapter().removeFromLibrary(mangaId); + libraryState.items = libraryState.items.filter(m => String(m.id) !== mangaId); } export async function updateMangaMeta(id: string, meta: Partial) { - await getAdapter().updateMangaMeta(id, meta) - if (String(seriesState.current?.id) === id) await loadManga(id) + await getAdapter().updateMangaMeta(id, meta); + if (String(seriesState.current?.id) === id) await loadManga(id); } export async function deleteMangaMeta(id: string, key: string) { - await getAdapter().deleteMangaMeta(id, key) - if (String(seriesState.current?.id) === id) await loadManga(id) + await getAdapter().deleteMangaMeta(id, key); + if (String(seriesState.current?.id) === id) await loadManga(id); } export async function refreshLibrary() { - libraryState.refreshing = true + libraryState.refreshing = true; try { - await getAdapter().checkForUpdates() - await loadLibrary() - toast({ kind: 'success', message: 'Library updated' }) + await getAdapter().checkForUpdates(); + await loadLibrary(); + addToast({ kind: "success", title: "Library updated" }); } catch (e) { - toast({ kind: 'error', message: 'Update failed', detail: String(e) }) + addToast({ kind: "error", title: "Update failed", body: String(e) }); } finally { - libraryState.refreshing = false + libraryState.refreshing = false; } } export async function stopLibraryUpdate() { - await getAdapter().stopLibraryUpdate() + await getAdapter().stopLibraryUpdate(); } export async function pollLibraryUpdateStatus() { - return getAdapter().getLibraryUpdateStatus() + return getAdapter().getLibraryUpdateStatus(); } export async function bulkRemoveFromLibrary(ids: Set) { - await Promise.allSettled([...ids].map(id => getAdapter().removeFromLibrary(String(id)))) - libraryState.items = libraryState.items.filter(m => !ids.has(m.id)) - libraryState.exitSelect() + await Promise.allSettled([...ids].map(id => getAdapter().removeFromLibrary(String(id)))); + libraryState.items = libraryState.items.filter(m => !ids.has(m.id)); + libraryState.exitSelect(); } export async function loadCategories() { try { - libraryState.categories = await getAdapter().getCategories() + const cats = await getAdapter().getCategories(); + libraryState.setCategories(cats); } catch (e) { - libraryState.error = String(e) + libraryState.error = String(e); } } -export async function createCategory(name: string) { - const category = await getAdapter().createCategory(name) - libraryState.categories = [...libraryState.categories, category] +export async function createCategory(name: string): Promise { + const category = await getAdapter().createCategory(name); + libraryState.setCategories([...libraryState.categories, category]); + return category; } export async function deleteCategory(id: number) { - await getAdapter().deleteCategory(id) - libraryState.categories = libraryState.categories.filter(c => c.id !== id) + await getAdapter().deleteCategory(id); + libraryState.setCategories(libraryState.categories.filter(c => c.id !== id)); } export async function updateCategoryOrder(id: number, position: number) { - libraryState.categories = await getAdapter().updateCategoryOrder(id, position) + const cats = await getAdapter().updateCategoryOrder(id, position); + libraryState.setCategories(cats); } export async function updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]) { - await getAdapter().updateMangaCategories(mangaId, addTo, removeFrom) + await getAdapter().updateMangaCategories(mangaId, addTo, removeFrom); } export async function updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]) { - await getAdapter().updateMangasCategories(mangaIds, addTo, removeFrom) + await getAdapter().updateMangasCategories(mangaIds, addTo, removeFrom); } \ No newline at end of file diff --git a/src/lib/request-manager/tracking.ts b/src/lib/request-manager/tracking.ts index 8a13ba3..96385d1 100644 --- a/src/lib/request-manager/tracking.ts +++ b/src/lib/request-manager/tracking.ts @@ -1,57 +1,57 @@ -import { getAdapter } from '$lib/request-manager' -import { trackingState } from '$lib/state/tracking.svelte' +import { getAdapter } from "$lib/request-manager"; +import { trackingState } from "$lib/state/tracking.svelte"; export async function loadTrackers() { - trackingState.loading = true - trackingState.error = null + trackingState.loading = true; + trackingState.error = null; try { - trackingState.trackers = await getAdapter().getTrackers() + trackingState.trackers = await getAdapter().getTrackers(); } catch (e) { - trackingState.error = String(e) + trackingState.error = String(e); } finally { - trackingState.loading = false + trackingState.loading = false; } } export async function loadMangaTrackRecords(mangaId: string) { - trackingState.recordsLoading = true - trackingState.recordsError = null + trackingState.recordsLoading = true; + trackingState.recordsError = null; try { - trackingState.records = await getAdapter().getMangaTrackRecords(mangaId) + trackingState.records = await getAdapter().getMangaTrackRecords(mangaId); } catch (e) { - trackingState.recordsError = String(e) + trackingState.recordsError = String(e); } finally { - trackingState.recordsLoading = false + trackingState.recordsLoading = false; } } export async function searchTracker(trackerId: string, query: string) { - trackingState.searchLoading = true - trackingState.searchError = null + trackingState.searchLoading = true; + trackingState.searchError = null; try { - trackingState.searchResults = await getAdapter().searchTracker(trackerId, query) + trackingState.searchResults = await getAdapter().searchTracker(trackerId, query); } catch (e) { - trackingState.searchError = String(e) + trackingState.searchError = String(e); } finally { - trackingState.searchLoading = false + trackingState.searchLoading = false; } } export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) { - await getAdapter().linkTracker(mangaId, trackerId, remoteId) - await loadMangaTrackRecords(mangaId) + await getAdapter().linkTracker(mangaId, trackerId, remoteId); + await loadMangaTrackRecords(mangaId); } export async function unlinkTracker(mangaId: string, recordId: string) { - await getAdapter().unlinkTracker(recordId) - await loadMangaTrackRecords(mangaId) + await getAdapter().unlinkTracker(recordId); + await loadMangaTrackRecords(mangaId); } export async function syncTracking(mangaId: string) { - trackingState.syncing = true + trackingState.syncing = true; try { - await getAdapter().syncTracking(mangaId) + await getAdapter().syncTracking(mangaId); } finally { - trackingState.syncing = false + trackingState.syncing = false; } } \ No newline at end of file diff --git a/src/lib/server-adapters/suwayomi/types.ts b/src/lib/server-adapters/suwayomi/types.ts index 5ebe4bf..7a6371a 100644 --- a/src/lib/server-adapters/suwayomi/types.ts +++ b/src/lib/server-adapters/suwayomi/types.ts @@ -87,6 +87,6 @@ export function mapCategory(raw: Record): Category { default: raw.default as boolean, includeInUpdate: raw.includeInUpdate as boolean, includeInDownload: raw.includeInDownload as boolean, - mangas: (raw.mangas as { nodes: Record[] })?.nodes?.map(mapManga), + mangas: { nodes: (raw.mangas as { nodes: Record[] })?.nodes?.map(mapManga) ?? [] }, } } \ No newline at end of file diff --git a/src/lib/state/app.svelte.ts b/src/lib/state/app.svelte.ts index 2439ceb..983620f 100644 --- a/src/lib/state/app.svelte.ts +++ b/src/lib/state/app.svelte.ts @@ -35,6 +35,10 @@ export const appState = $state({ authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', platform: 'web' as 'web' | 'tauri' | 'capacitor', version: '', + libraryFilter: '', + categories: [] as { id: number; name: string }[], + history: [] as unknown[], + toasts: [] as unknown[], }) export function setNavPage(next: NavPage) { app.setNavPage(next) } diff --git a/src/lib/state/downloads.svelte.ts b/src/lib/state/downloads.svelte.ts index 887b9ea..25f4151 100644 --- a/src/lib/state/downloads.svelte.ts +++ b/src/lib/state/downloads.svelte.ts @@ -1,18 +1,18 @@ -import type { DownloadItem } from '$lib/server-adapters/types' +import type { DownloadItem } from "$lib/server-adapters/types"; export const downloadsState = $state({ items: [] as DownloadItem[], error: null as string | null, -}) +}); export function activeDownloads() { - return downloadsState.items.filter(d => d.state === 'downloading') + return downloadsState.items.filter(d => d.state === "downloading"); } export function queuedDownloads() { - return downloadsState.items.filter(d => d.state === 'queued') + return downloadsState.items.filter(d => d.state === "queued"); } export function downloadCount() { - return downloadsState.items.length + return downloadsState.items.length; } \ No newline at end of file diff --git a/src/lib/state/extensions.svelte.ts b/src/lib/state/extensions.svelte.ts index 2c7bfe6..f93aabe 100644 --- a/src/lib/state/extensions.svelte.ts +++ b/src/lib/state/extensions.svelte.ts @@ -1,36 +1,37 @@ -import type { Extension, Source, Manga } from '$lib/types' +import type { Extension, Source, Manga } from "$lib/types"; export const extensionsState = $state({ - items: [] as Extension[], - sources: [] as Source[], - loading: false, - error: null as string | null, + items: [] as Extension[], + sources: [] as Source[], + activeSource: null as Source | null, + loading: false, + error: null as string | null, filter: { - query: '', + query: "", installed: false, - language: 'all', + language: "all", }, browseResults: [] as Manga[], browseLoading: false, - browseError: null as string | null, + browseError: null as string | null, browseHasMore: false, -}) +}); export function filteredExtensions() { - let result = extensionsState.items + let result = extensionsState.items; if (extensionsState.filter.installed) { - result = result.filter(e => e.installed) + result = result.filter(e => e.installed); } - if (extensionsState.filter.language !== 'all') { - result = result.filter(e => e.lang === extensionsState.filter.language) + if (extensionsState.filter.language !== "all") { + result = result.filter(e => e.lang === extensionsState.filter.language); } if (extensionsState.filter.query) { - const q = extensionsState.filter.query.toLowerCase() - result = result.filter(e => e.name.toLowerCase().includes(q)) + const q = extensionsState.filter.query.toLowerCase(); + result = result.filter(e => e.name.toLowerCase().includes(q)); } - return result + return result; } \ No newline at end of file diff --git a/src/lib/state/home.svelte.ts b/src/lib/state/home.svelte.ts index 36ce9f0..9324556 100644 --- a/src/lib/state/home.svelte.ts +++ b/src/lib/state/home.svelte.ts @@ -1,42 +1,42 @@ export interface HistoryEntry { - mangaId: number - mangaTitle: string - thumbnailUrl: string - chapterId: number - chapterName: string - chapterNumber: number - pageNumber: number - readAt: number + mangaId: number; + mangaTitle: string; + thumbnailUrl: string; + chapterId: number; + chapterName: string; + chapterNumber: number; + pageNumber: number; + readAt: number; } export interface ReadingStats { - currentStreakDays: number - totalChaptersRead: number - totalMinutesRead: number - totalMangaRead: number - longestStreakDays: number + currentStreakDays: number; + totalChaptersRead: number; + totalMinutesRead: number; + totalMangaRead: number; + longestStreakDays: number; } export const homeState = $state({ history: [] as HistoryEntry[], dailyReadCounts: {} as Record, stats: { - currentStreakDays: 0, - totalChaptersRead: 0, - totalMinutesRead: 0, - totalMangaRead: 0, + currentStreakDays: 0, + totalChaptersRead: 0, + totalMinutesRead: 0, + totalMangaRead: 0, longestStreakDays: 0, } as ReadingStats, heroSlots: [null, null, null, null] as [number | null, number | null, number | null, number | null], -}) +}); export function setHeroSlot(i: 1 | 2 | 3, mangaId: number | null) { - homeState.heroSlots[i] = mangaId + homeState.heroSlots[i] = mangaId; } export function recordRead(entry: HistoryEntry) { - homeState.history = [entry, ...homeState.history.filter(e => e.chapterId !== entry.chapterId)] - const dateStr = new Date(entry.readAt).toISOString().slice(0, 10) - homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1 - homeState.stats.totalChaptersRead++ + homeState.history = [entry, ...homeState.history.filter(e => e.chapterId !== entry.chapterId)]; + const dateStr = new Date(entry.readAt).toISOString().slice(0, 10); + homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1; + homeState.stats.totalChaptersRead++; } \ No newline at end of file diff --git a/src/lib/state/library.svelte.ts b/src/lib/state/library.svelte.ts index 567d720..16c772e 100644 --- a/src/lib/state/library.svelte.ts +++ b/src/lib/state/library.svelte.ts @@ -1,89 +1,242 @@ -import type { Manga } from '$lib/types' -import type { MangaStatus } from '$lib/server-adapters/types' +import type { Manga } from "$lib/types"; +import type { MangaStatus } from "$lib/server-adapters/types"; +import type { Category } from "$lib/types"; -export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded' -export type LibraryTab = 'saved' | 'downloaded' +export type LibrarySortOption = + | "alphabetical" + | "unread" + | "lastRead" + | "dateAdded" + | "totalChapters" + | "latestFetched" + | "latestUploaded"; + +export type LibrarySortDir = "asc" | "desc"; + +export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked"; + +export type LibraryStatusFilter = + | "ALL" + | "ONGOING" + | "COMPLETED" + | "ON_HIATUS" + | "CANCELLED" + | "PUBLISHING_FINISHED"; class LibraryState { - items = $state([]) - loading = $state(false) - error = $state(null) - refreshing = $state(false) + items = $state([]); + categories = $state([]); + loading = $state(false); + error = $state(null); + refreshing = $state(false); - tab = $state('saved') - sort = $state('alphabetical') - sortDesc = $state(false) + tab = $state("library"); - filter = $state({ - status: 'all' as MangaStatus | 'all', - unread: false, - downloaded: false, - bookmarked: false, - query: '', - }) + tabSort = $state>({}); + tabStatus = $state>({}); + tabFilters = $state>>>({}); - selected = $state(new Set()) - selectMode = $state(false) + hiddenTabs = $state>(new Set()); + pinnedTabOrder = $state([]); + defaultCategoryId = $state(null); + showAllInSaved = $state(true); + hideCompletedInSaved = $state(false); + categoryFrecency = $state>({}); + + filter = $state({ query: "" }); + + selected = $state(new Set()); + selectMode = $state(false); + + refreshProgress = $state({ finished: 0, total: 0 }); + refreshDone = $state(false); + + refreshingMangaId = $state(null); + refreshingCatId = $state(null); + + readonly COMPLETED_NAME = "Completed"; + + get completedCatId(): number | null { + return this.categories.find(c => c.name === this.COMPLETED_NAME && c.id !== 0)?.id ?? null; + } + + get categoryMangaMap(): Map { + const map = new Map(); + for (const cat of this.categories) { + map.set(cat.id, (cat as any).mangas?.nodes ?? []); + } + return map; + } + + get allTabIds(): string[] { + const catIds = this.categories.filter(c => c.id !== 0).map(c => String(c.id)); + const BUILTIN = ["library", "downloaded"]; + const known = new Set([...BUILTIN, ...catIds]); + const ordered: string[] = []; + const inOrder = new Set(); + for (const id of this.pinnedTabOrder) { + if (known.has(id) && !inOrder.has(id)) { ordered.push(id); inOrder.add(id); } + } + for (const id of [...BUILTIN, ...catIds]) { + if (!inOrder.has(id)) { ordered.push(id); inOrder.add(id); } + } + return ordered; + } + + get visibleTabIds(): string[] { + return this.allTabIds.filter(id => !this.hiddenTabs.has(id)); + } + + get visibleCategories(): Category[] { + const pinned = this.pinnedTabOrder; + const defId = this.defaultCategoryId; + const cats = this.categories.filter(c => c.id !== 0 && !this.hiddenTabs.has(String(c.id))); + const pinOrder = (id: number) => { const i = pinned.indexOf(String(id)); return i === -1 ? Infinity : i; }; + return [...cats].sort((a, b) => { + if (a.id === defId) return -1; + if (b.id === defId) return 1; + const pd = pinOrder(a.id) - pinOrder(b.id); + return pd !== 0 ? pd : (a as any).order - (b as any).order; + }); + } + + get counts(): Record { + const m: Record = { + library: this.showAllInSaved + ? this.items.filter(x => x.inLibrary).length + : (this.categoryMangaMap.get(0) ?? []).length, + downloaded: this.items.filter(x => (x.downloadCount ?? 0) > 0).length, + }; + for (const cat of this.visibleCategories) { + m[String(cat.id)] = (this.categoryMangaMap.get(cat.id) ?? []).length; + } + return m; + } filteredItems = $derived.by(() => { - let result = this.tab === 'downloaded' - ? this.items.filter(m => (m.downloadCount ?? 0) > 0) - : this.items.filter(m => m.inLibrary) + const tab = this.tab; - if (this.filter.unread) result = result.filter(m => (m.unreadCount ?? 0) > 0) - if (this.filter.downloaded) result = result.filter(m => (m.downloadCount ?? 0) > 0) - if (this.filter.bookmarked) result = result.filter(m => (m.bookmarkCount ?? 0) > 0) + let items: Manga[]; + if (tab === "library") { + items = this.showAllInSaved + ? this.items.filter(m => m.inLibrary) + : (this.categoryMangaMap.get(0) ?? []); - if (this.filter.status !== 'all') { - result = result.filter( - m => m.status?.toUpperCase().replace(/\s+/g, '_') === this.filter.status - ) - } - - if (this.filter.query) { - const q = this.filter.query.toLowerCase() - result = result.filter(m => m.title.toLowerCase().includes(q)) - } - - const sorted = [...result].sort((a, b) => { - switch (this.sort) { - case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0) - case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0) - case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0) - default: return a.title.localeCompare(b.title) + if (this.showAllInSaved && this.hideCompletedInSaved) { + const completedCat = this.categories.find(c => c.name === this.COMPLETED_NAME); + if (completedCat) { + const completedIds = new Set((this.categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id)); + items = items.filter(m => !completedIds.has(m.id)); + } } - }) + } else if (tab === "downloaded") { + items = this.items.filter(m => (m.downloadCount ?? 0) > 0); + } else { + items = this.categoryMangaMap.get(Number(tab)) ?? []; + } - return this.sortDesc ? sorted.reverse() : sorted - }) + const q = this.filter.query.trim().toLowerCase(); + if (q) items = items.filter(m => m.title.toLowerCase().includes(q)); - get hasActiveFilters() { - return this.filter.status !== 'all' - || this.filter.unread - || this.filter.downloaded - || this.filter.bookmarked + const status = this.tabStatus[tab] ?? "ALL"; + if (status !== "ALL") { + items = items.filter(m => { + const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN"; + return s === status; + }); + } + + const f = this.tabFilters[tab] ?? {}; + if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0); + if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0)); + if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0); + if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0); + + const { mode, dir } = this.tabSort[tab] ?? { mode: "alphabetical" as LibrarySortOption, dir: "asc" as LibrarySortDir }; + + const sorted = [...items].sort((a, b) => { + switch (mode) { + case "unread": return (b.unreadCount ?? 0) - (a.unreadCount ?? 0); + case "lastRead": return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0); + case "dateAdded": return (b.addedAt ?? 0) - (a.addedAt ?? 0); + case "totalChapters": return (b.chapters?.totalCount ?? 0) - (a.chapters?.totalCount ?? 0); + case "latestFetched": return Number(b.latestFetchedChapter?.uploadDate ?? 0) - Number(a.latestFetchedChapter?.uploadDate ?? 0); + case "latestUploaded": return Number(b.latestUploadedChapter?.uploadDate ?? 0) - Number(a.latestUploadedChapter?.uploadDate ?? 0); + default: return a.title.localeCompare(b.title); + } + }); + + return dir === "desc" ? sorted.reverse() : sorted; + }); + + get hasActiveFilters(): boolean { + const tab = this.tab; + const status = this.tabStatus[tab] ?? "ALL"; + const filters = this.tabFilters[tab] ?? {}; + return status !== "ALL" || Object.values(filters).some(Boolean); + } + + setTabSort(tab: string, mode: LibrarySortOption, dir?: LibrarySortDir) { + const prev = this.tabSort[tab]; + const newDir = dir ?? prev?.dir ?? "asc"; + this.tabSort = { ...this.tabSort, [tab]: { mode, dir: newDir } }; + } + + toggleTabSortDir(tab: string) { + const prev = this.tabSort[tab]; + const mode = prev?.mode ?? "alphabetical"; + const dir = prev?.dir === "asc" ? "desc" : "asc"; + this.setTabSort(tab, mode, dir); + } + + setTabStatus(tab: string, status: LibraryStatusFilter) { + this.tabStatus = { ...this.tabStatus, [tab]: status }; + } + + toggleTabFilter(tab: string, filter: LibraryContentFilter) { + const current = this.tabFilters[tab] ?? {}; + this.tabFilters = { ...this.tabFilters, [tab]: { ...current, [filter]: !current[filter] } }; + } + + clearTabFilters(tab: string) { + this.tabStatus = { ...this.tabStatus, [tab]: "ALL" }; + this.tabFilters = { ...this.tabFilters, [tab]: {} }; + } + + setCategories(cats: Category[]) { + this.categories = cats; + } + + bumpCategoryFrecency(catId: number) { + this.categoryFrecency = { ...this.categoryFrecency, [catId]: (this.categoryFrecency[catId] ?? 0) + 1 }; } enterSelect(id?: number) { - this.selectMode = true - if (id !== undefined) this.selected = new Set([id]) + this.selectMode = true; + if (id !== undefined) this.selected = new Set([id]); } exitSelect() { - this.selectMode = false - this.selected = new Set() + this.selectMode = false; + this.selected = new Set(); } toggleSelect(id: number) { - const next = new Set(this.selected) - if (next.has(id)) next.delete(id); else next.add(id) - this.selected = next - if (next.size === 0) this.exitSelect() + const next = new Set(this.selected); + if (next.has(id)) next.delete(id); else next.add(id); + this.selected = next; + if (next.size === 0) this.exitSelect(); } - selectAll() { - this.selected = new Set(this.filteredItems.map(m => m.id)) + selectAll(ids: number[]) { + this.selected = new Set(ids); + } + + guardTab() { + if (this.tab === "library" || this.tab === "downloaded") return; + const id = Number(this.tab); + if (!this.categories.some(c => c.id === id)) this.tab = "library"; } } -export const libraryState = new LibraryState() \ No newline at end of file +export const libraryState = new LibraryState(); \ No newline at end of file diff --git a/src/lib/state/notifications.svelte.ts b/src/lib/state/notifications.svelte.ts index 22b00e6..8c1757e 100644 --- a/src/lib/state/notifications.svelte.ts +++ b/src/lib/state/notifications.svelte.ts @@ -1,38 +1,37 @@ -export type ToastKind = 'info' | 'success' | 'error' | 'download' - export interface Toast { - id: string - kind: ToastKind - message: string - detail?: string - duration?: number + id: string; + kind: "success" | "error" | "info" | "download"; + title: string; + body?: string; + duration?: number; } export interface ActiveDownload { - chapterId: number - mangaId: number - progress: number + chapterId: number; + mangaId: number; + progress: number; } class NotificationStore { - toasts: Toast[] = $state([]) - activeDownloads: ActiveDownload[] = $state([]) + toasts: Toast[] = $state([]); + activeDownloads: ActiveDownload[] = $state([]); - toast(toast: Omit) { - this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5) + addToast(t: Omit) { + this.toasts = [...this.toasts, { ...t, id: Math.random().toString(36).slice(2) }].slice(-5); } dismissToast(id: string) { - this.toasts = this.toasts.filter(x => x.id !== id) + this.toasts = this.toasts.filter(x => x.id !== id); } setActiveDownloads(next: ActiveDownload[]) { - this.activeDownloads = next + this.activeDownloads = next; } } -export const notifications = new NotificationStore() +export const notifications = new NotificationStore(); -export function toast(toast: Omit) { notifications.toast(toast) } -export function dismissToast(id: string) { notifications.dismissToast(id) } -export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next) } \ No newline at end of file +export function addToast(t: Omit) { notifications.addToast(t); } +export function toast(t: Omit) { notifications.addToast(t); } +export function dismissToast(id: string) { notifications.dismissToast(id); } +export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next); } \ No newline at end of file diff --git a/src/lib/state/reader.svelte.ts b/src/lib/state/reader.svelte.ts index b7414db..0f3e67a 100644 --- a/src/lib/state/reader.svelte.ts +++ b/src/lib/state/reader.svelte.ts @@ -1,44 +1,44 @@ -import type { Manga, Chapter } from '$lib/types' -import type { Page } from '$lib/server-adapters/types' +import type { Manga, Chapter } from "$lib/types"; +import type { Page } from "$lib/server-adapters/types"; -export type ReadMode = 'single' | 'strip' -export type FitMode = 'width' | 'height' | 'original' -export type ReadDirection = 'ltr' | 'rtl' +export type ReadMode = "single" | "strip"; +export type FitMode = "width" | "height" | "original"; +export type ReadDirection = "ltr" | "rtl"; export const readerState = $state({ - manga: null as Manga | null, + manga: null as Manga | null, chapter: null as Chapter | null, chapters: [] as Chapter[], - pages: [] as Page[], + pages: [] as Page[], pagesLoading: false, - pagesError: null as string | null, + pagesError: null as string | null, - currentPage: 0, - mode: 'single' as ReadMode, - fit: 'width' as FitMode, - direction: 'ltr' as ReadDirection, - zoom: 1, + currentPage: 0, + mode: "single" as ReadMode, + fit: "width" as FitMode, + direction: "ltr" as ReadDirection, + zoom: 1, showControls: false, showSettings: false, - fullscreen: false, -}) + fullscreen: false, +}); export function currentPageData() { - return readerState.pages[readerState.currentPage] ?? null + return readerState.pages[readerState.currentPage] ?? null; } export function progress() { return readerState.pages.length > 0 ? (readerState.currentPage + 1) / readerState.pages.length - : 0 + : 0; } export function hasPrev() { - return readerState.currentPage > 0 + return readerState.currentPage > 0; } export function hasNext() { - return readerState.currentPage < readerState.pages.length - 1 + return readerState.currentPage < readerState.pages.length - 1; } \ No newline at end of file diff --git a/src/lib/state/series.svelte.ts b/src/lib/state/series.svelte.ts index 36af8c5..ce79d11 100644 --- a/src/lib/state/series.svelte.ts +++ b/src/lib/state/series.svelte.ts @@ -1,28 +1,132 @@ -import type { Manga, Chapter } from '$lib/types' +import type { Manga, Chapter } from "$lib/types"; +import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history"; +import type { MangaPrefs } from "$lib/types/settings"; +import { settingsState, updateSettings } from "$lib/state/settings.svelte"; +import { goto } from "$app/navigation"; -class SeriesState { - current = $state(null) - loading = $state(false) - error = $state(null) +export type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history"; +export type { MangaPrefs } from "$lib/types/settings"; - chapters = $state([]) - chaptersLoading = $state(false) - chaptersError = $state(null) +class SeriesStore { + current = $state(null); + loading = $state(false); + error = $state(null); - chapterSortDesc = $state(true) - chapterFilter = $state({ unread: false, downloaded: false, query: '' }) + chapters = $state([]); + chaptersLoading = $state(false); + chaptersError = $state(null); - filteredChapters = $derived.by(() => { - let result = this.chapters - if (this.chapterFilter.unread) result = result.filter(c => !c.read) - if (this.chapterFilter.downloaded) result = result.filter(c => c.downloaded) - if (this.chapterFilter.query) { - const q = this.chapterFilter.query.toLowerCase() - result = result.filter(c => c.name.toLowerCase().includes(q)) - } - const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber) - return this.chapterSortDesc ? sorted.reverse() : sorted - }) + activeMangaId = $state(null); + activeManga = $state(null); + previewManga = $state(null); + activeChapter = $state(null); + activeChapterList = $state([]); + bookmarks = $state([]); + markers = $state([]); + acknowledgedUpdates = $state>(new Set()); + + setActiveMangaId(next: number | null) { this.activeMangaId = next; } + setActiveManga(next: Manga | null) { this.activeManga = next; } + setPreviewManga(next: Manga | null) { this.previewManga = next; } + + openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { + this.activeChapter = chapter; + this.activeChapterList = chapterList; + if (manga !== undefined) this.activeManga = manga; + goto(`/reader/${this.activeManga!.id}/${chapter.id}`); + } + + closeReader() { + this.activeChapter = null; + this.activeChapterList = []; + } + + acknowledgeUpdate(mangaId: number) { + if (this.acknowledgedUpdates.has(mangaId)) return; + this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId]); + } + + addBookmark(entry: Omit, label?: string) { + this.bookmarks = [ + { ...entry, savedAt: Date.now(), label }, + ...this.bookmarks.filter(b => b.chapterId !== entry.chapterId), + ].slice(0, 200); + } + + removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId); } + clearBookmarks() { this.bookmarks = []; } + getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId); } + + addMarker(entry: Omit): string { + const id = Math.random().toString(36).slice(2); + this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }]; + return id; + } + + updateMarker(id: string, patch: Partial>) { + this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m); + } + + removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id); } + getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page); } + getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); } + getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); } + clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); } + + getPref(mangaId: number, key: K): MangaPrefs[K] { + const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {}; + return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K]; + } + + setPref(mangaId: number, key: K, value: MangaPrefs[K]) { + updateSettings({ + mangaPrefs: { + ...settingsState.settings.mangaPrefs, + [mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value }, + }, + }); + } + + get settings() { return settingsState.settings; } } -export const seriesState = new SeriesState() \ No newline at end of file +export const DEFAULT_MANGA_PREFS: MangaPrefs = { + sortMode: "source", + sortDir: "asc", + preferredScanlator: "", + scanlatorFilter: [], + scanlatorBlacklist: [], + scanlatorForce: false, + autoDownload: false, + downloadAhead: 0, + maxKeepChapters: 0, + deleteOnRead: false, + deleteDelayHours: 0, + pauseUpdates: false, + refreshInterval: "global", + coverUrl: "", +}; + +export const seriesState = new SeriesStore(); + +export const seriesStore = seriesState; + +export function setActiveMangaId(next: number | null) { seriesState.setActiveMangaId(next); } +export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next); } +export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next); } +export function openReader(ch: Chapter, list: Chapter[], manga?: Manga | null) { seriesState.openReader(ch, list, manga); } +export function closeReader() { seriesState.closeReader(); } +export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId); } +export function addBookmark(entry: Omit, label?: string) { seriesState.addBookmark(entry, label); } +export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId); } +export function clearBookmarks() { seriesState.clearBookmarks(); } +export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId); } +export function addMarker(entry: Omit): string { return seriesState.addMarker(entry); } +export function updateMarker(id: string, patch: Partial>) { seriesState.updateMarker(id, patch); } +export function removeMarker(id: string) { seriesState.removeMarker(id); } +export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page); } +export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId); } +export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId); } +export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId); } +export function getPref(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key); } +export function setPref(mangaId: number, key: K, value: MangaPrefs[K]) { seriesState.setPref(mangaId, key, value); } \ No newline at end of file diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index 527e6ac..f974270 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -1,38 +1,38 @@ -import type { Settings } from '$lib/types/settings' -import { DEFAULT_SETTINGS } from '$lib/types/settings' +import type { Settings } from "$lib/types/settings"; +import { DEFAULT_SETTINGS } from "$lib/types/settings"; -const KEY = 'moku_settings' +const KEY = "moku_settings"; function load(): Settings { try { - const raw = localStorage.getItem(KEY) - if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) } + const raw = localStorage.getItem(KEY); + if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }; } catch {} - return { ...DEFAULT_SETTINGS } + return { ...DEFAULT_SETTINGS }; } function save(s: Settings) { - try { localStorage.setItem(KEY, JSON.stringify(s)) } catch {} + try { localStorage.setItem(KEY, JSON.stringify(s)); } catch {} } -export const settingsState = $state({ settings: load() }) +export const settingsState = $state({ settings: load() }); -if (typeof document !== 'undefined') { - document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0) +if (typeof document !== "undefined") { + document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0); } export function updateSettings(patch: Partial) { - Object.assign(settingsState.settings, patch) - save(settingsState.settings) + Object.assign(settingsState.settings, patch); + save(settingsState.settings); - if (typeof document !== 'undefined') { + if (typeof document !== "undefined") { if (patch.uiZoom !== undefined) { - document.documentElement.style.zoom = String(patch.uiZoom) + document.documentElement.style.zoom = String(patch.uiZoom); } } } export function resetSettings() { - settingsState.settings = { ...DEFAULT_SETTINGS } - save(settingsState.settings) + settingsState.settings = { ...DEFAULT_SETTINGS }; + save(settingsState.settings); } \ No newline at end of file diff --git a/src/lib/state/tracking.svelte.ts b/src/lib/state/tracking.svelte.ts index 92b8ecd..c867be8 100644 --- a/src/lib/state/tracking.svelte.ts +++ b/src/lib/state/tracking.svelte.ts @@ -1,29 +1,149 @@ +import { getAdapter } from '$lib/request-manager' +import { settingsState } from '$lib/state/settings.svelte' +import { buildChapterList } from '$lib/components/series/lib/chapterList' import type { Tracker, TrackRecord } from '$lib/types' -import type { Chapter } from '$lib/types/chapter' -import type { MangaPrefs } from '$lib/types/settings' +import type { Chapter } from '$lib/types' +import type { ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList' -export const trackingState = $state({ - trackers: [] as Tracker[], - loading: false, - error: null as string | null, - syncing: false, +type RecordMap = Map - records: [] as unknown[], - recordsLoading: false, - recordsError: null as string | null, +class TrackingStore { + private byManga: RecordMap = $state(new Map()) - searchResults: [] as unknown[], - searchLoading: false, - searchError: null as string | null, -}) + trackers: Tracker[] = $state([]) + loading: boolean = $state(false) + error: string | null = $state(null) + syncing: boolean = $state(false) + recordsLoading: boolean = $state(false) + recordsError: string | null = $state(null) + searchResults: unknown[] = $state([]) + searchLoading: boolean = $state(false) + searchError: string | null = $state(null) + + private loadingFor = new Set() + + recordsFor(mangaId: number): TrackRecord[] { + return this.byManga.get(mangaId) ?? [] + } + + private setFor(mangaId: number, records: TrackRecord[]) { + const next = new Map(this.byManga) + next.set(mangaId, records) + this.byManga = next + } + + async loadForManga(mangaId: number) { + if (this.loadingFor.has(mangaId)) return + const existing = this.byManga.get(mangaId) + if (existing && existing.length > 0) return + + this.loadingFor.add(mangaId) + try { + const records = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[] + this.setFor(mangaId, records) + } catch (e) { + // silently ignore — tracking is non-critical + } finally { + this.loadingFor.delete(mangaId) + } + } + + async syncFromRemote( + mangaId: number, + record: TrackRecord, + chapters: Chapter[], + prefs: ChapterDisplayPrefs, + ): Promise<{ markedIds: number[] }> { + if (!settingsState.settings.trackerSyncBack) return { markedIds: [] } + + try { + await getAdapter().syncTracking(String(mangaId)) + const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[] + this.setFor(mangaId, fresh) + + const freshRecord = fresh.find(r => r.id === record.id) + if (!freshRecord) return { markedIds: [] } + + const markedIds = this._applyRemoteProgress(freshRecord, chapters, prefs) + return { markedIds } + } catch { + return { markedIds: [] } + } + } + + private _applyRemoteProgress( + record: TrackRecord, + chapters: Chapter[], + prefs: ChapterDisplayPrefs, + ): number[] { + const lastRead = record.lastChapterRead ?? 0 + if (lastRead <= 0) return [] + + const threshold = settingsState.settings.trackerSyncBackThreshold ?? null + const respectScanlator = settingsState.settings.trackerRespectScanlatorFilter ?? true + const activeScanlators: string[] | null = + respectScanlator && (prefs as any).scanlatorFilter?.length + ? (prefs as any).scanlatorFilter + : null + + return chapters + .filter(ch => { + if (ch.read) return false + if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false + return threshold !== null + ? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - threshold + : ch.chapterNumber <= lastRead + }) + .map(ch => ch.id) + } + + async updateFromRead( + mangaId: number, + chapter: Chapter, + chapterList: Chapter[], + prefs: ChapterDisplayPrefs, + ) { + const records = this.recordsFor(mangaId) + if (!records.length) return + try { + await getAdapter().syncTracking(String(mangaId)) + const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[] + this.setFor(mangaId, fresh) + } catch {} + } + + async updateFromUnread( + mangaId: number, + chapterList: Chapter[], + prefs: ChapterDisplayPrefs, + ) { + const records = this.recordsFor(mangaId) + if (!records.length) return + try { + await getAdapter().syncTracking(String(mangaId)) + const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[] + this.setFor(mangaId, fresh) + } catch {} + } + + clear(mangaId: number) { + const next = new Map(this.byManga) + next.delete(mangaId) + this.byManga = next + } +} + +export const trackingState = new TrackingStore() + +// Standalone export for components that run their own sync loop (e.g. TrackingSettings) export async function syncBackFromTracker( - records: TrackRecord[], + records: TrackRecord[], chapters: Chapter[], opts: { threshold: number | null respectScanlatorFilter: boolean - chapterPrefs: Partial + chapterPrefs: Partial }, markChaptersRead: (ids: string[], read: boolean) => Promise, ): Promise { @@ -46,8 +166,7 @@ export async function syncBackFromTracker( : ch.chapterNumber <= lastRead }) - if (toMark.length === 0) continue - + if (!toMark.length) continue await markChaptersRead(toMark.map(ch => String(ch.id)), true) marked.push(...toMark) } diff --git a/src/routes/browse/series/[mangaid]/+page.svelte b/src/routes/browse/series/[mangaid]/+page.svelte deleted file mode 100644 index 1841b10..0000000 --- a/src/routes/browse/series/[mangaid]/+page.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -

Series {$page.params.mangaId} — stub

\ No newline at end of file diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 7446edc..0139451 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -1,95 +1,415 @@ -
+ +{#if ctx} + ctx = null} /> +{/if} +{#if emptyCtx} + emptyCtx = null} /> +{/if} + \ No newline at end of file diff --git a/src/routes/series/[mangaid]/+page.svelte b/src/routes/series/[mangaid]/+page.svelte new file mode 100644 index 0000000..1eb6bae --- /dev/null +++ b/src/routes/series/[mangaid]/+page.svelte @@ -0,0 +1,12 @@ + + + \ No newline at end of file